經過編譯器插件實現代碼注入

原文:經過編譯器插件實現代碼注入 | AlloyTeam
做者:林大媽前端

背景問題

大型的前端系統通常是模塊化的。每當發現問題時,模塊負責人老是要重複地在瀏覽器中找出對應的模塊,略讀代碼後在對應的函數內打上斷點,最終開始排查。node

大部分狀況下,咱們會選擇在固定的位置(例如模塊的入口,或是類的構造函數)打上斷點。也就意味着打斷點的過程對於開發者來講是機械的勞動。那麼有沒有辦法在不污染源代碼的基礎上經過配置來爲代碼打上斷點呢?webpack

實現思路

要想不污染源代碼,只能選擇在編譯時進行處理,才能將想要的內容注入到目標代碼中。代碼編譯的基本原理是將源代碼處理成單詞串,再將單詞串組織成抽象語法樹,最終再經過遍歷抽象語法樹並轉換上面的節點而造成目標代碼。web

所以,代碼注入的關鍵點就在於在抽象語法樹造成時對語法樹節點進行處理。前端代碼一般會使用 babel 進行編譯。算法

熟悉 babel 的基本原理

babel 的組成

babel 的核心是 babel-core。babel-core 可被劃分紅三個部分,分別處理對應的三個編譯過程:npm

  1. babel-parser —— 負責將源代碼字符串「單詞化」並轉化成抽象語法樹
  2. babel-traverse —— 負責遍歷抽象語法樹並附加處理
  3. babel-generator —— 負責經過抽象語法樹生成目標代碼

babel-parser

整個 babel-parser 使用繼承的方式,根據功能的不一樣逐層包裝:數組

tokenizer

babel-parser 的一個核心是「tokenizer」,能夠理解爲「單詞生成器」。babel 維護了一個 state(一個全局的狀態),它會經過記錄一些元信息提供給編譯器,例如:瀏覽器

  • 「這段 JavaScript 代碼是否使用了嚴格模式?」
  • 「咱們如今識別到第幾行第幾列了?」
  • 「這段代碼裏有哪些註釋?」

tokenizer 的內部定義了不一樣的方法以識別不一樣的內容,例如:babel

  • 讀到引號時經過 readString 方法嘗試生成一個字符串 token
  • 讀到數字時經過 readNumber 方法嘗試生成一個數字 token

LVal/Expression/StatementParser

babel-parser 的另外一個核心是「parser」,能夠理解爲「語法樹生成器」。其中,StatementParser 是子類,當咱們引入 babel-parser 並調用 parse 方法時,識別過程將今後處啓動(babel 應該是將整個文件認爲是一個語句節點)。前端工程師

一樣地,這些 parser 的內部也都爲識別不一樣的內容而定義不一樣的方法,例如:

  • 識別到 true 時,生成一個 Boolean 字面量表達式
  • 識別到 @ 這個符號時,生成一個  裝飾器語句節點

babel-traverse

babel-traverse 提供方法遍歷語法樹。它使用訪問者模式,爲外界提供添加遍歷時附加操做的入口。

在訪問者模式(Visitor Pattern)中,咱們使用了一個訪問者類,它改變了元素類的執行算法。經過這種方式,元素的執行算法能夠隨着訪問者改變而改變。

TraversalContext

遍歷語法樹時,babel 一樣定義了一個 context 判斷是否須要遍歷以及遍歷的方式。

TraversalContext 先將節點和外來的訪問者進行有效化的處理,而後構造訪問隊列,最後啓動深度優先遍歷整棵語法樹的過程。

class TraversalContext {
 // ...
 visitQueue(queue: Array<NodePath>) {
 // 一些預處理
 // 深度優先遍歷
 for (const path of queue) {
 if (path.visit()) {
 stop = true;
 break;
 }
 }
 // ...
 }
 // ...
}

visitor

babel 使用的訪問者模式,很是地利於開發者編寫插件。編寫 babel 插件的核心思路就是編寫 visitor,以附加對語法樹進行的操做。

在 babel 中,visitor 是一個對象(能夠經過 babel 的 ts 聲明文件找到類型規範),經過在這個對象中新增 key(須要訪問的節點)和 value(執行的函數)可使遍歷語法樹時對應執行指定的操做:

// 如:編寫一個插件,每次遍歷到標識符時就輸出該變量名
const visitor = {
 Identifier(path, state) {
 console.log(path.node.name);
 },
};
// 或
const visitor = {
 Identifier: {
 enter(path, state) {
 console.log(path.node.name);
 },
 exit() {
 // do nothing...
 },
 },
};

path

path 是每一個 visitor 方法中傳入的第一個參數,它表示樹上的該節點與其它節點的關係。編寫 babel 插件,最核心的是瞭解並利用好 path 上掛載的元數據以及對外暴露的 API。

path 上有如下相對重要的屬性:

  • node 節點
  • parent 父節點
  • parentPath 父節點的 path
  • container 包含全部同級節點的元素
  • context 節點對應的 TraversalContext
  • contexts 節點對應的多個 TraversalContext
  • scope 節點的做用域
  • ……

path 的原型上還掛載了許多其它的處理方法:

  • get (靜態方法)獲取節點的屬性
  • insertBefore 在當前節點前增長指定的元素
  • insertAfter 在當前節點後增長指定的元素
  • unshiftContainer 將指定的節點插入該節點的 container 的首位
  • pushContainer 將指定的節點插入該節點的 container 的末位
  • ……

state

state 表示當前遍歷的狀態,記錄了一些元信息,與 tokenizer 的 state 相似。

babel-generator

babel-generator 主要實現了兩個功能:

  1. 使用緩衝區分步生成目標代碼
  2. 源碼映射(sourcemap)

babel-generator 暴露了 generate 函數,接收語法樹、配置以及源代碼爲參數。其中,語法樹用於生成目標代碼,而源代碼用做 sourcemap。babel-generator 中的代碼業務邏輯較多,沒有太過複雜的設計,但拆分函數很是細,全部的判斷以及不一樣種符號的處理都被拆開了,新增功能很是簡單。

Buffer

buffer 中定義了一個存放目標代碼的字符串數組,以及一個存放末尾符號(空格、分號以及'n')的隊列。字符串數組採用按行插入的方式。存放末尾符號的隊列用以處理行末多餘的空格(即每次插入末尾符號前 pop 出全部的空格)。

SourceMap

babel-generator 採用了 npm library source-map 來構建 sourceMap。babel 在輸出代碼時,只要位置不是在目標代碼的換行處,都會進行一次標記以提供參數給 source-map 庫,目前 source-map 庫具體內容還未細緻研究。

插件的具體實現

瞭解 babel 之後,結合咱們的需求,基本目標可定爲:編寫可配置的 babel 插件,使開發人員經過配置文件在特定位置下放斷點。

babel 插件的核心是 visitor,這裏咱們舉一個具體而特殊的例子來描述如何實現以上的目標:

將特定的註釋替換成調試語句

首先,應從 babel 構造的語法樹上找到對應的註釋節點。但咱們發現,在 babel 構造的語法樹中,不管何種註釋,都不是一個具體的節點:

例如,對於如下的代碼:

// @debug
const a = 1;

在它的語法樹中,註釋節點只屬於某段具體的代碼的"leadingComments"屬性,而非獨立的樹節點。再考慮如下代碼:

const a = 1;
// @debug
const b = 2;

在它的語法樹中,註釋節點既屬於第一段的"trailingComments"屬性,也屬於第二段代碼的"leadingComments"屬性。包括代碼和註釋同行,結果也是相同的。

所以,在編寫 visitor 前,須要注意兩個點:

  1. 註釋並非特定的語法樹節點,而是節點上的一個屬性。
  2. 遍歷全部語句時,前一句的"trailingComments"和後一句的"leadingComments"會發生重複。

採起的解決方案是:

  1. 直接在 visitor 中添加"CommentLine"屬性進行處理是無用的。可選擇在 traverse 時使用"enter"方法統一檢測全部節點的先後註釋。
  2. 「後顧」,當前節點有"trailingComments"須要替換時,要遍歷後一個兄弟節點的"leadingComments"進行去重,或者每次替換時直接將註釋內容刪除。

完整的 visitor 代碼以下:

export const visitor = {
 enter(path) {
 addDebuggerToDebugCommentLine(path);
 // 添加其它的處理方法……
 },
};
// 經過key值防止重複
let dulplicationKey = null;
function addDebuggerToDebugCommentLine(path) {
 const node = path.node;
 if (hasLeadingComments(node)) {
 // 遍歷全部的前綴註釋
 node.leadingComments.forEach((comment) => {
 const content = comment.value;
 // 檢測該key值與防重複key值相同
 if (path.key === dulplicationKey) {
 return;
 }
 // 檢測註釋是否符合debug模式
 if (!isDebugComment(content)) {
 return;
 }
 // 傳入參數,插入調試代碼
 path.insertBefore();
 });
 }
 if (hasTrailingComments(node)) {
 // 遍歷全部的後綴註釋
 node.trailingComments.forEach((comment) => {
 const content = comment.value;
 // 檢測註釋是否符合debug模式
 if (!isDebugComment(content)) {
 return;
 }
 // 防止下一個sibling節點重複遍歷註釋
 dulplicationKey = path.key + 1;
 // 傳入參數,插入調試代碼
 path.insertBefore();
 });
 }
}

上述的例子之因此說特殊,是由於註釋不是語法樹上的節點,而是節點上的一個屬性。當僅須要識別某類節點時,方法就更爲簡單了,直接經過爲 visitor 定義更多的方法便可完成:

export const visitor = {
 Expression(path) {
 addDebuggerToExpression(path);
 },
 Statement(path) {
 addDebuggerToStatement(path);
 },
 // 添加其它須要的方法……
};

當出現更復雜的狀況(例如要在調試語句中傳入參數)時,豐富以上的函數。經過使用解析註釋或在 webpack loader 中解析配置項文件得到參數,對應傳入便可。

用途

根據以上的代碼編譯出的代碼是通過處理後的代碼。它部署到某個測試環境後,有如下的用途:

  • 灰度某個用戶,便可隨時排查該用戶的使用問題。
  • 在項目中增長不污染源代碼的配置文件,使開發人員經過配置下放指定代碼。
  • 甚至還能夠增長可視化界面進行配置。

通用化

瞭解插件知識後,咱們能夠總結出插件的最大特色:幾乎能夠在代碼任意處修改任意內容。理論上,只要邏輯打通,語法樹有無窮的玩法。例如剛纔提到的根據配置下放調試代碼和常見的單測覆蓋率統計等。

所以,還能夠對插件進行更高級的抽象,作成插件工廠,可供用戶配置生成對應功能的插件並從新執行編譯等。


AlloyTeam 歡迎優秀的小夥伴加入。
簡歷投遞: alloyteam@qq.com
詳情可點擊 騰訊AlloyTeam招募Web前端工程師(社招)

clipboard.png

相關文章
相關標籤/搜索