在现代 Web 开发中,用户体验的核心之一是页面加载速度。为了提升性能,开发者们采用了多种优化技术,其中 流式渲染(Streaming Rendering) 是一种非常有效的方式。而在 HTTP/1.1 中,分块传输编码(Transfer-Encoding: chunked) 是实现流式渲染的核心技术之一。
一、什么是流式渲染?
流式渲染是一种逐步生成和交付页面内容的技术,在React 18
中引入renderToPipeableStream
并被广泛应用。与传统的渲染方式(等待所有内容生成完毕后再一次性发送)不同,流式渲染允许服务器在生成部分内容后立即发送给客户端,客户端可以逐步渲染这些内容,从而减少用户等待时间。
传统渲染如下:
流式渲染如下:
流式渲染的核心目标是:
- 提升首屏加载速度:尽早显示页面的关键内容。
- 优化用户体验:减少用户等待时间,提升交互响应速度。
- 节省服务器资源:避免缓存整个页面内容,减少内存占用。
流式渲染与渐进式渲染有什么区别?
这两货通常被认为是一个概念,核心点都是逐步渲染,但其实还是有一点区别的,它们的侧重点不同,一个是内容加载策略,一个是数据传输机制。
-
渐进式渲染 :通常是客户端驱动的优化手段,比如懒加载图片/组件、优先加载关键CSS、异步脚本加载(
async
/defer
)等。 -
流式渲染 :通常是服务端驱动的数据传输方式,由服务器主动分块发送数据,浏览器实时处理,比如使用分块传输编码
Transfer-Encoding: chunked
、Node.js 的管道和可读流renderToPipeableStream
、WebSockets
和Server Sent Events
等。
两者虽然都致力于提升用户体验,渐进式渲染 是策略驱动 的应用层优化(客户端发起),流式渲染 是协议驱动的传输层技术(服务端发起)。
二、Transfer-Encoding: chunked 的作用
在 HTTP/1.1 中,Transfer-Encoding: chunked
是实现流式渲染的核心原理。它允许服务器在不知道内容总长度的情况下,逐步生成并发送数据。通过分块传输编码,服务器可以将页面内容分成多个小块(chunks),逐个发送给客户端,最后以一个大小为 0 的块标记结束。
分块传输的格式
每个块由以下部分组成:
- 块大小 :以十六进制表示当前块的大小(字节数),后跟一个CRLF(
\r\n
)结尾。 - 块数据:实际的数据内容。
- 块结束符 :每个块以CRLF(
\r\n
)结束。 - 结束标记 :最后一个块的大小为
0
,表示传输结束,后跟两个换行符(\r\n\r\n
)。
比如:
js
5\r\n
Hello\r\n
4\r\n
Alan\r\n
0\r\n
\r\n
- 第一块:
5\r\nHello\r\n
,表示发送了 5 个字节的数据Hello
。 - 第二块:
4\r\nAlan\r\n
,表示发送了 4 个字节的数据Alan
。 - 最后一块:
0\r\n\r\n
,表示传输结束。
分块传输编码的工作原理
在开始实际应用之前,我们先来了解一下分块传输编码的原理,如图:
分块传输的应用场景
1. 动态生成内容
当服务器需要动态生成内容(如实时日志、流媒体、动态网页)时,分块传输编码非常有用。服务器可以在生成数据的同时逐步发送,而不需要等待所有数据生成完毕。
2. 实时日志数据
在实时日志展示场景中,服务器可以通过分块传输编码逐步发送日志内容,客户端可以实时渲染这些日志。
3. 大文件传输
对于大文件(如视频、图片或大型数据集),分块传输编码可以避免服务器缓存整个文件,从而节省内存资源。
可能有读者在这里会有个疑惑,大文件使用分块传输已经成为主流技术方案,但大一点的文件传输不是用断点续传吗?Transfer-Encoding: chunked
和HTTP 206
(断点续传)有什么区别?
Transfer-Encoding: chunked 与 断点续传
它们两个在处理大文件下载中都发挥着比较重要的作用,对用户体验有明显提升。但其实它们还是有着本质区别,比如chunked不需要知道文件大小,而断点续传必须提前知道文件大小,它是一个大小范围。
举个简单的例子,平常我们看视频,若是看直播,视频流是无法预知它的大小,可用chunked;而看网站视频,用206下载续传。
下面是两者的差异对比:
特性 | Transfer-Encoding: chunked |
HTTP 206 (断点续传) |
---|---|---|
触发方 | 服务器主动决定 | 客户端通过 Range 头请求触发 |
数据完整性 | 全量数据(分块传输) | 部分数据(指定字节范围) |
内容长度 | 无需 Content-Length |
必须提供 Content-Range |
典型场景 | 实时流、动态生成内容 | 大文件下载续传、多线程下载 |
三、分块传输编码在流式渲染中的应用
在现代 Web 开发中,影响用户体验的往往是渲染速度,而对一些动态内容数据复杂,上万个状态对应不同UI的情况下,渲染情况会变得异常复杂。这时候,通过一般的SSR手段已无法继续提升用户体验,这时候流式渲染是一种不错的选择。
下面基于express框架,使用分块传输编码Transfer-Encoding: chunked
实现一个动态生成HTML的例子。
首先设置HTTP头'Transfer-Encoding': 'chunked'
js
const express = require('express');
const app = express();
app.get('/', async (req, res) => {
// 设置头'Transfer-Encoding': 'chunked'
res.set({
'Content-Type': 'text/html;charset=utf-8',
'Cache-Control': 'no-cache',
'Transfer-Encoding': 'chunked',
Connection: 'keep-alive',
});
// ...
});
app.listen(3001, () => {
console.log('Server running on http://localhost:3001');
});
其次,需要模拟一个异步的函数
js
const sleep = (time) => {
return new Promise((res) => {
setTimeout(() => {
res(time)
}, time * 1000)
})
}
最后将模拟的数据写入,并返回给客户端
js
// 首先,发送 HTML 的开头部分
res.write('<!DOCTYPE html><html lang="zh"><head><meta charset="UTF-8"><title>Transfer-Encoding Chunked</title></head><body><h1>欢迎</h1><p>正在加载...</p>');
// 模拟延迟发送数据
let time = await sleep(1)
res.write(`<p>这是部分 1 内容,等待时间 ${time} s</p>`);
time = await sleep(2)
// 发送第三部分内容
res.write(`<p>这是部分 2 内容,等待时间 ${time} s</p>`);
time = await sleep(2)
// 发送第四部分内容
res.write(`<p>最后的部分内容,加载完成。</p>`);
// 最后结束响应
res.end('</body></html>');
而在实际应用中,我们应考虑更加复杂的场景,比如代理服务器头的设置,禁用代理缓冲X-Accel-Buffering
,优化传输内容格式Buffer.from
等等。
完整的例子如下:
js
// server.js
const express = require('express');
const app = express();
const sleep = (time) => {
return new Promise((res) => {
setTimeout(() => {
res(time)
}, time * 1000)
})
}
app.get('/', async (req, res) => {
res.set({
'Content-Type': 'text/html;charset=utf-8',
'Cache-Control': 'no-cache',
'Transfer-Encoding': 'chunked',
Connection: 'keep-alive',
});
// 首先,发送 HTML 的开头部分
res.write(Buffer.from('<!DOCTYPE html><html lang="zh"><head><meta charset="UTF-8"><title>Transfer-Encoding Chunked</title></head><body><h1>欢迎</h1><p>正在加载...</p>'));
// 模拟延迟发送数据
let time = await sleep(1)
res.write(Buffer.from(`<p>这是部分 1 内容,等待时间 ${time} s</p>`));
time = await sleep(2)
res.write(Buffer.from(`<p>这是部分 2 内容,等待时间 ${time} s</p>`));
time = await sleep(2)
res.write(Buffer.from(`<p>最后的部分内容,加载完成。</p>`));
// 最后结束响应
res.end(Buffer.from('</body></html>'));
});
app.listen(3001, () => {
console.log('Server running on http://localhost:3001');
});
运行node server.js
,在浏览器中打开http://localhost:3001
运行效果如下:
四、分块传输编码在边缘渲染中的应用
从上面的例子,我们已经修改了渲染的方式,对用户体验会有一定的提升,但要想更进一步,我们可以考虑将SSR
渲染变成ESR
渲染。
利用边缘节点的计算功能,快速响应部分HTML,提升LCP。
下面将结合Cloudflare 的 wrangler
框架,给出Transfer-Encoding: chunked
在边缘渲染中的应用。
js
// src/index.ts
export interface Env {
// Example binding to KV. Learn more at https://developers.cloudflare.com/workers/runtime-apis/kv/
// MY_KV_NAMESPACE: KVNamespace;
//
// Example binding to Durable Object. Learn more at https://developers.cloudflare.com/workers/runtime-apis/durable-objects/
// MY_DURABLE_OBJECT: DurableObjectNamespace;
//
// Example binding to R2. Learn more at https://developers.cloudflare.com/workers/runtime-apis/r2/
// MY_BUCKET: R2Bucket;
//
// Example binding to a Service. Learn more at https://developers.cloudflare.com/workers/runtime-apis/service-bindings/
// MY_SERVICE: Fetcher;
}
function fetchData(str: string, time: number): Promise<any> {
return new Promise(resolve => {
setTimeout(() => resolve(str), time); // 模拟 API 延迟
});
}
export default {
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext
): Promise<Response> {
const encoder = new TextEncoder();
// 设置响应头
const responseHeaders = {
'Content-Type': 'text/html;charset=utf-8',
'Transfer-Encoding': 'chunked',
Connection: 'keep-alive',
"Cache-Control": "no-store, no-cache, must-revalidate",
"X-Accel-Buffering": "no", // 禁用代理缓冲
"Content-Encoding": "identity" // 强制禁用压缩
};
const stream = new ReadableStream({
async start(controller) {
controller.enqueue(encoder.encode(`<p>正在加载......</p>`));
// 模拟延迟
let str = await fetchData(`<p>这是第 1 块数据,等待了 1 s</p>`, 1000);
controller.enqueue(encoder.encode(str));
str = await fetchData(`<p>这是第 2 块数据,等待了 1 s</p>`, 2000);
controller.enqueue(encoder.encode(str));
str = await fetchData(`<p>这是最后的数据,加载完成。</p>`, 1000);
controller.enqueue(encoder.encode(str));
controller.close();
}
});
return new Response(
stream,
{
headers: responseHeaders,
},
);
},
};
启动命令npm run start
,在浏览器中打开http://localhost:8787
效果如下:
从上述两个例子中,我们可以看到分块传输编码Transfer-Encoding: chunked
在前端流式渲染中发挥着重要的作用,尤其是那些无法简单通过SSG
和ISR
改造就能实现优化的场景,即无法充分发挥CDN缓存,无法摆脱SSR渲染方式的场景。
五、Transfer-Encoding: chunked
使用注意点
上述讲的都是分块传输编码给我们带来的便利,利用它可以在生成部分内容后立即发送,从而提升首屏加载速度,但实际应用中,它也会给我们带来很多的困扰。
-
首先,是复杂性的增加,客户端需要支持分块传输解码。
-
其次,它不支持 HTTP/1.0,分块传输编码是 HTTP/1.1 的特性,旧版客户端无法兼容。
-
此外,也是给我们开发带来的最大麻烦------调试困难,分块传输的数据格式较为复杂,调试和排查问题可能比较困难。
-
最后,需要注意的是在 HTTP/2 中,分块传输编码(Transfer-Encoding: chunked) 被移除,取而代之的是 二进制分帧(Binary Framing) 和 数据流(Stream) 机制。
由于HTTP2中默认使用数据流Stream
,同时Content-Encoding
会默认开启zgip,因此在使用边缘渲染时,需要考虑处理HTTP1.1
和HTTP2
的兼容配置问题。
最后,让我们一起总结一下,流式渲染正在打破传统 Web 的"全有或全无"模式,通过本文揭示的分块传输编码Transfer-Encoding: chunked
技术原理,开发者可以构建出真正具有流体美学的现代 Web 应用。
如果你有更好的应用和实现方式,也欢迎来一起讨论~