用 Biome 替掉 ESLint + Prettier:一次工具链瘦身的得与失

让我下决心动工具链的,是某天 CI 里一个三百多个文件的包,lint 阶段跑了快两分钟。本地用 lint-staged 提交一次也要等好几秒,等到我都忘了刚才改了啥。更烦的是隔三差五要去调 ESLint 和 Prettier 打架的问题——一个说这行该换行,另一个保存时又给改回去,两边各执一词,我夹在中间改配置。

那阵子 Biome 已经被身边几个项目用上了,反馈都是"快得离谱"。我挑了那个最慢的包做试点,花了大半天迁过去。结论是:大头收益拿到了,但确实也丢了一些东西,不是无脑替换。

ESLint + Prettier 这套到底烦在哪

先把痛点说清楚,不然没法判断换完到底值不值。

第一是两套工具、两套配置。Prettier 管格式(缩进、引号、分号、换行),ESLint 管代码质量(未用变量、可能的 bug、风格规则)。但 ESLint 自己也有一堆格式相关的规则,于是要装 eslint-config-prettier 把这些关掉,避免两边对着干。光是理顺"谁管什么、哪些要禁用"就够新人懵一阵。

第二是真的会打架。即便配了 eslint-config-prettier,遇到某些插件规则还是会出现保存格式化和 lint 自动修复来回拉锯的情况。我见过最离谱的一次,保存一下两个工具轮流改同一行,文件状态在两个版本之间反复横跳。

第三是大仓库慢。ESLint 是 JS 写的,规则又多,几百上千个文件跑一遍 AST 分析,时间肉眼可见。CI 上 lint 经常是除了测试之外最慢的一步。

第四是 flat config 迁移的折腾。从老的 .eslintrc 那套 cascading 配置迁到 flat config(eslint.config.js),插件怎么引、规则怎么组织、extends 换成什么,每个插件适配进度还不一样,迁起来很消耗耐心。我有几个项目就是卡在这一步迟迟没动。

这些加起来,让我觉得"一个工具同时把格式和 lint 都干了"这件事很有吸引力。

Biome:一个 Rust 工具干两件事

Biome 是用 Rust 写的,一个二进制同时做格式化和 lint(还有 import 整理等 assist 能力)。它的卖点就是把原来两个工具的活合并,并且因为是 Rust + 多核并行,速度快一个数量级。

我那个试点包,迁完之后实测:原来 ESLint 跑全量接近两分钟,Biome 全量 check 在两秒以内完成。这不是百分之几十的优化,是数量级的差距。第一次跑完我以为它没干活,去翻了下输出才确认确实扫了所有文件。本地 lint-staged 那点延迟更是直接没了感觉,保存即格式化几乎无延迟。

体感上最舒服的是:格式和 lint 用的是同一套解析、同一份配置,从根上就不存在两个工具打架的问题。保存格式化和 lint 修复出自同一个引擎,结果是一致的。

biome.json 配置

配置集中在一个 biome.json(或 biome.jsonc)里。一份够用的基础配置长这样:

1{
2  "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
3  "files": {
4    "includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
5  },
6  "formatter": {
7    "enabled": true,
8    "indentStyle": "space",
9    "indentWidth": 2,
10    "lineWidth": 100
11  },
12  "javascript": {
13    "formatter": {
14      "quoteStyle": "single",
15      "semicolons": "always"
16    }
17  },
18  "linter": {
19    "enabled": true,
20    "rules": {
21      "recommended": true
22    }
23  },
24  "assist": {
25    "enabled": true,
26    "actions": {
27      "source": {
28        "organizeImports": "on"
29      }
30    }
31  }
32}

格式相关的选项(引号、分号、缩进、行宽)就是过去 Prettier 那些,一一对得上。lint 用 recommended 这一组打底。命令也很简单:

1# 检查(lint + format 校验 + assist),不改文件
2npx biome check .
3
4# 检查并自动修复(含格式化、可修复的 lint、import 整理)
5npx biome check --write .
6
7# 只格式化
8npx biome format --write .

我习惯统一用 biome check --write,一条命令把格式、能自动修的 lint、import 排序全办了。

biome migrate:自动迁移配置

最让我省事的是迁移命令。不用手动把 ESLint/Prettier 的配置一条条翻译过来,Biome 能读现有配置自动生成对应的 biome.json

1# 先初始化一个 biome.json
2npx biome init
3
4# 把 .eslintrc / eslint.config.js 里的规则映射过来
5npx biome migrate eslint --write
6
7# 把 .prettierrc 的格式选项搬过来
8npx biome migrate prettier --write

migrate eslint 会尽量把你启用的 ESLint 规则映射到 Biome 对应的规则上,连一些插件规则也能识别。migrate prettier 则把 printWidthsingleQuotesemi 这些直接转成 Biome 的格式选项。

我跑完这两条,biome.json 基本成型,格式选项几乎一模一样搬了过来,省了对照文档逐项填的功夫。当然它只能迁能对应上的部分,对不上的规则会跳过——这正好引出了下面"缺什么"。

规则覆盖度:recommended、nursery 和分组

Biome 的 lint 规则按来源/领域分成若干 group,比如 correctness(确定的 bug)、suspicious(可疑写法)、style(风格)、complexityperformancea11y 等等。recommended: true 会打开各组里推荐的那批。

要单独调某条规则,按 group 路径写:

1{
2  "linter": {
3    "rules": {
4      "recommended": true,
5      "suspicious": {
6        "noExplicitAny": "warn"
7      },
8      "style": {
9        "useConst": "error",
10        "noNonNullAssertion": "off"
11      },
12      "nursery": {
13        "useSortedClasses": "error"
14      }
15    }
16  }
17}

nursery 是还在孵化、可能变动的新规则,默认不在 recommended 里,想用得手动开。我对 nursery 的态度是按需挑选——它升级时规则行为可能调整,所以我只开那些我确实需要、且短期内验证过的,不会整组打开。

总体覆盖度上,Biome 的内置规则数量这两年涨得很快,常用的那批质量类规则基本都有对应。对一个标准的 TS + React 项目,recommended 加几条手动调整,覆盖度已经够用。

用 overrides 处理不同目录的差异

实际项目里规则不可能一刀切。测试文件允许用 any 多一点,生成的代码目录干脆别 lint,脚本目录可以放松。这些靠 overrides 按路径覆盖:

1{
2  "overrides": [
3    {
4      "includes": ["**/*.test.ts", "**/*.spec.ts"],
5      "linter": {
6        "rules": {
7          "suspicious": { "noExplicitAny": "off" }
8        }
9      }
10    },
11    {
12      "includes": ["**/generated/**"],
13      "linter": { "enabled": false }
14    }
15  ]
16}

这套机制和 ESLint flat config 里按 files 分段配置是一个意思,但写在一个文件里、结构更扁,我个人觉得比 flat config 那种数组拼接清爽。迁移时这部分通常要手动补,因为 migrate 主要搬规则本身,目录级的差异化得自己重新组织一遍。

行内忽略和误报处理

总会有需要临时关掉某条规则的地方。Biome 的行内注释语法和 ESLint 不一样,要记一下:

1// biome-ignore lint/suspicious/noExplicitAny: 第三方类型没给好,先放过
2function parse(x: any) {}

格式是 biome-ignore lint/<group>/<rule>: <理由>,而且理由是必填的——不写理由这条 ignore 注释本身会报错。一开始我嫌它啰嗦,用久了反而觉得这个强制约束挺好,至少不会出现一堆没人记得为什么的 eslint-disable

缺什么:插件生态的空白

这是迁移里最现实的部分,也是我最终没把所有项目都换过去的原因。

ESLint 最大的资产是它的插件生态。biome migrate eslint 迁的时候,会明确告诉你哪些规则没有对应、被跳过了。我这个项目里跳过的主要集中在几类:

  • 特定框架的深度规则。一些框架专属插件提供的检查(比如某些框架的 hooks 依赖、生命周期约束、特定 API 误用),Biome 内置只覆盖了其中最常见的一部分,细的暂时没有。
  • import 排序的细粒度控制。Biome 的 organizeImports 能整理 import,但它的排序/分组规则不像专门的 import 排序插件那样可以精细到"先内置模块、再第三方、再别名、再相对路径,组间空行"这种程度。简单整理够用,强约束不够。
  • Tailwind 相关插件。类名排序、类名校验这类能力,过去靠 ESLint 的 Tailwind 插件,Biome 这边对应能力还不完整(部分进了 nursery,但不等于功能对齐)。

判断能不能换,关键就看你依赖的插件规则有几条落在这些空白里,以及这些规则对你是不是硬需求。对我那个试点包,丢掉的几条都不是致命的,我能接受;但另一个重度依赖框架专属规则的项目就不行。

迁移时几个真实的磕碰

biome migrate 不是按一下就万事大吉,迁完我还处理了几个具体问题,记下来给后面的人省点时间。

一是格式产物有细微差异。Biome 的格式化器和 Prettier 不是逐字节一致的,某些场景下换行、尾随逗号、链式调用的折叠方式会略有不同。所以迁移那次提交,diff 是整个仓库级别的——几乎每个文件都被重新格式化过。我的做法是把"切换工具链"和"业务改动"彻底分开成两次提交,并且在 git blame 里用 .git-blame-ignore-revs 把那次大规模格式化的 commit 忽略掉,免得以后 blame 全指向这一次重排:

# .git-blame-ignore-revs
a1b2c3d4...   # chore: migrate to Biome formatting

二是 CSS/部分文件类型的支持要确认。Biome 这两年把 CSS 格式化和 lint 也补上了,但如果你项目里有它还不完整支持的文件类型(某些模板语法、特定预处理器),要么继续交给原来的工具处理那部分,要么在 files.includes 里排除掉,别指望它全包。

三是团队要统一拉齐版本。Biome 升级时偶尔会调整默认格式,不同人本地版本不一致就会出现"你保存一下我保存一下来回改格式"的新型打架。我把 Biome 锁进 devDependencies 固定版本,并且 CI 用同一个版本校验,升级走单独 PR。这条做到了,工具内部的一致性优势才真正稳。

编辑器、CI、lint-staged 集成

集成这块没什么坑,比 ESLint+Prettier 还简单,因为只有一个工具要接。

编辑器装 Biome 官方扩展,把默认格式化器指过去:

1// .vscode/settings.json
2{
3  "editor.defaultFormatter": "biomejs.biome",
4  "editor.formatOnSave": true,
5  "editor.codeActionsOnSave": {
6    "source.fixAll.biome": "explicit",
7    "source.organizeImports.biome": "explicit"
8  }
9}

保存时自动格式化、自动修可修复的问题、整理 import,全由 Biome 一个引擎完成,不再有两个格式化器抢方向盘的情况。

CI 里就一条命令,加 --reporter 控制输出格式:

1- name: Lint & format check
2  run: npx biome ci .

biome ci 是专门给 CI 用的子命令,行为是只检查不改写,发现问题就非零退出。

lint-staged 配置也简化了,原来要分别跑 eslint 和 prettier,现在一行:

1{
2  "lint-staged": {
3    "*.{js,jsx,ts,tsx,json,css}": ["biome check --write --no-errors-on-unmatched"]
4  }
5}

--no-errors-on-unmatched 是因为 lint-staged 传进来的文件可能有 Biome 不处理的类型,加上它就不会因为匹配不到而报错。提交时这一步快到几乎无感,这是从慢吞吞的 ESLint 切过来最直接的爽点。

assist:import 自动整理

前面提到的 organizeImports 属于 Biome 的 assist 能力。assist 和 lint 是分开的概念:lint 是报告问题,assist 是提供"源代码动作",比如整理 import 顺序、去重。

1{
2  "assist": {
3    "enabled": true,
4    "actions": {
5      "source": {
6        "organizeImports": "on"
7      }
8    }
9  }
10}

开启后配合编辑器的 source.organizeImports.biome,保存时 import 会自动排好。我前面也说了,它的排序策略相对固定,做不到专门插件那种高度自定义的分组。对我来说"自动去重、基本有序"已经覆盖了日常九成需求,剩下一成想要的精细分组,只能说先忍着,或者在那个特定项目里继续用 ESLint。

我最终的折中

试点跑顺之后,我没有一刀切全公司迁过去,而是按项目分类处理。

绝大多数标准项目——普通的 TS + React/Vue 应用、工具库、Node 服务——直接换成 Biome 主力。这些项目对 lint 的需求就是"抓住明显的 bug 和可疑写法 + 统一格式",Biome 的 recommended 加几条调整完全够,换来的是数量级的速度提升和配置的简化。这一类我换得很果断。

少数重度依赖特定插件规则的项目,我保留 ESLint,但只让它跑那几条 Biome 没有的规则,格式化和绝大部分 lint 还是交给 Biome。也就是 Biome 做主力、ESLint 做补充,两者职责切开:ESLint 的配置里把所有 Biome 已经覆盖的规则关掉,只留独有的那几条。这样既拿到了 Biome 的速度,又没丢掉关键检查,代价是这类项目仍然维护着两套工具——但比起原来 ESLint + Prettier 那种全量两套,已经轻了很多。

这次瘦身,速度和配置复杂度上的收益是实打实的,大仓库 lint 从分钟级掉到秒级,两个工具打架的老毛病彻底消失。丢的主要是插件生态里那些细分规则,能不能接受完全取决于你的项目有多依赖它们。所以我的建议很朴素——先拿一个不太关键、又跑得慢的包做试点,用 biome migrate 迁一遍,重点看它打印出来的"跳过的规则"列表,对照一下里面有没有你离不开的。看完那份列表,换不换、怎么换,答案基本就清楚了。