很多的JavaScript工具都需要對JavaScript源碼進行轉換,包括壓縮器(minifier)和轉譯器(transpiler).這些工具所使用的轉換的技術可以分為兩種:對源碼進行非破壞式的(non-destructive)修改和從語法樹完全再生(full regeneration)出新的源碼.這兩種技術服務於不同的需求,且往往是相輔相成的.
無論選擇哪種技術,輸入的源碼都需要先被解析.這項任務可以交給一個解析器(比如Esprima)來做.之后,再對解析器生成的語法樹進行兩種不同的操作,如下圖所示:

如果使用非破壞式的修改,則我們需要利用語法樹中相關語法節點(syntax node)和詞法單元(tokens)的位置信息來計算出應該在輸入源碼的哪段位置處進行修改.舉個簡單的例子,就是把源碼中字符串兩邊的雙引號轉換成單引號(或者反或來):通過定位字符串字面量,我們就能知道引號的具體位置,從而能夠對這個引號進行原位替換(in-place replacement),注意字符串本身的內容可能需要轉義,因為其中可能包含引號.
完全再生的方式可以用在語法轉譯(syntax transpilation)的需求中.比如,如果我們現在就想使用上ECMAScript 6中的塊級作用域(block scope)特性,則我們需要對自己寫的代碼進行轉換(已經有了現成的defs.js),讓輸出的代碼能夠正確的運行在目前更通用的ECMAScript 5環境中.具體要做的就是將let聲明語句轉換成等效的var語句(主要考慮如何對變量的作用域進行限制).
非破壞式轉換的優點是,我們不會丟失那些輸入源碼中與語法無關且不影響程序執行但也有必要保留的那部分代碼.比如,在將雙引號轉換成單引號這一需求中,所有已有的縮進,注釋等應當被完全保留.非破壞式轉換工具只對它感興趣的部分代碼做修改,其他的所有代碼都應該保持完全不變.
但如果我們想要編寫的這個工具不需要保留輸入源碼中的注釋和縮進,則完全扔掉原始代碼,根據語法樹生成一份新源碼的方式會更簡單點.比如,一個壓縮器生成的源碼從語義上要完全等同於輸入源碼,只是少了額外的空白,就應該使用完全再生的方式.另外現在大部分高級壓縮器還會去做一些縮短變量名,移除無用代碼的一些處理,因為這些處理能讓代碼變的更短.
譯者注:目前比較有名的三個壓縮器YUI Compressor, Closure Compiler, UglifyJS都是使用再生的方式生成源碼的.不過前兩者使用的解析器是Mozilla的Rhino(Java編寫),后者使用的解析器是parse-js(JavaScript編寫).
Esprima作為目前最好的js parser in js,怎么會沒有基於Esprima的壓縮器呢.於是我咨詢了本文的作者,也就是Esprima的作者,他讓我看看Escodegen和Esmangle.其中,Escodegen是一個代碼生成器,可以把AST轉換成JavaScript代碼,剛好干了和解析器相反的工作,這里有一個demo.Esmangle是一個壓縮器,但它和其他的壓縮器不同,它的輸入是解析器生成的AST,返回的是壓縮過的AST,也就是說Esprima + Esmangle + Escodegen配合在一起,才能算是完整的JavaScript代碼壓縮器,這里有一個demo.
如果要做代碼覆蓋率分析,則代碼插裝(code instrumentation)是最重要的一步操作.一個代碼覆蓋率工具比如Istanbul會把它的插裝代碼(instrumentation code)包裝在目標代碼的每個語法節點上.通過這種方式,就可以跟蹤到那些真正被JavaScript引擎執行過的語句和代碼分支了.這樣的插裝器(instrumenter)也是代碼再生技術的又一個用武之地.在代碼插裝完畢之后,新生成的代碼馬上就要被解釋器執行了,也就沒有人會去關心代碼長什么樣,有沒有縮進等外觀方面的事情了.
譯者注:講一個我自己的真實案例,情節稍有簡化.就是在公司的項目中,需要在js文件中拼接mastache模板字符串,像這樣
var template =
'<ul>' + '{{#list}}' +
'<li>' +
'{{value}}' +
'</li>' + '{{/list}}' +
'</ul>' +
......顯然,這種寫法可維護性不好.於是我想出了一種解決辦法,就是利用提取函數多行注釋來實現多行字符串.像這樣
var template = heredoc(function(){/* <ul> {{#list}} <li> {{value}} </li> {{/list}} </ul>
...... */})兩種寫法下template的值應該是一樣的.heredoc是一個工具函數,負責從參數函數的source里提取出多行注釋作為字符串,怎么實現的我這里就不說了.
重點是,在發布的時候,這樣的代碼會經過UglifyJS的壓縮.注釋被刪除,程序錯誤,這是可以預料到的.於是我寫了一個node腳本,負責在發布的時候把所有js文件中的heredoc函數的調用轉換成單行的字符串,轉換之后的代碼就變成了
......
var template = '<ul>{{#list}}<li>{{value}}</li>{{/list}</ul>'
......省略號代表了其他部分的代碼,是不會有任何修改的.下一步再交給UglifyJS壓縮,這樣就沒問題了.
這個node腳本是怎么寫的,我正是用到了本文中所講的非破壞式修改源碼的技術,使用的解析器是Esprima.其代碼比起雙引號轉單引號的那個例子要復雜一些,只遍歷tokens數組是不夠的,需要遍歷整棵語法樹以及comments數組.完整的代碼如下
還有個瀏覽器中的demovar fs = require("fs"); var path = process.argv[2]; var esprima = require("esprima"); var source = fs.readFileSync(path, "utf-8"); var ast = esprima.parse(source, { //將源碼解析成ast comment: true, //把所有的注釋節點放到ast.comments數組內 range: true //輸出所有語法節點的位置信息 }); var collectedDatas = []; JSON.stringify(ast, function (key, value) { //遍歷所有的語法節點,找到heredoc的函數調用,抽取出多行注釋 if (value && value.type === "CallExpression" && value.callee.name === "heredoc" && value.arguments.length === 1 && value.arguments[0].type === "FunctionExpression" && value.arguments[0].body.body.length === 0) { //找到heredoc函數調用,且參數必須是一個不包含任何語句的空函數 var heredocCallExpression = value; var blockStatementRange = heredocCallExpression.arguments[0].body.range; var blockStatementSource = source.slice(blockStatementRange[0] + 1, blockStatementRange[1] - 1); var offsetLeft = blockStatementSource.match(/^\s*/)[0].length; var offsetRight = blockStatementSource.match(/\s*$/)[0].length; var commentRange = [blockStatementRange[0] + offsetLeft + 1, blockStatementRange[1] - offsetRight - 1]; //假設這個空函數只包含一個多行注釋,計算出該注釋的位置信息 ast.comments.some(function (comment) { //和解析出來的comments數組做對比,如果有相同位置信息的,則說明已經正確定位到了一個約定好的多行字符串寫法 if (comment.range[0] == commentRange[0] && comment.range[1] == commentRange[1]) { var commentSourceRange = [commentRange[0] + 2, commentRange[1] - 2]; var commentSource = source.slice(commentSourceRange[0], commentSourceRange[1]); var escapedCommentSource = ("'" + commentSource.replace(/(?=\\|')/g, "\\") + "'").replace(/\s*^\s*/mg, ""); collectedDatas.push({ range: heredocCallExpression.range, replaceString: escapedCommentSource }); } }); } return value; }) for (var i = collectedDatas.length - 1; i >= 0; i--) { //從后往前修改輸入源碼,就可以不用考慮偏移量的問題了 var range = collectedDatas[i].range; var replaceString = collectedDatas[i].replaceString; source = source.slice(0, range[0]) + replaceString + source.slice(range[1]); } fs.writeFileSync(path, source, "utf-8"); //將修改后的源碼寫回源文件
