5

手写 JS 引擎来解释一道赋值面试题

 2 years ago
source link: https://developer.51cto.com/article/700815.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

本文转载自微信公众号「神光的编程秘籍」,作者神说要有光。转载本文请联系神光的编程秘籍公众号。

有这样一道面试题:

let a = { n: 1};

a.x = a = { n: 2};

console.log(a.x);

问输出的是啥。

这道题输出的是 undefined,因为赋值是从左往右进行的,也就是先把 {n: 2} 赋值给 a.x 再赋值给 a。

再加个变量 preA 引用赋值之前的 a 就能看出来:

1345e302385f8523232230e42418bc4e013ca1.webp

这就是运算符优先级的问题,就像加减乘除运算符也是从左往右按照优先级来算一样。

写这篇文章不是为了讲运算符优先级问题,而是想自己实现一个 JS 引擎来解释执行这段代码。

怎么实现 JS 引擎呢?

JS 引擎的构成

一个 JS 引擎由四部分构成:Parser(解析器)、Interperter(解释器)、JIT Compiler(JIT 编译器)、Garbage Collector(垃圾收集器)。

89be53137c4cccd906574258d22bfb7903217a.webp

源码经过 Parser 解析成 AST,也就是计算机能处理的对象树的结构,然后用解释器递归的解释每个节点,这就是解释执行的过程。

但是解释执行比较慢,为了优化速度又引入了 JIT 编译器,会把经常执行的热点代码编译成机器码直接执行。

同时,内存是有限制的,要不断的循环清除不再被使用的内存,也就是垃圾回收,这是 GC 做的事情。

如果我们自己实现简单的 JS 引擎,那可以省略掉 JIT 编译器和 GC,这两个分别是优化时间和空间的,不是必须的。

也就是只要实现 Parser 和 Interpreter(解释器)就行:

121658467129f730d1854439ee3659719b5b3b.webp

这就是我们要实现的 JS 引擎的结构。

简易 JS 引擎实现思路

分析Parser 可以是任意的 JS Parser,我们直接用 @babel/parser。

它产生的 AST 可以用 astexplorer.net 可视化的查看:

a2be1ad571925b1c720133c36536c1b85ed594.webp

怎么解释执行呢?

解释 AST 也就是递归解释每个 AST 节点,那具体的 AST 节点又怎么解释呢?

比如这个 {n:1} 的对象表达式,它对应的是 ObjectExpression 节点,它有 ObjectProperty 属性的子节点,子节点有 key、value 属性。

自然可以想到,解释 ObjectExpression 节点就是取出 AST 中的数据构造一个对象返回:

541f40463d655d722ee571a334b6722a99aad6.webp

再比如 let a = { n: 1} 这条赋值语句,它对应的是 VariableDeclaration 节点,下面有多个 VariableDeclarator 子节点,这是因为一条声明语句可以声明多个变量,比如 let a = 1, b =1; 而具体的声明 VariableDeclarator 里分别有 id 和 init 部分。

4300a9271c583fd7da396322fda206e1363f09.webp

init 部分就是 ObjectExpression,解释它就是构造一个对象返回。那么解释整个声明自然就是在作用域中放一个名字为 id 节点的 value 为名字的变量,值就是 init 节点的解释执行的结果。

Identifier 是标识符的意思,也就是这里的 a。

对象表达式 ObjectExpression 和声明语句 VariableDeclaration 的解释执行就是这样,不难吧。

其他 AST 节点的解释也是这样,递归的解释每个节点,这就是解释执行 JS 代码的实现原理。

知道了该怎么做,那我们来写代码实现下吧:

声明语句的解释执行

我们先实现声明语句的解释执行,后面再实现赋值语句的解释执行:

Parser 使用 babel parser,用它的 parse 方法把源码转成 AST,然后解释执行 AST:

const parser = require('@babel/parser');

function eval() {
    const ast = parser.parse(code);
    evaluate(ast.program);
}

const scope = new Map();

function evaluate(node) {

}

const code = `
let a = { dong: 111};
let b = { guang: 222};
let c = b;
`
eval(code);

console.log(scope);

我们声明了一个 Map 对象作为全局作用域。

接下来就是实现解释器了,也就是 evaluate 方法。

我们前面分析过了,解释执行就是递归处理每个 AST,每种 AST 的处理方式不同:

const astInterpreters = {
    Program(node) {
        node.body.forEach(item => {
            evaluate(item);
        })
    },
    VariableDeclaration(node) {
    },
    VariableDeclarator(node) {
    },
    ObjectExpression(node) {
    },
    Identifier(node) {
        return node.name;
    },
    NumericLiteral(node) {
        return node.value;
    }
}

function evaluate(node) {
    try {
        return astInterpreters[node.type](node);
    } catch(e) {
        console.error('不支持的节点类型:', e);
    }
}
我们实现了几个简单的 AST 的解释

我们实现了几个简单的 AST 的解释:Program 根节点,它的解释执行就是解释执行 body 的每一条语句。Identifier(标识符) 就是取 name 属性,NumericLiteral(数字字面量)就是取 value 属性。

然后是对象表达式 ObjectExpression 的解释了,这个就是构造一个对象返回:

ObjectExpression(node) {
    const obj = {};
    node.properties.forEach(prop => {
        const key = evaluate(prop.key);
        const value = evaluate(prop.value);
        obj[key] = value;
    });
    return obj;
}

也就是取 properties 中的每个节点,拿到 key 和 value 的解释执行结果,设置到对象中,最后把对象返回。

声明语句的解释就是在作用域(我们声明的 Map)中设置下就行:

VariableDeclaration(node) {
    node.declarations.forEach((item) => {
        evaluate(item);
    });
},
VariableDeclarator(node) {
    const declareName = evaluate(node.id);
    if (scope.get(declareName)) {
        throw Error('duplicate declare variable:' + declareName);
    } else {
        const valueNode = node.init;
        let value;
        if (valueNode.type === 'Identifier') {
            value = scope.get(valueNode.name);
        } else {
            value = evaluate(valueNode);
        }
        scope.set(declareName, value);
    }
},

VariableDeclaration 是声明语句,因为具体的声明可能有多个,所以要循环执行每个声明。

具体的声明 VariableDeclarator 就是在 scope 中设置变量名和它的值。

变量名是 node.id 的执行结果,如果声明过就报错,因为只能声明一次。

否则就取 node.init 的值设置到 scope 中,也就是 scope.set(declarationName, value)。

但是值的处理要注意下,如果是 Identifier 也就是标识符,它其实是一个引用,比如变量 a,那么我们要先从作用域中拿到它的具体值。

这些节点的解释执行逻辑写好了,那么我们就能解释这段代码了:

let a = { dong: 111};
let b = { guang: 222};
let c = b;

声明了 a、b、c 三个变量,a、b 初始值都是对象字面量、c 是引用自 b。

执行之后我们打印下 scope:

e64bc57925f0805088e969d3fca8d8095679c4.webp

执行成功!我们实现了最简单的 JS 引擎!

当然,只是声明还不够,接下来再实现赋值语句的解释:

赋值语句的解释执行

赋值语句的解释也就是解释 AssignmentExpression 节点,用 astexplorer.net 看下它的结构:

63ee60f21bf59f1c6392439a925b08433af67a.webp

它外面怎么还包裹了个 ExpressionStatement 节点呢?

因为表达式不能直接执行,语句才是执行的基本单位,那么表达式包裹一层表达式语句(ExpressionStatement)就可以了。

AssignmentExpression 有 left 和 right 属性,分别是 = 左右部分对应的节点。

如果 right 还是 AssignmentExpression 呢?

那么要继续取 right,知道拿到不是 AssignmentExpression 的节点,这就是要赋值的值。

而它左边的所有的节点都是赋值的目标,从左到右依次赋值即可。

所以,AssignmentExpression 节点的解释执行是这样的:

我们不是要顺着 right 往右找么,那就声明 curNode 代表当前节点,然后 while 循环往右找,过程中的所有 left 都放到一个数组里:

let curNode = node;
const targetArr = [curNode.left];
while(curNode.right.type === 'AssignmentExpression') {
    curNode = curNode.right;
    targetArr.push(curNode.left);
}

最后一个 right 就是赋值的值的 AST,解释执行之后拿到它的值。

const value = evaluate(curNode.right);

然后把 value 赋值给 targetArr 中的所有变量就行了,也就是从左往右依次赋值:

这里要区分下 a 和 a.x 的赋值:

如果是 a,也就是 Identifier,那么设置道作用域中就行,也就是 scope.set(varName, value)。

如果是 a.x,也就是 MemberExpression,那么要从作用域中拿到对象的部分也就是 scope.get(objName) 的部分,然后再设置属性。

targetArr.forEach(target => {
    if (target.type === 'Identifier'){
        const varName = evaluate(target);
        scope.set(varName, value);
    } else if (target.type === 'MemberExpression') {
        const objName = evaluate(target.object);
        const obj = scope.get(objName);

        const propName = evaluate(target.property);
        obj[propName] = value;
    }  
})

赋值语句的解释执行的完整代码如下:

AssignmentExpression(node) {
    let curNode = node;
    const targetArr = [curNode.left];
    while(curNode.right.type === 'AssignmentExpression') {
        curNode = curNode.right;
        targetArr.push(curNode.left);
    }
    const value = evaluate(curNode.right);

    targetArr.forEach(target => {
        if (target.type === 'Identifier'){
            const varName = evaluate(target);
            scope.set(varName, value);
        } else if (target.type === 'MemberExpression') {
            const objName = evaluate(target.object);
            const obj = scope.get(objName);

            const propName = evaluate(target.property);
            obj[propName] = value;
        }  
    })
} 

实现了声明语句、赋值语句的解释其实就达到我们的目标了。我们执行下开头代码:

let a = { n: 1};
let preA = a;
a.x = a = { n: 2};

看下 scope 中的值:

f834d6405de71bfef4754040e29ee9793f8746.webp

为啥 a.x 是 undefined 不就解释清楚了么。

全部代码上传到了 github:https://github.com/QuarkGluonPlasma/babel-article-code

我们做了一道赋值语句的面试题,它考察的就是运算符从左到右执行。

但是只是知道赋值运算符怎么执行的还不够,我们自己写了一个 JS 引擎来执行它。

JS 引擎由 Parser、Interpreter(解释器)、JIT Compiler、Garbage Collector 四部分构成。其中 JIT 编译器和 GC 分别是优化时间和空间用的,不是必须的。所以,我们实现了只有 Parser 和 Interpreter 的 JS 引擎。

Parser 使用任何 JS parser 都行,我们使用了 babel parser,解释器的实现就是递归解释每个节点,我们分别实现了声明语句和赋值语句的解释执行。

最终,我们得到了最开始的结果,并且还清楚的知道了赋值语句是怎么解释执行的。

(当然,因为只需要解释这几行代码,解释器并不完善,更完善的解释器可以看 《Babel 插件通关秘籍》小册中的 JS 解释器的案例。)


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK