一句话项目 slogan:用 Rust 重写前端工具链,把 Prettier 和 ESLint 合成一个。
这是「一天一个开源项目」系列的一篇。今天讲 Biome。
现在的前端项目,格式化用 Prettier,lint 用 ESLint。两套配置、两套依赖、两套不同的规则实现。更烦的是它们经常打架——Prettier 排完的代码,ESLint 的 indent 规则可能又报错;eslint --fix 改完,Prettier 再说一句「这里格式不对」。
新项目接入这两样,光是解决它们之间的冲突就要花掉半天。老项目想换工具,还要担心自定义规则丢失、插件生态覆盖不到。
Biome 想把它们合成一个。
项目速览
⭐ 25,979 Stars | 🍴 1,287 Forks | 📄 MIT OR Apache-2.0 协议 | 🔧 11,352 次提交 | 🐛 474 个 Open Issues
截至 2026-10-21。项目创建于 2023-07-27,2.6 万星。核心贡献者 ematipico 2,144 次、Conaclos 764 次、dyc3 741 次——来自 Biome 商业版的团队,商业公司反过来养着这个开源项目。
阅读价值与前置说明
读完你应该能回答:
- Biome 怎么做到只用写一次解析器,就支持 JS、TS、CSS、GraphQL、HTML、JSON 六门语言?
- 它的格式化器和 Prettier 兼容度 97%,剩下 3% 差在哪?
- 为什么它要自己写一套格式化 IR,而不是直接用 Prettier 的 Doc IR?
- 「容错解析」对一个 linter 到底意味着什么?
前置知识:
- 熟悉 ESLint 规则体系(知道
correctness/style这类分类) - 知道 Prettier 的工作原理大致是「AST → Doc IR → 打印」
- 了解 CSS 预处理器和 GraphQL 的基本概念
- 可选:读过 rust-analyzer 或 rustc 的源码,能更快理解 rowan 那套设计
项目背景与完整档案
官方定位(README):"A toolchain for web projects, aimed to provide functionalities to maintain them. Biome offers formatter and linter, usable via CLI and LSP."
基础信息:
- npm 包名:
@biomejs/biome,当前版本 2.6.1 - 协议:
MIT OR Apache-2.0(双许可,仓库里LICENSE-MIT和LICENSE-APACHE都在) - 最新 GitHub release 标签:2.5.15(2026-09-30)——npm 上已经发到 2.6.1,GitHub release 标签落后一步
- 实现语言:Rust(28.8MB),无 Node.js 运行时依赖
- 分发形式:每个平台一个原生二进制(Linux / macOS / Windows,x64 与 arm64)
- 额外产物:
@biomejs/js-api(WASM/Node 嵌入)、@biomejs/backend-jsonrpc(LSP 后端)、@biomejs/lsp(编辑器扩展) - 独立 crate:
biome_rowan单独发布在 crates.io,当前 0.7.0,累计下载 341 万
项目数据(截至 2026-10-21):
- ⭐ Stars 25,979 | 🍴 Forks 1,287
- 🔧 提交 11,352 | 🐛 Open Issues 474
- 👥 贡献者 200+,Top 10 贡献 6,200+ 次
- 🗓️ 创建于 2023-07-27,最近推送 2026-10-21
- 💻 代码构成:Rust 28.8MB / JavaScript 2.1MB / TypeScript 1.4MB
它解决什么问题
两套工具并行的普遍痛点:
- 配置割裂:
.prettierrc和.eslintrc各写各的,规则还会重叠(indent两边都有) - 互相打架:Prettier 和 ESLint 的
indent、quotes规则语义不同,修复循环 - 性能叠加:两个工具都要完整解析一遍代码,编辑器里各跑一次 LSP
- 依赖沉重:Prettier + ESLint + 若干插件,node_modules 里多出几百个包
Biome 的解法路径是递进的:
第一步,把两个功能合进一个二进制——一次解析,格式化结果直接复用给 lint。第二步,用自己写的解析器支撑多语言——不用 Babel(JS)、postcss(CSS)、graphql-js(GraphQL)三套 AST。第三步,做 LSP,让编辑器的格式化/修复体验不输给原来的两件套。
使用场景:
- 新项目起步:不想在 Prettier + ESLint 之间做配置取舍,一条命令解决
- 大仓库提速:格式化一个大仓库通常在百毫秒级,Prettier 在同一个仓库常常要几秒
- 多语言项目:一个仓库里有 CSS、GraphQL、JSONC,Biome 统一处理,不需要再配 Stylelint 和 graphql-eslint
快速上手
安装:
npm install --save-dev --save-exact @biomejs/biome日常使用:
# 检查(格式化 + lint + assist 一起跑)
npx @biomejs/biome check .
# 自动修复
npx @biomejs/biome check --write .
# 只格式化
npx @biomejs/biome format --write .
# 只 lint
npx @biomejs/biome lint --write .
# CI 模式:只报错不修改,退出码非 0
npx @biomejs/biome ci从 ESLint 迁移:
npx @biomejs/biome migrate eslint这条命令会读你的 .eslintrc,把能映射的规则转成 Biome 配置。规则映射不是手写的——xtask/codegen/src/generate_migrate_eslint.rs 从 Biome 自己的规则元数据生成,源信息标了 RuleSourceKind::{SameLogic, Inspired},也就是「逻辑等价」和「受启发」是分开标注的。severity 合并时取更严格的一侧:
rule.set_level(rule.level().max(rule_severity.into()));不能映射的规则、依赖插件实现的规则、nursery 里的实验性规则,会被明确列出来让你手动处理,而不是悄悄丢掉。
其他迁移命令:
npx @biomejs/biome migrate prettier # 迁移 Prettier 配置
npx @biomejs/biome migrate stylelint # 迁移 Stylelint核心特性拆解
格式化——支持 JS / TS / JSX / TSX / JSON / JSONC / CSS / GraphQL / GritQL,与 Prettier 的兼容度 97%。注意官方语言支持表和 README 存在出入:官网把 HTML / Vue / Svelte / Astro 标为「实验性」,SCSS / YAML / Markdown 标为「开发中」,但 README 已经把 Markdown、YAML、SCSS 列进支持列表。
Lint——600+ 条规则,分 8 组:a11y / complexity / correctness / nursery / performance / security / style / suspicious。规则大多来自 ESLint 和 typescript-eslint。
Domains 机制——除了 8 个分组,还有一层正交的 RuleDomain:Astro、Drizzle、React、React Native、Test、Solid、Next、Qwik、Svelte、Vue、Project、Tailwind、Turborepo、Playwright、Types。开启某个 domain 会连带触发执行成本级别的开关:
if domains.contains(&RuleDomain::Project) { self.requires_project_scan |= true; }
if domains.contains(&RuleDomain::Types) { self.requires_types |= true; }也就是说 domain 配置驱动的不是「多几条规则」,而是「要不要扫全项目」「要不要做类型推断」。
LSP——内置语言服务器,VS Code 和 Open VSX 都有扩展。格式化、lint、code action 都在编辑器里实时跑。
Assist actions——除格式化和 lint 外还有第三类动作,比如 useGraphqlSortedDefinitions(2.6.0 新增)。
biome_rowan 独立可用——crates/biome_rowan/Cargo.toml 里 publish = true,crates.io 上独立分发,自带 LICENSE。
深度设计剖析
设计亮点一:无损语法树,让「每种语言只写语法,不写基础设施」
常规方案:
Prettier 每种语言各写一套:JS 用 Babel AST、SCSS 用 postcss、GraphQL 用 graphql-js,格式化后再各自映射到同一套 Doc IR。问题在于 AST 是有损的——注释和空白在 parse 阶段就被剥离了,要保留就得额外做「重新插回 trivia」的往返。
Biome 的做法:
它把 rowan(Rust 社区的 lossless syntax tree 库)fork 了一份,放进 crates/biome_rowan/,首行注释就是:
//! A generic library for lossless syntax trees.rowan 的核心是 green/red 双树:green tree 是不可变、可共享的持久化树,red tree 是它的轻量游标视图。Biome 的 fork 里有个细节值得注意——GreenNodeData 的 Slot 枚举显式区分三种情况:
pub(crate) enum Slot {
Node { rel_offset: TextSize, node: GreenNode },
Token { rel_offset: TextSize, token: GreenToken },
/// An empty slot for a child that was missing in the source because:
/// * it's an optional child which is missing for this node
/// * it's a mandatory child but it's missing because of a syntax error
Empty { rel_offset: TextSize },
}这个 Empty slot 是关键:语法错误不会让子节点索引错位,所以 formatter 和 linter 可以按固定偏移取子节点,不用先判断「这个节点存不存在」。
语言抽象极小:
pub trait Language: Sized + Clone + Copy + fmt::Debug + Eq + Ord + std::hash::Hash {
type Kind: SyntaxKind;
type Root: AstNode<Language = Self> + Clone + Eq + fmt::Debug;
}再加一个 SyntaxFactory 负责把解析出的 children 装配成节点,遇到不合法的就降级成 kind.to_bogus()。
而语法描述本身是用 ungrammar 写的,代码生成 —— crates/biome_parser/CONTRIBUTING.md 说得很直接:
We use a fork of
ungrammar… a codegen script that reads.ungramfiles and all the files needed to write a parser.
xtask/codegen/src/ 下有 generate_syntax_kinds.rs、generate_nodes.rs、generate_node_factory.rs、generate_syntax_factory.rs 等。
依赖矩阵最终长这样:
| 能力层 | 共享 crate | 各语言 crate |
|---|---|---|
| CST | biome_rowan | biome_{js,css,graphql,json,html,yaml,markdown,grit}_syntax |
| Parser | biome_parser | biome_{...}_parser |
| Formatter | biome_formatter | biome_{...}_formatter |
| Linter | biome_analyze | biome_{js,css,graphql,json}_analyze |
为什么这么做:
因为 formatter 和 linter 必须在同一棵树上工作。formatter 要保留注释和空白(full fidelity),linter 要能精确定位到出错的 token 并给出 source map。如果用 Prettier 式的有损 AST,格式化前得先做一次 trivia 往返,lint 的错误定位也会失真。
于是「统一」的落点不是统一 AST,而是统一 CST 基础设施。新增一门语言 = 写 .ungram + lexer + 少量 parse rule,parser/formatter/linter 的基础设施零成本继承。
工程价值与代价:
代价是 green/red 双树带来的内存和实现复杂度。crates/biome_rowan/src/arc.rs 自己实现了 ThinArc 引用计数来压缩 green node 的内存,src/green/node.rs 里用 static_assert!(mem::size_of::<GreenNodeHead>() == 8) 锁死了头部大小。
还有一个真实的坑——green tree 的递归 Drop 在病态输入下会爆栈,所以 crates/biome_parser/src/tree_sink.rs 加了硬上限:
/// Maximum depth of the green tree produced by the parser. This bounds the
/// recursive `Drop` depth of syntax nodes on pathological input.
const MAX_TREE_DEPTH: usize = 3072;设计亮点二:自研格式化 IR,重写而非复制 Prettier 的 Doc
常规方案:
Prettier 用自己的 Doc IR(group / line / ifBreak / indent / fill / breakParent / conditionalGroup…),各语言手写 AST → Doc 的 printer。
Biome 的做法:
它定义了自己的 IR(crates/biome_formatter/src/format_element.rs):
/// Language agnostic IR for formatting source code.
#[derive(Clone, Eq, PartialEq)]
pub enum FormatElement {
Space,
HardSpace,
Line(LineMode),
ExpandParent,
SourcePosition(TextSize),
Token { text: &'static str },
Text { text: Box<str>, text_width: TextWidth },
LocatedTokenText { slice: TokenText, text_width: TextWidth },
MappedText { text: Box<str>, source_position: TextSize },
MappedLocatedTokenText { slice: TokenText, source_position: TextSize },
LineSuffixBoundary,
Interned(Interned),
BestFitting(BestFittingVariants),
Tag(Tag),
}和 Prettier 相比,有三个变体是没有的:
LocatedTokenText { slice: TokenText }—— 注释写明「Implementing by taking a slice from aSyntaxTokento avoid allocating a new string」。零拷贝复用 CST 里的 token 文本MappedText/SourcePosition—— 源码位置内建在 IR 里,source map 不需要额外 passInterned(Interned)—— 避免best_fitting!宏深拷贝 IR
但最有说服力的证据是,源码里显式标注了和 Prettier 的对应关系。crates/biome_formatter/src/builders.rs:2206:
/// # Prettier
/// Equivalent to Prettier's `breakParent` IR element
pub const fn expand_parent() -> ExpandParentbuilders.rs:2047:
Creates a boundary group, Biome's equivalent of Prettier's
conditionalGroupcalled with a single state:conditionalGroup([content]). [...] The "conditional" in Prettier's name refers to picking the first of several states that fits, which a single state never does. For several states, the closest Biome element isbest_fitting!.
macros.rs:315:
This IR is similar to Prettier's
conditionalGroup. The printer measures each variant, except theMostExpanded, inFlatmode to find the first variant that fits [...]
所以关系是:对标 Prettier Doc IR,但重命名并重构了语义(expand_parent / boundary_group / best_fitting!),并明确写出差异点。
其中 boundary_group 是 Biome 独有的:hard line break 的展开会向外传播,但传播到 boundary group 就停住。format_element/document.rs 的 propagate_expand() 实现了这个算法,用 Enclosing 枚举区分 Group / BestFitting / BoundaryGroup 三种。
为什么这么做:
关键判断是:要达到 Prettier 级别的输出质量,就必须实现同等级别的 Doc 语义;但不必复用 Prettier 的 IR。 重写拿到了三样 Prettier 没有的东西:零拷贝 token 切片、内建 source map、以及 boundary_group 这种更精确的展开控制。
架构上还有个 Rust 特有问题要解决。crates/biome_formatter/src/lib.rs:1427:
For example, the
biome_js_formattercrate isn't able to implement [Format] on [JsIfStatement] because both the [Format] trait and [JsIfStatement] are external types (Rust's orphan rule).
于是引入了 FormatRule<T> trait,各语言 formatter crate 只需实现 FormatRule,共享同一套 Format 基础设施。
工程价值与代价:
代价写在 best_fitting! 的文档里:
Be mindful of using this IR element as it has a considerable performance penalty: There are multiple representation for the same content […] The worst case complexity is that the printer tires each variant. This can result in quadratic complexity if used in nested structures.
项目细节补充
容错解析不是可选功能。 crates/biome_parser/CONTRIBUTING.md 列了两条设计目标:「Performance-Neutral Error Recovery」和「Preservation of Valid Tree Structure」——后者指解析器应该把出错的片段标成 bogus,而不是让整个父节点失效。实现上 ParseRecoveryTokenSet 会把跳过的 token 全包进一个 Bogus 节点。
这里有个反直觉的例外:推测解析(speculative parsing)时容错是被主动关掉的。源码注释解释了原因:
Error-recovery is disabled when doing speculative parsing because it can then make the impression that the parser was able to correctly parse a syntax when, in fact, it only skipped over the tokens.
也就是说,容错是为了给人看的(编辑器里别整篇变红),不是为了给机器看的。
性能相关的设计值得一提。 crates/biome_formatter/src/group_id.rs 用 NonZeroU32 做 group id,release 构建下退化成只有 4 字节:
#[cfg(not(debug_assertions))]
pub type GroupId = release::GroupId; // #[repr(transparent)] struct GroupId { value: NonZeroU32 }
#[cfg(debug_assertions)]
pub type GroupId = debug::GroupId; // 多带一个 &'static str name 仅用于 Debug发号用 AtomicU32。
社区态度:项目通过 Open Collective 和 GitHub Sponsors 接受资助,同时有商业版。这是 Rust 工具链里少见的「商业公司养开源核心」结构。
项目资源汇总
官方资源
- GitHub:biomejs/biome
- 官网与文档:biomejs.dev
- 语言支持表:biomejs.dev/internals/language-support
- 开源协议:MIT OR Apache-2.0
- 更新日志:仓库内
CHANGELOG.md - 贡献指南:仓库内
CONTRIBUTING.md - npm 包:
@biomejs/biome(当前 2.6.1) - crates.io:biome_rowan(当前 0.7.0)
相关资源
- Prettier:Biome 格式化兼容的目标对象,兼容度 97%
- ESLint:Biome lint 规则的来源,
migrate eslint的迁移源 - rowan:Biome 的 rowan 分叉的来源,无损语法树的原始实现
媒体报道与延伸阅读
- DeepWiki 架构导读(第三方自动生成,覆盖 CST 架构、formatter IR、规则引擎、多语言支持矩阵)
- 官方 benchmark 仓库(与 Prettier / ESLint 的性能对比数据来源)
核心要点回顾
- Biome 用 Rust 重写前端工具链,把 Prettier 和 ESLint 合成一个原生二进制
- 自研 rowan 分叉提供无损语法树,让「每种语言只写语法、不写基础设施」成为可能
- 格式化 IR 重写而非复制 Prettier Doc,换来零拷贝 token 切片、内建 source map 和
boundary_group - 兼容度 97% 与 600+ 规则的前提下,规则映射由代码生成保证单一事实源
适合谁
- 被 Prettier + ESLint 的配置冲突和双重依赖折磨的前端开发者
- 需要多语言(CSS / GraphQL / JSONC)统一处理的仓库
- 关注解析器设计、IR 抽象、工具链工程化的开发者
一句话评价
它没有让格式化变得更聪明,它只是让同一份代码只被解析一次。
