当前位置: 首页 > 科技观察

手写JS引擎讲解一道assignment面试题

时间:2023-03-16 17:21:48 科技观察

本文转载自微信公众号《神光的编程秘籍》,作者神说必有光。转载本文请联系神光编程秘籍公众号。有这么一道面试题:leta={n:1};a.x=a={n:2};控制台日志(a.x);请问输出是什么。这道题的输出是未定义的,因为赋值是从左到右进行的,即先将{n:2}赋给a.x,再赋给a。通过在赋值前加一个变量preA来引用a可以看出:这就是运算符优先级的问题,就像加减乘除运算符也是按照从左到右的优先级计算的一样。写这篇文章不是为了讲运算符优先级,而是实现一个JS引擎来解释执行这段代码。如何实现一个JS引擎?一个JS引擎的组成一个JS引擎由四部分组成:Parser(解析器)、Interperter(解释器)、JITCompiler(即时编译器)、GarbageCollector(垃圾收集器)。源代码被Parser解析成AST,就是计算机可以处理的对象树结构,然后每个节点被解释器递归解释,就是解释执行的过程。但是,解释和执行相对较慢。为了优化速度,引入了JIT编译器,将频繁执行的热点代码编译成机器码直接执行。同时,内存是有限的,不再使用的内存必须不断地循环清理,也就是垃圾回收,也就是GC干的事情。如果我们自己实现一个简单的JS引擎,可以省略JIT编译器和GC,分别优化时间和空间,不是必须的。即只实现Parser和Interpreter(解释器):这就是我们要实现的JS引擎的结构。简单JS引擎实现思路分析Parser可以是任何JSParser,我们直接使用@babel/parser。其生成的AST可以用astexplorer.net直观查看:如何解释执行?解释AST就是递归解释每一个AST节点,那么具体的AST节点怎么解释呢?比如这个{n:1}对象表达式,它对应的是ObjectExpression节点,它有ObjectProperty属性的子节点,子节点有key和value属性。自然可以想到,解释ObjectExpression节点就是把AST中的数据取出来构造一个对象返回:再比如赋值语句leta={n:1},对应VariableDeclaration节点,有下面有多个VariableDeclarator子节点,这是因为一个声明语句可以声明多个变量,比如leta=1,b=1;而具体的声明VariableDeclarator分别有id和init部分。init部分是ObjectExpression,说明是构造一个对象返回。那么解释整条语句自然就是把id节点的一个名为value的变量放在scope中,value就是init节点解释执行的结果。Identifier是标识符的意思,这里是一个。对象表达式ObjectExpression和声明语句VariableDeclaration的解释和执行就是这样,不难。其他AST节点的解释也是如此,对每个节点进行递归解释,这就是解释执行JS代码的实现原理。现在我们知道要做什么了,让我们编写代码来实现它:声明语句的解释和执行。我们先实现声明语句的解释和执行,然后是赋值语句的解释和执行:Parser使用babel解析器,利用其parse方法解析源代码转换为AST,然后解释执行AST:constparser=require('@babel/parser');functioneval(){constast=parser.parse(code);evaluate(ast.program);}constscope=newMap();functionevaluate(node){}constcode=`leta={dong:111};letb={guang:222};letc=b;`评估(代码);console.log(范围);我们将Map对象声明为全局范围。下一步是实现解释器,即evaluate方法。我们之前分析过,解释执行是递归处理每一个AST,每一个AST的处理方式都不一样:constastInterpreters={Program(node){node.body.forEach(item=>{evaluate(item);})},VariableDeclaration(node){},VariableDeclarator(node){},ObjectExpression(node){},Identifier(node){returnnode.name;},NumericLiteral(node){返回节点值;}}functionevaluate(node){try{returnastInterpreters[node.type](node);}catch(e){console.error('不支持的节点类型:',e);}}WeimplementedafewsimpleASTinterpretersforourImplementedseveralsimpleASTinterpretations:程序根节点,它的解释和执行就是解释和执行body的每一条语句。Identifier(标识符)是取name属性,NumericLiteral(数字字面量)是取value属性。然后是对象表达式ObjectExpression的解释,这个是构造一个对象return:ObjectExpression(node){constobj={};node.properties.forEach(prop=>{constkey=evaluate(prop.key);constvalue=evaluate(prop.value);obj[key]=value;});returnobj;}就是取properties中的每个节点,得到key和value的解释执行结果,设置到对象中,最后把对象return。声明语句的解释是设置在范围内(我们声明的Map):VariableDeclaration(node){node.declarations.forEach((item)=>{evaluate(item);});},VariableDeclarator(node){constdeclareName=evaluate(node.id);if(scope.get(declareName)){throwError('重复声明变量:'+declareName);}else{constvalueNode=node.init;让值;if(valueNode.type==='Identifier'){value=scope.get(valueNode.name);}else{value=evaluate(valueNode);}scope.set(declareName,value);}},VariableDeclaration是声明语句,因为具体的语句可能有多个,所以每个语句都要循环执行。VariableDeclarator的具体声明是设置作用域内的变量名及其值。变量名是node.id的执行结果。如果声明过,会报错,因为只能声明一次。否则,取node.init的值并将其设置为作用域,即scope.set(declarationName,value)。但要注意值的处理。如果是一个Identifier,也就是标识符,其实就是一个引用,比如变量a,那么我们需要先从作用域中获取它的具体值。这些节点的解释和执行逻辑都写完了,那么我们就可以解释这段代码了:leta={dong:111};让b={广:222};让c=b;声明a,b,c三个变量,a和b的初值是对象字面量,c是从b引用的。执行完后,我们打印scope:执行成功!我们实现了最简单的JS引擎!当然仅仅声明是不行的,然后执行赋值语句的解释:赋值语句的解释执行赋值语句的解释,也就是解释AssignmentExpression节点,用astexplorer.net看它的结构:为什么用ExpressionStatement节点包裹它?因为表达式不能直接执行,语句是执行的基本单位,所以表达式可以用一层表达式语句(ExpressionStatement)包裹起来。AssignmentExpression有left和right属性,分别是=的左右部分对应的节点。如果right仍然是AssignmentExpression怎么办?然后继续向右取,直到得到一个不是AssignmentExpression的节点,这就是要赋值的节点。而它左边的所有节点都是赋值的目标,从左到右依次赋值即可。所以,AssignmentExpression节点的解释执行是这样的:我们不是要顺着右边往右找,然后声明curNode代表当前节点,然后用while循环往右找,和把进程中的所有左边都放到一个数组中:letcurNode=node;consttargetArr=[curNode.left];while(curNode.right.type==='AssignmentExpression'){curNode=curNode.right;targetArr.push(curNode.left);}最后右边被赋值的AST,它的值是经过解释执行后得到的。constvalue=evaluate(curNode.right);然后给targetArr中的所有变量赋值,即从左到右依次赋值:这里要区分a和a.x的赋值:如果是a,就是Identifier,那么只要设置scope,即scope.set(varName,value)。如果是a.x,也就是MemberExpression,那么从scope中获取对象的部分就是scope.get(objName)的部分,然后设置属性。也就是:targetArr.forEach(target=>{if(target.type==='Identifier'){constvarName=evaluate(target);scope.set(varName,value);}elseif(target.type==='MemberExpression'){constobjName=evaluate(target.object);constobj=scope.get(objName);constpropName=evaluate(target.property);obj[propName]=value;}})赋值语句的解释执行的完整代码如下:AssignmentExpression(node){letcurNode=node;consttargetArr=[curNode.left];while(curNode.right.type==='AssignmentExpression'){curNode=curNode.right;targetArr.push(curNode.left);}constvalue=evaluate(curNode.right);targetArr.forEach(target=>{if(target.type==='Identifier'){constvarName=evaluate(target);scope.set(varName,value);}elseif(target.type==='MemberExpression'){constobjName=evaluate(target.object);constobj=scope.get(对象名称);constpropName=evaluate(target.property);obj[propName]=值;}})}实现了声明语句和赋值语句的解释,其实达到了我们的目的。让我们执行开始的代码:leta={n:1};让preA=a;a.x=a={n:2};查看scope中的值:为什么a.x是undefined,为什么不解释清楚。所有代码已经上传到github:https://github.com/QuarkGluonPlasma/babel-article-code总结我们做了一道赋值语句的面试题,从左到右考察运算符的执行情况。但是仅仅知道赋值运算符是如何执行的还不够。我们写了一个JS引擎来执行它。JS引擎由四部分组成:Parser、Interpreter(解释器)、JITCompiler和GarbageCollector。其中,JIT编译器和GC分别用于优化时间和空间,并不是必须的。所以,我们实现了一个只有Parser和Interpreter的JS引擎。解析器可以使用任何JS解析器。我们使用babel解析器。解释器的实现是递归地解释每个节点。我们分别实现了声明语句和赋值语句的解释和执行。最终,我们得到了初步的结果,也清楚地知道了赋值语句是如何被解释和执行的。(当然,因为只需要解释这几行代码,所以解释器并不完美,更完整的解释器可以参考小册子的JS解释器案例《Babel 插件通关秘籍》。)