javascript
java实现语法分析器_200 行 JS 代码,带你实现代码编译器
一、前言
對于前端同學來說,編譯器可能適合神奇的魔盒 ,表面普通,但常常給我們驚喜。
編譯器,顧名思義,用來編譯,編譯什么呢?當然是編譯代碼咯 。
其實我們也經常接觸到編譯器的使用場景:
- React 中 JSX 轉換成 JS 代碼;
- 通過 Babel 將 ES6 及以上規范的代碼轉換成 ES5 代碼;
- 通過各種 Loader 將 Less / Scss 代碼轉換成瀏覽器支持的 CSS 代碼;
- 將 TypeScript 轉換為 JavaScript 代碼。
- and so on...
使用場景非常之多,我的雙手都數不過來了。
雖然現在社區已經有非常多工具能為我們完成上述工作,但了解一些編譯原理是很有必要的。接下來進入本文主題:「200行JS代碼,帶你實現代碼編譯器」。
二、編譯器介紹
Java進階:架構設計、并發編程等核心知識、電子書、視頻、面試題等免費獲取?shimo.im2.1 程序運行方式
現代程序主要有兩種編譯模式:靜態編譯和動態解釋。推薦一篇文章《Angular 2 JIT vs AOT》介紹得非常詳細。
靜態編譯
簡稱 「AOT」(Ahead-Of-Time)即 「提前編譯」 ,靜態編譯的程序會在執行前,會使用指定編譯器,將全部代碼編譯成機器碼。
(圖片來自:http://segmentfault.com/a/1190000008739157)
在 Angular 的 AOT 編譯模式開發流程如下:
- 使用 TypeScript 開發 Angular 應用
- 運行 ngc 編譯應用程序
- 使用 Angular Compiler 編譯模板,一般輸出 TypeScript 代碼
- 運行 tsc 編譯 TypeScript 代碼
- 使用 Webpack 或 Gulp 等其他工具構建項目,如代碼壓縮、合并等
- 部署應用
動態解釋
簡稱 「JIT」(Just-In-Time)即 「即時編譯」 ,動態解釋的程序會使用指定解釋器,一邊編譯一邊執行程序。
(圖片來自:http://segmentfault.com/a/1190000008739157[1])
在 Angular 的 JIT 編譯模式開發流程如下:
- 使用 TypeScript 開發 Angular 應用
- 運行 tsc 編譯 TypeScript 代碼
- 使用 Webpack 或 Gulp 等其他工具構建項目,如代碼壓縮、合并等
- 部署應用
AOT vs JIT
AOT 編譯流程:
(圖片來自:http://segmentfault.com/a/1190000008739157)
JIT 編譯流程:
(圖片來自:http://segmentfault.com/a/1190000008739157)
特性AOTJIT編譯平臺(Server) 服務器(Browser) 瀏覽器編譯時機Build (構建階段)Runtime (運行時)包大小較小較大執行性能更好-啟動時間更短-
除此之外 AOT 還有以下優點:
- 在客戶端我們不需要導入體積龐大的 angular 編譯器,這樣可以減少我們 JS 腳本庫的大小。
- 使用 AOT 編譯后的應用,不再包含任何 HTML 片段,取而代之的是編譯生成的 TypeScript 代碼,這樣的話 TypeScript 編譯器就能提前發現錯誤。總而言之,采用 AOT 編譯模式,我們的模板是類型安全的。
2.2 現代編譯器工作流程
摘抄維基百科中對 編譯器[2]工作流程介紹:
?一個現代編譯器的主要工作流程如下:源代碼(source code)→ 預處理器(preprocessor)→ 編譯器(compiler)→ 匯編程序(assembler)→ 目標代碼(object code)→ 鏈接器(linker)→ 可執行文件(executables),最后打包好的文件就可以給電腦去判讀運行了。?
這里更強調了編譯器的作用:「將原始程序作為輸入,翻譯產生目標語言的等價程序」。
目前絕大多數現代編譯器工作流程基本類似,包括三個核心階段:
三、編譯器實現
本文將通過 「The Super Tiny Compiler[3]」 源碼解讀,學習如何實現一個輕量編譯器,最終「實現將下面原始代碼字符串(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 編譯器核心工作流程:
圖中詳細流程如下:
上述流程看完后可能一臉懵逼,不過沒事,請保持頭腦清醒,先有個整個流程的印象,接下來我們開始閱讀代碼:
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:'('})。
語法分析器
「語法分析器方法」 parser 的主要任務:將「詞法分析器」返回的「詞法單元數組」,轉換為能夠描述語法成分及其關系的中間形式(「抽象語法樹 AST」)。
// 語法分析器 參數:詞法單元數組tokens function parser(tokens) {let current = 0; // 設置當前解析的詞法單元的索引,作為游標// 遞歸遍歷(因為函數調用允許嵌套),將詞法單元轉成 LISP 的 AST 節點function walk() {// 獲取當前索引下的詞法單元 tokenlet 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',div: [],};// 循環填充 ast.divwhile (current < tokens.length) {ast.div.push(walk());}// 最后返回astreturn ast; }3.4 轉換階段
在轉換階段中,定義了轉換器 transformer 函數,使用詞法分析器返回的 LISP 的 AST 對象作為參數,將 AST 對象轉換成一個新的 AST 對象。
為了方便代碼組織,我們定義一個遍歷器 traverser 方法,用來處理每一個節點的操作。
在看「遍歷器」 traverser 方法時,建議結合下面介紹的「轉換器」 transformer 方法閱讀:
重要一點,這里通過 _context 引用來「維護新舊 AST 對象」,管理方便,避免污染舊 AST 對象。
3.5 代碼生成
接下來到了最后一步,我們定義「代碼生成器」 codeGenerator 方法,通過遞歸,將新的 AST 對象代碼轉換成 JavaScript 可執行代碼字符串。
// 代碼生成器 參數:新 AST 對象 function codeGenerator(node) {switch (node.type) {// 遍歷 div 屬性中的節點,且遞歸調用 codeGenerator,按行輸出結果case 'Program':return node.div.map(codeGenerator).join('n');// 表達式,處理表達式內容,并用分號結尾case 'ExpressionStatement':return (codeGenerator(node.expression) +';');// 函數調用,添加左右括號,參數用逗號隔開case 'CallExpression':return (codeGenerator(node.callee) +'(' +node.arguments.map(codeGenerator).join(', ') +')');// 標識符,返回其 namecase 'Identifier':return node.name;// 數值,返回其 valuecase '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 43.7 工作流程小結
總結 The Super Tiny Compiler 編譯器整個工作流程:「1、input => tokenizer => tokens」「2、tokens => parser => ast」「3、ast => transformer => newAst」「4、newAst => generator => output」
其實多數編譯器的工作流程都大致相同:
四、手寫 Webpack 編譯器
根據之前介紹的 The Super Tiny Compiler編譯器核心工作流程,再來手寫 Webpack 的編譯器,會讓你有種眾享絲滑的感覺~
話說,有些面試官喜歡問這個呢。當然,手寫一遍能讓我們更了解 Webpack 的構建流程,這個章節我們簡要介紹一下。
4.1 Webpack 構建流程分析
從啟動構建到輸出結果一系列過程:
解析 Webpack 配置參數,合并 Shell 傳入和 webpack.config.js 文件配置的參數,形成最后的配置結果。
上一步得到的參數初始化 compiler 對象,注冊所有配置的插件,插件監聽 Webpack 構建生命周期的事件節點,做出相應的反應,執行對象的 run 方法開始執行編譯。
從配置的 entry 入口,開始解析文件構建 AST 語法樹,找出依賴,遞歸下去。
遞歸中根據「文件類型」和 「loader 配置」,調用所有配置的 loader 對文件進行轉換,再找出該模塊依賴的模塊,再遞歸本步驟直到所有入口依賴的文件都經過了本步驟的處理。
遞歸完事后,得到每個文件結果,包含每個模塊以及他們之間的依賴關系,根據 entry 配置生成代碼塊 chunk 。
輸出所有的 chunk 到文件系統。
注意:在構建生命周期中有一系列插件在做合適的時機做合適事情,比如 UglifyPlugin 會在 loader 轉換遞歸完對結果使用 UglifyJs 壓縮「覆蓋之前的結果」。
4.2 代碼實現
手寫 Webpack 需要實現以下三個核心方法:
- createAssets : 收集和處理文件的代碼;
- createGraph :根據入口文件,返回所有文件依賴圖;
- bundle : 根據依賴圖整個代碼并輸出;
1. createAssets
function createAssets(filename){const content = fs.readFileSync(filename, "utf-8"); // 根據文件名讀取文件內容// 將讀取到的代碼內容,轉換為 ASTconst 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}})` }總結
以上是生活随笔為你收集整理的java实现语法分析器_200 行 JS 代码,带你实现代码编译器的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: oraclf 复杂查询练习_SQL复杂查
- 下一篇: git 拉代码_一篇文章理清Git