周二下午三个实测加一个冷知识:前端做打包压缩的 fflate、要不要为估 token 打包 2.5 MB 的分词器、node -p 当口算器,以及 HP-35 那种"没有括号"的算术。所有数字都是我今天在本地跑出来的。
fflate:地图数据压缩,一个 0 依赖的包
导出中心要打包几个图层、离线包要压一份 GeoJSON、轨迹存本地前要瘦身------这类需求一般靠后端。但前端也能自己干:npm i fflate。
我装的是 0.8.3,MIT,0 依赖 ,main 指向 ./lib/index.cjs,也就是说 CommonJS 产物是有的,webpack 4 的 Vue 2 项目能直接用(不用再吃昨天那种纯 ESM 的亏)。整个 ESM 浏览器版源码 90922 字节,我直接 gzip 压了一下是 22376 字节------注意这是没经过压缩器的源码体积,真实产物会更小一点。
用它跑了一组真实的地图数据。一条 2000 点的行车轨迹做成 GeoJSON,然后用前天的 simplify-js 抽稀到 89 点,比较"压缩"和"抽稀"各自带来多少收益:
原始 GeoJSON 字符串 45674 B
抽稀后(89 点) 2325 B
gzip level=0 45697 B(省 -0.1%,白加了 23 B 头)
gzip level=1 13286 B(省 70.9%)
gzip level=6 13505 B(省 70.4%)
gzip level=9 13505 B(省 70.4%)
抽稀之后再 gzip 913 B(相对原始省 98.0%)
两个反直觉的点:
- level 1 这次比 level 6/9 更小 (13286 < 13505)。别条件反射写
level: 9,坐标数据冗余集中在小数尾数上,最高级别不一定更划算,而且慢。 level: 0不是"不打包",是"不压缩" ------它照样加容器头,45674 变 45697。想存原文就用strToU8,别过一遍 gzip。
三种格式的差别也顺手量了:compressSync(raw DEFLATE)和 gzipSync 在这条数据上都是 13505 B,zipSync 的容器是 13605 B,多花 100 B 换"能装多个文件 + 文件名"。
打包多图层是它最常用的形态,我实测三个文件装一起是 14725 B,解回来逐字节一致:
js
import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate'
const packed = zipSync({
'track.json': strToU8(trackGeoJSON),
'fences.json': strToU8(fenceGeoJSON),
'meta.txt': strToU8('图层说明:轨迹 + 围栏'),
})
// packed 可以直接塞给 new Blob([packed]) 触发下载
const files = unzipSync(packed)
const track = JSON.parse(strFromU8(files['track.json'])) // true
const meta = strFromU8(files['meta.txt']) // 图层说明:轨迹 + 围栏
踩了两个坑要说清楚。第一,解码函数叫 strFromU8 不是 u8ToStr ------我按直觉写 u8ToStr 直接 TypeError: u8ToStr is not a function(它确实导出了 DecodeUTF8 / EncodeUTF8,但没有 u8ToStr)。第二,同步版够快但会占主线程:这条 45 KB 的数据单次 gzip 约 2.6 ms ,20 次 53 ms,量级不大;数据上 MB 的话改用同名小写的异步版,我实测 zip/unzip 回调版能正常跑完(err 为 null,中文内容完整还原)。
AI 一角:想在前端限制 prompt 长度,值不值得打包一个分词器
做个"输入超过上下文就自动截断"的功能,最正经的做法是引一个 tokenizer。但它在浏览器里要多大的包?我拿 js-tiktoken 实测:版本 1.0.21,MIT,1 个依赖(base64-js),入口 ./dist/index.cjs。
体积是关键的那个数字:
dist/index.js 5469 KB -> gzip 2523 KB # 默认入口,7 种词表全部内联
dist/lite.js 73 B # lite 入口只是个壳
dist/ranks/cl100k_base.js 1065 KB -> gzip 494 KB
dist/ranks/o200k_base.js 2271 KB -> gzip 1116 KB
它的 exports 里给了子路径:.、./lite、./ranks/cl100k_base、./ranks/gpt2、./ranks/p50k_base、./ranks/p50k_edit、./ranks/r50k_base、./ranks/o200k_base。也就是说正经做法是用 lite 入口 + 按需异步拉一个词表分片,gzip 成本从 2.5 MB 降到 494 KB。这个我实测跑通了:
js
const ranks = await import('js-tiktoken/ranks/cl100k_base')
const { Tiktoken } = await import('js-tiktoken/lite')
const enc = new Tiktoken(ranks) // 注意:传整个模块
enc.encode('今天中午把地图轨迹抽稀了一下,前端少渲染两千个点。').length // 33
两个坑都是我自己撞出来的。js-tiktoken/lite 只导出 Tiktoken 和 getEncodingNameForModel,没有 getEncoding ------照全量版的写法直接调会抛 TypeError: getEncoding is not a function;构造参数得传整个 ranks 模块(源码第 68 行读的是 ranks.bpe_ranks),只把 bpe_ranks 传进去会得到 Cannot read properties of undefined (reading 'split')。两种写法算出的 token 数和全量入口一致(都是 33)。
但 494 KB 对一个输入框来说仍然偏大。所以前端这一层更适合用粗估,我拿 7 条真实形态的文本(中文句子、中英混合、JS 代码、POI id 的 JSON、URL、提示词模板)以 cl100k_base 为基准测了两条口算规则:
规则 A:汉字按 1 token/字,其余按 4 字符/token
中文一句 真实 33 / 粗估 24(-27.3%) JS 代码 真实 31 / 粗估 22(-29.0%)
JSON 长串 真实 429 / 粗估 217(-49.4%) URL 真实 53 / 粗估 31(-41.5%)
平均绝对偏差 31.5%,最差 -49.4%
规则 B:中文(含中文标点)按 1.4 token/字,其余按 3.5 字符/token
平均绝对偏差 22.0%,最差仍是 JSON 长串 -42.0%
顺便量到一件更能说明问题的事:同样一条文本,token/字符 的比值跨度有 6 倍------英文句子 0.212,纯中文句子 1.320(一个汉字常被拆成 2~3 个 token)。所以"按字符数截断"这件事本身就不可能精确。
最不准的是纯数字 id 列表和 URL(都低估 40% 上下),因为它们字符种类少、被切碎得厉害,恰好是"批量传 POI id 给模型"的常见场景。工程上的可用做法是把粗估当下限、留足余量,真正的判定交给服务端:
js
const zh = (s) => (s.match(/[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]/g) || []).length
const roughTokens = (s) => Math.ceil(zh(s) * 1.4 + (s.length - zh(s)) / 3.5)
// 粗估只用来做 UI 提示;真要卡住请求,按 1.5 倍上限兜底
const budget = Math.floor((maxContext - reserveForAnswer) / 1.5)
if (roughTokens(prompt) > budget) trimFromTheMiddle(prompt)
小技巧:node -p 是一台随身口算器,尤其在 Git Bash 里
node -e 要写 console.log,node -p 直接把表达式的返回值打出来。实测对比:node -e "Date.now()" 什么都不输出,node -p "Date.now()" 输出 1791866086438。
几件每天都会碰到的事,一行解决:
bash
# 日志里那串 13 位毫秒时间戳到底是几点(指定时区,别信本地默认)
node -p 'new Date(1791693000000).toLocaleString("zh-CN",{timeZone:"Asia/Shanghai"})'
# -> 2026/10/11 12:30:00
node -p 'new Date(1791693000000).toISOString()'
# -> 2026-10-11T04:30:00.000Z
# 反过来:明天中午 12:30 的时间戳
node -p '+new Date("2026-10-14T12:30:00+08:00")'
# -> 1791952200000
# 两个时间戳差几小时
node -p '((1791866086438-1791693000000)/3.6e6).toFixed(2)+" 小时"'
# -> 48.08 小时
# base64 解一下(浏览器 localStorage 里存的、JWT 的头)
node -p 'atob("eyJhIjoxfQ==")'
# -> {"a":1}
# 字节换算,别在心里除以 1000 还是 1024
node -p '[1048576/1024/1024, (2.6*20/1000).toFixed(3)].join(" | ")'
# -> 1 | 0.052
顺手还能读包信息,不用打开文件翻:
bash
node -p 'require("./node_modules/fflate/package.json").version'
# -> 0.8.3
两个细节:在 bash 里用单引号把整个表达式包起来 ,里面的字符串就能放心用双引号;表达式跨多行或要写语句时,再退回 node -e。
冷知识:HP-35 是世界上第一台手持科学计算器,它连括号都不需要
惠普官方博物馆页面对它的描述是:1972 年推出,"world's first handheld scientific calculator"(世界上第一台手持科学计算器),35 个按键,用 LED 显示。那个页面没写具体的月和日,所以这里当冷知识讲,不当"历史上的今天"讲。
它输入用的是 RPN(逆波兰式,也就是后缀表达式):先数后符,3 + 4 按成 3 4 +。这在今天有个体感的理由------它能省掉括号和优先级。
我写了个二十行的求值器验证这件事,并且和 JS 自己的计算结果对了一遍:
3 + 4 中缀 3 键 -> 后缀 3 键 少 0 | 值 7 == 7
(3 + 4) * 5 中缀 7 键 -> 后缀 5 键 少 2 | 值 35 == 35
((2 + 3) * (4 - 1)) / 5 中缀 15 键 -> 后缀 9 键 少 6 | 值 3 == 3
2 * (3 + 4 * (5 - 6)) 中缀 13 键 -> 后缀 9 键 少 4 | 值 -2 == -2
1 + 2 + 3 + 4 + 5 中缀 9 键 -> 后缀 9 键 少 0 | 值 15 == 15
合计 中缀 47 键,后缀 35 键;后缀里出现的括号数量 = 0
js
function evalRPN(tokens) {
const stack = []
for (const t of tokens) {
if (['+', '-', '*', '/'].includes(t)) {
const b = stack.pop(), a = stack.pop()
stack.push(t === '+' ? a + b : t === '-' ? a - b : t === '*' ? a * b : a / b)
} else {
stack.push(+t)
}
}
return stack[0]
}
嵌套越深省得越多(第三条少 6 个键),因为中缀的每一层嵌套都要一对括号。代价换成了顺序不能错 :同一批 token 换个位置,2 3 + 4 * 是 20,2 3 * 4 + 就是 10,我实测确认过。
这东西跟前端有什么关系?关系在于它把"优先级"变成"顺序",实现只剩一个数组。你写过搜索条件的嵌套组合------(A and B) or (C and not D)------那种要递归下降才能算的树,编译成后缀之后就是一个平铺数组加一个栈,测试也好写:给定 token 序列,结果唯一。表达式引擎的最小内核大概就是 15 行。
对了,上面那五条表达式的后缀加起来正好 35 个字符,跟它的按键数撞了。纯属巧合,但一台 1972 年的机器用 35 个键干掉了括号,这个巧合挺配它。
最后
四段话讲的是同一件事的两面:省。fflate 用二十来 KB 的库换 98% 的体积;粗估规则用 22% 的误差换掉那 494 KB 的词表下载;node -p 用一行命令换掉一次打开浏览器控制台;RPN 用记顺序换掉括号。
而每一处"省"都对应一个明确的代价,且代价是可量的------压缩级别要实测才知哪个更小,粗估偏差要拿真分词器跑一遍才知道留几倍余量,后缀表达式要试两组 token 顺序才发现它会变值。所谓经验,大概就是知道自己这次省下的东西要在哪里还回去。
明天中午见。