14 KB 法则:把作品集放进一次 TCP 往返
TCP 在连接建立时大约会先发送 14 KB,然后暂停等待 ACK。把整页塞进这个窗口,它就能在一次往返内到达。这个站点首页是这么做到的。
本站首页的 Live Proof 条最终稳定在 14.0 KB 的总传输字节(HTML、CSS、JS 合计)。这不是为了好看选的整数。是你想让页面在一次 TCP 往返内到达就必须遵守的预算。
为什么是 14 KB
TCP 连接建立时,服务器不会一次发完所有数据。它先发一小批,等客户端确认后再继续。这是为了防止一个还不知道带宽多少的发送方淹没网络。
那一小批的大小叫 initial congestion window(initcwnd)。从 2013 年的 RFC 6928 开始,Linux 与主流 Web 服务器使用 10 段 作为默认 initcwnd。多数路径上 Maximum Segment Size 约为 1460 字节,所以:
10 段 × 1460 字节 = 14,600 字节 ≈ 14.3 KB包头会再吃掉一点点,实用的简化记法是 “前 ~14 KB 的 HTML / CSS / JS 在一次往返内到达;之后的字节要等第二次往返”。
第二次往返的开销,等于你的访客到边缘节点的 round-trip time。Vercel 部署的话,同区域 20 ms,跨大洲 300 ms 都有。守在 14 KB 之下,相当于免掉每位首访者本来要付的那一次往返。
TCP slow-start 的样子
客户端 服务端
│ │
│ ─── SYN ───────────────────────▶ │
│ ◀──────────────── SYN/ACK ────── │ (1 RTT,建立连接)
│ ─── ACK + GET / ──────────────▶ │
│ │
│ ◀── 10 段(~14 KB)───────────── │ (1 RTT,第一批)
│ ─── ACK ──────────────────────▶ │
│ │
│ ◀── 后续段 ───────────────────── │ (第 2 RTT 之后)
│ │如果页面塞进第一批,浏览器可以立刻开始解析与渲染。塞不下的话,后续字节就被一次你无法加速的往返挡住。
本站如何保持在 14 KB 以下
我在 2026 年 6 月重做了这个站点,并将其迁移到 Astro 7 + Tailwind 4 + TypeScript 6。两轮工作对预算最关键:
1. 去掉那些价值不足以抵消其体积的依赖。 我移除了九个包。每个包在它原本所在的典型页面上的大致重量:
lenis → 约 9 KB(minified + brotli)
motion → 约 22 KB
astro-icon → 约 5 KB(再加每个图标的 SVG 负载)
@iconify-json/mdi → 约 3 KB
astro-i18next → 约 6 KB
i18next → 约 14 KB
深浅色切换脚本 + CSS → 约 3 KB
glass-card CSS(旧版) → 约 2 KB
主题检测内联脚本 → 约 1 KB合计约 65 KB 的 JS 与 CSS 离开,重设计里完全用不到。
2. 让 Tailwind 4 自动剪枝。 Tailwind 4 的 CSS-first 配置只发出 source 文件里实际被引用的 utility。首页上的生成样式表大概 5 KB。没有 tailwind.config.ts,也没有运行时 JIT 编译开销。
3. 能内联就内联。 Astro 的 build.inlineStylesheets: 'auto' 把小样式表塞进 HTML,省掉一个额外请求。自建的 SVG 图标随 HTML 负载一同传输。Google Fonts 通过 preconnect 提前,字体文件并行加载;因为是子资源,不算进第一次往返的预算里。
任何页面都能自测
把这段脚本贴在页面底部,看一下你自己的预算:
<script is:inline>
(function () {
function fmtKb(b) { return b < 1024 ? Math.round(b) + ' B' : (b / 1024).toFixed(1) + ' KB'; }
function fmtMs(ms) { return ms < 1000 ? Math.round(ms) + ' ms' : (ms / 1000).toFixed(2) + ' s'; }
window.addEventListener('load', function () {
setTimeout(function () {
var nav = performance.getEntriesByType('navigation')[0];
var total = nav && nav.transferSize ? nav.transferSize : 0;
performance.getEntriesByType('resource').forEach(function (r) {
if (r.transferSize) total += r.transferSize;
});
console.log('页面体积:', fmtKb(total));
console.log('domInteractive:', fmtMs(nav.domInteractive));
}, 250);
});
})();
</script>transferSize 已经算上了压缩。如果超预算,DevTools 网络面板顶部的横条会告诉你哪个资源最重。
在 CI 里强制执行预算
进入 14 KB 之后,唯一能守住的办法是让构建在超线的时候失败。一份给 Astro 静态构建的最小 GitHub Action:
name: Page weight budget
on: [push, pull_request]
jobs:
budget:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci && npm run build
- name: Check homepage weight
run: |
BUDGET_BYTES=$((14 * 1024))
PAGE=$(stat -c %s dist/index.html)
CSS=$(find dist/_astro -name '*.css' -exec stat -c %s {} + | awk '{s+=$1} END {print s+0}')
JS=$(find dist/_astro -name '*.js' -exec stat -c %s {} + | awk '{s+=$1} END {print s+0}')
TOTAL=$((PAGE + CSS + JS))
echo "homepage payload: $TOTAL bytes (budget: $BUDGET_BYTES)"
if [ $TOTAL -gt $BUDGET_BYTES ]; then
echo "::error::page payload exceeds 14 KB budget"
exit 1
fi它在部署之前跑。如果 PR 加进了一个把首页推过线的依赖,合并就会被拦下。
值得知道的几个注意点
- 14 KB 的数字适用于到你源站的第一次 TCP 连接。已经预热过的连接上拉取的子资源不付同样的代价。
- HTTP/2 与 HTTP/3 在单连接上多路复用请求,但在初始连接上仍然遵守 TCP slow-start。
- 与客户端长连接的 CDN(一部分回头访客)会完全跳过 slow-start。这条规则对首次访客与冷缓存场景最有意义。
- 压缩很重要。14 KB 是 “在网线上的字节”,即 gzip/brotli 之后。同一个站点未压缩可能有 50 KB,也仍然能塞下。
总结
这种优化是 Lighthouse 分数已经把其他都拉满之后才显形的那种。如果你还没定下排版、配色或字体,先做那些。一旦设计稳定,14 KB 法则就是性能里最便宜的赢面之一。你在做减法,不是加法。
本站整页此刻就在预算之下:14.0 KB 传输量,桌面浏览器上 160 ms 的 domInteractive。Live Proof 条每次页面加载都在测。一旦越线,CI 会在你看到之前就把构建拦下。
更多性能笔记,以及偶尔关于 Shopify Plus 的吐槽,都在 X:@jaimesolis。想给自己的项目搭一套带预算约束的构建,联系表单点两下就到。