【圖文詳解】200行JS代碼,帶你實現代碼編譯器(人人都能學會)

011ead2e167b86d1d4def84147fbbdf6c6bb1c01.jpg

最近看到掘金、前端公衆號好多 ES2020 的文章,想說一句:放開我,我還學得動!css


先問你們一句,平常項目開發中你能離開 ES6 嗎?
前端

1、前言

對於前端同窗來講,編譯器可能適合神奇的魔盒🎁,表面普通,但經常給咱們驚喜。
編譯器,顧名思義,用來編譯,編譯什麼呢?固然是編譯代碼咯🌹。

其實咱們也常常接觸到編譯器的使用場景:node

  • React 中 JSX 轉換成 JS 代碼;
  • 經過 Babel 將 ES6 及以上規範的代碼轉換成 ES5 代碼;
  • 經過各類 Loader 將 Less / Scss 代碼轉換成瀏覽器支持的 CSS 代碼;
  • 將 TypeScript 轉換爲 JavaScript 代碼。
  • and so on...


使用場景很是之多,個人雙手都數不過來了。😄
雖然如今社區已經有很是多工具能爲咱們完成上述工做,但瞭解一些編譯原理是頗有必要的。接下來進入本文主題:200行JS代碼,帶你實現代碼編譯器
webpack

2、編譯器介紹

2.1 程序運行方式

現代程序主要有兩種編譯模式:靜態編譯和動態解釋。推薦一篇文章《Angular 2 JIT vs AOT》介紹得很是詳細。
git

靜態編譯

簡稱 AOT(Ahead-Of-Time)即 提早編譯 ,靜態編譯的程序會在執行前,會使用指定編譯器,將所有代碼編譯成機器碼。

(圖片來自:segmentfault.com/a/119000000…

在 Angular 的 AOT 編譯模式開發流程以下:github

  • 使用 TypeScript 開發 Angular 應用
  • 運行 ngc 編譯應用程序
    • 使用 Angular Compiler 編譯模板,通常輸出 TypeScript 代碼
    • 運行 tsc 編譯 TypeScript 代碼
  • 使用 Webpack 或 Gulp 等其餘工具構建項目,如代碼壓縮、合併等
  • 部署應用

動態解釋

簡稱 JIT(Just-In-Time)即 即時編譯 ,動態解釋的程序會使用指定解釋器,一邊編譯一邊執行程序。
(圖片來自:https://segmentfault.com/a/1190000008739157[1]

在 Angular 的 JIT 編譯模式開發流程以下:web

  • 使用 TypeScript 開發 Angular 應用
  • 運行 tsc 編譯 TypeScript 代碼
  • 使用 Webpack 或 Gulp 等其餘工具構建項目,如代碼壓縮、合併等
  • 部署應用

AOT vs JIT

AOT 編譯流程:(圖片來自:segmentfault.com/a/119000000…面試

JIT 編譯流程:(圖片來自:segmentfault.com/a/119000000…express

特性 AOT JIT
編譯平臺 (Server) 服務器 (Browser) 瀏覽器
編譯時機 Build (構建階段) Runtime (運行時)
包大小 較小 較大
執行性能 更好 -
啓動時間 更短 -

除此以外 AOT 還有如下優勢:編程

  • 在客戶端咱們不須要導入體積龐大的 angular 編譯器,這樣能夠減小咱們 JS 腳本庫的大小
  • 使用 AOT 編譯後的應用,再也不包含任何 HTML 片斷,取而代之的是編譯生成的 TypeScript 代碼,這樣的話 TypeScript 編譯器就能提早發現錯誤。總而言之,採用 AOT 編譯模式,咱們的模板是類型安全的。

2.2 現代編譯器工做流程

摘抄維基百科中對 編譯器[2]工做流程介紹:

一個現代編譯器的主要工做流程以下: 源代碼(source code)→ 預處理器(preprocessor)→ 編譯器(compiler)→ 彙編程序(assembler)→ 目標代碼(object code)→ 連接器(linker)→ 可執行文件(executables),最後打包好的文件就能夠給電腦去判讀運行了。

這裏更強調了編譯器的做用:將原始程序做爲輸入,翻譯產生目標語言的等價程序

編譯器三個核心階段.png
編譯器三個核心階段.png

目前絕大多數現代編譯器工做流程基本相似,包括三個核心階段:

  1. 解析(Parsing :經過詞法分析和語法分析,將原始代碼字符串解析成 抽象語法樹(Abstract Syntax Tree)
  2. 轉換(Transformation:對抽象語法樹進行轉換處理操做;
  3. 生成代碼(Code Generation:將轉換以後的 AST 對象生成目標語言代碼字符串。

3、編譯器實現

本文將經過 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 活躍維護者之一。

讓咱們開始吧~

3.1 The Super Tiny Compiler 工做流程

如今對照前面編譯器的三個核心階段,瞭解下 The Super Tiny Compiler  編譯器核心工做流程:
The Super Tiny Compiler編譯器工做流程.png

圖中詳細流程以下:

  1. 執行 入口函數,輸入 原始代碼字符串做爲參數;
// 原始代碼字符串
(add 2 (subtract 4 2))
複製代碼
  1. 進入 「解析階段(Parsing)」,原始代碼字符串經過 「詞法分析器(Tokenizer)」轉換爲詞法單元數組,而後再經過 「語法分析器(Parser)」「詞法單元數組」轉換爲 「抽象語法樹(Abstract Syntax Tree 簡稱 AST)」,並返回;

解析階段 - 詞法分析.png
解析階段 - 語法分析.png

  1. 進入 轉換階段(Transformation),將上一步生成的 AST 對象 導入 轉換器(Transformer),經過 轉換器中的 遍歷器(Traverser),將代碼轉換爲咱們所需的 新的 AST 對象

轉換階段.png

  1. 進入 代碼生成階段(Code Generation),將上一步返回的 新 AST 對象經過 代碼生成器(CodeGenerator),轉換成  JavaScript Code

代碼生成階段.png

  1. 代碼編譯結束,返回 JavaScript Code



上述流程看完後可能一臉懵逼,不過沒事,請保持頭腦清醒,先有個整個流程的印象,接下來咱們開始閱讀代碼:

3.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;
}
複製代碼

3.3 解析階段

在解析階段中,咱們定義詞法分析器方法 tokenizer  和語法分析器方法 parser 而後分別實現:

// 詞法分析器 參數:原始代碼字符串 input
function tokenizer(input) {};

// 語法分析器 參數:詞法單元數組tokens
function parser(tokens) {};
複製代碼

詞法分析器

詞法分析器方法 tokenizer 的主要任務:遍歷整個原始代碼字符串,將原始代碼字符串轉換爲詞法單元數組(tokens),並返回。
在遍歷過程當中,匹配每種字符並處理成詞法單元壓入詞法單元數組,如當匹配到左括號( ( )時,將往詞法單元數組(tokens)壓入一個詞法單元對象{type: 'paren', value:'('})。
詞法分析器工做流程.png

// 詞法分析器 參數:原始代碼字符串 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)。
語法分析器工做流程.png

// 語法分析器 參數:詞法單元數組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;
}
複製代碼

3.4 轉換階段

在轉換階段中,定義了轉換器 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 對象。

3.5 代碼生成

接下來到了最後一步,咱們定義代碼生成器 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);
}
}
複製代碼

3.6 編譯器測試

截止上一步,咱們完成簡易編譯器的代碼開發。接下來經過前面原始需求的代碼,測試編譯器效果如何:

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
複製代碼

3.7 工做流程小結

總結 The Super Tiny Compiler 編譯器整個工做流程:
一、input => tokenizer => tokens
二、tokens => parser => ast
三、ast => transformer => newAst
四、newAst => generator => output

其實多數編譯器的工做流程都大體相同: The Super Tiny Compiler編譯器工做流程(方法實現).png

4、手寫 Webpack 編譯器

根據以前介紹的 The Super Tiny Compiler編譯器核心工做流程,再來手寫 Webpack 的編譯器,會讓你有種衆享絲滑的感受~


話說,有些面試官喜歡問這個呢。固然,手寫一遍能讓咱們更瞭解 Webpack 的構建流程,這個章節咱們簡要介紹一下。

4.1 Webpack 構建流程分析

從啓動構建到輸出結果一系列過程:

  1. 初始化參數

解析 Webpack 配置參數,合併 Shell 傳入和 webpack.config.js 文件配置的參數,造成最後的配置結果。

  1. 開始編譯

上一步獲得的參數初始化 compiler 對象,註冊全部配置的插件,插件監聽 Webpack 構建生命週期的事件節點,作出相應的反應,執行對象的 run 方法開始執行編譯。

  1. 肯定入口

從配置的 entry 入口,開始解析文件構建 AST 語法樹,找出依賴,遞歸下去。

  1. 編譯模塊

遞歸中根據文件類型loader 配置,調用全部配置的 loader 對文件進行轉換,再找出該模塊依賴的模塊,再遞歸本步驟直到全部入口依賴的文件都通過了本步驟的處理。

  1. 完成模塊編譯並輸出

遞歸完過後,獲得每一個文件結果,包含每一個模塊以及他們之間的依賴關係,根據 entry 配置生成代碼塊 chunk

  1. 輸出完成

輸出全部的 chunk 到文件系統。

注意:在構建生命週期中有一系列插件在作合適的時機作合適事情,好比 UglifyPlugin 會在 loader 轉換遞歸完對結果使用 UglifyJs 壓縮覆蓋以前的結果
Webpack 構建流程.png

4.2 代碼實現

手寫 Webpack 須要實現如下三個核心方法:

  • createAssets : 收集和處理文件的代碼;
  • createGraph :根據入口文件,返回全部文件依賴圖;
  • bundle : 根據依賴圖整個代碼並輸出;

1. createAssets

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
}
}
複製代碼

2. createGraph

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;
}
複製代碼

3. bunlde

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}})
`

}
複製代碼

5、總結

本文從編譯器概念和基本工做流程開始介紹,而後經過 The Super Tiny Compiler 譯器源碼,詳細介紹核心工做流程實現,包括詞法分析器語法分析器遍歷器轉換器的基本實現,最後經過代碼生成器,將各個階段代碼結合起來,實現了這個號稱多是有史以來最小的編譯器。
本文也簡要介紹了手寫 Webpack 的實現,須要讀者自行完善和深刻喲! 是否是以爲很神奇~

固然經過本文學習,也僅僅是編譯器相關知識的邊山一腳,要學的知識還有很是多,不過好的開頭,更能促進咱們學習動力。加油!

最後,文中介紹到的代碼,我存放在 Github 上:

  1. [learning]the-super-tiny-compiler.js [4]
  2. [writing]webpack-compiler.js [5]

6、參考資料

  1. 《The Super Tiny Compiler》 [6]
  2. 《有史以來最小的編譯器源碼解析》 [7]
  3. 《Angular 2 JIT vs AOT》 [8]

關於我

Author 王平安
E-mail pingan8787@qq.com
博 客 www.pingan8787.com
微 信 pingan8787
每日文章推薦 https://github.com/pingan8787/Leo_Reading/issues
ES小冊 js.pingan8787.com

Reference

[1]

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

相關文章
相關標籤/搜索