怎么放心地升级依赖:用对比代替祈祷

前几天给这个站升级依赖,pnpm outdated 列出来 34 个包有新版本,其中 11 个是大版本。以前我升级依赖的方式基本是:全部升到最新,跑一下 dev,点几个页面,没报错就提交,然后祈祷。

这次换了一个办法:每升级一个东西,先想清楚它会影响什么输出,升级前把那份输出存下来,升级后再生成一份,逐字对比。 结果有几处很有意思:有的大版本升级前后一个字节都没变,有的「小改动」让构建直接失败,还有一处多出来的 14KB,最后查到是一段注释惹的祸。

先分类,别一次全升

34 个包里,大部分是小版本和补丁,按语义化版本的约定不该有破坏性变更,可以一起升。真正要逐个看的是大版本:

包 版本 破坏性变更 会影响什么
marked 16 → 18 v17 改了列表的解析,v18 去掉块级元素末尾的空行 每一篇文章的 HTML
@nuxtjs/sitemap、robots 7 → 8、5 → 6 内部依赖的 site-config 升到 v4 sitemap 和 robots.txt
resend 4 → 6 v5 把 React 邮件渲染器改成可选依赖 构建能不能过
@vueuse 14 → 15 不再支持 Node 20 构建环境
nuxt-gtag 3 → 5 同一个 ID 重复配置的处理方式变了 统计代码
TypeScript 5.9 → 7 用 Go 重写的原生版本 vue-tsc 还不一定兼容

读完更新说明,TypeScript 7 暂时不升,@types/node 也不升到 26(它对应 Node 26,而项目用的是 24)。剩下的分三批做,每批单独提交:

  1. 清理没用的包,升级小版本,再加上几个影响面小的大版本
  2. 换掉 Tailwind 的接入方式
  3. marked、sitemap、robots

升级之前,先存一份「现在的样子」

动手之前,我先用当前的代码构建了一次,存下三样东西:

  • 入口 CSS:dist/_nuxt/entry.*.css
  • 每一篇已发布内容渲染出来的 HTML:一个小脚本,从数据库取出全部 65 篇已发布内容,用项目里的渲染函数各渲染一遍,存成 JSON
  • sitemap 和 robots.txt:本地用 wrangler 跑生产构建,把 sitemap_index.xml、各语言的 sitemap 和 robots.txt 抓下来

渲染脚本很短,关键是它直接调用项目自己的 shared/markdown.ts,而不是另写一套:

const rows = await (await fetch(`${SUPABASE_URL}/rest/v1/posts?select=id,content&is_published=eq.true`, {
  headers: { apikey: SUPABASE_KEY, Authorization: `Bearer ${SUPABASE_KEY}` }
})).json()

const { render } = await import(pathToFileURL(path.resolve('shared/markdown.ts')).href)
const html = Object.fromEntries(rows.map(r => [r.id, render(r.content)]))
fs.writeFileSync(out, JSON.stringify(html))

Node 24 可以直接运行 TypeScript 文件(node --experimental-strip-types),不用额外装任何工具。

第一批:构建直接失败

小版本全部升完,再把 resend 升到 6,一构建就挂了:

Cannot resolve "@react-email/render" from ".../resend/dist/index.mjs"
and externals are not allowed!

resend 从 v5 开始,把 React 邮件渲染器改成了「可选依赖」:不用 React 写邮件就不用装。但 SDK 的代码里仍然会按需加载它,而 Cloudflare 的构建要求所有依赖都能解析,不允许留下外部模块。

这个站全站只有一处发邮件:反馈通知,用到的只是一个发送接口。所以我没有去折腾构建配置,而是直接去掉了 SDK,改成十几行 fetch 调 Resend 的 REST 接口。这和之前 R2 不用 AWS SDK、改用 aws4fetch 是同一个思路:在 Worker 上,SDK 越轻越好,只用一个接口就别装整个 SDK。

还有一个小插曲:resend 升到 6 之后,锁文件里仍然留着 React 19 和 react-dom。它们是旧版本解析时装上的,新版本虽然改成了可选依赖,pnpm 却沿用了已有的解析结果。把 resend 删掉重新安装,这两个包才从锁文件里消失。

一段注释惹的 14KB

第一批里还删了几个没用的包,其中一个是 @tailwindcss/typography。站上的长文排版是自己写的,全站没有一处用到它提供的 prose 类。

构建完对比入口 CSS,比升级前小了 15KB。只删了一个「没被用到」的插件,为什么会少这么多?我写了个小脚本,把两份 CSS 按规则拆开,看哪些规则只在旧版里有、哪些内容变了:

only in base: 81 rules, 14187 bytes
  .prose
  .prose :where(p):not(:where([class~=not-prose] ...))
  ...
changed: 71
  .inset-0
    - inset: calc(var(--spacing)*0)
    + inset: 0

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

变化的那 71 条,是 Tailwind 小版本升级带来的输出规范化,比如 calc(var(--spacing)*0) 直接写成 0,渲染结果一样。

如果不做对比,我只会看到「CSS 小了一点」,然后高高兴兴地提交,永远不会知道那 14KB 从哪来。

第二批:换掉 Tailwind 的接入方式

项目里唯一一个预发布版本的依赖,是 @nuxtjs/tailwindcss 7.0.0-beta.1。这个模块的 v7 一直停在 beta,而 Tailwind 官方给 Nuxt 的接入方式,是直接用 @tailwindcss/vite 插件。

换之前我读了一遍这个模块的源码。它在 Tailwind v4 下只做了两件事:替你加上 @tailwindcss/vite 插件;生成一份 @source 清单。而后者只挂在一个别名上,项目的 main.css 从来没有引用过它。也就是说,这个模块实际起作用的,就只有「加插件」这一件事。

那么换掉它之后,输出应该完全不变。实际对比的结果是:新旧两份 CSS 逐字节一致,连文件名里的哈希都一样。

这是对比最好的一种结果:它不只告诉你「没坏」,还证明了你对这个模块的理解是对的。

第三批:65 篇文章,逐字对比

marked 是 Markdown 渲染器,站上每一篇文章都经过它。从 16 升到 18,跨了两个大版本,改的恰好是列表解析和块级元素的空行处理,正是文章里最常见的东西。

升级后用同一个脚本把 65 篇内容重新渲染一遍,和升级前那份逐篇比较:

changed posts: 0 of 65

一篇都没变。为了确认比对的确实是新版本,我又单独检查了一次:项目里加载到的 marked 版本号是 18.0.14,没有比错对象。

sitemap 和 robots 两个模块也一样:新旧构建的 sitemap 和 robots.txt,除了注释里的生成器版本号和生成时间,其余完全一致。后台页面的 x-robots-tag: noindex 也还在。

顺带还处理了一件事:这两个模块的新版本多了一个 peer 依赖 zod,我把它显式写进了 package.json。pnpm 的严格模式不会替你把传递依赖提升上来,不写的话,某次安装之后可能就找不到它了。

结果

三批升级、四个提交,最后的变化是:

  • 入口 CSS 从 247KB 降到 232KB(大部分是那 14KB 的 .prose)
  • Worker 包(服务端代码)从 3.39MB 降到 2.85MB
  • 去掉了项目里唯一的 beta 依赖
  • 加了 .node-version 固定 Node 24,本地和 Cloudflare 构建用同一个版本
  • 文章渲染、sitemap、robots.txt 的输出,逐字未变

方法本身

回头看,这套办法其实就一句话:对每一个升级,找到它能影响的那份输出,升级前后各存一份,逐字比较。

升级的东西 对比什么
CSS 框架、构建插件 构建出来的 CSS
Markdown 渲染器 每一篇内容渲染出的 HTML
SEO 模块 sitemap、robots.txt
服务端 SDK 构建能不能过,Worker 包有多大

对比的结果只有三种,每一种都有用:

  • 完全一致:放心提交,还顺便验证了你对这个依赖的理解。
  • 有差异,但能解释:比如 Tailwind 的输出规范化,看一眼确认无害再提交。
  • 有差异,解释不了:比如那 14KB。这正是要停下来查的地方,它往往会带出一个你原本不知道的问题。

比起「跑一下 dev,点几个页面,没报错就提交」,这套办法多花的时间不多,脚本写一次就能一直用。换来的是每次升级都知道自己改了什么,而不是祈祷。

更多