深入 Biome — 現代 Rust 前端工具鏈

Biome 簡介

Biome 是一個基於 Rust 開發的前端工具鏈工具,它提供了完善的 Analyzer / Linter / Formatter 的能力支持。目前 Biome 提供了 JS、TS 、JSON/JSONC 以及 CSS 和 GriftQL 等語言的支持。

在 Linter 方面,目前 Biome 內置支持了約 320 條以上規則支持 (參考: https://next.biomejs.dev/linter/),幾乎完整覆蓋了 ESLint 以及社區 Lint 規則集中的大部分 Lint 規則,在 Formatter 上面,Biome 提供了 97% 以上的 Prettier 的能力對齊 (參考: https://next.biomejs.dev/blog/biome-wins-prettier-challenge/),這其中包括對於 JS 、TS 以及 JSX 等支持。

Biome 是由 Rome fork 而來,目前完全由社區來驅動維護, 同時 Rome 也已經不再維護:

Biome 優勢

前面簡單介紹了一下 Biome 目前提供的能力,那麼至於爲什麼要選擇 Biome,主要優勢在於:

試用指南

執行如下命令:

npx @biomejs/biome check --write .

該命令可以直接在你項目中使用默認的 biome 配置對項目進行 formatter、lintter 以及 import 語句的排序等操作。

如果想了解更多 Biome 的用法,可以參考官方文檔: https://next.biomejs.dev/guides/getting-started/

Biome 官方也提供了非常方便的 playground 去進行體驗: https://next.biomejs.dev/playground/

Parser 介紹

Ok,前面簡單介紹了 Biome 的作用以及使用方式,下面我們將從 Biome 的一些底層實現開始,瞭解到 Biome 是如何通過 Rust 來實現一個 Formatter / Lintter 工具的。

首先我們先從 Biome 的 Parser 開始,前面提到 Biome 支持多種語言的 Linter 以及 Formatter 的能力,那麼它就需要提供一套這些語言的 Parser 來作爲基礎支撐。

Parser 流程以及架構

Parser 的處理流程可以參考下圖:

畫板

以上是 Biome 的 Parser 處理流程,其實對於通用的 Parser 流程也基本如此:接受源碼,對源碼進行詞法分析,語法分析,生成語法樹,然後 Analyzer 以及 Lintter 等模塊通過語法樹去進行結果處理。

前端生態常見的工具鏈,例如 eslint 使用了 babel(https://babeljs.io/docs/babel-parser) 的 parser,這也是許多 JS 前端工具鏈的選擇,webpack 則使用 acorn(https://github.com/acornjs/acorn) 提供的 parser。

當然,目前也有部分 JS 工具鏈開始採用 Rust 生態的 parser,例如 prettier 開始使用 oxc-parser(https://github.com/prettier/prettier/pull/17472)。

主流的 Rust 工具鏈基本都會採用 SWC 去做 Parser(https://play.swc.rs/),Bundler 例如 Rspack、Farm 等,deno 同樣也使用了 SWC, 主要原因在於 SWC 對於 babel 有很強的兼容,而 Vue 團隊推出的 Rust Bundler Rolldown 使用了 oxc-parser(https://playground.oxc.rs/),這同樣是個優秀的新起方案,對於 estree 有完美的兼容,性能也很棒 (參考 benchmark: https://github.com/oxc-project/bench-javascript-parser-written-in-rust)。

那麼 Biome 也開發了自己的 Rust Parser,它的 Parser 相比其他的 Parser 有什麼區別呢?

Red-Green Tree

以下爲前面提到的一些 Parser 的行爲,在處理一段正確的語法時,例如圖中的 const a = 1;能正常 Parse 出對應的 AST 結構來,但對於一些有語法錯誤的代碼則不會輸出 AST 結構:

可以把對應的代碼寫在 SWC playground 上來測試: https://play.swc.rs/。

而對於 Biome 的 Parser 而言表現行爲則會如下:

首先 Biome 的 Parser 不管源代碼語句是合法還是非法都會輸出一份 AST 出來,同時在生成 AST 之前, Biome 會生成 Green Tree 和 Red Tree 的結構。

在展開介紹 Green Tree 和 Red Tree 之前,我們先介紹下 Biome 的 Parser 代碼結構,目前 Biome 的 Parser 邏輯主要在倉庫路徑 crates/biome_parser裏面 (源碼: https://github.com/biomejs/biome/tree/main/crates/biome_parser)。

這個 Rust crate 是其他所有 parser 的基礎,例如 biome_{js/json/css/html/graphql}_parser都是基於它實現,同時 Lexing(詞法分析) 處理也是在 biome_parser 這個 crates 中實現。

以上面的 biome_js_parser(Biome 中對於 JavaScript 以及 TypeScript 的 Parser 處理都在裏面) 爲例,Biome 會先把 source code 處理成一個綠樹和紅樹的數據結構。

其中綠樹是一個顆數據結構不可變 (Immutable) 的樹,包含所有的語法類型以及文本長度,綠樹上有源代碼的所有信息,大致的數據結構形式如下:

如圖是 const a = 1;代碼的綠樹構造,以上爲綠樹節點的數據結構構造,這裏使用了 Rust Analyzer 的一份簡單數據結構,表達的意思應該差不多:

#[derive(PartialEq, Eq, Clone)]
struct Node {
    kind: SyntaxKind,                        // 節點類型標籤
    text_lenusize,                         // 文本長度
    childrenVec<Arc<Either<Node, Token>>>, // 子節點(內部節點或Token)
}

#[derive(PartialEq, Eq, Clone)]
struct Token {
    kind: SyntaxKind,  // Token類型
    textString,      // 實際文本內容
}

本質上所有的綠樹節點都可以表示成一個 Node(例如上面的 const a = 1) 或者 Token (例如 a),這裏節點都通過 SyntaxKind 去區分節點類型 (例如 a是 SyntaxKind::JS_IDENTIFIER_BINDING ),這種表示形式可以讓節點在存儲的時候都具有相同的內存佈局,便於內存池化以及數據結構的共享。同時也更方便去保存任意的語法結構,包括不完整以及語法錯誤的代碼。

然後 Red Tree 則是一顆數據結構可變 (Mutable) 的樹,紅樹實際上是由綠樹構造而來,它本質上是綠樹的一層封裝,數據結構大概如下,這裏同樣採用 Rust Analyzer 提供的數據結構:

type SyntaxNode = Arc<SyntaxData>;

struct SyntaxData {
    offsetusize,              // 在整個文件中的絕對偏移量
    parentOption<SyntaxNode>, // 父節點指針
    green: Arc<GreenNode>,      // 包裝的 GreenNode
}

它主要解決了綠樹沒辦法進行父節點訪問以及無法區分具體結構的問題,舉個例子:

let x = 1;
x = x + 1; // 第一個 x + 1 
x = 3;
x = x + 1; // 第二個 x + 1

這裏 x + 1這個 JsBinaryExpression 實際上在綠樹中是個同樣的節點,但在紅樹中會有它的偏移量 (offset參數)。同樣紅樹也會記錄了節點跟節點之間的父子關聯關係。

而 Biome 的 AST 則是通過紅樹 (SyntaxNode) 構造而來,同時 Biome 的 AST 和目前 JS 的 ESTree 規範並不兼容,它通過自己定義的一套 DSL 規範來約束生成的 AST,具體參考: https://github.com/biomejs/biome/blob/main/xtask/codegen/js.ungram。

Biome 中 RedTree 到 AST 節點的轉換是個零成本的抽象過程,以 const a = 1;這個聲明語句爲例子,它的 AST 節點的結構體定義如下:

#[derive(Clone, PartialEq, Eq, Hash)]
pub struct JsVariableStatement {
    pub(crate) syntax: SyntaxNode,  // 包裝的 Red Tree 節點
}

而轉換過程也很簡單,內部實現大致如下:

impl AstNode for JsVariableStatement {
    fn can_cast(kind: SyntaxKind) -> bool {
        kind == JS_VARIABLE_STATEMENT  // 檢查節點類型
    }
    
    fn cast(syntax: SyntaxNode) -> Option<Self> {
        if Self::can_cast(syntax.kind()) {
            Some(Self { syntax })  // 零成本包裝
        } else {
            None
        }
    }
}

這裏可以看到 AST 節點實際上只是對 Red tree 的 SyntaxNode 做了一次包裝,JsVariableStatement 這一 AST 節點和 SyntaxNode 兩者內存佈局完全相同,在轉換的時候也是零成本:

let syntax_node: SyntaxNode = /* */;

// 紅樹節點 -> AST 節點
let ast_node = JsVariableStatement { syntax: syntax_node };
// or
let ast_node = JsVariableStatement::cast(syntax_node);

通過 Red Tree 節點產生的 AST 整體結構如圖所示:

這裏可以看出來,Biome 的 Token 節點前後都會附帶前導和尾隨的空格信息 (Whitespace("" )),當然除了這些還會包含更多信息,這裏例子只展示了空格,會包括換行符、空格、單行和多行註釋等:

pub enum TriviaPieceKind {
    Newline,           // 換行符
    Whitespace,        // 空白字符
    SingleLineComment, // 單行註釋
    MultiLineComment,  // 多行註釋
    Skipped,          // 跳過的 token
}

// token 都會攜帶前導和尾隨的 trivia
pubfn token_with_trivia(
    &mutself,
    kind: L::Kind,
    text: &str,
    leading: &[TriviaPiece],
    trailing: &[TriviaPiece],
)

Biome 在 Parser 遇到錯誤的節點時,也不會丟棄信息,會處理成 Bogus 節點,無法解析的內容也會保存在語法樹中,這更便於錯誤恢復 (LSP 場景),錯誤的節點 AST 如圖 (源碼:function}):

Biome 對於不合理的語法節點,會通過在正確 AST 結構基礎上添加 missing 字段,同時使用 bogus node 來表示不合理的語法節點,這對於後續進行節點的錯誤恢復提供了基礎。

紅綠樹實際上是由 C# 這門語言的編譯團隊開發出來的,在 rust-analyzer 以及 swift 中同樣也得到了使用,目前 Biome 使用了一個 forked 版本的 rowan,Rowan 則是目前 rust-analyzer 構造紅綠樹結構的 crates,更多資料可以參考:

Linter

Ok,前面介紹完 Biome Parser 目前和主流工具鏈裏面 Parser 存在的一些區別之後,這一節開始介紹介紹 Linter,它在 Biome 倉庫內部又被稱爲 Analyzer。

Linter 流程以及架構

回到最初的流程圖上來,在 parser 生成了對應了 AST 結構之後,就可以開始 Analyzyer 的流程了。

畫板

Biome 的 Analyzer 的 Rust Crates 的構造關聯如圖:

畫板

其中在 biome_js_analyzer包含了目前各種 Linter Rule 的實現 (大概約有 320 條),同時 biome_js_analyzer會調用 biome_analyzer::Analyzer並執行 Analyzer::run方法,在這一過程中會執行各種 Lint 規則以及收集到對應診斷的結果 (通過 Analyzer::emit_signal方法)。

首先我們先參考 biome_analyzer::Analyzer 這一結構體的參數類型:

裏面的字段不用全部理解,不過也有些註釋可以清楚:

同時可以看到因爲不同的語言的 Analyzer 都需要使用這個 Struct,因此它的泛型參數也定義了對應的 L: Language語言參數。

這裏還是以 biome_js_analyze來分析,在裏面調用了 Analyzer::run方法之後,整體的 JS / TS Lint 分析流程就開始了,其中 biome_analyze::PhaseRunner首先會通過 ast vistor 在每個階段針對 AstNode 執行特定的分析任務、生成診斷結果以及代碼修復 (fix) 等操作,然後分析並應用抑制註釋 (// biome-ignore) 去篩掉不應該丟出來的診斷信息,簡單貼一段代碼:

這裏 Analyzer 的分析整體流程都在圖中 for (index, (phase, mut visitors)) in phases.into_iter().enumerate()中處理,具體的代碼展開不介紹了,大致的執行流程如下:

這裏其實主要就是在不同的 Phase 階段進行處理,Phase 階段大致分爲語法分析、語義分析,在不同的階段調用不用的 PahseRunner 以及 AST Visitors 出處理對應的邏輯:

這裏分析完 biome_analyze 底層的一些實現之後,我們回到 biome_js_analyze,即調用 anayzer::run入口處,這一段的代碼邏輯在 biome_js_analyze:analyze處,它是整個 js analyzer 邏輯的發起點,主要完成這樣一些操作:

這裏的具體代碼邏輯集中在 analyze_with_inspect_matcher中,貼一部分這個方法的代碼:

// 接收 analyze 需要的參數
pubfn analyze_with_inspect_matcher<'a, V, F, B>(
    root: &LanguageRoot<JsLanguage>,
    filter: AnalysisFilter,
    inspect_matcher: V,
    options: &'a AnalyzerOptions,
    plugins: AnalyzerPluginSlice<'a>,
    services: JsAnalyzerServices,
    mut emit_signal: F,
) -> (Option<B>, Vec<DiagnosticError>)
where
    VFnMut(&MatchQueryParams<JsLanguage>) + 'a,
    FFnMut(&dyn AnalyzerSignal<JsLanguage>) -> ControlFlow<B> + 'a,
    B'a,
{
// 構建對應的 lint rule registry
letmut registry = RuleRegistry::builder(&filter, root);
  visit_registry(&mut registry);
let (registry, mut services, diagnostics, visitors, categories) = registry.build();

// 初始化 biome_analyze::Analyzer 
letmut analyzer = Analyzer::new(
      METADATA.deref(),
      InspectMatcher::new(registry, inspect_matcher),
      parse_linter_suppression_comment,
      Box::new(JsSuppressionAction),
      &mut emit_signal,
      categories,
  );

// 給每個階段添加對應的 biome_analyze::Visitor
for ((phase, _), visitor) in visitors {
    analyzer.add_visitor(phase, visitor);
  }

// 執行 Analyzer::run
  (analyzer.run(AnalyzerContext { root: root.clone(), range: filter.range, services, options }), diagnostics) 
}

對比 Eslint 的 Rust 實現

Ok,前面介紹了一下目前 Biome Analyzer 的基礎架構,現在我們來看具體每個 lint 要如何實現,舉個例子,以 Eslint 官方的自定義 Lint Rule 開發手冊作爲例子 (https://eslint.org/docs/latest/extend/custom-rule-tutorial):

我們要實現一個 Lint Rule,名稱叫做 enfore-foo-bar,規則表達的意思很簡單,就是所有 const 定義的變量 foo都應該賦值爲字符串字面量 "bar":

// show error
const foo = "foo123";

// should be:
const foo = "bar";

Eslint 的實現如下:

create(context) {
    return {
        // Performs action in the function on every variable declarator
        VariableDeclarator(node) {
            // Check if a `const` variable declaration
            if (node.parent.kind === "const") {
                // Check if variable name is `foo`
                if (node.id.type === "Identifier" && node.id.name === "foo") {
                    // Check if value of variable is "bar"
                    if (node.init && node.init.type === "Literal" && node.init.value !== "bar") {
                        // report error
                        context.report({ ... });
                    }
                }
            }
        }
    }
}

再使用 Biome 開發之前,我們先看下具體的 AST 結構 (const foo = "foo123"):

這裏實現思路就很簡單了,我們先找 foo這個 JsVariableDeclarator,然後判斷它的祖父是不是個包含 const的 JsVariableDeclaration,如果是,再去判斷字符串字面量值是不是 "bar"就可以了。

Ok,直接使用 Biome 來梭一個代碼出來:

impl Rule for EnforceFooBar {
    // define the ast node when visitor enter
    type Query = Ast<JsVariableDeclarator>;
    // for diagnotics and action check
    type State = ();
    // return value type for fn `run` 
    type Signals = Option<Self::State>;
    // Lint Rule's Option by user
    type Options = ();

    // The main logic for linter
     // - return Some(()) when to report error
    // - return None **when not** to report error
    fn run() -> Option<Self::State> {
        let node = ctx.query();
        let parent = node
            .parent::<JsVariableDeclaratorList>()?
            .parent::<JsVariableDeclaration>()?;

        // check if a `const` variable declaration
        if parent.is_const() {
            // Check if variable name is `foo`
            if node.id().ok()?.text() == "foo" {
                // Check if value of variable is "bar"
                let init_exp = node.initializer()?.expression().ok()?;
                let literal_exp = init_exp.as_any_js_literal_expression()?;
                if literal_exp.as_static_value()?.text() != "bar" {
                    // Report error to Biome
                    // The details of error message is implemented by "diagnostic" method
                    returnSome(());
                }
            }
        }
        None
    }
}

這裏直接對比一下兩邊的代碼實現:

Rust

JavaScript

其實整理上來看,兩邊代碼看着也算比較相同 (強行相同 hhh),從代碼上來看,如果你想給 Biome 實現一個 Rust Lint Rule 其實按照這裏的結構來實現就好,在最開始並不需要深度瞭解 Rust 以及 Biome,這其實對於一些前端開發同學來說會很友好。

Biome 社區

如果你對 Biome 感興趣的話 (不管是使用還是參與貢獻),可以通過下面這些渠道關注或者瞭解 Biome:

關於 Biome Roadmap 2025,可以參考筆者之前寫過的一篇文章: https://mp.weixin.qq.com/s/HAjnsndRJ4XGeiCkemCpZw

總結

本文沒介紹 Formatter 相關的實現,這塊其實看源碼會覺得不是很難,可以參考 Prettier 的一些架構設計: https://prettier.io/docs/technical-details,寫 Rust 工具鏈的內容比較難繃,後續有空再補一補。

參考文檔:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/bk6G8zD6MPgVUm02RzpeqQ