02判断5 min

从 ESLint 到 Biome 的五次迁移:这个决定早就定了

当你在五个项目里做同一次迁移,而且都没停下来想,它就不再是偏好,而成了判断。过程中会坏掉什么,又会失去什么。

作者 Salva Castells

封面图: Aris riyanto, CC BY-SA 4.0.
本文目录

在一个图像生成的 SaaS 里,我在同一个提交里删掉了 .prettierrc、eslint.config.mjs 和 package.json 里的七个依赖。lockfile 少了 545 行。第二天我在另一个项目做了同样的事,接着又是一个。到第五次,我已经不在评估任何东西了:打开仓库,去掉 ESLint 和 Prettier,写一个 biome.json。

这已经不是偏好。这是判断,而判断应该经得起辩护。

第一次是什么推动了我

不是速度。是依赖树。我删掉的那份配置是这样的:

const compat = new FlatCompat({ baseDirectory: __dirname });
export default [...compat.extends("next/core-web-vitals", "next/typescript", "prettier")];

一个 FlatCompat,让 flat config 能读懂旧配置。一个 eslint-config-prettier,唯一的工作是关掉那些跟 Prettier 打架的 ESLint 规则。一个 eslint-plugin-tailwindcss 和一个 prettier-plugin-tailwindcss,从两个不同的方向做着类似的事。六个包存在的理由,只是为了让其他工具不要互相踩脚。

Biome 在同一个二进制里做格式化和 lint。这个问题就消失了,因为不再有两个工具需要协调。

会坏掉、却没人提醒的东西

迁移不是删掉文件然后继续。真正的事情是紧接着的那次格式化扫荡:一个提交里改动 78 个文件,而你什么都审不了,因为全是引号和缩进。做掉,接受它。

真正的问题出现在你认真打开 linter 的时候。在一个 Telegram 提醒机器人里,用推荐规则跑的第一遍在网站部分给出了 60 个错误。几乎没有一个是噪音:

  • 没有关联的 label。 登录表单的字段少了 htmlFor/id。用 Next 配置的 ESLint 从来没告诉过我。
  • 装饰性的 SVG。 规则不认 aria-hidden 的简写形式;必须显式写 aria-hidden="true"。
  • 命名冲突。 一个导入的 TaskList 类型和一个同名组件撞上了。编译完全没问题,但它是错的。
  • 用索引当 key。 在一个轮播文字组件里,每个字符的 key 是 map 的索引。解决办法不是把规则静音:而是在 memo 里预先算出绝对偏移,好拿到一个稳定的 key。

这才是有意思的地方。60 个里,大多数是已经躺了好几个月的真实缺陷。剩下的用有文档说明的抑制解决了:只跑一次的副作用、固定尺寸的骨架屏循环、一个盖住 react-remove-scroll-bar 的 !important、OAuth 头像用的 <img>。

一个没有注释解释原因的 biome-ignore 是债。有注释的是决定。

速度重要,是因为它决定你能把 lint 放在哪里

这是各种对比里被说得最多、又解释得最差的一点。数字本身不重要。重要的是当它足够快时,你能把这个命令插到哪里。

用 ESLint 时,lint 住在 CI 里,因为把它塞进本地 build 就等于把自己判去等待。换成 Biome 之后,脚本变成了这样:

"build": "bun run lint && next build",
"lint": "biome check .",
"lint:fix": "biome check --write ."

lint 排在 build 前面。红了就不编译。这一行就是全部的差别:错误不再是十五分钟后在某个 runner 上被发现,而是在 build 启动之前就被发现。工具一慢,这一行就让人受不了,最后你会把它拿掉。

你会失去什么

两件事,值得点名。

第一是插件生态。如果你依赖某个非常具体的 ESLint 插件,它不存在,而且大概率也不会有。我在去掉 eslint-plugin-tailwindcss 时验证过:类名顺序是 Tailwind 的格式化器管的,不是 linter,所以我没想它。但这是运气好的个案,不是普遍规律。

第二件更蠢,也更烦人:默认配置在各处并不一样。一个项目跑出了 "indentStyle": "tab",另一个是 "indentStyle": "space" 加 "indentWidth": 2。几周后我不得不提交一个内容只有"把 biome.json 里的 tab 换成空格"的提交,好让所有项目一致。把 $schema 钉死到确切版本,并在项目之间复制同一份 biome.json。这个工具的舒服之处在于它几乎没有配置;那就利用这一点,只留一份。

我今天的质量栈

Biome、严格模式的 TypeScript、测试,再加 knip 处理死代码。基本就这些。

knip 是用的人最少、也最让人惊讶的那一块。在 Telegram 机器人上跑一遍,删掉了 19 个死文件和 21 个没用到的依赖:和元包重复的 @radix-ui/* 子包、整个 @react-three/fiber 和 three、date-fns,还有 eslint——它在被移除几个月后仍然装着。另外还揪出一整簇已经没人调用的 action 和用例。

但 knip 被我故意挡在 CI 外面。在一个大仓库里第一遍就给出 107 条既有问题:把它接进门禁,第一天就是红的,最后你会习惯性忽略红色。它作为手动工具存在,每隔几个月跑一次,清理放在自己的提交里,合并前用 tsc 和测试验证。

配置必须把入口写明确,否则它会删掉你半个应用。在一个带 Next 的 monorepo 里,得手动声明哪些文件是入口:

"entry": ["app/**/{page,layout,route,sitemap,robots,icon}.{ts,tsx}"],
"ignore": ["components/ui/**"],
"ignoreDependencies": ["sharp", "tailwindcss", "tw-animate-css"]

shadcn 生成的组件放进 ignore,因为它们总是比你用到的多,这很正常。sharp 和 tailwindcss 放进 ignoreDependencies,因为没人 import 它们,它们是在 build 里被用的。

慢的 linter 最终会变成一个没人看的独立 job。快的 linter 能进 build,变成项目的地板。