手摸手教你實現(xiàn)一個新的JS語法
我們會擴展一個 js 的新語法,探索下 js 新語法都是怎么實現(xiàn)的,然后再把這個新語法編譯到 css。
具體會涉及到:
js parser 的歷史和標準 css parser 和 html parser acorn 插件的寫法 postcss 語法插件的寫法
轉(zhuǎn)譯器及其原理
我: 昊昊,你知道前端領(lǐng)域的轉(zhuǎn)譯器有哪些么?
昊昊: 轉(zhuǎn)譯器(transpiler)是源碼轉(zhuǎn)源碼,前端領(lǐng)域的太多了,比如 babel、typescript、terser、eslint、prettier、postcss、posthtml、vue template compiler 等。

babel 用于 es next、flow、typescript、jsx 等語法轉(zhuǎn)目標環(huán)境支持的 js typescript 用于處理 typescript 語法,并進行類型檢查,然后轉(zhuǎn)成 es5 或者 es3 terser 用于 parse es6 的代碼,并進行壓縮和混淆,輸出處理后的代碼 prettier 用于處理各種 css、js、html 等代碼,進行格式化代碼,然后輸出格式化后的代碼 eslint 是對代碼風格和一些常見錯誤進行靜態(tài)檢查,通過 --fix 還可以自動修復(fù) postcss 用于 css 的 parse,之后通過插件對 ast 進行各種處理,最后輸出處理后的 css posthtml 和 postcss 類似,不過是用于 html 處理的。 vue template compiler 是 vue 專用的,用于把 vue template 轉(zhuǎn)成優(yōu)化以后的 render 函數(shù)
我: 挺全面的了,前端領(lǐng)域主要的轉(zhuǎn)譯器差不多是這些,再加上 taro、uniapp 等基于上述轉(zhuǎn)譯器的小程序轉(zhuǎn)譯器。當然還有 rust 寫的類似 babel 的 swc,或者 go 寫的打包工具 esbuild 里自帶的 js transpiler,這些不是 js 寫的,就先不討論了。
昊昊: 光哥,這些轉(zhuǎn)譯器的實現(xiàn)原理是啥?
我: 轉(zhuǎn)譯器是源碼轉(zhuǎn)源碼,其實不管是啥轉(zhuǎn)譯器都分為三步。
第一步,parse,把源碼 parse 成抽象語法樹 AST,通過一棵樹形的數(shù)據(jù)結(jié)構(gòu)來記錄源碼中的信息,這樣計算機才能理解源碼。
第二步,transform,理解了源碼之后,就是進行各種轉(zhuǎn)換了,轉(zhuǎn)譯器全稱轉(zhuǎn)換編譯器,主要工作就在于轉(zhuǎn)換上,對 ast 進行不同目的的增刪改。
第三步,generate,轉(zhuǎn)換完的 ast 進行遞歸打印,生成新的代碼,并且生成記錄之前的源碼和之后的源碼的關(guān)聯(lián)關(guān)系的 sourcemap。
雖然都是分為這三個階段,但是具體的名字可能不同,比如 vue template compiler 中就把 transform 叫做 optimize,以為它主要是做優(yōu)化后續(xù)渲染的一些轉(zhuǎn)換;postcss 第三步叫做 stringifier。具體名字不用糾結(jié)。
昊昊: 我對這三步都干了啥很好奇啊,光哥,你能給我講講不
我: 可以啊。就像我說的,轉(zhuǎn)譯器都分為這三步,那么咱換個維度,分別分析 parse、transform、generate 這三個階段,縱向?qū)Ρ雀鞣N轉(zhuǎn)譯器里面的實現(xiàn)。
不過內(nèi)容有些多,分為三部分來講吧,先講 parse 部分。
JS Parser
我: 先從 JS Parser 開始吧。昊昊,你覺得為啥要用 JS 寫 JS parser。
昊昊: 是因為前端工程化吧,有了 node 之后可以用 js 寫 js 代碼的工具鏈,包括語法轉(zhuǎn)換、壓縮混淆,還有打包工具等,這些都需要 parser 的支持。
我: 對,確實是工程化領(lǐng)域的工具鏈造成了對 parser 的需求。最早的 JS 寫的 JS parser 是 esprima。當時 Mozilla 公布了它的 JS 引擎 SpiderMonkey 的 parser api 和 ast 標準。于是 esprima 就基于它的 ast 標準實現(xiàn)了 parser。后來形成了 estree 標準,這個對其 SpiderMonkey 的 ast。所以當聽到 SpiderMonkey 的 ast 時,就是說 estree 標準的 ast。比如 terser 的文檔中就叫 SpiderMonkey ast。
昊昊: 我知道了,SpiderMonkey 的 api 是參照物,estree 是對它的兼容和擴充,然后最早的實現(xiàn)是 esprima。
我: 對,因為有了 esprima 這個 parser,很多 js 的轉(zhuǎn)譯工具就可以直接基于它做了,比如 eslint。eslint 最早就是基于 esprima 的,前期一切都挺好。但是當 js 到了 es6 以后,更新速度加快,而 esprima 的更新速度跟不上,這導(dǎo)致 eslint 的使用者經(jīng)常抱怨這個問題。所以 eslint 干脆 fork 了一份 esprima,自己擴展語法,這就是 espree。espree 自己單干了,但也是 estree 標準的實現(xiàn)。
后來社區(qū)迎來了更好的 JS parser,就是現(xiàn)在最常用的 acorn。它速度更快,支持新語法,而且支持插件擴展,全面超過了 esprima。所以大批之前基于 esprima 的就改為了基于 acorn。其中當然包括 eslint,在 espree2.0 之后,底層的 parser 實現(xiàn)就改成了 acorn。
acorn 的插件機制使得開發(fā)者可以擴展一些新的語法,這樣使得它能滿足各種定制需求。我覺得一個好的插件機制很重要,webpack 不也是靠這些才成功的么。
昊昊: acorn 可以擴展新的語法么,怎么做啊
我: 比如我想擴展一個關(guān)鍵字,叫 ssh,這個 ssh 會生成一個新的 ast 節(jié)點,這樣是不是就達到了擴展新語法的目的。那么該怎么做呢?
acorn 插件的形式是一個函數(shù),接受舊 Parser,返回繼承舊 Parser 的新 Parser,這個 Parser 通過重寫一些方法,達到擴展的目的。這種基于繼承和重寫的擴展在面向?qū)ο箢I(lǐng)域還是挺常見的。
那重寫啥方法呢?比如我想直接ssh;來使用,這樣首先要注冊一個關(guān)鍵字,用于分詞的時候分出來,需要在構(gòu)造器里面修改 this.keywords 的正則表達式,這個正則表達式就用于 keywords 的分詞。
acorn Parser 的入口方法是 parse,我們要在 parse 方法里面設(shè)置 keywords。
parse(program) {
var newKeywords = "break case catch continue debugger default do else finally for function if return switch throw try var while with null true false instanceof typeof void delete new in this const class extends export import super";
newKeywords += " ssh";// 增加一個關(guān)鍵字
this.keywords = new RegExp("^(?:" + newKeywords.replace(/ /g, "|") + ")$")
return(super.parse(program));
}
然后注冊一個新的 token 類型來標識它
Parser.acorn.keywordTypes["ssh"] = new TokenType("ssh",{keyword: "ssh"});
這樣 acorn 就會在 parse 的時候分出 ssh 這個關(guān)鍵字
然后是語法階段,acorn 會對不同的 AST 類型調(diào)用不同的 parseXxx 方法,我們這里要覆蓋 parseStatement,因為 ssh; 是一個 statement。
this.type 是當前處理的 token 的類型,如果是新的 ssh 類型的話,就用 this.next() 消耗掉這個 token,然后組裝成 AST。否則調(diào)用父類的 parse 邏輯。
parseStatement(context, topLevel, exports) {
var starttype = this.type;
if (starttype == Parser.acorn.keywordTypes["ssh"]) {
var node = this.startNode();
return this.parseSshStatement(node);
}
else {
return(super.parseStatement(context, topLevel, exports));
}
}
通過 this.startNode 創(chuàng)建新節(jié)點之后就是往這個節(jié)點填內(nèi)容了,通過 this.next 把 ssh 這個單詞消耗掉,然后返回一個對應(yīng)的 ast
parseSshStatement(node) {
this.next();
return this.finishNode({value: 'ssh'},'sshStatement');//新增加的ssh語句
};
到了這里就大功告成了。其實也不難:
擴展詞法分析階段要修改對應(yīng)的正則,注冊對應(yīng)的 tokenType。 擴展語法分析階段,要重寫對應(yīng)的 parseXxx 方法,然后創(chuàng)建新節(jié)點,消耗掉 token,來產(chǎn)生新的 ast 節(jié)點返回。
完整代碼這樣:
const acorn = require("acorn");
const Parser = acorn.Parser;
const tt = acorn.tokTypes;
const TokenType = acorn.TokenType;
//添加一個ssh的關(guān)鍵字
Parser.acorn.keywordTypes["ssh"] = new TokenType("ssh",{keyword: "ssh"});
function wordsRegexp(words) {
return new RegExp("^(?:" + words.replace(/ /g, "|") + ")$")
}
var sshKeyword = function(Parser) {
return class extends Parser {
parse(program) {
var newKeywords = "break case catch continue debugger default do else finally for function if return switch throw try var while with null true false instanceof typeof void delete new in this const class extends export import super";
newKeywords += " ssh";
this.keywords = wordsRegexp(newKeywords);// 重新設(shè)置關(guān)鍵字
return(super.parse(program));
}
parseStatement(context, topLevel, exports) {
var starttype = this.type;
if (starttype == Parser.acorn.keywordTypes["ssh"]) {
var node = this.startNode();
return this.parseSshStatement(node);
}
else {
return(super.parseStatement(context, topLevel, exports));
}
}
parseSshStatement(node) {
this.next();
return this.finishNode({value: 'ssh'},'sshStatement');//新增加的ssh語句
};
}
}
const newParser = Parser.extend(sshKeyword);
我們調(diào)用一下我們定制的新 Parser,就可以發(fā)現(xiàn)他能處理 ssh 關(guān)鍵字了,我們成功的實現(xiàn)了新語法!就算 typescript、jsx、flow 等新語法的實現(xiàn)也是一樣的方式,只不過那些更繁瑣。將來你有什么好的擴展語法的想法,比如語言級別內(nèi)置一個 dsl,像 jsx 那樣,就可以這樣來改 parser。
var program =
`
ssh;
const a = 1;
`;
newParser.parse(program);
結(jié)果:
{
"type": "Program",
"start": 0,
"end": 30,
"body": [
{
"value": "ssh",
"type": "sshStatement",
"end": 11
},
{
"type": "EmptyStatement",
"start": 11,
"end": 12
},
{
"type": "VariableDeclaration",
"start": 17,
"end": 29,
"declarations": [
{
"type": "VariableDeclarator",
"start": 23,
"end": 28,
"id": {
"type": "Identifier",
"start": 23,
"end": 24,
"name": "a"
},
"init": {
"type": "Literal",
"start": 27,
"end": 28,
"value": 1,
"raw": "1"
}
}
],
"kind": "const"
}
],
"sourceType": "script"
}
昊昊: 哇,好棒的插件機制,還能擴展新語法。除了 acorn,還有別的 js parser 有插件機制么?
我: 我印象中沒有,esprima、typescript等都沒有語法的插件,這種只能等待官方去實現(xiàn)了。
昊昊: 那 babel、espree 等都是基于 acorn 的,他們都做了哪些改動和擴充呢?
我: espree 只是增加了一些屬性,ast 保持 estree 兼容。而 @babel/parser 除了在一些節(jié)點添加屬性之外,也擴展了很多新節(jié)點,所以它是不兼容 estree 標準的。他做了這些修改:
把 Literal 替換成了 StringLiteral、NumericLiteral, BigIntLiteral, BooleanLiteral, NullLiteral, RegExpLiteral 把 Property 替換成了 ObjectProperty 和 ObjectMethod 把 MethodDefinition 替換成了 ClassMethod Program 和 BlockStatement 也支持 'use strict' 等指令的解析,對應(yīng)的 ast 是 Directive 和 DirectiveLiteral ChainExpression 替換為了 ObjectMemberExpression 和 OptionalCallExpression ImportExpression 替換為了 CallExpression 并且 callee 屬性設(shè)置為 Import 等
它的 api 大概是這樣的,plugins 就是它擴展的一些語法支持。
require("@babel/parser").parse("code", {
sourceType: "module",
plugins: [
"jsx",
"typescript"
]
});
具體可以在 @babel/parser 的文檔來查,整體基本是對 AST 的細化,其實也很好理解,比如一個數(shù)字類型,拆分為整數(shù)和浮點數(shù)顯然方便更細粒度的處理啊,免去了各種判斷。因為 babel 的 parser 是暴露 api 給開發(fā)者用來修改 ast 的,所以能省去很多判斷的 ast 細化是很有必要的。
我們剛學(xué)會了寫 acorn 的插件,可以實現(xiàn) Literal 細化這個。
parseLiteral (...args) {
const node = super.parseLiteral(...args);
switch(typeof node.value) {
case 'number':
node.type = 'NumericLiteral';
break;
case 'string':
node.type = 'StringLiteral';
break;
}
return node;
}
其實并不難,但是卻能省去開發(fā)者很多判斷,雖然失去了 estree 的兼容性,不得不說這是一種很不錯的設(shè)計權(quán)衡。就像 hooks 明明可以用 map 實現(xiàn),支持任意順序,卻最終選擇了用數(shù)組實現(xiàn),只能寫在頂層,犧牲了一些靈活性換取了更簡潔的寫法。
這都是很棒的架構(gòu) treade off(權(quán)衡)。
昊昊: 那其他的 JS 轉(zhuǎn)譯器呢,prettier、terser 等,他們的 parser 是啥?
我:
prettier 是基于 @babel/parser 和 typescript 等 parser 的, terser 是有自己的一套 ast 標準。你可能會問為什么 terser 是自己的一套,為什么不統(tǒng)一呢?
這個問題其實 terser 在 2012 年就回答過了,主要是是 terser 的 ast 上有很多方法,而且不同 ast 節(jié)點之間有繼承關(guān)系,而 estree 標準的 ast 是純粹的數(shù)據(jù)結(jié)構(gòu)。也就是貧血模型和富血模型的區(qū)別。terser 覺得改動成本比較大,就一直沒改??梢运?why not switching to SpiderMonkey AST 這篇文章,那里有官方解釋。
其實我覺得改是可以改的,就是需要重寫,terser 沒改而已。
但這樣我覺得是個需要去解決的問題,影響還是有的,主要是性能。babel 是 estree 標準的細化也就是 SpiderMonkey 的 ast,而 terser 是自己的一套,這樣用兩個工具的時候就不能直接復(fù)用 ast,得轉(zhuǎn)換一遍或者先打印成字符串再重新 parse,而且 sourcemap 也得關(guān)聯(lián)上。不管哪種方式,性能都會有損耗。
terser 用自己的 parser 和 ast,最開始的 uglify 不支持 es6 的語法,后來有了 uglify-es,后來又放棄了,干脆重寫了一遍,就是現(xiàn)在的 terser。關(guān)鍵是它重寫了依然用的自己的 ast...
這個問題在 js 的工具鏈中一直存在沒解決,一般都會先用 babel 或者 typescript 來轉(zhuǎn)一次源碼,然后變成字符串后再用 terser 轉(zhuǎn)一次,把 sourcemap 也做下關(guān)聯(lián)。
現(xiàn)在一些別的語言寫的 parser 解決了這個問題,比如 rust 寫的 swc,他就實現(xiàn)了 parser 并且自己做了 minifier,這樣不需要切換兩套 ast,也不用 sourcemap 多一層映射,效率就會高一些。再加上編譯型語言比解釋型語言做工具方面快很多。所以性能差距挺明顯的。
希望 JS 社區(qū)能出一個基于 estree 系列 parser 來做壓縮的工具吧,替代掉 terser。babel-minify 是做這個的,但還在 0.x 階段,希望盡快能夠到 1.0 吧。
CSS Parser
昊昊: 光哥,JS parser 和一些轉(zhuǎn)譯器我大概知道了,那 css 呢
我: css 的轉(zhuǎn)譯器流行的就 postcss 一個,less、sass 等是為了增強 css 能力的 dsl,和 postcss 這種專用做轉(zhuǎn)譯器的工具定位上有不同。而且更重要的是 postcss 的 parser 也支持插件機制,默認支持 css,但是可以通過插件支持各種語法。這里說的是 syntax parser,是用于擴展支持的語法的,一般我們說的 postcss插件是后面的 transform parser。
你看,流行的方案基本都是有好的插件機制的,這是規(guī)律,比如 acorn、postcss、webpack 等都是。因為這樣才能利用社區(qū)的力量去彌補各方面的不足,才能形成生態(tài)。
我們上面寫了一個 acorn 語法插件,接下來在 postcss 的語法插件里面用一下,現(xiàn)學(xué)現(xiàn)用嘛。讓 postcss 支持 js 語法!是不是聽起來聽高大上的,其實怎么 parse 的 postcss 不關(guān)心,只要你輸出給它的是 postcss 的 ast 就可以了。
分析一下思路:postcss 可以傳入 parser 和 stringifier 來自己實現(xiàn),其實 eslint、prettier 等也可以自定義 parser,文檔中可以找到相關(guān)介紹。我們這里只實現(xiàn) parser。
目標是組裝出 postcss 的 ast,這個分別調(diào)用 postcss.root、postcss.rule、postcss.decl 既可。源碼封裝成 Input 對象,然后對它進行 parse,之后返回組裝好的 postcss 的 ast 就行了。
const postcss = require('postcss');
(async ()=> {
const code = `ssh;`;
class MyParser {
parse(input){
const root = postcss.root();
const ast = newParser.parse(input.css, {ecmaVersion: 6});
ast.body.forEach(item => {
if (item.type === 'sshStatement') {
const rule = postcss.rule();
rule.selector = "ssh";
const decl = postcss.decl();
decl.prop = 'background';
decl.value = 'green;';
rule.nodes.push(decl);
root.nodes.push(rule);
}
});
return root
}
}
cosnt parser = (code) => {
let input = new postcss.Input(code)
const parser = new MyParser(input);
return parser.parse(input);
}
const result = await postcss().process(code, { parser, from: '' });
console.log(result.content);
})()
結(jié)果:
ssh {background: green;}
昊昊: 哇,把 js 的新語法編譯到 css,好酷哦。
我: 而且不只是 parser 可以自定義,stringifier 也可以,比如打印的時候坐下語法高亮啥的。
HTML Parser
昊昊: 光哥,那還有 html 的 parser 呢?
我: html 的也和 css 的差不多,有很多 dsl 也就是各種模版引擎,編譯到 html。專門用作轉(zhuǎn)譯器的主要是 posthtml,它的 parser 用的是 htmlparser2。流程和 postcss 差不多,但是他只支持 transform plugin,不支持 syntaxt plugin。區(qū)分這倆插件的方式很簡單:
syntaxt plugin 是擴展語法的,所以輸入的是字符串,輸出的是 ast;
transform plugin 是對 ast 進行轉(zhuǎn)換的,所以輸入輸出都是 ast。
雖然 posthtml 不支持 syntaxt plugin,但你可以拿到某個節(jié)點之后取內(nèi)容自己 parse 啊,然后生成 html 的 ast,比如 md 轉(zhuǎn) html、各種模版引擎轉(zhuǎn) html 等。
昊昊: 感覺各種 parser 好多啊,有 acorn、htmlparser2、postcss 這些通用的 parser,也有各個轉(zhuǎn)譯器自己實現(xiàn)的 parser。
我: 所以學(xué)習東西不要陷入到使用中啊,了解一下有哪些 parser 只是擴展下視野,學(xué)習怎么寫 parser 要去了解詞法分析語法分析這些東西,而不是學(xué)習某個 parser 的使用。但是一般情況下也不會手寫復(fù)雜的 parser, html parser 還可以手寫,比如 vue template compiler。但是復(fù)雜的就沒必要了,可以用 antlr 這種 parser generator 來生成。
parse 只是轉(zhuǎn)譯的開始,重頭戲在 parse 之后呢。
