Express 响应返回函数 res.send VS res.end

2025/7/10 ExpressNode

在处理 express + puppeteer 生成 pdf 需求的时候,使用 res.send(buffer)给浏览器,发现下载的pdf文件显示已损坏无法打开,但是在服务端生成的pdf是正常的,但是使用 res.end(buffer) 就对了, 这是什么原因呢?

🌙 核心观点:res.end() 是基础,res.send() 是增强

  • res.end() (opens new window): 这是 Node.js 原生的 http.ServerResponse 模块中的方法。它非常底层,功能单一:告诉服务器所有响应头和正文都已发送完毕,该响应被视为已完成。
  • res.send() (opens new window): 这是 Express.js 框架提供的高级方法。它在内部封装了 res.end(),并在其之上增加了许多智能和便捷的功能,极大地简化了开发。

可以把 res.end() 看作是汽车的手动挡,你需要自己处理很多细节;而 res.send() 则是自动挡,它为你处理了大部分常规操作。

🌙 对比分析表格

为了更直观地比较,请看表格:

特性 res.end([data], [encoding]) res.send([body])
来源 Node.js 原生 http 模块 Express.js 框架
功能 基础、底层。主要用于结束响应。 高级、智能。封装了响应逻辑。
Content-Type 从不自动设置。你必须手动 res.setHeader() 自动设置。根据内容推断(application/json, text/html 等)。
ETag 自动生成。 自动生成(对于非流式响应),用于 HTTP 缓存。
处理对象/数组 会抛出错误。它只接受 String 或 Buffer。 会自动将对象/数组 JSON.stringify,并设置 Content-Type: application/json
处理字符串 直接发送。 如果字符串包含 HTML 标签,会自动设置 Content-Type: text/html
处理 null/undefined 抛出错误或行为不确定。 发送一个空的响应体。
链式调用 调用后,响应立即结束。 调用后,响应也立即结束。

🌙 深入剖析:res.send() 错在哪里?

当传递一个 Bufferres.send() 时,Express 会执行一系列的智能判断和操作,其中一个关键步骤是:

它会自动设置 Content-Type 响应头为 application/octet-stream

application/octet-stream 是一种通用的二进制数据MIME类型,它告诉浏览器:“这是一些二进制数据,我不知道它具体是什么,你自己看着办吧,通常是提示用户下载。”

虽然这听起来没问题,但对于 PDF 这种有明确类型的文件,它引发了两个问题:

  1. 类型信息丢失: 浏览器没有接收到明确的 Content-Type: application/pdf 指令。虽然它仍然会尝试下载,但在处理文件时可能会因为缺少这个关键的类型提示而出错,导致某些浏览器或PDF阅读器认为文件结构损坏。
  2. 可能的额外处理: res.send 的内部逻辑比 res.end 复杂得多。在某些 Express 版本或特定中间件环境下,它在识别到是 Buffer 并设置 Content-Type 的过程中,可能会进行一些微小的、不易察觉的编码转换或处理,而这些处理对于结构极其敏感的 PDF 文件来说是致命的,任何一个字节的改变都可能导致整个文件损坏。

🌙 res.end() 为何能成功?

res.end() 是 Node.js 原生的、非常底层的方法。它的行为简单粗暴:

你给我一个 Buffer,我就把这个 Buffer 的每一个原始字节原封不动地写入响应流,然后结束响应。

它不会:

  • 不会自动设置任何 Content-Type
  • 不会Buffer 内容进行任何检查或修改。

所以,当你在使用 res.end(buffer) 时,你可能在调用它之前已经手动设置了正确的 Content-Type,或者即使没有设置,浏览器在接收到这串纯净、未被篡改的二进制流时,也能更好地猜测和处理它(尽管这不推荐)。数据的完整性得到了 100% 的保证。


🌙 如何正确地发送PDF(或其他二进制文件)

正确的代码实现:

import express, { Request, Response } from 'express';
import puppeteer from 'puppeteer';

const app = express();

app.get('/generate-pdf', async (req: Request, res: Response) => {
  try {
    const browser = await puppeteer.launch();
    const page = await browser.newPage();
  
    // 假设我们有一个HTML模板
    const htmlContent = '<h1>Invoice</h1><p>Details...</p>';
    await page.setContent(htmlContent, { waitUntil: 'networkidle0' });
  
    // 生成PDF Buffer
    const pdfBuffer = await page.pdf({
      format: 'A4',
      printBackground: true
    });
  
    await browser.close();
    
    // 1. 设置正确的 Content-Type,明确告诉浏览器这是一个PDF文件
    res.setHeader('Content-Type', 'application/pdf');

    // 2. 设置 Content-Disposition,让浏览器弹出下载框,并指定文件名
    res.setHeader('Content-Disposition', 'attachment; filename=invoice.pdf');
  
    // 3. (可选但推荐) 设置 Content-Length,让浏览器知道文件大小,可以显示下载进度
    res.setHeader('Content-Length', pdfBuffer.length);
    
    // 4.使用 res.end 发送 buffer数据
    res.end(pdfBuffer);
    
    // 或者可以将 pdf临时生成保存在服务器,然后使用 res.sendFile
    // res.sendFile(pdfFilePath)

  } catch (error) {
    console.error('Failed to generate PDF:', error);
    res.status(500).send('An error occurred while generating the PDF.');
  }
});

app.listen(3000, () => {
  console.log('Server is running on port 3000');
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

res.send()Buffer 类型进行了“自作聪明”的处理,而 res.end() 则忠实地、不加修改地发送了原始数据。

🌙 实战对比

🌙 场景 1: 发送一个简单的文本字符串

import express, { Request, Response } from 'express';
const app = express();

// 使用 res.end()
app.get('/end-text', (req: Request, res: Response) => {
  // 手动设置 Content-Type
  res.setHeader('Content-Type', 'text/plain');
  res.end('Hello from res.end()');
});

// 使用 res.send()
app.get('/send-text', (req: Request, res: Response) => {
  // Express 自动设置 Content-Type: text/html; charset=utf-8 (即使是纯文本)
  res.send('Hello from res.send()');
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

分析: res.send() 更省事,你不需要关心 Content-Type

🌙 场景 2: 发送 JSON 数据 (最常见的场景)

import express, { Request, Response } from 'express';
const app = express();

const user = { id: 1, name: 'Alex' };

// 使用 res.end()
app.get('/end-json', (req: Request, res: Response) => {
  try {
    // 1. 必须手动将对象转换为 JSON 字符串
    const jsonString = JSON.stringify(user);
    // 2. 必须手动设置正确的 Content-Type
    res.setHeader('Content-Type', 'application/json');
    // 3. 发送字符串
    res.end(jsonString);
  } catch (error) {
    res.status(500).end('Server error');
  }
});

// 使用 res.send()
app.get('/send-json', (req: Request, res: Response) => {
  // 一步到位!
  // Express 会自动:
  // 1. 调用 JSON.stringify(user)
  // 2. 设置 Content-Type: application/json; charset=utf-8
  // 3. 设置 ETag 头
  res.send(user); 
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

分析: 这个场景完美地展示了 res.send() 的强大之处。它将一个三步操作简化为一步,并且做得更完善(自动设置 ETag)。

🌙 场景 3: 发送无内容的响应

import express, { Request, Response } from 'express';
const app = express();

// 比如处理一个 DELETE 请求成功后
app.delete('/some-resource/:id', (req: Request, res: Response) => {
  // 两种方式都可以,但 res.send() 更符合 Express 的书写习惯

  // 方式一:使用 end
  // res.status(204).end();

  // 方式二:使用 send (更推荐)
  // .send() 会处理好一切,即使没有内容要发送
  res.status(204).send(); 
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14

分析: 在这种情况下,res.status(204).send() 是更符合 Express 编码风格(idiomatic)的写法。

🌙 结论与最佳实践

使用 Express 处理响应,应该遵循以下准则:

  1. 始终优先使用 res.send():在 99% 的情况下,res.send() 都是你的正确选择。它更安全、更方便、功能更强大,并且充分利用了 Express 框架的优势。它是为日常应用开发而设计的。

  2. 什么时候会用到 res.end()

    • 极限性能优化:在极少数需要压榨每一分性能的场景下,你可能想绕过 res.send() 的所有检查和处理逻辑,直接发送一个预先准备好的 Buffer。
    • 不使用 Express 时:如果你直接使用 Node.js 的原生 http 模块创建服务器,那你只能使用 res.end()
  3. 当然,如果是express v4.8 以上,推荐使用 res.sendFile(path [, options] [, fn]) (opens new window)来专门处理文件。

项目:基于 Puppeteer 的 PDF 文档生成服务 (opens new window)