浅析Transfer-Encoding: chunked在流式与边缘渲染中的应用

在现代 Web 开发中,用户体验的核心之一是页面加载速度。为了提升性能,开发者们采用了多种优化技术,其中 流式渲染(Streaming Rendering) 是一种非常有效的方式。而在 HTTP/1.1 中,分块传输编码(Transfer-Encoding: chunked) 是实现流式渲染的核心技术之一。


一、什么是流式渲染?

流式渲染是一种逐步生成和交付页面内容的技术,在React 18中引入renderToPipeableStream并被广泛应用。与传统的渲染方式(等待所有内容生成完毕后再一次性发送)不同,流式渲染允许服务器在生成部分内容后立即发送给客户端,客户端可以逐步渲染这些内容,从而减少用户等待时间。

传统渲染如下:

流式渲染如下:

流式渲染的核心目标是:

  1. 提升首屏加载速度:尽早显示页面的关键内容。
  2. 优化用户体验:减少用户等待时间,提升交互响应速度。
  3. 节省服务器资源:避免缓存整个页面内容,减少内存占用。

流式渲染与渐进式渲染有什么区别?

这两货通常被认为是一个概念,核心点都是逐步渲染,但其实还是有一点区别的,它们的侧重点不同,一个是内容加载策略,一个是数据传输机制。

  • 渐进式渲染 :通常是客户端驱动的优化手段,比如懒加载图片/组件、优先加载关键CSS、异步脚本加载(async/defer)等。

  • 流式渲染 :通常是服务端驱动的数据传输方式,由服务器主动分块发送数据,浏览器实时处理,比如使用分块传输编码Transfer-Encoding: chunked、Node.js 的管道和可读流renderToPipeableStreamWebSocketsServer Sent Events等。

两者虽然都致力于提升用户体验,渐进式渲染策略驱动 的应用层优化(客户端发起),流式渲染协议驱动的传输层技术(服务端发起)。


二、Transfer-Encoding: chunked 的作用

在 HTTP/1.1 中,Transfer-Encoding: chunked 是实现流式渲染的核心原理。它允许服务器在不知道内容总长度的情况下,逐步生成并发送数据。通过分块传输编码,服务器可以将页面内容分成多个小块(chunks),逐个发送给客户端,最后以一个大小为 0 的块标记结束。

分块传输的格式

每个块由以下部分组成:

  1. 块大小 :以十六进制表示当前块的大小(字节数),后跟一个CRLF(\r\n)结尾。
  2. 块数据:实际的数据内容。
  3. 块结束符 :每个块以CRLF(\r\n)结束。
  4. 结束标记 :最后一个块的大小为 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: chunkedHTTP 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在前端流式渲染中发挥着重要的作用,尤其是那些无法简单通过SSGISR改造就能实现优化的场景,即无法充分发挥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.1HTTP2的兼容配置问题。

最后,让我们一起总结一下,流式渲染正在打破传统 Web 的"全有或全无"模式,通过本文揭示的分块传输编码Transfer-Encoding: chunked技术原理,开发者可以构建出真正具有流体美学的现代 Web 应用。

如果你有更好的应用和实现方式,也欢迎来一起讨论~

相关推荐
懒人村杂货铺7 分钟前
forwardRef
前端
115432031q19 分钟前
基于SpringBoot养老院平台系统功能实现十七
java·前端·后端
(; ̄ェ ̄)。26 分钟前
在nodejs中使用RabbitMQ(二)发布订阅
javascript·后端·node.js·rabbitmq
浪浪山小白兔1 小时前
CSS 渐变效果详解——线性渐变与径向渐变
前端·css
VillanelleS1 小时前
React进阶之React状态管理&CRA
前端·javascript·react.js
一路向前的月光1 小时前
React(5)
前端·react.js·前端框架
LLLuckyGirl~1 小时前
webpack配置之---output.path
前端·webpack·node.js
前端_yu小白1 小时前
vue2项目生产环境移除console.log
前端·javascript·vue.js
B.-1 小时前
Flutter 中的生命周期
android·前端·flutter·ios
三原1 小时前
Vue Playground 演练场源码解读(四)- 终篇
前端·vue.js·源码