
戳藍字「前端技術優選」關注我們哦!在前端圈子裡,對於 Babel,大家肯定都比較熟悉了。如果哪天少了它,對於前端工程師來說肯定是個噩夢。Babel 的工作原理是怎樣的可能了解的人就不太多了。本文將主要介紹 Babel 的工作原理以及怎麼寫一個 Babel 插件。Babel 是怎麼工作的
Babel 是一個 JavaScript 編譯器。做與不做注意很重要的一點就是,Babel 只是轉譯新標準引入的語法,比如:哪些在 Babel 範圍外?對於新標準引入的全局變量、部分原生對象新增的原型鏈上的方法,Babel 表示超綱了。對於上面的這些 API,Babel 是不會轉譯的,需要引入 polyfill 來解決。Babel 編譯的三個階段 Babel 的編譯過程和大多數其他語言的編譯器相似,可以分為三個階段:解析(Parsing):將代碼字符串解析成抽象語法樹。轉換(Transformation):對抽象語法樹進行轉換操作。生成(Code Generation): 根據變換後的抽象語法樹再生成代碼字符串。
為了理解 Babel,我們從最簡單一句 console 命令下手解析(Parsing)Babel 拿到源代碼會把代碼抽象出來,變成 AST (抽象語法樹),學過編譯原理的同學應該都聽過這個詞,全稱是 Abstract Syntax Tree。抽象語法樹是源代碼的抽象語法結構的樹狀表示,樹上的每個節點都表示源代碼中的一種結構,只所以說是抽象的,是因為抽象語法樹並不會表示出真實語法出現的每一個細節,比如說,嵌套括號被隱含在樹的結構中,並沒有以節點的形式呈現,它們主要用於源代碼的簡單轉換。console.log('zcy'); 的 AST 長這樣:
{"type":"Program","body":[{"type":"ExpressionStatement","expression":{"type":"CallExpression","callee":{"type":"MemberExpression","computed":false,"object":{"type":"Identifier","name":"console"},"property":{"type":"Identifier","name":"log"}},"arguments":[{"type":"Literal","value":"zcy","raw":"'zcy'"}]}}],"sourceType":"script"}
上面的 AST 描述了源代碼的每個部分以及它們之間的關係。AST 是怎麼來的?
語法單元通俗點說就是代碼中的最小單元,不能再被分割,就像原子是化學變化中的最小粒子一樣。Javascript 代碼中的語法單元主要包括以下這麼幾種:標識符:可能是一個變量,也可能是 if、else 這些關鍵字,又或者是 true、false 這些常量注釋:對於計算機來說,知道是這段代碼是注釋就行了,不關心其具體內容其實分詞說白了就是簡單粗暴地對字符串一個個遍歷。為了模擬分詞的過程,寫了一個簡單的 Demo,僅僅適用於和上面一樣的簡單代碼。Babel 的實現比這要複雜得多,但是思路大體上是相同的。對於一些好奇心比較強的同學,可以看下具體是怎麼實現的,鏈接在文章底部。
functiontokenizer(input){consttokens=[];constpunctuators=[',','.','(',')','=',';'];letcurrent=0;while(current<input.length){letchar=input[current];if(punctuators.indexOf(char)!==-1){tokens.push({type:'Punctuator',value:char,});current++;continue;}//檢查空格,連續的空格放到一起letWHITESPACE=/\s/;if(WHITESPACE.test(char)){current++;continue;}//標識符是字母、$、_開始的if(/[a-zA-Z\$\_]/.test(char)){letvalue='';while(/[a-zA-Z0-9\$\_]/.test(char)){value+=char;char=input[++current];}tokens.push({type:'Identifier',value});continue;}//數字從0-9開始,不止一位constNUMBERS=/[0-9]/;if(NUMBERS.test(char)){letvalue='';while(NUMBERS.test(char)){value+=char;char=input[++current];}tokens.push({type:'Numeric',value});continue;}//處理字符串if(char==='"'){letvalue='';char=input[++current];while(char!=='"'){value+=char;char=input[++current];}char=input[++current];tokens.push({type:'String',value});continue;}//最後遇到不認識到字符就拋個異常出來thrownewTypeError('Unexpectedcharactor:'+char);}returntokens;}constinput=`console.log("zcy");`console.log(tokenizer(input));
[{"type":"Identifier","value":"console"},{"type":"Punctuator","value":"."},{"type":"Identifier","value":"log"},{"type":"Punctuator","value":"("},{"type":"String","value":"'zcy'"},{"type":"Punctuator","value":")"},{"type":"Punctuator","value":";"}]語義分析則是將得到的詞彙進行一個立體的組合,確定詞語之間的關係。考慮到編程語言的各種從屬關係的複雜性,語義分析的過程又是在遍歷得到的語法單元組,相對而言就會變得更複雜。簡單來說語法分析是對語句和表達式識別,這是個遞歸過程,在解析中,Babel 會在解析每個語句和表達式的過程中設置一個暫存器,用來暫存當前讀取到的語法單元,如果解析失敗,就會返回之前的暫存點,再按照另一種方式進行解析,如果解析成功,則將暫存點銷毀,不斷重複以上操作,直到最後生成對應的語法樹。轉換(Transformation)Plugins插件應用於 babel 的轉譯過程,尤其是第二個階段 Transformation,如果這個階段不使用任何插件,那麼 babel 會原樣輸出代碼。PresetsBabel 官方幫我們做了一些預設的插件集,稱之為 Preset,這樣我們只需要使用對應的 Preset 就可以了。每年每個 Preset 只編譯當年批准的內容。而 babel-preset-env 相當於 ES2015 ,ES2016 ,ES2017 及最新版本。Plugin/Preset 路徑如果 Plugin 是通過 npm 安裝,可以傳入 Plugin 名字給 Babel,Babel 將檢查它是否安裝在 node_modules 中。"plugins":["babel-plugin-myPlugin"]也可以指定你的 Plugin/Preset 的相對或絕對路徑。"plugins":["./node_modules/asdf/plugin"]Plugin/Preset 排序如果兩次轉譯都訪問相同的節點,則轉譯將按照 Plugin 或 Preset 的規則進行排序然後執行。Preset 的順序則剛好相反(從最後一個逆序執行)。{"plugins":["transform-decorators-legacy","transform-class-properties"]}將先執行 transform-decorators-legacy 再執行 transform-class-properties{"presets":["es2015","react","stage-2"]}會按以下順序運行: stage-2, react, 最後 es2015。那麼問題來了,如果 presets 和 plugins 同時存在,那執行順序又是怎樣的呢?答案是先執行 plugins 的配置,再執行 presets 的配置。@babel/plugin-proposal-decorators@babel/plugin-proposal-class-properties@babel/plugin-transform-runtime
//.babelrc文件{"presets":[["@babel/preset-env"]],"plugins":[["@babel/plugin-proposal-decorators",{"legacy":true}],["@babel/plugin-proposal-class-properties",{"loose":true}],"@babel/plugin-transform-runtime",]}生成(Code Generation)用 babel-generator 通過 AST 樹生成 ES5 代碼。如何編寫一個 Babel 插件 基礎的東西講了些,下面說下具體如何寫插件,只做簡單的介紹,感興趣的同學可以看 Babel 官方的介紹。插件格式先從一個接收了當前 Babel 對象作為參數的 Function 開始。exportdefaultfunction(babel){//plugincontents}exportdefaultfunction({types:t}){//}接着返回一個對象,其 visitor 屬性是這個插件的主要訪問者。exportdefaultfunction({types:t}){return{visitor:{//visitorcontents}};};visitor 中的每個函數接收 2 個參數:path 和 stateexportdefaultfunction({types:t}){return{visitor:{CallExpression(path,state){}}};};寫一個簡單的插件我們先寫一個簡單的插件,把所有定義變量名為 a 的換成 b ,先看下 var a = 1 的 AST{"type":"Program","start":0,"end":10,"body":[{"type":"VariableDeclaration","start":0,"end":9,"declarations":[{"type":"VariableDeclarator","start":4,"end":9,"id":{"type":"Identifier","start":4,"end":5,"name":"a"},"init":{"type":"Literal","start":8,"end":9,"value":1,"raw":"1"}}],"kind":"var"}],"sourceType":"module"}從這裡看,要找的節點類型就是 VariableDeclarator ,下面開始擼代碼exportdefaultfunction({types:t}){return{visitor:{VariableDeclarator(path,state){if(path.node.id.name=='a'){path.node.id=t.identifier('b')}}}}}我們要把 id 屬性是 a 的替換成 b 就好了。但是這裡不能直接 path.node.id.name = 'b' 。如果操作的是Object,就沒問題,但是這裡是 AST 語法樹,所以想改變某個值,就是用對應的 AST 來替換,現在我們用新的標識符來替換這個屬性。import*asbabelfrom'@babel/core';constc=`vara=1`;const{code}=babel.transform(c,{plugins:[function({types:t}){return{visitor:{VariableDeclarator(path,state){if(path.node.id.name=='a'){path.node.id=t.identifier('b')}}}}}]})console.log(code);//varb=1實現一個簡單的按需打包功能例如我們要實現把 import { Button } from 'antd' 轉成 import Button from 'antd/lib/button'通過對比 AST 發現,specifiers 里的 type 和 source 不同。//import{Button}from'antd'"specifiers":[{"type":"ImportSpecifier",...}]//importButtonfrom'antd/lib/button'"specifiers":[{"type":"ImportDefaultSpecifier",...}]import*asbabelfrom'@babel/core';constc=`import{Button}from'antd'`;const{code}=babel.transform(c,{plugins:[function({types:t}){return{visitor:{ImportDeclaration(path){const{node:{specifiers,source}}=path;if(!t.isImportDefaultSpecifier(specifiers[0])){//對specifiers進行判斷,是否默認倒入constnewImport=specifiers.map(specifier=>(t.importDeclaration([t.ImportDefaultSpecifier(specifier.local)],t.stringLiteral(`${source.value}/lib/${specifier.local.name}`))))path.replaceWithMultiple(newImport)}}}}}]})console.log(code);//importButtonfrom"antd/lib/Button";當然 babel-plugin-import 這個插件是有配置項的,我們可以對代碼做以下更改。exportdefaultfunction({types:t}){return{visitor:{ImportDeclaration(path,{opts}){const{node:{specifiers,source}}=path;if(source.value===opts.libraryName){//...}}}}}至此,這個插件我們就編寫完成了。
Babel 常用 API @babel/coreBabel 的編譯器,核心 API 都在這裡面,比如常見的 transform、parse。@babel/clicli 是命令行工具, 安裝了 @babel/cli 就能夠在命令行中使用 babel 命令來編譯文件。當然我們一般不會用到,打包工具已經幫我們做好了。@babel/nodebabylonbabel-traverse用於對 AST 的遍歷,維護了整棵樹的狀態,並且負責替換、移除和添加節點。babel-types用於 AST 節點的 Lodash 式工具庫, 它包含了構造、驗證以及變換 AST 節點的方法,對編寫處理 AST 邏輯非常有用。babel-generatorBabel 的代碼生成器,它讀取 AST 並將其轉換為代碼和源碼映射(sourcemaps)。總結 文章主要介紹Babel 編譯代碼的過程和原理以及簡單編寫了一個 babel 插件,歡迎大家對內容進行指正和討論。