Content-Disposition响应头必须手动设置才能触发浏览器下载,否则内容将直接渲染;需正确使用filename*支持中文名,防范路径遍历,流式传输避免OOM,前端用fetch+Blob+模拟点击,注意IE11兼容及临时文件清理。

Content-Disposition 响应头必须手动设置
浏览器不会自动把响应当文件下载,除非你明确告诉它“这是个要保存的文件”。关键就在 Content-Disposition 响应头。不设、设错、或被中间件覆盖,都会导致页面直接渲染内容(比如显示 JSON 或 HTML 源码),而不是弹出保存框。
常见错误现象:Response to preflight request doesn't pass access control check(CORS 预检失败)、下载后文件名乱码、点开变成网页而非下载。
-
Content-Disposition: attachment; filename="report.pdf"是最常用形式;filename*才支持 UTF-8 中文名(如filename*=UTF-8''%E6%8A%A5%E8%A1%A8.pdf) - 某些框架(如 Express)默认不设置该头,需显式调用
res.setHeader()或res.download() - Nginx 或 CDN 可能会删掉自定义响应头,需检查
proxy_pass后是否保留Content-Disposition
流式传输大文件时别用 res.send()
用 res.send(data) 加载整个文件到内存再发出去,几 MB 就可能 OOM;100MB 文件基本不可行。真实场景里,文件往往来自磁盘、数据库 BLOB 或远程 API,必须边读边传。
使用场景:导出日志、生成报表、转发用户上传的原始文件。
- Node.js 中优先用
fs.createReadStream(path).pipe(res),配合res.setHeader('Content-Length', size)更稳妥 - 如果文件路径不可信(比如含用户输入),务必做路径遍历校验,否则
../../etc/passwd会被解析成真实系统路径 - 不要在流管道中混用
res.write()和res.end(),容易破坏 HTTP 分块编码,导致 Chrome 下载中断
前端触发下载不能只靠 window.location.href
GET 请求带长参数(比如 Base64 编码的文件内容)会超 URL 长度限制,Safari 和 IE 直接失败;而且无法控制下载文件名、没错误反馈、无法加认证头。
正确做法是用 fetch() + response.blob() + URL.createObjectURL(),兼容现代浏览器。
- 避免直接
fetch('/api/download').then(r => r.blob()).then(b => window.open(URL.createObjectURL(b)))—— 这样打不开,得用document.createElement('a')模拟点击 - 服务端返回 4xx/5xx 时,
response.ok是 false,但blob()仍会成功执行,需先检查状态再转 blob - IE11 不支持
response.blob(),要用msSaveOrOpenBlob()降级,但注意它不支持设置文件名,得靠服务端Content-Disposition
后端生成文件时,临时文件清理容易被忽略
很多实现用 fs.writeFileSync(tempPath, content) 再 res.download(tempPath),但没配 process.on('exit') 或 try/finally 清理,跑几天就填满磁盘。
性能影响:频繁创建删除小文件对 ext4/XFS 影响不大,但 NFS 或容器卷上可能变慢;更严重的是 inode 耗尽——即使磁盘有空间,也会因无可用 inode 导致写入失败。
- 优先走内存流(如
new PassThrough().end(content)),完全避开临时文件 - 非用临时文件不可时,用
tmpdir+ 唯一前缀,并在res.on('finish')和res.on('error')里统一fs.unlink() - 别依赖定时任务扫临时目录,有些请求异常断开,
finish事件根本不会触发
最麻烦的其实是调试:Chrome DevTools 的 Network 标签页里看不到 Content-Disposition?那八成是被 Service Worker 拦截了,或者响应被缓存了。关掉 SW、清空缓存、用隐身窗口重试,比查文档快得多。










