TL;DR:并非 bug 而是服务端压缩导致
Content-Length
和实际大小不一致。
原由是在写 🚫 AbortController 的 5 个注意点 这篇文章的时候,查阅资料发现一篇很好的文章 从 Fetch 到 Streams ------ 以流的角度处理网络请求 里面有一段代码可以用 fetch
展示下载进度:
js
let total = null;
let loaded = 0;
const logProgress = (reader) => {
return reader.read().then(({ value, done }) => {
if (done) {
console.log('Download completed');
return;
}
loaded += value.length;
if (total === null) {
console.log(`Downloaded ${loaded}`);
} else {
console.log(`Downloaded ${loaded} of ${total} (${(loaded / total * 100).toFixed(2)}%)`);
}
return logProgress(reader);
});
};
fetch('/foo').then((res) => {
total = res.headers.get('content-length');
return res.body.getReader();
}).then(logProgress);
最近正好也在使用 UX 和 DX 都非常舒服的 Antd X 组件库,我就顺手用 ant-design-x.antgroup.com/components/... 这个网页的 index.html 运行下上述代码。
js
Downloaded 3609 of 66421 (5.43%)
Downloaded 69145 of 66421 (104.10%)
Downloaded 174735 of 66421 (263.07%)
Downloaded 240271 of 66421 (361.74%)
Downloaded 336472 of 66421 (506.57%)
Downloaded 402008 of 66421 (605.24%)
Downloaded 442947 of 66421 (666.88%)
Downloaded 508483 of 66421 (765.55%)
Downloaded 520704 of 66421 (783.94%)
Download completed
为啥最终进度停止在"783.94%"而非"100%"?🤷♂️
第一反应是我们代码的问题,首先得弄明白代码里面 value
是什么?是字符串吗? 通过增加 console.log(value.slice(0, 10))
发现是一个 Uint8Array
数组。
难道我们应该转成字符串再累加长度。尝试用 TextEncoder
转成字符串:
js
let total = null;
let loaded = 0;
const logProgress = (reader) => {
return reader.read().then(({ value, done }) => {
if (done) {
console.log('Download completed');
return;
}
// 新增代码 ✅
const decoder = new TextDecoder('utf-8');
const text = decoder.decode(value, { stream: true });
console.log(text.slice(0, 30));
loaded += text.length;
if (total === null) {
console.log(`Downloaded ${loaded}`);
} else {
console.log(
`Downloaded ${loaded} of ${total} (${((loaded / total) * 100).toFixed(
2
)}%)`
);
}
return logProgress(reader);
});
};
fetch(location.href)
.then((res) => {
total = res.headers.get('content-length');
return res.body.getReader();
})
.then(logProgress)
.then((result) => console.log('result:', result));
新增代码:
js
const decoder = new TextDecoder('utf-8');
const text = decoder.decode(value, { stream: true });
console.log(text.slice(0, 30));
通过打印确实发现变成了可读的字符串。但还是不对呀🙄 782.15%
!
有没有可能 AntD X index.html 本身返回的 Content-Length
就是错误的,这就是标题说的 Bug,但现在也只是猜测,我们继续往下看。
Content-Length
如何计算?
接下来我们将进入本文的"第一层"。首先了解下 Content-Length
是如何计算的。
通过 AI 搜索后找到了这么一篇文章 Be cautious about Content-Length HTTP headers ,一篇将近十年的文章 。里面引用了 HTTP/1.1 RFC 2616 对 Content-Length
的精确描述:
The Content-Length entity-header field indicates the size of the entity-body, in decimal number of OCTETs, sent to the recipient
关键字:number of OCTETs
OCTETs 表示八位字节
再看看 MDN
The HTTP
Content-Length
header indicates the size, in bytes, of the message body sent to the recipient.
Content-Length: <length>
// The length in octets.
关键字同样:in bytes 和 octets
确认了就是字节数(8 bit = 1 Byte)。问问大模型 is content-length equal to string length 结论一样。
Be cautious about Content-Length HTTP headers 文章中还提到一个有趣的事实,Node.js 官方文档计算Content-Length
有误:
The official docs of Node.js for the HTTP module, shows usage of string length, but that is not really a safe thing to do. We should always use
Buffer.byteLength
or the corresponding methods in other languages to count the number of bytes.
毕竟是 9 年前,现在看官方文档已经纠正过来还特意嘱咐。
Content-Length
value should be in bytes, not characters. UseBuffer.byteLength()
to determine the length of the body in bytes.
Content-Length
的值应该以字节为单位,而不是字符个数。请使用Buffer.byteLength()
来确定 body 的字节大小。
Node.js 哪个版本出现的错误?翻了个遍发现是 v5.x。v6.x 已经纠正并且特意嘱咐:
Note that Content-Length is given in bytes not characters . The above example works because the string
'hello world'
contains only single byte characters. If the body contains higher coded characters thenBuffer.byteLength()
should be used to determine the number of bytes in a given encoding. And Node.js does not check whether Content-Length and the length of the body which has been transmitted are equal or not.请注意,
Content-Length
是以字节为单位,而不是字符 。上面的例子能够正常工作是因为字符串'hello world'
只包含单字节字符。如果正文包含高位编码字符,那么应该使用Buffer.byteLength()
来确定在给定编码下正文的字节数。此外,Node.js 并不会检查Content-Length
和已传输正文的长度是否相等。
还有句话很有意思:
Node.js does not check whether Content-Length and the length of the body which has been transmitted are equal or not.
其实最新版的 Node.js v23.9.0 已经会做二者不一致的检查了:
`response.strictContentLength` 在 v18.10.0, v16.18.0 增加。
验证下
既然文字有问题,我们拿图片试试。
大家可以打开这张图片控制台执行下代码:
js
let total = null;
let loaded = 0;
const logProgress = (reader) => {
return reader.read().then(({ value, done }) => {
if (done) {
console.log('Download completed');
return;
}
loaded += value.length;
if (total === null) {
console.log(`Downloaded ${loaded}`);
} else {
console.log(`Downloaded ${loaded} of ${total} (${(loaded / total * 100).toFixed(2)}%)`);
}
return logProgress(reader);
});
};
fetch('https://logmedia.ir/wp-content/uploads/2023/10/Abort-Logpedia.webp').then((res) => {
total = res.headers.get('content-length');
return res.body.getReader();
}).then(logProgress).then(result => console.log('result:', result));
进度正常 ✅。 那说明我们的代码是正确的,那应该就是 index.html 返回了错误的 Content-Length
,误用字符串 length
计算,本文似乎可以完结了 🎉。
第一次反转
别着急,我们再反面验证下。我们拿到 ant-design-x.antgroup.com/components/... 的 HTML 源码然后分别用 string.prototype.length
和 Buffer.length
计算二者长度和实际该网页返回的 Content-Length
做对比,看看能否有什么神奇的事情(现在开始我深度怀疑 Antd X 采用字符串长度,继续验证下)。
注意以下代码 Node.js 中运行:
js
// html 太长省略了大家可自行获取网站源码(获取过程没有想象得简单)
const html = '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">...'
const byteLength = Buffer.byteLength(html);
console.log('Buffer.byteLength:', byteLength);
console.log('string.length: ', html.length);
运行结果:
js
❯ node content-length.js
Buffer.byteLength: 520704
string.length: 519514
对比下
然而不是提到的两种计算办法之一 -_-||
,无效怀疑!难道 Content-Length
还有其他计算方式?或者说会被其他因素干扰?
Content-Length
究竟如何计算或还会受哪些因素影响?继续探寻第二层
本以为周五一天能写完,现在感觉要烂尾了,好在过了周末发现点有趣的东西本文才得以继续。
欢迎来到第二层,我们继续踏雪寻梅。我们试试其他一些流行的中英文网站。
实验 1:baidu.com 浏览器打开无 `Content-Length`
实验 2:cn.bing.com 浏览器打开无 `Content-Length`
再次发现惊天大坑:baidu.com 无 Content-Length
bing.com 和 github 也没有,这是为啥?正常吗?
简短答案:正常。至于为什么先卖个关子等会再回答。
💡 小提示:截图内红字标注在
Content-Encoding
处。
我们继续用 curl 试试(因为相对于浏览器环境 curl 是一个更纯净的客户端)。
js
❯ curl -I https://cn.bing.com
HTTP/1.1 200 OK
Cache-Control: private
Content-Length: 13267 # 🔥
Content-Type: text/html; charset=utf-8
...
js
❯ curl -I https://www.baidu.com/
HTTP/1.1 200 OK
Accept-Ranges: bytes
Cache-Control: private, no-cache, no-store, proxy-revalidate, no-transform
Connection: keep-alive
Content-Length: 277 # 🔥
Content-Type: text/html
Date: Mon, 10 Mar 2025 01:08:02 GMT
Etag: "575e1f60-115"
Last-Modified: Mon, 13 Jun 2016 02:50:08 GMT
Pragma: no-cache
Server: bfe/1.0.8.18
二者居然都返回了。再试试权威的英文网站 MDN(最后反过头来还是 MDN 拯救了这篇文章,本来以为会烂尾)。
浏览器请求 developer.mozilla.org/en-US/ 没有 Content-Length
意外发现一个新头部 X-Goog-Stored-Content-Length
:
但是当我们用 fetch
的时候却发现同时返回了 Content-Length
和 X-Goog-Stored-Content-Length
以及还有一个 X-Goog-Stored-Content-Encoding: identity
。Amazing 啊,这又是个啥?
且二者不相等,到底应该取谁?我们问问 AI:prophetes.ai/c?source=qu...
X-Goog-Stored-Content-Length
是 Google 云存储的 header,表示存储的对象大小,不受压缩和编码的影响(可见 MDN 使用了 Google 云存储服务)Content-Length
表示从发送到客户端的请求体大小。如果请求被压缩(比如 gzip)则其表示的是压缩后的大小。
可见二者的目的不一样,大小自然也不一定相同。现在我们知道了 Content-Length
并非真实文件大小,而代码里面是客户端解压缩后 的长度自然结果 783.94%
远大于 100%
(逆向思维,其实刚好反映了压缩率)。
AI 回答:content-length not equal to x-goog-stored-content-length The discrepancy between the Content-Length
and X-Goog-Stored-Content-Length
headers can occur due to several reasons, primarily related to how data is encoded or transferred:
Encoding Differences:
- The
Content-Length
header represents the size of the response body in bytes as it is sent to the client. If the response is compressed (e.g., gzip), theContent-Length
will reflect the compressed size[6].- On the other hand,
X-Goog-Stored-Content-Length
indicates the size of the object as stored in Google Cloud Storage, which is independent of any compression or encoding applied during transfer[3].Chunked Transfer Encoding:
- If chunked transfer encoding is used, the
Content-Length
header may not be present or may not match the actual size of the data being transferred. This is because chunked encoding sends data in chunks without specifying the total size upfront[9].Content Mismatch:
- In some cases, there might be a mismatch between the actual data size and the reported
Content-Length
due to server-side issues or data corruption during transfer[10].Proxy or Middleware Modifications:
- If a proxy or middleware modifies the response (e.g., adding headers or compressing the data), the
Content-Length
header might not accurately reflect the original size of the stored object[1].Conclusion:
The difference between
Content-Length
andX-Goog-Stored-Content-Length
is often due to encoding or transfer mechanisms. To ensure accurate data handling, it is essential to consider the context in which these headers are used and verify whether compression, chunked encoding, or proxy modifications are involved.
了解了我们回头看看上面的截图。
MDN 入口页,content-length 需要结合 content-encoding 一起看
ant-design-x.antgroup.com/components/... 经过 gzip 压缩 Content-Encoding: gzip
故 Content-Length 66421
远小于 Buffer.byteLength: 520704
和 string.length: 519514
,真实长度虽然服务端没有给出,但一定是 520704
也就是 Buffer.byteLength
。
不返回 Content-Length
头部正常吗
再回答上面的关子。不返回 Content-Length
头部正常吗?
还真是正常 developer.mozilla.org/en-US/docs/... 说:
数据是按一系列块(chunks)发送的。内容可以以未知大小的流的形式发送,以作为一系列长度受限的缓冲区序列进行传输。这样,发送方可以保持连接处于打开状态,并告知接收方何时已收到完整消息。必须省略"Content-Length"头部 。在每个块的开头,一串十六进制数字表示块数据的大小(以八位字节为单位),随后是
\r\n
,然后是块本身,再跟一个\r\n
。终止块是一个零长度的块。
同时 serverfault.com/questions/1... 提到:
Apache is chunking the reply so the content size is not known. For many people this is desirable (page loads faster). This comes at a cost of not being able to report the download progress.
Apache 将返回值做了分块导致内容大小不可知(译者:chunked 则必然无法发送
Content-Length
)Apache uses chunked encoding only if the compressed file size is larger than the DeflateBufferSize. Increasing this buffer size will therefore prevent the server using chunked encoding also for larger files, causing the Content-Length to be sent even for zipped data.
当大于
DeflateBufferSize
才会开启分块传输,如果将其放大,则会阻止文件被分块传输也就是说此时没有分块会返回Content-Length
。
小结: 什么时候返回 Content-Length
?
受 Transfer-Encoding
影响,如下图:
客户端能禁止 chunked 吗?简单来说不能,因为没有类似 accept-transfer-encoding 的请求头,但是可以通过自定义请求头协商,比如服务端业务请求里面指定返回 content-length
这样 Apache 或 NGINX 就不会分块发送(Antd X 没有被 chunked,返回了 Content-Length
;meta 搜的搜索结果页有 Transfer-Encoding
,故没有 Content-Length
,baidu.com 同样有 chunked
没有返回 Content-Length
,但是 bing.com 没有 chunked 也没有返回 Content-Length
)
总结:Transfer-Encoding
有则必须省略 Content-Length
,因为此时大小是未知的。没有则 Content-Length
可有可无取决于服务端实现。
如何强制不压缩,让其返回实际内容的 Content-Length
既然我们已经知道了何时会触发压缩,那我们在原来的代码 fetch 加一个头部 'Accept-Encoding': 'identity'
试试。
解释
Accept-Encoding identity
:指示服务器不作任何修改或压缩(还是那句话协议和实现是两回事)
浏览器控制台运行:
ts
// bun-accept-encoding-identity.ts
let total = null;
let loaded = 0;
const logProgress = (reader) => {
return reader.read().then(({ value, done }) => {
if (done) {
console.log('Download completed');
return;
}
loaded += value.length;
if (total === null) {
console.log(`Downloaded ${loaded}`);
} else {
console.log(
`Downloaded ${loaded} of ${total} (${((loaded / total) * 100).toFixed(
2
)}%)`
);
}
return logProgress(reader);
});
};
fetch(`https://ant-design-x.antgroup.com/components/use-x-agent-cn`, { headers: { 'Accept-Encoding': 'identity' } })
.then((res) => {
console.log('Content-Length', res.headers.get('Content-Length'))
console.log('Content-Encoding', res.headers.get('Content-Encoding'))
total = res.headers.get('Content-Length');
return res.body.getReader();
})
.then(logProgress)
.then((result) => console.log('result:', result));
加了没用,仍然返回了压缩后的长度,因为浏览器会自动处理 Accept-Encoding 请求头,通常会根据浏览器的能力自动设置为支持的压缩方式(如 gzip, deflate, br)。因此,手动设置 Accept-Encoding 请求头通常会被忽略或覆盖。此时需要非浏览器环境的 HTTP 客户端(如 Node.js)或 curl,这种情况你才可以完全控制请求头的内容。
我们使用 bun 试试
ts
❯ bun run accept-encoding-identity.ts
Content-Length 520704
Content-Encoding null
Downloaded 3039 of 520704 (0.58%)
Downloaded 15479 of 520704 (2.97%)
...
Downloaded 520704 of 520704 (100.00%)
Download completed
可以看到加了禁止压缩的头部后生效了,Content-Length 等于实际大小 100.00%
。
将 'Accept-Encoding': 'identity'
注释掉试试:
ts
❯ bun run bun-accept-encoding-identity.ts
Content-Length null
Content-Encoding gzip
Downloaded 16384
Downloaded 32768
...
Downloaded 520704
Download completed
result: undefined
走到了默认压缩策略 gzip
增加浏览器默认压缩策略 'Accept-Encoding': 'gzip, deflate, br, zstd'
ts
❯ bun run bun-accept-encoding-identity.ts
Content-Length 66421
Content-Encoding gzip
Downloaded 66421 of 66421 (100.00%)
Downloaded 132842 of 66421 (200.00%)
...
Downloaded 520704 of 66421 (783.94%)
Download completed
result: undefined
发送一个不支持的 encoding 呢?
如果客户端通过 Accept-Encoding 指定服务器不支持的压缩方式,那会还会返回压缩后的内容吗?答案:不会,服务器通常会选择不对响应体进行压缩,而是返回未压缩的内容。 大家可以试试指定
Accept-Encoding: br
。
Accept-Encoding | Content-Length | Content-Encoding | Transfer-Encoding |
---|---|---|---|
identity | 520704 | gzip | null |
- | 66421 | gzip | null |
gzip | 66421 | gzip | null |
br | 520704 | null | null |
Bun 实验总结:可以通过 identity
禁止压缩,原始内容大小为 520704
压缩后为 66421
,且仅支持 gzip
压缩。
curl 试试
js
❯ curl -I https://ant-design-x.antgroup.com/components/use-x-agent-cn
HTTP/1.1 200 OK
Server: Tengine
Content-Type: text/html; charset=utf-8
Content-Length: 520704
curl 默认不压缩,返回原始大小 520704
,让 curl 看看压缩后的大小,同样为 66421
:
ts
❯ curl -sH 'Accept-Encoding: gzip' -I https://ant-design-x.antgroup.com/components/use-x-agent-cn | grep -i content
Content-Type: text/html; charset=utf-8
Content-Length: 66421
Content-Encoding: gzip
还有一个有趣的事实,即使客户端指定压缩,但服务器仍然可以选择不压缩:
- body 很小没有达到压缩的阈值
- 要发送的数据已经经过压缩,再次压缩不会减少传输的数据量。这适用于预先压缩过的图像格式(如 JPEG)。比如本文用于实验的 webp 图片。
- 服务器过载,无法分配计算资源来进行压缩。例如,微软建议如果服务器使用超过其计算能力的 80%,则不应进行压缩。
从服务端实现角度看看压缩后的大小是否为 66421 Bytes
gzip 压缩:
js
// gzip.js
const zlib = require('zlib');
const fs = require('fs');
// 读取文件并压缩
const input = fs.createReadStream('input.html');
const output = fs.createWriteStream('input.html.gz');
// 使用 zlib 创建 GZIP 压缩流
input.pipe(zlib.createGzip()).pipe(output);
output.on('finish', () => {
console.log('文件已成功压缩为 GZIP 格式');
});
js
❯ node gzip.js
文件已成功压缩为 GZIP 格式
大小使用 ls
以及 Node.js 计算:
js
❯ ls -l input.html input.html.gz
-rw-r--r-- 1 liuchuanzong 1049089 520704 Mar 11 08:46 input.html
-rw-r--r-- 1 liuchuanzong 1049089 66505 Mar 11 08:47 input.html.gz
js
// 分别计算 'input.html.gz' 大小以及 Buffer.byteLength 大小
fs.stat('input.html.gz', (err, stats) => {
if (err) {
console.error(err);
return;
}
console.log('文件大小:', stats.size, '字节');
});
const buffer = fs.readFileSync('input.html.gz');
console.log('Buffer 长度:', buffer.length, '字节', Buffer.byteLength(buffer));
js
Buffer 长度: 66505 字节 66505
文件大小: 66505 字节
二者都表明压缩后大小为 66505 Bytes 而非 66421 这是为何?难道是文件存储会比实际大一些?或者 gzip 有会有其他因素影响?确实 level 参数可以控制压缩大小。
zip level
接下来实验下不同 level 对应压缩后大小
js
input.pipe(zlib.createGzip({ level })).pipe(output);
总体来说 level 越大压缩越狠,详见 expressjs/compression 文档。
原始大小:520704
Level | Size | 说明 |
---|---|---|
-1 | 66505 | 默认值 |
0 | 520797 | 理论上不压缩但实际比原始大小更大了 |
1 | 86195 | |
2 | 81009 | |
3 | 77995 | |
4 | 71967 | |
5 | 68214 | |
6 | 66505 | 指向 0 |
7 | 66166 | |
8 | 65943 | |
9 | 65943 | 最佳压缩 |
折线图由 AI 生成
结论:没有找到一个 level 让其等于 66421,故 Antd X 服务端应该没有使用 Node.js 的 zlib,暂且放弃。
有什么危害
如果是 GET 请求没啥大的 危害,甚至都可以不返回,其他危害参考 Be cautious about Content-Length HTTP headers
Key Takeaway
Content-Length
并非字符数而是字节数,且反应的是压缩后的大小。故需要结合Content-Encoding
一起看。- 建议开启 response.strictContentLength 防止
Content-Length
计算错误。 Transfer-Encoding
开启 chunked 则不会返回Content-Length
。- 一个页面可以多种方式获取如浏览器、fetch、curl、Node.js,作为不同的客户端,返回的内容不尽相同,curl 作为请求客户端相对来说更加纯净。
- 某些请求头浏览器会覆盖掉手动设置的值比如
Accept-Encoding
,你需要在非浏览器环境方可指定。 vary: Accept-Encoding
:服务端可以根据其值动态返回压缩或未压缩内容。