GeoJSONcn
GeoJSONcn / 技术专栏 / 静态 GeoJSON 分发加速:…

静态 GeoJSON 分发加速:Brotli 预压缩 + 边缘内容协商,下载体积砍到四成

发布于 2026-08-14 · GeoJSONcn 技术团队 · 阅读约 15 分钟

> 主题: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 直发310ms18.4 MB~0
运行时 gzip(Worker)190ms5.7 MB高(每请求 gzip)
预压缩 br + 边缘协商120ms2.6 MB近 0(只选文件)

结论很直白:静态 GeoJSON 就该走「构建期压一次、边缘只做选择」。运行时压缩是给动态接口留的,不是给一个发布一次的被读资产准备的。你省下的不只是用户那 2 秒等待,还有边缘节点每请求的 CPU 配额——这在按量计费的 Worker 上,是实打实的账单。

> 本站的 GeoJSON下载 与行政区划边界数据,预览免费、导出(GeoJSON/PNG/SVG/EMF, ¥1.99/次起)付费;下载链路同样按本文「预压缩 + 边缘协商」方式加速分发。