作用域與閉包 - 最簡解釋器實(shí)現(xiàn)
(給前端大學(xué)加星標(biāo),提升前端技能.)
作者:fedeoo
https://fedeoo.github.io/learning-ecma262/01 - Scope & Closure.html
張飛,叫fedeoo,畢業(yè)之后工作7年一直專注前端,在新浪和阿里工作一段時(shí)間,現(xiàn)在在澳洲工作。
正文從這開始~~
如果你對 JS 閉包的實(shí)現(xiàn)有興趣,就跟著這篇文章一起實(shí)現(xiàn)這一個(gè)最簡單的解釋器來重新認(rèn)識(shí)一下閉包。
開始之前
如果是從頭開始實(shí)現(xiàn)一個(gè)解釋器,避免不了從詞法分析和語法分析開始。我們對這部分工作沒有興趣,所以直接使用現(xiàn)有的分析器 acorn 。acorn 是一款比較流行的分析器,babel 底層依賴的就是 acorn。另外,esprima 也是一個(gè)不錯(cuò)的選擇。這兒我們選擇 acorn,還有一個(gè)經(jīng)常會(huì)用到的在線工具 astexplorer。acorn 解析得到的抽象語法樹符合 estree 的規(guī)范,項(xiàng)目中也依賴了 @types/estree。
在此之前,希望你大概明白 AST 抽象語法樹是怎么回事,知道如何遍歷一棵樹。
這兒選擇 Typescript,在開發(fā)中也隨時(shí)可以查看特定節(jié)點(diǎn)的數(shù)據(jù)類型,代碼也比較容易閱讀。測試使用 Jest,運(yùn)行起來比較簡單。即使并不追求 TDD,測試也可以幫助我們持續(xù)迭代。建議配置在 IDE 中運(yùn)行和調(diào)試測試。
算術(shù)表達(dá)式解析
我們從最簡單的算術(shù)表達(dá)式開始,這節(jié)的目的就是對算術(shù)表達(dá)式進(jìn)行求值:給定任意的算術(shù)表達(dá)式,形如 1 + 2 * 3 - 4 / 5 我們能夠正確得到結(jié)果。
AST 抽象語法樹
在開始之前,我們需要先打開 astexplorer,對 AST 有一個(gè)感性的認(rèn)識(shí)。當(dāng)左側(cè)的表達(dá)式為 1 + 2 時(shí),右側(cè)的得到的 AST 是這樣子的 (這兒以 JSON 形式表示):
{"type":"Program","body":[{"type":"ExpressionStatement","expression":{"type":"BinaryExpression","left":{"type":"Literal","value":1},"operator":"+","right":{"type":"Literal","value":2}}}]}
這兒移除了標(biāo)注代碼位置的數(shù)據(jù),以及字面量的 raw 屬性,其中 raw 表示原始字符串。如果將右操作數(shù)改為 16 進(jìn)制形式 1 + 0x2,我們就會(huì)發(fā)現(xiàn) raw 為 '0x2'。這棵語法樹根節(jié)點(diǎn)表示一段程序,其 body 屬性是一個(gè)數(shù)組表示可能有很多條表達(dá)式語句。對于二元表達(dá)式來說,有左右兩個(gè)節(jié)點(diǎn)。這兒的左右節(jié)點(diǎn)是字面量。我們改動(dòng)一下左側(cè)內(nèi)容為 1 + 2 * 3 我們看到,右側(cè)的節(jié)點(diǎn)成為了一個(gè)二元表達(dá)式,而這個(gè)新的二元表達(dá)式的兩個(gè)左右節(jié)點(diǎn)分別為字面量 2 和 字面量 3。
代碼實(shí)現(xiàn)
我們給自己的解釋器起一個(gè)名字,隨便起一個(gè)就叫 Z2(V8)吧。我們可以先寫一個(gè)簡單的測試:
it('should return sum when adding two numbers',()=>{const z2 =new Z2();expect(z2.run('3 + 4')).toBe(7);});
我們借助 acorn 得到 AST,之后遍歷這棵語法樹得到執(zhí)行結(jié)果就可以了。對每個(gè)節(jié)點(diǎn)類型,我們在一個(gè)單獨(dú)的函數(shù)中處理。整個(gè)的文件結(jié)構(gòu):
import{Parser}from'acorn';class Z2 {run(code:string){const program:Node=Parser.parse(code);returnthis.evaluate(program);}evaluate(node:ESTree.Node){switch(node.type){case'Program':returnthis.evaluateProgram(node);case'ExpressionStatement':returnthis.evaluateExpressionStatement(node);case'BinaryExpression':returnthis.evaluateBinaryExpression(node);case'Literal':returnthis.evaluateLiteral(node);default:thrownewError(`Unknown node type: ${node.type}`);}}}
現(xiàn)在我們只需要實(shí)現(xiàn)單獨(dú)的各個(gè)節(jié)點(diǎn)處理函數(shù)就可以了。各個(gè)節(jié)點(diǎn)只關(guān)注當(dāng)前的節(jié)點(diǎn)操作就可以了,對 evaluateBinaryExpression 來說,我們需要遞歸的調(diào)用 this.evaluate 以對子節(jié)點(diǎn)進(jìn)行求值。
evaluateBinaryExpression(node:ESTree.BinaryExpression){const{operator}= node;const left =this.evaluate(node.left);const right =this.evaluate(node.right);switch(operator){case'+':return left + right;case'-':return left - right;case'*':return left * right;case'/':return left / right;default:thrownewError(`Unknown operator: ${node.type}`);}}
而字面量的處理就更簡單了
evaluateLiteral(node:ESTree.Literal){return node.value;}
現(xiàn)在可以可以測試一下,對于混合的算術(shù)運(yùn)算,Z2 依然返回正確的結(jié)果。
it('should return correct value given mixed math expression',()=>{const z2 =new Z2();expect(z2.run('3 + 4 * 2')).toBe(11);expect(z2.run('8 + 12 / 3 + 4 * 2')).toBe(20);expect(z2.run('1 + 2 * 3 - 4 / 5')).toBe(6.2);});
當(dāng)然即使有括號(hào)改變優(yōu)先級(jí),一樣不會(huì)影響程序的正確性,因?yàn)?acorn 已經(jīng)給我們返回了正確的語法樹。
it('should return correct value given expression include parentheses',()=>{const z2 =new Z2();expect(z2.run('(3 + 4) * 2')).toBe(14);});
變量和環(huán)境
這次我們嘗試引入變量,我們的代碼也很簡單。我們可以添加一條新的測試用例:
it('should calculate correct value given expression with variable',()=>{const z2 =new Z2();expect(z2.run(`var a = 3;a * 9;`)).toBe(27);});
我們在 astexplorer 查看一下這兩個(gè)代碼的語法樹:第一部分是變量聲明;第二部分的表達(dá)式?jīng)]有太大變化,只是左側(cè)的操作數(shù)類型是標(biāo)識(shí)符 { type: 'Identifier', name: 'a' },右側(cè)依然是字面量。也就是說在對表達(dá)式求值時(shí),我們知道的只有標(biāo)識(shí)符的名稱 a,所以,我們需要從一個(gè)地方獲取到標(biāo)識(shí)符的值,簡單的理解為變量環(huán)境 Environment?,F(xiàn)在這個(gè)變量環(huán)境只是一個(gè)符號(hào)表,即便如此,我們還是創(chuàng)建一個(gè)單獨(dú)的類吧。
classEnvironment{private symbolTable ={};set(name, value){this.symbolTable[name]= value;}get(name:string){returnthis.symbolTable[name];}}
在 Z2 run() 中每次運(yùn)行代碼之前,都先創(chuàng)建一個(gè)新的環(huán)境 this.env = new Environment();。接下來的工作就是添加對變量聲明和標(biāo)識(shí)符的處理函數(shù),
evaluateVariableDeclaration(node){node.declarations.forEach((declaration)=>{if(declaration.id.type ==='Identifier'){this.env.set(declaration.id.name,this.evaluate(declaration.init));}});}
變量聲明是一個(gè)數(shù)組因?yàn)槲覀兛梢酝瑫r(shí)聲明多個(gè)變量,這兒我們只處理左側(cè)類型是標(biāo)識(shí)符的變量聲明。暫時(shí)不考慮形如 const { name, id } = user; 復(fù)雜的聲明。我們做的也很簡單,只是將變量值存放在環(huán)境中。至于標(biāo)識(shí)符的處理就更簡單了,只需讀取變量的值。
evaluateIdentifier(node:ESTree.Identifier){returnthis.env.get(node.name);}
同樣的方式,我們添加對賦值語句的支持,同時(shí)添加一些測試用例。
函數(shù)和閉包
接下來我們要引入函數(shù)和閉包。我們先來考察一下最最簡單的函數(shù)聲明和調(diào)用:
var a =3;function outer(){var a =5;return a;}outer();
相比較于變量聲明,函數(shù)聲明包含的屬性有點(diǎn)多,不過我們先只關(guān)注最基本的情況,其中最重要的是參數(shù) params 和函數(shù)體 body,暫時(shí)不考慮 params,在解釋函數(shù)聲明時(shí),同樣的我們需要把函數(shù)存放到上下文中, 簡單起見,我們只是把整個(gè)節(jié)點(diǎn)存放進(jìn)去。對于函數(shù)調(diào)用,這是一個(gè) CallExpression 我們需要做的就是根據(jù)標(biāo)識(shí)符名稱找到我們存放的函數(shù)對象(現(xiàn)在只是一個(gè)節(jié)點(diǎn)),然后解釋執(zhí)行函數(shù)。在上面的例子中,兩個(gè)同名變量是不同的,函數(shù)內(nèi)部是一個(gè)單獨(dú)的環(huán)境。在進(jìn)入函數(shù)之前,我們需要?jiǎng)?chuàng)建一個(gè)新的環(huán)境,將當(dāng)前環(huán)境設(shè)置為新環(huán)境,在函數(shù)調(diào)用結(jié)束之后,我們需要恢復(fù)原來的環(huán)境。
實(shí)現(xiàn)代碼如下,同時(shí)我們還要添加對 ReturnStatement 和 BlockStatement 的處理函數(shù)。
evaluateFunctionDeclaration(node:ESTree.FunctionDeclaration){this.env.init(node.id.name, node);}evaluateCallExpression(node:ESTree.CallExpression){const fnNode =this.env.get(node.callee.name);const env =newEnvironment();const currentEnv =this.env;// 保存調(diào)用者環(huán)境this.env = env;const result =this.evaluate(fnNode.body);this.env = currentEnv;// 恢復(fù)調(diào)用者環(huán)境return result;}
閉包的處理
我們希望函數(shù)內(nèi)部可以訪問函數(shù)外部的變量,我們添加一個(gè)測試用例:
it('should be able to access outer variable in function',()=>{const z2 =new Z2();expect(z2.run(`var a = 3;function outer() {return a;}outer();`)).toBe(3);});
在上面的實(shí)現(xiàn)中,我們創(chuàng)建的 Environment 是一個(gè)全新的。為了訪問到外部的環(huán)境變量,我們做需要一點(diǎn)點(diǎn)的變通:把當(dāng)前的環(huán)境作為外部環(huán)境引用傳進(jìn)去。const env = new Environment(this.env); 我們的思路是:在查找標(biāo)識(shí)符的時(shí)候,也就是 Environment.get(name) 函數(shù)實(shí)現(xiàn)中,先在當(dāng)前的環(huán)境查,如果查找不到,我們就在外部查。修改后的 Environment 類:
classEnvironment{private outer:Environment;private symbolTable ={};constructor(outer:Environment=null){this.outer = outer;}set(name, value){this.symbolTable[name]= value;}get(name){if(this.symbolTable[name]!==undefined){returnthis.symbolTable[name];}if(this.outer){returnthis.outer.get(name);}returnundefined;}}
修改代碼通過上面的測試用例。
現(xiàn)在我們思考一下在我們創(chuàng)建一個(gè)新的變量環(huán)境時(shí),是否應(yīng)該傳入當(dāng)前的變量環(huán)境。的確,我們這樣做通過了測試。但是,我們得思考一下,這意味著什么?這意味著,一個(gè)函數(shù)中的變量依賴于調(diào)用者環(huán)境。而一個(gè)函數(shù)可能在幾十個(gè)地方被調(diào)用,而我們根本無法得知函數(shù)里面的外部環(huán)境變量究竟是在哪兒定義的。
我們來看看實(shí)際當(dāng)中我們希望的外部環(huán)境是什么?現(xiàn)在我們添加一個(gè)閉包的測試用例,當(dāng)然這個(gè)測試現(xiàn)在不會(huì)通過。
var b =5;function outer(){var b =10;function inner(){var a =20;return a + b;}return inner;}var fn = outer();fn();
我們對閉包并不陌生,上面這個(gè)的這個(gè)例子中,fn 只是 inner 的引用,在我們調(diào)用 fn 時(shí),我們希望外部環(huán)境是 inner 所在的環(huán)境,也就是說我們希望將函數(shù)聲明時(shí)的環(huán)境作為外部環(huán)境。修改代碼,在函數(shù)聲明時(shí)保存整個(gè)函數(shù)和當(dāng)時(shí)的變量環(huán)境。
evaluateFunctionDeclaration(node:ESTree.FunctionDeclaration){this.env.init(node.id.name,this.createFunction(node));}createFunction(node){return{parentEnv:this.env,node,};}
在函數(shù)調(diào)用時(shí)將其作為外部環(huán)境
const fn =this.env.get(node.callee.name);const env =newEnvironment(fn.parentEnv);
修改代碼通過上面的測試用例。現(xiàn)在我們再來看閉包的話,我們發(fā)現(xiàn)所謂的閉包就是函數(shù)和它所在的環(huán)境。完整的代碼見 https://github.com/fedeoo/learning-ecma262/tree/master/src
總結(jié)
閉包已經(jīng)被大家講爛了,個(gè)人覺得理解一個(gè)東西最好的辦法就是實(shí)現(xiàn)一遍。簡單的解釋器非常容易實(shí)現(xiàn)(如果不需要做語法分析),建議大家讀完之后自己實(shí)現(xiàn)一遍,也就 100 多行代碼。這兒并沒有遵照 ECMAScript 規(guī)范來實(shí)現(xiàn),因?yàn)橐氩簧俑拍睿热邕\(yùn)行時(shí)返回的是 CompletionRecord 類型,解析標(biāo)識(shí)符得到的是 Reference 類型。如果想嚴(yán)格的按照規(guī)范實(shí)現(xiàn)或者學(xué)習(xí)規(guī)范,可以參考項(xiàng)目 engine262。針對想從頭開始寫解釋器的同學(xué),建議閱讀 esprima 源碼。除非你是在學(xué)編譯原理,跟人覺得沒有必要從頭開始,事實(shí)上已經(jīng)有不錯(cuò)的工具,根據(jù)產(chǎn)生式來直接生成語法樹了。
分享前端好文,點(diǎn)亮?在看?
