每日壁纸

Biome

RustMIT OR Apache-2.0biomejs/biome官网

/---

一句话项目 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 .ungram files 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
CSTbiome_rowanbiome_{js,css,graphql,json,html,yaml,markdown,grit}_syntax
Parserbiome_parserbiome_{...}_parser
Formatterbiome_formatterbiome_{...}_formatter
Linterbiome_analyzebiome_{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 a SyntaxToken to avoid allocating a new string」。零拷贝复用 CST 里的 token 文本
  • MappedText / SourcePosition —— 源码位置内建在 IR 里,source map 不需要额外 pass
  • Interned(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() -> ExpandParent

builders.rs:2047:

Creates a boundary group, Biome's equivalent of Prettier's conditionalGroup called 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 is best_fitting!.

macros.rs:315:

This IR is similar to Prettier's conditionalGroup. The printer measures each variant, except the MostExpanded, in Flat mode 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_formatter crate 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 工具链里少见的「商业公司养开源核心」结构。

项目资源汇总

官方资源

相关资源

  • Prettier:Biome 格式化兼容的目标对象,兼容度 97%
  • ESLint:Biome lint 规则的来源,migrate eslint 的迁移源
  • rowan:Biome 的 rowan 分叉的来源,无损语法树的原始实现

媒体报道与延伸阅读

核心要点回顾

  • Biome 用 Rust 重写前端工具链,把 Prettier 和 ESLint 合成一个原生二进制
  • 自研 rowan 分叉提供无损语法树,让「每种语言只写语法、不写基础设施」成为可能
  • 格式化 IR 重写而非复制 Prettier Doc,换来零拷贝 token 切片、内建 source map 和 boundary_group
  • 兼容度 97% 与 600+ 规则的前提下,规则映射由代码生成保证单一事实源

适合谁

  • 被 Prettier + ESLint 的配置冲突和双重依赖折磨的前端开发者
  • 需要多语言(CSS / GraphQL / JSONC)统一处理的仓库
  • 关注解析器设计、IR 抽象、工具链工程化的开发者

一句话评价

它没有让格式化变得更聪明,它只是让同一份代码只被解析一次。

← Previous projectECC
Next project →shadcn/ui

评论区未配置

在 .env 中填入NEXT_PUBLIC_GISCUS_REPO/NEXT_PUBLIC_GISCUS_REPOSITORY_ID/NEXT_PUBLIC_GISCUS_CATEGORY/NEXT_PUBLIC_GISCUS_CATEGORY_ID 后重新构建站点即可启用评论。