个人站性能优化实录:从 1.4MB 到 290KB,大头都不在我以为的地方

最近给这个站做了一轮性能优化。开始前我以为要在 JS 里抠字节,结果最大的几块浪费,一块在字体,一块在一个我没写过一行代码的模块里,还有一块在一段注释里。

先放结论。同一篇长文,Windows 访客第一次打开要下载的东西:

优化前 优化后
JS(gzip) 270 KB 208 KB
CSS(gzip) 58 KB 16 KB
字体 1131 KB(21 个文件) 68 KB(2 个文件)
合计 1458 KB 292 KB
额外的接口请求 1 次 0 次

首页是 1204 KB → 310 KB。Mac 和 iPhone 访客本来就不下载中文字体,对他们来说 JS 加 CSS 从 327 KB 降到 224 KB,少了三成。

下面按发现的顺序讲。

怎么测

优化最怕「感觉快了」。我的做法是把优化前的那个提交和现在的版本各构建一次,都用 wrangler 在本地跑起来,然后在浏览器里完整加载同一个页面,记录实际请求了哪些文件,再逐个取回来用 gzip 压缩计量。

有两个细节:

用 127.0.0.1 而不是 localhost 访问。 我在 localhost 上登录过后台,浏览器会带着登录 cookie,看到的是管理员视角。cookie 是按域名存的,换成 127.0.0.1 就是一个干净的访客。

别只看 HTML 里预加载了什么。 我一开始是解析页面 HTML 里的 modulepreload 链接来算的,结果旧版本每个页面都只有 11 个文件,一看就不对:旧版只预加载入口文件,页面自己的代码要等入口执行完才去请求,只看 HTML 会漏算一大截。改成在浏览器里等页面完全加载完,再读 performance.getEntriesByType('resource'),才是真实的数字。

先看打包分析

Nuxt 自带 nuxt analyze,会生成一份可视化的打包报告。按 npm 包汇总之后,入口文件里排在前面的是:

  • @supabase/auth-js:34 KB
  • nuxt:28 KB
  • @nuxtjs/i18n:20 KB
  • @supabase/realtime-js:16 KB
  • @supabase/phoenix:10 KB

Supabase 的三个库加起来 60 KB,比 Nuxt 本身还大。问题是,这个站的前台根本不在浏览器里连 Supabase。

一:访客根本用不到的 Supabase 客户端

这个站的文章、想法都是服务端接口从 Supabase 取了再返回的,浏览器从来不直连数据库。浏览器端唯一用到 Supabase 的地方,是后台登录和退出。

但 @nuxtjs/supabase 这个模块会注册一个浏览器端插件,在每个访客打开页面时初始化一个 Supabase 客户端,于是它的登录、实时通信、查询三个库全都进了入口包。

解决办法分两步。先把登录和退出改成服务端接口:服务端的 Supabase 客户端本来就能把会话写进 cookie,之后每个接口照常从 cookie 里读身份,token 过期后的刷新也在服务端完成。

// server/api/auth/login.post.ts
const supabase = await serverSupabaseClient(event)
const { error } = await supabase.auth.signInWithPassword({ email, password })

然后在 nuxt.config 里把模块的浏览器端插件去掉,服务端的部分不受影响:

hooks: {
  'app:resolve'(app) {
    app.plugins = app.plugins.filter((p) => {
      const src = (typeof p === 'string' ? p : p.src).replace(/\\/g, '/')
      return !(src.includes('@nuxtjs/supabase/') && src.includes('/plugins/supabase.client'))
    })
  }
}

代价是浏览器端不能再用 useSupabaseClient 这类组合式函数了。对这个站来说,本来也只有登录那一处在用。

二:最大的一块,是字体

JS 抠完一轮,我在自己的 Windows 电脑上打开一篇长文,看了一眼字体请求:21 个文件,1.1 MB。比全站所有 JS 和 CSS 加起来还多三四倍。

之前我自托管了思源黑体,用的是按字符切片的方式:一个字体切成一百多片,页面用到哪些字就下载哪几片。原因是 Windows 上的微软雅黑只有常规和粗体两档字重,中等字重的标题会被吸回常规,和正文一样粗。

切片听起来很省,但一篇长文用到的汉字,会分布在二十来个切片里,每片 50 KB 左右。而且切片方案还有一个不容易注意到的成本:101 条 @font-face 声明都写在入口 CSS 里,压缩后约 40 KB。这份 CSS 阻塞渲染,所有访客都要下载,包括字体栈里苹方排在前面、从来用不上思源黑体的 Mac 访客。

最后的决定是:中文全部用系统自带的字体。Mac 和 iPhone 是苹方,Windows 是微软雅黑,安卓是系统自带的思源黑体。代价是 Windows 上的中文标题没那么粗了,只靠字号和正文拉开层次。为了标题粗一点点,让每个 Windows 访客多等 1 MB,不值得。

顺着字体又发现两件事:

  • 西文字体也有一堆用不上的声明。 Inter 和 Lora 没有指定字符集,@nuxt/fonts 按默认生成了西里尔文、希腊文、越南文的声明,一共 24 条,站上一个这样的字都没有。显式写上 subsets: ['latin'] 就只剩 4 条。
  • Inter 一直只有 400 一个字重。 站上有二十多处 font-medium、font-semibold、font-bold,落在英文字母上,全是浏览器在 400 上「假加粗」出来的。改成 400–700 的可变字重之后,标题里的英文才第一次是真正的粗体。

三:一段注释里的单词

升级依赖时,我顺手删了 @tailwindcss/typography。这个插件提供 prose 系列排版类,而站上的长文排版是自己写的,全站没有一个地方用到 prose。

删掉之后,入口 CSS 小了约 14 KB(未压缩)。一个没被用到的插件,怎么会生成这么多样式?

原因是 Tailwind 会扫描项目里所有文件的文本,把「长得像类名」的词都当成类名。而我在 Markdown.vue 的一段注释里写着:「不再挂 prose / prose-slate」。就是这句注释里的 prose,让插件每次构建都生成一整套排版规则,发给每一个访客。

四:只有管理员用得到的东西,别让访客下载

页眉右上角有一个「+」菜单,是给我自己快速写文章用的,只有登录后才出现。反馈弹窗也只有点了「反馈」的人才会打开。但它们都是静态引入的,下拉菜单、弹出层、输入框的代码,每个访客都要下载。

改成按需加载:确认是管理员之后才加载那个菜单,第一次点「反馈」时才加载弹窗。

这里还藏着一个比体积更隐蔽的问题。为了判断「是不是管理员」,页眉每次打开页面都会请求一次 /api/auth/me。这个接口的结果因人而异,不能缓存,所以每个访客每看一页,都会实实在在打到服务端一次。Cloudflare Pages 的免费额度是每天 10 万次服务端请求,页面本身都有边缘缓存挡着,这个接口却在每一次页面浏览里消耗额度。

解决办法很简单:Supabase 的会话 cookie 在浏览器里是读得到的。没有这个 cookie,就说明从没登录过,直接判定不是管理员,不发请求。绝大多数访客从此一次都不会打到这个接口。

五:date-fns 和一个时区问题

站上的日期只有三种格式:2026.09.27、09.27、2026.09。为此引入整个 date-fns 并不划算,换成浏览器自带的 Intl.DateTimeFormat 就够了。

换的时候发现了一个一直没注意到的问题:date-fns 按运行环境的本地时区格式化。服务端渲染跑在 Cloudflare 上,时区是 UTC;浏览器是访客的本地时间。北京时间凌晨 0 点到 8 点发的内容,服务端渲染出来是前一天,浏览器接管后又变成当天,日期会跳一下。

新写的函数固定用北京时间,服务端和浏览器算出来的一样:

const parts = new Intl.DateTimeFormat('en-CA', {
  timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit'
})

没有继续做的

剩下的 JS 主要是 Vue 的运行时、Nuxt、路由和多语言,这些是框架本身,再抠收益很小。还有 tailwind-merge(约 11 KB),shadcn 的组件靠它处理样式覆盖,去掉会带来一堆细碎的样式冲突,不值得。

回头看

这轮优化最大的几块,没有一块是我自己写的业务代码:

  • 一个模块默认注册的浏览器端插件
  • 一个默认生成全部字符集的字体配置
  • 一段注释里的单词
  • 一个每页都要问一次服务端的身份判断

它们都不报错,页面照样好好的,只是每个访客都在为它们多付一点代价。所以优化的第一步不是改代码,而是先量:在一个干净的浏览器里,把页面实际下载的东西一个个列出来。很多问题,看到清单的那一刻就已经有答案了。

更多