❝最近看到掘金、前端公衆號好多 ES2020 的文章,想說一句:放開我,我還學得動!css
❞
先問你們一句,平常項目開發中你能離開 ES6 嗎?
前端
對於前端同窗來講,編譯器可能適合神奇的魔盒🎁,表面普通,但經常給咱們驚喜。
編譯器,顧名思義,用來編譯,編譯什麼呢?固然是編譯代碼咯🌹。
其實咱們也常常接觸到編譯器的使用場景:node
使用場景很是之多,個人雙手都數不過來了。😄
雖然如今社區已經有很是多工具能爲咱們完成上述工做,但瞭解一些編譯原理是頗有必要的。接下來進入本文主題:「200行JS代碼,帶你實現代碼編譯器」。
webpack
現代程序主要有兩種編譯模式:靜態編譯和動態解釋。推薦一篇文章《Angular 2 JIT vs AOT》介紹得很是詳細。
git
簡稱 「AOT」(Ahead-Of-Time)即 「提早編譯」 ,靜態編譯的程序會在執行前,會使用指定編譯器,將所有代碼編譯成機器碼。
(圖片來自:segmentfault.com/a/119000000…)
在 Angular 的 AOT 編譯模式開發流程以下:github
簡稱 「JIT」(Just-In-Time)即 「即時編譯」 ,動態解釋的程序會使用指定解釋器,一邊編譯一邊執行程序。(圖片來自:https://segmentfault.com/a/1190000008739157[1])
在 Angular 的 JIT 編譯模式開發流程以下:web
「AOT 編譯流程:」(圖片來自:segmentfault.com/a/119000000…)面試
「JIT 編譯流程:」(圖片來自:segmentfault.com/a/119000000…)express
特性 | AOT | JIT |
---|---|---|
編譯平臺 | (Server) 服務器 | (Browser) 瀏覽器 |
編譯時機 | Build (構建階段) | Runtime (運行時) |
包大小 | 較小 | 較大 |
執行性能 | 更好 | - |
啓動時間 | 更短 | - |
除此以外 AOT 還有如下優勢:編程
摘抄維基百科中對 編譯器[2]工做流程介紹:
❝一個現代編譯器的主要工做流程以下: 源代碼(source code)→ 預處理器(preprocessor)→ 編譯器(compiler)→ 彙編程序(assembler)→ 目標代碼(object code)→ 連接器(linker)→ 可執行文件(executables),最後打包好的文件就能夠給電腦去判讀運行了。
❞
這裏更強調了編譯器的做用:「將原始程序做爲輸入,翻譯產生目標語言的等價程序」。
目前絕大多數現代編譯器工做流程基本相似,包括三個核心階段:
本文將經過 「The Super Tiny Compiler」 源碼解讀,學習如何實現一個輕量編譯器,最終「實現將下面原始代碼字符串(Lisp 風格的函數調用)編譯成 JavaScript 可執行的代碼」。
Lisp 風格(編譯前) | JavaScript 風格(編譯後) | |
---|---|---|
2 + 2 | (add 2 2) | add(2, 2) |
4 - 2 | (subtract 4 2) | subtract(4, 2) |
2 + (4 - 2) | (add 2 (subtract 4 2)) | add(2, subtract(4, 2)) |
話說 The Super Tiny Compiler 號稱「多是有史以來最小的編譯器」,而且其做者 James Kyle 也是 Babel 活躍維護者之一。
讓咱們開始吧~
如今對照前面編譯器的三個核心階段,瞭解下 The Super Tiny Compiler 編譯器核心工做流程:
「圖中詳細流程以下:」
// 原始代碼字符串
(add 2 (subtract 4 2))
複製代碼
上述流程看完後可能一臉懵逼,不過沒事,請保持頭腦清醒,先有個整個流程的印象,接下來咱們開始閱讀代碼:
首先定義一個入口方法 compiler
,接收原始代碼字符串做爲參數,返回最終 JavaScript Code:
// 編譯器入口方法 參數:原始代碼字符串 input
function compiler(input) {
let tokens = tokenizer(input);
let ast = parser(tokens);
let newAst = transformer(ast);
let output = codeGenerator(newAst);
return output;
}
複製代碼
在解析階段中,咱們定義「詞法分析器方法」 tokenizer
和「語法分析器方法」 parser
而後分別實現:
// 詞法分析器 參數:原始代碼字符串 input
function tokenizer(input) {};
// 語法分析器 參數:詞法單元數組tokens
function parser(tokens) {};
複製代碼
「詞法分析器方法」 tokenizer
的主要任務:遍歷整個原始代碼字符串,將原始代碼字符串轉換爲「詞法單元數組(tokens)」,並返回。
在遍歷過程當中,匹配每種字符並處理成「詞法單元」壓入「詞法單元數組」,如當匹配到左括號( (
)時,將往「詞法單元數組(tokens)「壓入一個」詞法單元對象」({type: 'paren', value:'('}
)。
// 詞法分析器 參數:原始代碼字符串 input
function tokenizer(input) {
let current = 0; // 當前解析的字符索引,做爲遊標
let tokens = []; // 初始化詞法單元數組
// 循環遍歷原始代碼字符串,讀取詞法單元數組
while (current < input.length) {
let char = input[current];
// 匹配左括號,匹配成功則壓入對象 {type: 'paren', value:'('}
if (char === '(') {
tokens.push({
type: 'paren',
value: '('
});
current++;
continue; // 自增current,完成本次循環,進入下一個循環
}
// 匹配右括號,匹配成功則壓入對象 {type: 'paren', value:')'}
if (char === ')') {
tokens.push({
type: 'paren',
value: ')'
});
current++;
continue;
}
// 匹配空白字符,匹配成功則跳過
// 使用 \s 匹配,包括空格、製表符、換頁符、換行符、垂直製表符等
let WHITESPACE = /\s/;
if (WHITESPACE.test(char)) {
current++;
continue;
}
// 匹配數字字符,使用 [0-9]:匹配
// 匹配成功則壓入{type: 'number', value: value}
// 如 (add 123 456) 中 123 和 456 爲兩個數值詞法單元
let NUMBERS = /[0-9]/;
if (NUMBERS.test(char)) {
let value = '';
// 匹配連續數字,做爲數值
while (NUMBERS.test(char)) {
value += char;
char = input[++current];
}
tokens.push({ type: 'number', value });
continue;
}
// 匹配形雙引號包圍的字符串
// 匹配成功則壓入 { type: 'string', value: value }
// 如 (concat "foo" "bar") 中 "foo" 和 "bar" 爲兩個字符串詞法單元
if (char === '"') {
let value = '';
char = input[++current]; // 跳過左雙引號
// 獲取兩個雙引號之間全部字符
while (char !== '"') {
value += char;
char = input[++current];
}
char = input[++current];// 跳過右雙引號
tokens.push({ type: 'string', value });
continue;
}
// 匹配函數名,要求只含大小寫字母,使用 [a-z] 匹配 i 模式
// 匹配成功則壓入 { type: 'name', value: value }
// 如 (add 2 4) 中 add 爲一個名稱詞法單元
let LETTERS = /[a-z]/i;
if (LETTERS.test(char)) {
let value = '';
// 獲取連續字符
while (LETTERS.test(char)) {
value += char;
char = input[++current];
}
tokens.push({ type: 'name', value });
continue;
}
// 當遇到沒法識別的字符,拋出錯誤提示,並退出
throw new TypeError('I dont know what this character is: ' + char);
}
// 詞法分析器的最後返回詞法單元數組
return tokens;
}
複製代碼
「語法分析器方法」 parser
的主要任務:將「詞法分析器」返回的「詞法單元數組」,轉換爲可以描述語法成分及其關係的中間形式(「抽象語法樹 AST」)。
// 語法分析器 參數:詞法單元數組tokens
function parser(tokens) {
let current = 0; // 設置當前解析的詞法單元的索引,做爲遊標
// 遞歸遍歷(由於函數調用容許嵌套),將詞法單元轉成 LISP 的 AST 節點
function walk() {
// 獲取當前索引下的詞法單元 token
let token = tokens[current];
// 數值類型詞法單元
if (token.type === 'number') {
current++; // 自增當前 current 值
// 生成一個 AST節點 'NumberLiteral',表示數值字面量
return {
type: 'NumberLiteral',
value: token.value,
};
}
// 字符串類型詞法單元
if (token.type === 'string') {
current++;
// 生成一個 AST節點 'StringLiteral',表示字符串字面量
return {
type: 'StringLiteral',
value: token.value,
};
}
// 函數類型詞法單元
if (token.type === 'paren' && token.value === '(') {
// 跳過左括號,獲取下一個詞法單元做爲函數名
token = tokens[++current];
let node = {
type: 'CallExpression',
name: token.value,
params: []
};
// 再次自增 current 變量,獲取參數詞法單元
token = tokens[++current];
// 遍歷每一個詞法單元,獲取函數參數,直到出現右括號")"
while ((token.type !== 'paren') || (token.type === 'paren' && token.value !== ')')) {
node.params.push(walk());
token = tokens[current];
}
current++; // 跳過右括號
return node;
}
// 沒法識別的字符,拋出錯誤提示
throw new TypeError(token.type);
}
// 初始化 AST 根節點
let ast = {
type: 'Program',
body: [],
};
// 循環填充 ast.body
while (current < tokens.length) {
ast.body.push(walk());
}
// 最後返回ast
return ast;
}
複製代碼
在轉換階段中,定義了轉換器 transformer
函數,使用詞法分析器返回的 LISP 的 AST 對象做爲參數,將 AST 對象轉換成一個新的 AST 對象。
爲了方便代碼組織,咱們定義一個遍歷器 traverser
方法,用來處理每個節點的操做。
// 遍歷器 參數:ast 和 visitor
function traverser(ast, visitor) {
// 定義方法 traverseArray
// 用於遍歷 AST節點數組,對數組中每一個元素調用 traverseNode 方法。
function traverseArray(array, parent) {
array.forEach(child => {
traverseNode(child, parent);
});
}
// 定義方法 traverseNode
// 用於處理每一個 AST 節點,接受一個 node 和它的父節點 parent 做爲參數
function traverseNode(node, parent) {
// 獲取 visitor 上對應方法的對象
let methods = visitor[node.type];
// 獲取 visitor 的 enter 方法,處理操做當前 node
if (methods && methods.enter) {
methods.enter(node, parent);
}
switch (node.type) {
// 根節點
case 'Program':
traverseArray(node.body, node);
break;
// 函數調用
case 'CallExpression':
traverseArray(node.params, node);
break;
// 數值和字符串,忽略
case 'NumberLiteral':
case 'StringLiteral':
break;
// 當遇到沒法識別的字符,拋出錯誤提示,並退出
default:
throw new TypeError(node.type);
}
if (methods && methods.exit) {
methods.exit(node, parent);
}
}
// 首次執行,開始遍歷
traverseNode(ast, null);
}
複製代碼
在看「遍歷器」 traverser
方法時,建議結合下面介紹的「轉換器」 transformer
方法閱讀:
// 轉化器,參數:ast
function transformer(ast) {
// 建立 newAST,與以前 AST 相似,Program:做爲新 AST 的根節點
let newAst = {
type: 'Program',
body: [],
};
// 經過 _context 維護新舊 AST,注意 _context 是一個引用,從舊的 AST 到新的 AST。
ast._context = newAst.body;
// 經過遍歷器遍歷 處理舊的 AST
traverser(ast, {
// 數值,直接原樣插入新AST,類型名稱 NumberLiteral
NumberLiteral: {
enter(node, parent) {
parent._context.push({
type: 'NumberLiteral',
value: node.value,
});
},
},
// 字符串,直接原樣插入新AST,類型名稱 StringLiteral
StringLiteral: {
enter(node, parent) {
parent._context.push({
type: 'StringLiteral',
value: node.value,
});
},
},
// 函數調用
CallExpression: {
enter(node, parent) {
// 建立不一樣的AST節點
let expression = {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: node.name,
},
arguments: [],
};
// 函數調用有子類,創建節點對應關係,供子節點使用
node._context = expression.arguments;
// 頂層函數調用算是語句,包裝成特殊的AST節點
if (parent.type !== 'CallExpression') {
expression = {
type: 'ExpressionStatement',
expression: expression,
};
}
parent._context.push(expression);
},
}
});
return newAst;
}
複製代碼
重要一點,這裏經過 _context
引用來「維護新舊 AST 對象」,管理方便,避免污染舊 AST 對象。
接下來到了最後一步,咱們定義「代碼生成器」 codeGenerator
方法,經過遞歸,將新的 AST 對象代碼轉換成 JavaScript 可執行代碼字符串。
// 代碼生成器 參數:新 AST 對象
function codeGenerator(node) {
switch (node.type) {
// 遍歷 body 屬性中的節點,且遞歸調用 codeGenerator,按行輸出結果
case 'Program':
return node.body.map(codeGenerator)
.join('\n');
// 表達式,處理表達式內容,並用分號結尾
case 'ExpressionStatement':
return (
codeGenerator(node.expression) +
';'
);
// 函數調用,添加左右括號,參數用逗號隔開
case 'CallExpression':
return (
codeGenerator(node.callee) +
'(' +
node.arguments.map(codeGenerator)
.join(', ') +
')'
);
// 標識符,返回其 name
case 'Identifier':
return node.name;
// 數值,返回其 value
case 'NumberLiteral':
return node.value;
// 字符串,用雙引號包裹再輸出
case 'StringLiteral':
return '"' + node.value + '"';
// 當遇到沒法識別的字符,拋出錯誤提示,並退出
default:
throw new TypeError(node.type);
}
}
複製代碼
截止上一步,咱們完成簡易編譯器的代碼開發。接下來經過前面原始需求的代碼,測試編譯器效果如何:
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const source = "(add 2 (subtract 4 2))";
const target = compiler(source); // "add(2, (subtract(4, 2));"
const result = eval(target); // Ok result is 4
複製代碼
總結 The Super Tiny Compiler 編譯器整個工做流程:
「一、input => tokenizer => tokens」
「二、tokens => parser => ast」
「三、ast => transformer => newAst」
「四、newAst => generator => output」
其實多數編譯器的工做流程都大體相同:
根據以前介紹的 The Super Tiny Compiler編譯器核心工做流程,再來手寫 Webpack 的編譯器,會讓你有種衆享絲滑的感受~
話說,有些面試官喜歡問這個呢。固然,手寫一遍能讓咱們更瞭解 Webpack 的構建流程,這個章節咱們簡要介紹一下。
從啓動構建到輸出結果一系列過程:
解析 Webpack 配置參數,合併 Shell 傳入和 webpack.config.js
文件配置的參數,造成最後的配置結果。
上一步獲得的參數初始化 compiler
對象,註冊全部配置的插件,插件監聽 Webpack 構建生命週期的事件節點,作出相應的反應,執行對象的 run
方法開始執行編譯。
從配置的 entry
入口,開始解析文件構建 AST 語法樹,找出依賴,遞歸下去。
遞歸中根據「文件類型」和 「loader 配置」,調用全部配置的 loader 對文件進行轉換,再找出該模塊依賴的模塊,再遞歸本步驟直到全部入口依賴的文件都通過了本步驟的處理。
遞歸完過後,獲得每一個文件結果,包含每一個模塊以及他們之間的依賴關係,根據 entry
配置生成代碼塊 chunk
。
輸出全部的 chunk
到文件系統。
注意:在構建生命週期中有一系列插件在作合適的時機作合適事情,好比 UglifyPlugin
會在 loader 轉換遞歸完對結果使用 UglifyJs
壓縮覆蓋以前的結果。
手寫 Webpack 須要實現如下三個核心方法:
createAssets
: 收集和處理文件的代碼;
createGraph
:根據入口文件,返回全部文件依賴圖;
bundle
: 根據依賴圖整個代碼並輸出;
function createAssets(filename){
const content = fs.readFileSync(filename, "utf-8"); // 根據文件名讀取文件內容
// 將讀取到的代碼內容,轉換爲 AST
const ast = parser.parse(content, {
sourceType: "module" // 指定源碼類型
})
const dependencies = []; // 用於收集文件依賴的路徑
// 經過 traverse 提供的操做 AST 的方法,獲取每一個節點的依賴路徑
traverse(ast, {
ImportDeclaration: ({node}) => {
dependencies.push(node.source.value);
}
});
// 經過 AST 將 ES6 代碼轉換成 ES5 代碼
const { code } = babel.transformFromAstSync(ast, null, {
presets: ["@babel/preset-env"]
});
let id = moduleId++;
return {
id,
filename,
code,
dependencies
}
}
複製代碼
function createGraph(entry) {
const mainAsset = createAssets(entry); // 獲取入口文件下的內容
const queue = [mainAsset];
for(const asset of queue){
const dirname = path.dirname(asset.filename);
asset.mapping = {};
asset.dependencies.forEach(relativePath => {
const absolutePath = path.join(dirname, relativePath); // 轉換文件路徑爲絕對路徑
const child = createAssets(absolutePath);
asset.mapping[relativePath] = child.id;
queue.push(child); // 遞歸去遍歷全部子節點的文件
})
}
return queue;
}
複製代碼
function bundle(graph) {
let modules = "";
graph.forEach(item => {
modules += `
${item.id}: [
function (require, module, exports){
${item.code}
},
${JSON.stringify(item.mapping)}
],
`
})
return `
(function(modules){
function require(id){
const [fn, mapping] = modules[id];
function localRequire(relativePath){
return require(mapping[relativePath]);
}
const module = {
exports: {}
}
fn(localRequire, module, module.exports);
return module.exports;
}
require(0);
})({${modules}})
`
}
複製代碼
本文從編譯器概念和基本工做流程開始介紹,而後經過 The Super Tiny Compiler 譯器源碼,詳細介紹核心工做流程實現,包括「詞法分析器」、「語法分析器」、「遍歷器」和「轉換器」的基本實現,最後經過「代碼生成器」,將各個階段代碼結合起來,實現了這個號稱「多是有史以來最小的編譯器。」
本文也簡要介紹了「手寫 Webpack 的實現」,須要讀者自行完善和深刻喲! 是否是以爲很神奇~
固然經過本文學習,也僅僅是編譯器相關知識的邊山一腳,要學的知識還有很是多,不過好的開頭,更能促進咱們學習動力。加油!
最後,文中介紹到的代碼,我存放在 Github 上:
Author | 王平安 |
---|---|
pingan8787@qq.com | |
博 客 | www.pingan8787.com |
微 信 | pingan8787 |
每日文章推薦 | https://github.com/pingan8787/Leo_Reading/issues |
ES小冊 | js.pingan8787.com |
https://segmentfault.com/a/1190000008739157: https://segmentfault.com/a/1190000008739157
[2]編譯器: https://zh.wikipedia.org/wiki/%E7%B7%A8%E8%AD%AF%E5%99%A8?wprov=srpw1_0
[3]The Super Tiny Compiler: https://the-super-tiny-compiler.glitch.me/
[4][learning]the-super-tiny-compiler.js: https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Frontend/learningSourceCode/%5Blearning%5Dthe-super-tiny-compiler.js
[5][writing]webpack-compiler.js: https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Frontend/learningSourceCode/%5Bwriting%5Dwebpack-compiler.js
[6]《The Super Tiny Compiler》: https://the-super-tiny-compiler.glitch.me/
[7]《有史以來最小的編譯器源碼解析》: https://segmentfault.com/a/1190000016402699
[8]《Angular 2 JIT vs AOT》: https://segmentfault.com/a/1190000008739157