Babel系列專題
最近的技術專案裡大量用到了需要修改原始檔程式碼的需求,也就理所當然的用到了Babel及其外掛開發。這一個專題我們介紹下Babel相關的知識及使用。
對於剛開始接觸程式碼編譯轉換的同學,單純的介紹Babel相關的概念只是會當時都能看懂,但是到了自己去實現一個需求的時候就又會變得不知所措,所以我們再介紹中穿插一些例子。
大概分為以下幾塊:
0、Babel基礎介紹
1、使用npm上好用的Babel外掛提升開發效率
2、使用Babel做程式碼轉換使用到的模組及執行流程
3、示例:類中插入方法、類方法中插入程式碼
4、Babel外掛開發介紹
5、示例:通過Babel實現打包構建優化 — 元件模組按需打包
0.Babel基礎介紹
用到的名詞:
- AST:Abstract Syntax Tree, 抽象語法樹
- DI: Dependency Injection, 依賴注入
我們在實際的開發過程中,經常有需要修改js原始碼的需求,比如一下幾種情形:
- ES6/7轉化為瀏覽器可支援的ES5甚至ES3程式碼;
- JSX程式碼轉化為js程式碼(原來是Facebook團隊支援在瀏覽器中執行轉換,現在轉到在babel外掛中維護);
- 部分js新的特性動態注入(用的比較多的就是babel-plugin-transform-runtime);
- 一些便利性特性支援,比如:React If/Else/For/Switch等標籤支援;
於是,我們就需要一款支援動態修改js原始碼的模組,babel則是用的最多的一個。
Babel的解析引擎
Babel使用的引擎是babylon,babylon並非由babel團隊自己開發的,而是fork的acorn專案,不過acorn引擎只提供基本的解析ast的能力,遍歷還需要配套的acorn-travesal, 替換節點需要使用acorn-,而這些開發,在Babel的外掛體系開發下,變得一體化了。
如何使用
使用方式有很多種:
- webpack中作為js(x)檔案的loader使用;
- 單獨在Node程式碼中引入使用;
- 命令列中使用: package.json中配置: “scripts”: { “build”: “rimraf lib && babel src –out-dir lib” }
命令中執行:npm run build。
通常,如果我們在專案根目錄下配置一個.babelrc檔案,其配置規則會被babel引入並使用。
1、使用npm上好用的Babel外掛提升開發效率
在使用webpack做打包工具的時候,我們隊js(x)檔案使用的loader通常就是babel-loader,babel只是提供了最基礎的程式碼編譯能力,主要用到的一些程式碼轉換則是通過外掛的方式實現的。在loader中配置外掛有兩種方式:presets及plugins,這裡要注意presets配置的也是外掛,只是優先順序比較高,而且他的執行順序是從左到右的,而plugins的優先順序順序則是從右到左的。我們經常用到的外掛會包括:ES6/7轉ES5程式碼的babel-plugin-es2015,React jsx程式碼轉換的babel-plugin-react,對新的js標準特性有不同支援程度的babel-plugin-stage-0等(不同階段js標準特性的制定是不一樣的,babel外掛支援程度也就不一樣,0表示完全支援),將瀏覽器裡export語法轉換為common規範exports/module.exports的babel-plugin-add-module-exports,根據執行時動態插入polyfill的babel-plugin-transform-runtime(絕不建議使用babel-polyfill,一股腦將所有polyfill插入,打的包會很大),對Generator進行編譯的babel-plugin-transform-regenerator等。想了解更多的配置可以參見這篇文章:如何寫好.babelrc?Babel的presets和plugins配置解析(https://excaliburhan.com/post/babel-preset-and-plugins.html?hmsr=toutiao.io&utm_medium=toutiao.io&utm_source=toutiao.io)
如果你是基於完全元件化(標籤式)的開發模式的話,如果能提供常用的控制流標籤如:If/ElseIf/Else/For/Switch/Case等給我們的話,那麼我們的開發效率則會大大提升。在這裡我要推薦一款實現了這些標籤的babel外掛:jsx-control-statement,建議在你的專案中加入這個外掛並用起來,不用再艱難的書寫三元運算子,會大大提升你的開發效率。
2、使用Babel做程式碼轉換使用到的模組及執行流程
Babel將原始碼轉換AST之後,通過遍歷AST樹(其實就是一個js物件),對樹做一些修改,然後再將AST轉成code,即成原始碼。
將js原始碼轉換為AST用到的模組叫:babylon,對樹進行遍歷並做修改用到的模組叫:babel-traverse,將修改後的AST再生成js程式碼用到的模組則是:babel-generator。而babel-core模組則是將三者結合使得對外提供的API做了一個簡化,使用babel-core只需要執行以下的簡單程式碼即可:
1 2 3 4 5 |
import { transform } from 'babel-core'; var result = babel.transform("code();", options); result.code; result.map; result.ast; |
我們在Node中使用的時候一般都是使用的三步轉換的方式,方便做更多的配置及操作。所以整個的難點主要就在對AST的操作上,為了能對AST做一些操作後進而能對js程式碼做到修改,babel對js程式碼語法提供了各種型別,比如:箭頭函式型別ArrowFunctionExpression,for迴圈裡的continue語句型別:ContinueStatement等等,我們主要就是根據這些不同的語法型別來對AST做操作(生成/替換/增加/刪除節點),具體有哪些型別全部在:babel-types。
其實整個大的操作流程還是比較簡單的,我們直接上例子好了。
Babel使用案例0:往類中插入方法
比如我們有這樣的需求:我們有一個jsx程式碼模板,該模板中有一個類似與下面的元件類:
1 2 3 4 5 6 7 |
class MyComponent extends React.Component { constructor(props, context) { super(props, context); } // 其他程式碼 } |
我們會需要根據當前的DSL生成對應的render方法並插入進MyComponent
元件類中,該如何實現呢?
上面已經講到,我們對程式碼的操作其實是通過對程式碼生成的AST操作生成一個新的AST
來完成的,而對AST的操作則是通過babel-traverse
這個庫來實現的。
該庫通過簡單的hooks函式的方式,給我們提供了在遍歷AST時可以操作當前被遍歷到的節點的相關操作,要獲取並修改(增刪改查)當前節點,我們需要知道AST都有哪些節點型別,而所有的節點型別都存放於babel-types
這個庫中。我們先看完整的實現程式碼,然後再分析:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
// 先引入相關的模組 const babylon = require('babylon'); const Traverse = require('babel-traverse').default; const generator = require('babel-generator').default; const Types = require('babel-types'); const babel = require('babel-core'); // === helpers === // 將js程式碼編譯成AST function parse2AST(code) { return babylon.parse(code, { sourceType: 'module', plugins: [ 'asyncFunctions', 'classConstructorCall', 'jsx', 'flow', 'trailingFunctionCommas', 'doExpressions', 'objectRestSpread', 'decorators', 'classProperties', 'exportExtensions', 'exponentiationOperator', 'asyncGenerators', 'functionBind', 'functionSent' ] }); } // 直接將一小段js通過babel.template生成對應的AST function getTemplateAst(tpl, opts = {}) { let ast = babel.template(tpl, opts)({}); if (Array.isArray(ast)) { return ast; } else { return [ast]; } } /** * 檢測傳入引數是否已在插入程式碼中定義 */ checkParams = function(argv, newAst) { let params = []; const vals = getAstVals(newAst); if (argv && argv.length !== 0) { for (let i = 0; i < argv.length; i++) { if (vals.indexOf(argv[i]) === -1) { params.push(Types.identifier(argv[i])); } else { throw TypeError('引數名' + argv[i] + '已在插入程式碼中定義,請更名'); } } } return params; } const code = ` class MyComponent extends React.Component { constructor(props, context) { super(props, context); } // 其他程式碼 } `; const insert = [ { // name為方法名 name: 'render', // body為方法體 body: ` return ( <div>我是render方法的返回內容</div> ); `, // 方法引數 argv: null, // 如果原來的Class有同名方法則強制覆蓋 isCover: true } ]; const ast = parse2AST(code); Traverse(ast, { // ClassBody表示當前類本身節點 ClassBody(path) { if (!Array.isArray(insert)) { throw TypeError('插入欄位型別必須為陣列'); } for (let key in insert) { const methodObj = insert[key], name = methodObj.name, argv = methodObj.argv, body = methodObj.body, isCover = methodObj.isCover; if (typeof name !== 'string') { throw TypeError('方法名必須為字串'); } const newAst = getTemplateAst(body, { sourceType: "script" }); const params = checkParams(argv, newAst); // 通過Types.ClassMethodAPI,生成方法AST const property = Types.ClassMethod('method', Types.identifier(name), params, Types.BlockStatement(newAst)); // 插入進AST path.node.body.push(property); } } }); console.log(generator(ast).code); |
其中,最核心的地方就是下面的這一行程式碼:
1 |
const property = Types.ClassMethod('method', Types.identifier(name), params, Types.BlockStatement(newAst)); |
確定好我們要進行怎麼樣的操作(比如要往一個類中插入一個方法),休閒要確定是怎樣的鉤子名(這裡是ClassBody),然後通過要插入的程式碼生成對應的AST,生成AST可以通過Babel.Types的相關方法一點點生成,但是這裡有個比較方便的API:babel.template,然後通過path的相關操作將新生成的AST插入即可。
穿插:AST樹的建立方法
一些AST樹的建立方法,有: 1、使用babel-types定義的建立方法建立 比如建立一個var a = 1;
1 2 3 4 5 6 7 8 9 10 |
types.VariableDeclaration( 'var', [ types.VariableDeclarator( types.Identifier('a'), types.NumericLiteral(1) ) ] ) |
如果使用這樣建立一個ast節點,肯定要累死了,可以:
- 使用replaceWithSourceString方法建立替換
- 使用template方法來建立AST結點
- template方法其實也是babel體系中的一部分,它允許使用一些模板來建立ast節點
比如上面的var a = 1可以使用:
1 2 3 4 5 6 |
var gen = babel.template(`var NAME = VALUE;`); var ast = gen({ NAME: t.Identifier('a'), VALUE: t.NumberLiteral(1) }); |
也可以簡單寫:
1 2 3 |
var gen = babel.template(`var a = 1;`); var ast = gen({}); |
Babel使用案例1:往類的方法中插入程式碼
這個案例會更復雜一點,大家可以先試著去實現下,明天再講解具體實現。
往方法中要插入程式碼,我們先找下類中方法的babel-types值是什麼,查閱文件:babel-types,可以發現是叫:ClassMethod。於是就可以像下面這樣實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
const injectCode = [{ name: 'constructor', code: insertCodeNext, }]; const ast = parse2AST(originCode); Traverse(ast, { ClassMethod(path) { if (!Array.isArray(injectCode)) { throw TypeError('插入欄位型別必須為陣列'); } // 獲取當前方法的名字 const methodName = path.get('body').container.key.name; for (let key in injectCode) { const inject = injectCode[key], name = inject.name, code = inject.code, pos = inject.pos; if (methodName === name) { const newAst = getTemplateAst(code, { sourceType: "script" }); if (pos === 'prev') { Array.prototype.unshift.apply(path.node.body.body, newAst); } else { Array.prototype.push.apply(path.node.body.body, newAst); } } } } }); console.log(generator(ast).code); |
其實跟往Class中插入method一樣的道理。
4、Babel外掛開發介紹
Babel的外掛就是一個帶有babel引數的函式,該函式返回類似於babel-traverse的配置物件,即下面的格式:
1 2 3 4 5 6 7 8 9 10 11 |
module.exports = function(babel) { var t = babel.types; return { visitor: { ImportDeclaration(path, ref) { var opts = ref.opts; // 配置的引數 } } }; }; |
在babel外掛的時候,配置的引數就會存放在ref引數裡,見上面的程式碼所所示。具體可以參見babel外掛手冊:https://github.com/thejameskyle/babel-handbook/blob/master/translations/zh-Hans/plugin-handbook.md。
下面我們看一個具體的示例。
5、示例:通過Babel實現打包構建優化 — 元件模組按需打包
需求
比如,我們有一個UI元件庫,在入口檔案中會把所有的元件放在這裡,並export出對外服務,大概類似於如下的程式碼:
1 2 3 |
export Button from './lib/button/index.js'; export Input from './lib/input/index.js'; // ...... |
那麼我們在使用的時候就可以如下引用:
1 |
import {Button} from 'ant' |
這樣就有一個問題,就是比如我們只是用了一個Button元件,這樣引用就會導致會把所有的元件打包進來,導致整個js檔案會非常大。我們能不能把程式碼動態實時的編譯成如下的程式碼來解決這個問題?
1 |
import Button from 'ant/lib/button'; |
我們可以寫個babel外掛來實現這樣的需求。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
// 入口檔案 var extend = require('extend'); var astExec = require('./ast-transform'); // 一些個變數預設 var NEXT_MODULE_NAME = 'ant'; var NEXT_LIB_NAME = 'lib'; var MEXT_LIB_NAME = 'lib'; module.exports = function(babel) { var t = babel.types; return { visitor: { ImportDeclaration: function ImportDeclaration(path, _ref) { var opts = _ref.opts; var next = opts.next || {}; var nextJsName = next.nextJsName || NEXT_MODULE_NAME; var nextCssName = next.nextCssName || NEXT_MODULE_NAME; var nextDir = next.dir || NEXT_LIB_NAME; var nextHasStyle = next.hasStyle; var node = path.node; var baseOptions = { node: node, path: path, t: t, jsBase: '', cssBase: '', hasStyle: false }; if (!node) { return; } var jsBase; var cssBase; if (node.source.value === nextJsName) { jsBase = nextJsName + '/' + nextDir + '/'; cssBase = nextCssName + '/' + nextDir + '/'; astExec(extend(baseOptions, { jsBase: jsBase, cssBase: cssBase, hasStyle: nextHasStyle })); } } } }; }; |
這裡將部分的功能單獨放到了一個ast-transform檔案中,程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
function transformName(name) { if (!name) return ''; return name.replace(/[A-Z]/g, function(ch, index) { if (index === 0) return ch.toLowerCase(); return '-' + ch.toLowerCase(); }); } module.exports = function astExec(options) { var node = options.node; // 當前節點 var path = options.path; // path輔助處理變數 var t = options.t; // babel-types var jsBase = options.jsBase; var cssBase = options.cssBase; var hasStyle = options.hasStyle; node.specifiers.forEach(specifier => { if (t.isImportSpecifier(specifier)) { var comName = specifier.imported.name; var lcomName = transformName(comName); var libName = jsBase + lcomName; var libCssName = cssBase + lcomName + '/index.scss'; // AST節點操作 path.insertAfter(t.importDeclaration([t.ImportDefaultSpecifier(t.identifier(comName))], t.stringLiteral(libName))); if (hasStyle) { path.insertAfter(t.importDeclaration([], t.stringLiteral(libCssName))); } } }); // 把原來的程式碼刪除掉 path.remove(); }; |
這樣我們在用的時候就可以像下面這樣使用: 在.babelrc
檔案中像下面這樣配置即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
{ "presets": [...], // babel-preset-react等 "plugins" :[ [ 'armor-fusion', { next: { jsName: 'ant', //js庫名,預設值:ant cssName: 'ant', //css庫名,當如果其他的主題包時,可以換成別的主題包名,預設值:ant dir: 'lib', //目錄名,一般不需要設定,預設值:lib hasStyle: true //會編譯出scss引用,不加則預設不會編譯 } } ] ] } |
以上的外掛實現大家可以整理到自己的github上去,能給你的前端面試加分也說不定哦。
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式