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