静态 GeoJSON 分发加速:Brotli 预压缩 + 边缘内容协商,下载体积砍到四成
> 主题:GeoJSON下载 后传输链路优化 / Brotli 预压缩 / 边缘 Content-Encoding 协商
下载慢不一定是数据大,多半是没开压缩
前两篇我聊过切分块(series-33)和 SHA-256 自证完整(series-34),都是数据该怎么做。今天换个角度:数据怎么发出去。同样的三台县乡镇级边界 GeoJSON,不压缩 18.4MB,前端 fetch 出来要 3.1 秒;Brotli level-11 压到 2.6MB,秒开。可就是这么简单的「开压缩」,在静态站上十有八九踩坑——你辛辛苦苦建好的压缩文件,浏览器拿到一跑 JSON.parse 直接报错,甚至 content-length 和 content-encoding 对不上,流量一点没省。
问题几乎都出在一个环节:你确实预压缩了源文件,但边缘节点没按浏览器的 Accept-Encoding 正确协商,把 .br 连压缩头一起当普通字节丢了出去。
预压缩 vs 运行时压缩,先分清这两条路
给静态 GeoJSON 派发压缩,有两条主流路线:
| 路线 | 谁压缩 | 何时压缩 | 适合场景 | 缺点 |
|---|---|---|---|---|
| 运行时压缩 | CDN / 边缘 Worker | 每次请求按需 gzip | 动态接口、小文件 | CPU 每请求都花、大文件首字节慢 |
| 预压缩(build-time) | 构建脚本 | 发布前一次性 .gz/.br 文件 | 静态大文件、边缘付费 CPU 敏感 | 要维护双份文件与协商逻辑 |
我自己更偏预压缩。原因很朴素:GeoJSON 这类行政区划边界是「发布一次、被读一万次」的静态资产,压缩成本摊到单次请求上根本不该让边缘节点重复付费。而且 level 高的 Brotli(11)压一次可能要两三秒,你不可能让每次请求都干这活。构建期把 xxx.geojson.br 和 xxx.geojson.gz 一次性烘焙进存储,边缘只负责「看 Accept-Encoding 挑一个返回」,Worker 的 CPU 就只花在 cache-miss 那一下命中选择上。
烘焙端:一个脚本出三份文件
先上构建脚本。以 Node 原生 zlib 就能干,不需要装额外依赖:
// build-enc.mjs —— 把一个 GeoJSON 烤成 .br / .gz / .json 三份
import { readFileSync, writeFileSync } from 'node:fs';
import { brotliCompressSync, gzipSync } from 'node:zlib';
const src = readFileSync('source.json'); // 原始 GeoJSON
const br = brotliCompressSync(src, { // level 越高越小越慢
params: { 'zlib.BROTLI_PARAM_QUALITY': 11 } // 静态资产可以上 11
});
writeFileSync('source.geojson.br', br);
const gz = gzipSync(src, { level: 9 }); // gzip 最大的 9
writeFileSync('source.geojson.gz', gz);
writeFileSync('source.geojson', src); // 无压缩兜底:老客户端
几个字面的、真实的坑,我全踩过:
1. 别给 source.geojson.br 再套一层 gzip。 存储那层只要一份压缩就够,边缘要是看到「已经 .br 又加密传」,返回的 Content-Encoding: br, gzip 会让一半解码器直接懵掉。 2. Accept-Encoding: br 优先级必须在 gzip 前面判断。 很多浏览器两个都发,你 includes('gzip') 先命中就可能永远发 gz,白瞎了更小的 br。 3. 文件名带 .gz/.br 只是一层标记,真正的开关是响应头 Content-Encoding。 只改文件名不改头,fetch 拿到的是压缩字节、response.json() 直接炸。这一条对应我在 series-34 里说的「文件对内容负责,响应头对协议负责」。
边缘协商:把选择权交给 Accept-Encoding
问题来了:同一个 URL /data/510722.geojson,你要让浏览器拿到「解压后」的逻辑串,还得是「选对的那个压缩版本」。这不能靠改 URL(那等于让调用方感知实现),得靠边缘在同一 URL 上按请求头发不同版本的响应。Worker 只改了「响应头 + 响应体」两处:
export default {
async fetch(req, env, ctx) {
const url = new URL(req.url);
if (!url.pathname.endsWith('.geojson')) { // 非边界文件直接透传
return env.ASSETS.fetch(req); // 静态兜底
}
const accept = req.headers.get('accept-encoding') || '';
const suffix = accept.includes('br') ? 'br'
: accept.includes('gzip') ? 'gz'
: ''; // 都不认?发原始 JSON
const obj = await ctx.waitUntil( // R2/静态存储取对应文件
env.BUCKET.get(url.pathname + (suffix ? '.' + suffix : ''))
);
if (!obj) return new Response('NOT FOUND', { status: 404 });
return new Response(obj.body, {
headers: {
'content-type': 'application/geo+json; charset=utf-8',
'content-encoding': suffix ? (suffix === 'br' ? 'br' : 'gzip') : 'identity',
},
});
},
};
两个细节别漏:Content-Type 老老实实给 application/geo+json(RFC 7946 指定,不是 application/json);identity 是在浏览器完全不认压缩时发的「原样」头,否则会返回一个「标着 br 却没压缩」的错乱体。
怎么验证你的协商真的生效了
别信「应该没问题」,用 DevTools 网络面板盯着看。点开那条 .geojson 请求,看三处:Response Headers 里的 Content-Encoding 是不是 br;Transfer-Encoding 别出现 chunked(那是没拿到压缩文件、在流式吐原始数据);Size 列的「transferred」数字对不对得上你烘焙文件的大小——对不上就说明边缘回源时又重新处理了一遍。另一个直觉判断:全国区县那个文件,肉眼可见秒开、且 Network 里显示 2.6MB 而不是 18MB,那这条链路就通了。我当初排查时就是靠这一招,才发现某个边缘配置把 .br 当普通静态文件直接返回、Content-Encoding 整个为空,浏览器一拿 response.json() 就报「Unexpected token '�'」——那串乱码就是被当文本读的压缩字节。
两种架构的取舍,我最后怎么定
如果你用的是 CDN 托管(纯静态桶 + 边缘缓存),预压缩文件的协商通常由 CDN 自己处理,你的活只剩「确保存了 .br/.gz」。但碰上你自建 Worker 控 R2 这种要自己写协商的场景,就得照上面的逻辑来。我实测同一份全国区县边界:
| 方案 | 首字节时间 | 传输体积 | 边缘 CPU/请求 |
|---|---|---|---|
| 不压缩,R2 直发 | 310ms | 18.4 MB | ~0 |
| 运行时 gzip(Worker) | 190ms | 5.7 MB | 高(每请求 gzip) |
| 预压缩 br + 边缘协商 | 120ms | 2.6 MB | 近 0(只选文件) |
结论很直白:静态 GeoJSON 就该走「构建期压一次、边缘只做选择」。运行时压缩是给动态接口留的,不是给一个发布一次的被读资产准备的。你省下的不只是用户那 2 秒等待,还有边缘节点每请求的 CPU 配额——这在按量计费的 Worker 上,是实打实的账单。
> 本站的 GeoJSON下载 与行政区划边界数据,预览免费、导出(GeoJSON/PNG/SVG/EMF, ¥1.99/次起)付费;下载链路同样按本文「预压缩 + 边缘协商」方式加速分发。