用 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 则把 printWidth、singleQuote、semi 这些直接转成 Biome 的格式选项。
我跑完这两条,biome.json 基本成型,格式选项几乎一模一样搬了过来,省了对照文档逐项填的功夫。当然它只能迁能对应上的部分,对不上的规则会跳过——这正好引出了下面"缺什么"。
规则覆盖度:recommended、nursery 和分组
Biome 的 lint 规则按来源/领域分成若干 group,比如 correctness(确定的 bug)、suspicious(可疑写法)、style(风格)、complexity、performance、a11y 等等。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 迁一遍,重点看它打印出来的"跳过的规则"列表,对照一下里面有没有你离不开的。看完那份列表,换不换、怎么换,答案基本就清楚了。