摘要:抽象語法樹是怎么生成的談到這點,就要說到計算機是怎么讀懂我們的代碼的。需要注意什么狀態狀態是抽象語法樹轉換的敵人,狀態管理會不斷牽扯我們的精力,而且幾乎所有你對狀態的假設,總是會有一些未考慮到的語法最終證明你的假設是錯誤的。
現在談到 babel 肯定大家都不會感覺到陌生,雖然日常開發中很少會直接接觸到它,但它已然成為了前端開發中不可或缺的工具,不僅可以讓開發者可以立即使用 ES 規范中的最新特性,也大大的提高了前端新技術的普及(學不動了...)。但是對于其轉換代碼的內部原理我們大多數人卻知之甚少,所以帶著好奇與疑問,筆者嘗試對其原理進行探索。
Babel 是一個通用的多功能 JavaScript 編譯器,但與一般編譯器不同的是它只是把同種語言的高版本規則轉換為低版本規則,而不是輸出另一種低級機器可識別的代碼,并且在依賴不同的拓展插件下可用于不同形式的靜態分析。(靜態分析:指在不需要執行代碼的前提下對代碼進行分析以及相應處理的一個過程,主要應用于語法檢查、編譯、代碼高亮、代碼轉換、優化、壓縮等等)
babel 做了什么和編譯器類似,babel 的轉譯過程也分為三個階段,這三步具體是:
解析 Parse
將代碼解析生成抽象語法樹( 即AST ),也就是計算機理解我們代碼的方式(擴展:一般來說每個 js 引擎都有自己的 AST,比如熟知的 v8,chrome 瀏覽器會把 js 源碼轉換為抽象語法樹,再進一步轉換為字節碼或機器代碼),而 babel 則是通過 babylon 實現的 。簡單來說就是一個對于 JS 代碼的一個編譯過程,進行了詞法分析與語法分析的過程。
轉換 Transform
對于 AST 進行變換一系列的操作,babel 接受得到 AST 并通過 babel-traverse 對其進行遍歷,在此過程中進行添加、更新及移除等操作。
生成 Generate
將變換后的 AST 再轉換為 JS 代碼, 使用到的模塊是 babel-generator。
而 babel-core 模塊則是將三者結合使得對外提供的API做了一個簡化。
此外需要注意的是,babel 只是轉譯新標準引入的語法,比如ES6箭頭函數:而新標準引入的新的原生對象,部分原生對象新增的原型方法,新增的 API 等(Proxy、Set 等), 這些事不會轉譯的,需要引入對應的 polyfill 來解決。
而我們編寫的 babel 插件則主要專注于第二步轉換過程的工作,專注于對于代碼的轉化規則的拓展,解析與生成的偏底層相關操作則有對應的模塊支持,在此我們理解它主要做了什么即可。
比如這樣一段代碼:
console.log("hello")
則會得到這樣一個樹形結構(已簡化):
{ "type": "Program", // 程序根節點 "body": [ { "type": "ExpressionStatement", // 一個語句節點 "expression": { "type": "CallExpression", // 一個函數調用表達式節點 "callee": { "type": "MemberExpression", // 表達式 "object": { "type": "Identifier", "name": "console" }, "property": { "type": "Identifier", "name": "log" }, "computed": false }, "arguments": [ { "type": "StringLiteral", "extra": { "rawValue": "hello", "raw": ""hello"" }, "value": "hello" } ] } } ], "directives": [] }
其中的所有節點名詞,均來源于 ECMA 規范 。
抽象語法樹是怎么生成的談到這點,就要說到計算機是怎么讀懂我們的代碼的。解析過程分為兩個步驟:
1.分詞: 將整個代碼字符串分割成語法單元數組(token)
JS 代碼中的語法單元主要指如標識符(if/else、return、function)、運算符、括號、數字、字符串、空格等等能被解析的最小單元。比如下面的代碼生成的語法單元數組如下:
在線分詞工具
function demo (a) { console.log(a || "a"); } => [ { "type": "Keyword","value": "function" }, { "type": "Identifier","value": "demo" }, { "type": "Punctuator","value": "(" }, { "type": "Identifier","value": "a" }, { "type": "Punctuator","value": ")" }, { "type": "Punctuator","value": "{ " }, { "type": "Identifier","value": "console" }, { "type": "Punctuator","value": "." }, { "type": "Identifier","value": "log" }, { "type": "Punctuator","value": "(" }, { "type": "Identifier","value": "a" }, { "type": "Punctuator","value": "||" }, { "type": "String","value": ""a"" }, { "type": "Punctuator","value": ")" }, { "type": "Punctuator","value": "}" } ]
2.語義分析: 在分詞結果的基礎上分析語法單元之間的關系。
語義分析則是將得到的詞匯進行一個立體的組合,確定詞語之間的關系??紤]到編程語言的各種從屬關系的復雜性,語義分析的過程又是在遍歷得到的語法單元組,相對而言就會變得更復雜。
先理解兩個重要概念,即語句和表達式。
語句(statement),即指一個具備邊界的代碼區域,相鄰的兩個語句之間從語法上來講互補影響,即調換順序也不會產生語法錯誤。
表達式(expression),則指最終有個結果的一小段代碼,他可以嵌入到另一個表達式,且包含在語句中。
簡單來說語義分析既是對語句和表達式識別,這是個遞歸過程,在解析中,babel 會在解析每個語句和表達式的過程中設置一個暫存器,用來暫存當前讀取到的語法單元,如果解析失敗,就會返回之前的暫存點,再按照另一種方式進行解析,如果解析成功,則將暫存點銷毀,不斷重復以上操作,直到最后生成對應的語法樹。
{"type": "Program", "body": [{ "type": "FunctionDeclaration", "id": { "type": "Identifier", "name": "demo" }, "params": [{ "type": "Identifier", "name": "a" }], "body": { "type": "BlockStatement", "body": [{ "type": "ExpressionStatement", "expression": { "type": "CallExpression", "callee": { "type": "MemberExpression", "computed": false, "object": { "type": "Identifier", "name": "console" }, "property": { "type": "Identifier", "name": "log" } }, "arguments": [{ "type": "LogicalExpression", "operator": "||", "left": { "type": "Identifier", "name": "a" }, "right": { "type": "Literal", "value": "a", "raw": ""a"" } }] } }] }, }]}
推薦
the-super-tiny-compiler 這是一個只用了百來行代碼的簡單編譯器開源項目,里面的作者也很用心的編寫了詳盡的注釋,通過代碼可以更好地理解這個過程。
了解源代碼的 AST 結構則是我們轉換過程的關鍵點,可以借助直觀的樹形結構轉換 AST Explorer,更加直觀的理解 AST 結構。
Visitors
對于這個遍歷過程,babel 通過實例化 visitor 對象完成,既其實我們生成出來的 AST 結構都擁有一個 accept 方法用來接收 visitor 訪問者對象的訪問,而訪問者其中也定義了 visit 方法(即開發者定義的函數方法)使其能夠對樹狀結構不同節點做出不同的處理,借此做到在對象結構的一次訪問過程中,我們能夠遍歷整個對象結構。(訪問者設計模式:提供一個作用于某對象結構中的各元素的操作表示,它使得可以在不改變各元素的類的前提下定義作用于這些元素的新操作)
遍歷結點讓我們可以定位并找到我們想要操作的結點,在遍歷每一個節點時,存在enter和exit兩個時態周期,一個是進入結點時,這個時候節點的子節點還沒觸達,遍歷子節點完成的后,會離開該節點并觸發exit方法。
Paths
Visitors 在遍歷到每個節點的時候,都會給我們傳入 path 參數,包含了節點的信息以及節點和所在的位置,供我們對特定節點進行修改,之所以稱之為 path 是其表示的是兩個節點之間連接的對象,而非指當前的節點對象。path屬性有幾個重要的組成,主要如下:
例如,如果訪問到下面這樣的一個節點
{ type: "FunctionDeclaration", id: { type: "Identifier", name: "square" } }
而他的 path 關聯路徑得到的對象則是這樣的。
{ "parent": { "type": "FunctionDeclaration", "id": {...},... }, { "node": { "type": "Identifier", "name": "square" } } }
可以看到 path 其實是一個節點在樹中的位置以及關于該節點各種信息的響應式表示,即我們訪問過程中操作的并不是節點本身而是路徑,且其中包含了添加、更新、移動和刪除節點有關的其他很多方法,當調用一個修改樹的方法后,路徑信息也會被更新。主要目的還是為了簡化操作,盡可能做到無狀態。
實際運用
假如有如下代碼:
NEJ.define(["./modal"], function(Modal){}); => transform 為 define(["./modal"], function(Modal){});
我們想要把 NEJ.define轉化為 define,為了將模塊依賴系統轉換為標準的 AMD 形式,則可以用編寫 babel 插件的方式去做。
首先我們先分析需要訪問修改的 AST 結構
{ ExpressionStatement { expression: CallExpression { callee: MemberExpression { object: Identifier { name: "NEJ" } property: Identifier { name: "define" } } arguments: [ ArrayExpression{}, FunctionExpression{} ] } } } => 轉化為下面這樣 { ExpressionStatement { expression: CallExpression { callee: Identifier { name: "define" } arguments: [ ArrayExpression{}, FunctionExpression{} ] } } }
分析結構可以看到,arguments 是代碼中傳入的參數部分,這部分保持不變直接拿到就可以了,我們需要修改的是 MemberExpression 表達式節點下的name 為 "NEJ" 的 Identifier部分,由于修改后的結構是一個CallExpression函數調用形式的表達式,那么整體思路現在就是創建一個CallExpression替換掉原來的 MemberExpression即可。這里借用了 babel-type( 為 babel提供多種輔助函數,類似于 loadsh 與 js之間的關系)創建節點。
const babel = require("babel-core"); const t = require("babel-types"); const code = "NEJ.define(["./modal"], function(Modal){});"; let args = []; const visitor = { ExpressionStatement(path) { if (path.node && path.node.arguments) { args = path.node.arguments; } }, MemberExpression(path) { if (path.node && path.node.object && path.node.object.name === "NEJ") { path.replaceWith(t.CallExpression( t.identifier("define"), args )) } } } const result = babel.transform(code, { plugins: [{ visitor }] }) console.log(result.code)
執行后即可看到結果
define((["./modal"], function (Modal) {});
在代碼中可以看到,對于每一步訪問到的節點我們都要嚴格的判斷是否與我們預想的類型一致,這樣不僅是為了排除到其他情況,更是為了防止 Visitor 在訪問相同節點時誤入到其中,但是它可能沒有需要的屬性,那么就非常容易出錯或者誤傷,嚴格的控制節點的獲取流程將會省去不少不必要的麻煩。
需要注意什么State 狀態
狀態是抽象語法樹 AST 轉換的敵人,狀態管理會不斷牽扯我們的精力,而且幾乎所有你對狀態的假設,總是會有一些未考慮到的語法最終證明你的假設是錯誤的。
Scope 作用域
在 JavaScript 中,每當你創建了一個引用,不管是通過變量(variable)、函數(function)、類型(class)、參數(params)、模塊導入(import)還是標簽(label)等,它都屬于當前作用域。
當編寫一個轉換時,必須要小心作用域。我們得確保在改變代碼的各個部分時不會破壞已經存在的代碼。在添加一個新的引用時需要確保新增加的引用名字和已有的所有引用不沖突,或者僅僅想找出使用一個變量的所有引用, 我們只想在給定的作用域(Scope)中找出這些引用。
作用域可以被表示為如下形式:
{ path: path, block: path.node, parentBlock: path.parent, parent: parentScope, bindings: [...] }
即在創建一個新的作用域的時候,需要給出它的路徑和父作用域,之后在遍歷的過程中它會在該作用域內收集所有的引用,收集完畢后既可以在作用域上調用方法。
例如下面代碼中,我么需要將函數中的 n 轉換為 x 。
function square(n) { return n * n; } var n = 1; // 定義的 visitor(錯誤版?) let paramName; const MyVisitor = { FunctionDeclaration(path) { const param = path.node.params[0]; paramName = param.name; param.name = "x"; }, Identifier(path) { if (path.node.name === paramName) { path.node.name = "x"; } } };
如果不考慮作用域的問題,則會導致函數外的 n 也被轉變,所以在轉換的過程中我們可以在 FunctionDeclaration 節點中進行 n 的轉變,把需要遍歷的轉換方法放在其中,防止對外部的代碼產生作用。
// 改進后 const updateParamNameVisitor = { Identifier(path) { if (path.node.name === this.paramName) { path.node.name = "x"; } } }; const MyVisitor = { FunctionDeclaration(path) { const param = path.node.params[0]; const paramName = param.name; param.name = "x"; path.traverse(updateParamNameVisitor, { paramName }); } }; path.traverse(MyVisitor);
Bindings 綁定
所有引用屬于特定的作用域,引用和作用域的這種關系稱作為綁定。
例如需要將 const 轉換為 var,并且對 const 聲明的值給予只讀保護。
const a = 1; const b = 4; function test (){ let a = 2; a = 3; } a = 34;
而對于上面的這種情況,由于 function 有自己的作用域,所以在 function 內 a 可以被修改,而在外面則不能被修改。所以在實際應用中就需要考慮到綁定關系。
使用配置常見做法是設置一個根目錄下的 .babelrc 文件,統一將 babel 的設置都放在這里。
常用 options 字段說明
env:env 的核心目的是通過配置得知目標環境的特點,然后只做必要的轉換。例如目標瀏覽器支持 es2015,那么 es2015 這個 preset 其實是不需要的,于是代碼就可以小一點(一般轉化后的代碼總是更長),構建時間也可以縮短一些。如果不寫任何配置項,env 等價于 latest,也等價于 es2015 + es2016 + es2017 三個相加(不包含 stage-x 中的插件)。
plugins:要加載和使用的插件,插件名前的babel-plugin-可省略;plugin列表按從頭到尾的順序運行
presets:要加載和使用的preset ,每個 preset 表示一個預設插件列表,preset名前的babel-preset-可省略;presets列表的preset按從尾到頭的逆序運行(為了兼容用戶使用習慣)
同時設置了presets和plugins,那么plugins的先運行;每個preset和plugin都可以再配置自己的option
常見的配置方法
{ "plugins": [ "transform-remove-strict-mode", ["transform-nej-module", {"mode": "web"}] ], "presets": [ "env" ] }參考
Babel 插件手冊
Babel是如何讀懂JS代碼的
推薦工具
AST Explorer 在線生成 AST
Esprima 可以查看分詞結果
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://m.specialneedsforspecialkids.com/yun/97687.html
摘要:整理收藏一些優秀的文章及大佬博客留著慢慢學習原文協作規范中文技術文檔協作規范阮一峰編程風格凹凸實驗室前端代碼規范風格指南這一次,徹底弄懂執行機制一次弄懂徹底解決此類面試問題瀏覽器與的事件循環有何區別筆試題事件循環機制異步編程理解的異步 better-learning 整理收藏一些優秀的文章及大佬博客留著慢慢學習 原文:https://www.ahwgs.cn/youxiuwenzhan...
摘要:的轉譯過程分為三個階段。標準為例,提供了如下的一些的只轉譯該年份批準的標準,而則代指最新的標準,包括和。未完待續,繼續學習繼續補充哦參考文獻深入理解原理及其使用 Babel Babel的轉譯過程分為三個階段: parsing, transforming, generating。babel只是轉譯新標準引入的語法,比如ES6的箭頭函數轉譯成ES5的函數;而新標準引入的原生對象,部分原生對...
摘要:下面是用實現轉成抽象語法樹如下還支持繼承以下是轉換結果最終的結果還是代碼,其中包含庫中的一些函數??梢允褂眯碌囊子谑褂玫念惗x,但是它仍然會創建構造函數和分配原型。 這是專門探索 JavaScript 及其所構建的組件的系列文章的第 15 篇。 想閱讀更多優質文章請猛戳GitHub博客,一年百來篇優質文章等著你! 如果你錯過了前面的章節,可以在這里找到它們: JavaScript 是...
摘要:深入淺出指的是添加在標準第六版中的編程語言的新特性,簡稱為。登場在一個具體的項目中,使用有幾種不同的方法。這是為了避免在安裝時使用根管理權限。以用戶角度展示系統響應速度,以地域和瀏覽器維度統計用戶使用情況。 深入淺出 ES6 指的是添加在 ECMASript 標準第六版中的 JavaScript 編程語言的新特性,簡稱為 ES6。 雖然 ES6 剛剛到來,但是人們已經開始談論 ES7 ...
摘要:接著上一篇文章深入了解一的處理步驟的三個主要處理步驟分別是解析,轉換,生成。模塊是的代碼生成器,它讀取并將其轉換為代碼和源碼映射抽象語法樹抽象語法樹在以上三個神器中都出現過,所以對于編譯器來說至關重要。 接著上一篇文章《深入了解babel(一)》 Babel 的處理步驟 Babel 的三個主要處理步驟分別是: 解析(parse),轉換(transform),生成(generate)。對...
閱讀 818·2023-04-25 20:18
閱讀 2100·2021-11-22 13:54
閱讀 2543·2021-09-26 09:55
閱讀 3910·2021-09-22 15:28
閱讀 2980·2021-09-03 10:34
閱讀 1716·2021-07-28 00:15
閱讀 1642·2019-08-30 14:25
閱讀 1286·2019-08-29 17:16