Express路由内WebSocket仅首次调用正常问题排查
问题分析与解决方案
问题描述
搭建ExpressJS服务器并结合ws包实现WebSocket功能,首次调用/download接口时,WebSocket消息正常发送且文件能成功下载;但第二次刷新客户端页面后,消息虽能发送,下载却失败,服务器抛出Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client错误并崩溃。
服务端代码
import express from 'express'; import { WebSocketServer } from 'ws'; import stream from 'stream'; // 原代码缺失该导入,需补充 const app = express(); const server = app.listen(3000); const wss = new WebSocketServer({ server }); // 将wss实例存入app,方便路由中获取 app.set('wss', wss); app.get('/download', (req, res) => { const wss = req.app.get('wss'); wss.on('connection', async (ws) => { ws.send('hello world'); // 模拟异步数据处理 const myData = 'test,data\n1,2\n3,4'; // 原代码未定义该变量,需补充业务逻辑 const fileContents = Buffer.from(myData); const readStream = new stream.PassThrough(); readStream.end(fileContents); res.set('Content-disposition', `attachment; filename=test.csv`); res.set('Content-Type', 'text/csv'); readStream.pipe(res); ws.close() }) })
客户端代码
axios.get('http://'+ window.location.host +'/download', { responseType: 'blob', }).then(response=>{ // 创建浏览器内存中的文件链接 const href = URL.createObjectURL(response.data); // 创建a标签并触发下载 const link = document.createElement('a'); link.href = href; link.setAttribute('download', `file.csv`); document.body.appendChild(link); link.click(); // 清理资源 document.body.removeChild(link); URL.revokeObjectURL(href); }) const ws = new WebSocket('ws://' + window.location.host); ws.addEventListener('message', function(m) { // 处理WebSocket消息 console.log('收到消息:', m.data); })
错误原因
核心问题是每次请求/download接口时,都会给全局的wss对象新增一个connection事件监听器:
- 第一次请求时,绑定第一个监听器,WebSocket连接触发该监听器,正常响应
res并完成下载。 - 第二次请求时,又绑定第二个监听器;当新的WebSocket连接建立时,
wss会触发所有已绑定的connection监听器(包括第一次的)。 - 第一次监听器中的
res已经在第一次请求时完成响应,再次执行res.set()和readStream.pipe(res)时,就会触发Cannot set headers after they are sent to the client错误——同一个res对象被重复尝试发送响应头。
此外,客户端先发起下载请求再创建WebSocket连接的时序,可能导致请求与WebSocket连接的匹配混乱。
修复方案
方案1:通过请求ID匹配WebSocket连接(推荐,无竞态问题)
- 路由生成唯一请求ID,通过响应头传递给客户端。
- 客户端携带该ID建立WebSocket连接,服务端根据ID匹配对应请求的
res。
修改后的服务端代码:
import express from 'express'; import { WebSocketServer } from 'ws'; import stream from 'stream'; import { v4 as uuidv4 } from 'uuid'; // 需安装uuid包:npm install uuid const app = express(); const server = app.listen(3000); const wss = new WebSocketServer({ server }); const pendingRequests = new Map(); // 存储待匹配的请求ID与res // 全局绑定一次connection监听器 wss.on('connection', (ws) => { ws.on('message', (data) => { const requestId = data.toString(); const res = pendingRequests.get(requestId); if (res) { ws.send('hello world'); // 业务数据处理逻辑 const myData = 'test,data\n1,2\n3,4'; const fileContents = Buffer.from(myData); const readStream = new stream.PassThrough(); readStream.end(fileContents); res.set('Content-disposition', `attachment; filename=test.csv`); res.set('Content-Type', 'text/csv'); readStream.pipe(res); ws.close(); pendingRequests.delete(requestId); // 清理已完成的请求 } }); }); app.get('/download', (req, res) => { const requestId = uuidv4(); res.set('X-Request-ID', requestId); // 将ID通过响应头传给客户端 pendingRequests.set(requestId, res); });
修改后的客户端代码:
axios.get('http://'+ window.location.host +'/download', { responseType: 'blob', }) .then(response=>{ const requestId = response.headers['x-request-id']; // 拿到请求ID后再建立WebSocket连接 const ws = new WebSocket('ws://' + window.location.host); ws.addEventListener('open', () => { ws.send(requestId); // 发送请求ID给服务端匹配 }); ws.addEventListener('message', function(m) { console.log('收到消息:', m.data); }); // 处理文件下载 const href = URL.createObjectURL(response.data); const link = document.createElement('a'); link.href = href; link.setAttribute('download', `file.csv`); document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(href); }) .catch(err => console.error('下载失败:', err));
方案2:使用once绑定临时监听器(简单但有竞态风险)
在路由中用once替代on绑定connection事件,确保监听器仅执行一次,避免重复绑定:
服务端代码修改:
app.get('/download', (req, res) => { const wss = req.app.get('wss'); // 使用once,监听器仅触发一次 wss.once('connection', async (ws) => { ws.send('hello world'); const myData = 'test,data\n1,2\n3,4'; const fileContents = Buffer.from(myData); const readStream = new stream.PassThrough(); readStream.end(fileContents); res.set('Content-disposition', `attachment; filename=test.csv`); res.set('Content-Type', 'text/csv'); readStream.pipe(res); ws.close(); }); });
注意:此方案存在小概率竞态问题——若多个客户端同时发起请求,可能出现监听器被错误匹配的情况,方案1更可靠。
额外注意事项
- 补充原代码缺失的
stream模块导入。 - 完善
myData变量的业务数据逻辑。 - 客户端需调整时序,确保拿到请求ID后再建立WebSocket连接,避免匹配失败。
内容的提问来源于stack exchange,提问作者Nate
相关产品推荐
相关产品推荐

