preface

In terms of compilation principles, for a small front end, it’s both far and near, far because if you were just doing business development, you probably wouldn’t need to touch it. But when you dig a little deeper, you’ll find that the front-end world is full of compile-principle apps that are actually very close to you, like Webpack, Rollup, Babel, and even PostCSS. The recent multi-terminal packaging frameworks TARO and MPVUE are also inseparable from the application of compilation principles;

I recall the courses and books on the principles of compilation in college, which are very difficult to understand. After work, if the content of work is out of line with the principles of compilation, I will inevitably become more and more unfamiliar. But if you want to go to a higher level of technology, as one of the basic computer disciplines, the principle of compilation is necessary to master;

In the process of using Babel to optimize the code, I slowly have a further understanding of the principle of compilation, under the inspiration of predecessors (JSJS), try to implement a JS interpreter, with JS to explain JS;

Disclaimer: the principles of compilation go far beyond the scope of this article, and there are many more that are not covered in this article.

Warehouse address: https://github.com/jackie-gan…

The preparatory work

First of all, what is a JS interpreter? To put it simply, run JS using JS;

Since we need to use JS to run JS, we need to read JS and actively execute JS.

For example, how to execute console.log(‘123’); What about the statement of:

  • First, you need to explain the individual words in the JS statementconsole,log,'123';
  • Then find out what syntax they belong to, for exampleconsole.log('123');Actually belongs to aCallExpression.calleeIs aMemberExpression;
  • Finally foundMemberExpressionIn theconsoleObject and then find itslogFunction, and finally execute the function, output123;

So in order to read and execute JS, we need the following tools:

  • Acorn, code parsing tool, can transform JS code into the corresponding AST syntax tree;
  • ASTExplorer to visually view the AST syntax tree;

Implementation approach

  • The first step, byacornConvert the code toASTThe syntax tree.
  • The second step is to customize the traversal and node handler functions.
  • The third step, in the handler function, executes the object code and recursively.
  • Fourth, the interpreter entry function handles the first one firstASTNode;

Traversal implementation

The AST syntax tree transformed by Acorn conforms to the ESTree specification, for example by looking at console.log(‘123’) through ASTExplorer; The AST syntax tree after the statement conversion looks like this:

As you can see, there are different node types in the syntax tree, so you need to continue defining the handler for each node:

const es5 = {
  Program() {},
  ExpressionStatement() {},
  BlockStatement() {},
  ThisExpression() {},
  ObjectExpression() {},
  BinaryExpression() {},
  Literal() {},
  Identifier() {},
  VariableDeclaration() {},
  ...
};

Next, we need to implement a traversal that recursively traverses the nodes of the syntax tree until the syntax tree is finally traversed:

const vistorsMap = { ... es5 }; export function evaluate(astPath: AstPath<ESTree.Node>) { const visitor = vistorsMap[astPath.node.type]; return visitor(astPath); }

Node handler function

The AST syntax tree node is treated just like the DOM tree node. After traversing the node, the node is treated according to the specification.

This paper has only implemented the code interpretation of ES5 specification so far, so the processing nodes are mainly ES5 nodes. The following is an example of the processing methods of some nodes:

The Program node

As the root node of the whole AST syntax tree, it only needs to traverse the body attribute of the node in turn, and the order of nodes in the body is the execution order of JS statements.

Program: (astPath: AstPath<ESTree.Program>) => {
  const { node, scope, evaluate } = astPath; 
  node.body.forEach((bodyNode) => {
    evaluate({ node: bodyNode, scope, evaluate });
  });
},

BinaryExpression node

To process a binary operand expression node, you first evaluate both the left and right expressions, then perform the corresponding evaluation according to the operator, and finally return the processing result.

BinaryExpression: (astPath: AstPath<ESTree.BinaryExpression>) => { const { node, scope, evaluate } = astPath; const leftVal = evaluate({ node: node.left, scope, evaluate }); const rightVal = evaluate({ node: node.right, scope, evaluate }); const operator = node.operator; const calculateFunc = { '+': (l, r) => l + r, '-': (l, r) => l - r, '*': (l, r) => l * r, '/': (l, r) => l / r, '%': (l, r) => l % r, '<': (l, r) => l < r, '>': (l, r) => l > r, '<=': (l, r) => l <= r, '>=': (l, r) => l >= r, '==': (l, r) => l == r, '===': (l, r) => l === r, '! =': (l, r) => l ! = r, '! ==': (l, r) => l ! == r }; if (calculateFunc[operator]) return calculateFunc[operator](leftVal, rightVal); else throw `${TAG} unknow operator: ${operator}`; }

WhileStatement node

The node of the While loop contains the test and body properties; The test property is the condition of the while loop, so we need to continue recursive traversal, and the body represents the logic inside the while loop, so we need to continue recursive traversal.

WhileStatement: (astPath: AstPath<ESTree.WhileStatement>) => { const { node, scope, evaluate } = astPath; const { test, body } = node; while (evaluate({ node: test, scope, evaluate })) { const result = evaluate({ node: body, scope, evaluate }); if (Signal.isBreak(result)) break; if (Signal.isContinue(result)) continue; if (Signal.isReturn(result)) return result.result; }}

An extra note here is that in a While loop, you may encounter a break, continue, or return keyword to terminate the loop logic; So you need to do extra processing on these keywords;

Keyword processing

Break, continue, and return also have corresponding node types BreakStatement, ContinueStatement, and ReturnStatement. We need to define an additional keyword-base class, Signal, whose instance serves as the return value of these keybyte point-type functions so that their upper level can handle it;

BreakStatement: () => {return new Signal('break'); } ContinueStatement: () => {return new Signal('continue'); } ReturnStatement: (astPath: AstPath<ESTree.ReturnStatement>) => { const { node, scope, evaluate } = astPath; Return new Signal('return', node.argument? evaluate({ node: node.argument, scope, evaluate }) : undefined); }

Signal base classes are as follows:

type SignalType = 'break' | 'continue' | 'return'; export class Signal { public type: SignalType public value? : any constructor(type: SignalType, value? : any) { this.type = type; this.value = value; } private static check(v, t): boolean { return v instanceof Signal && v.type === t; } public static isContinue(v): boolean { return this.check(v, 'continue'); } public static isBreak(v): boolean { return this.check(v, 'break'); } public static isReturn(v): boolean { return this.check(v, 'return'); }}

More node processing

Because there are too many types of AST nodes, this article is too long. If you need to see the processing of other nodes, you can go directly to the Git repository to check.

When dealing with the VariableDeclaration node, that is, the VariableDeclaration, a problem arises: where should the defined variables be stored?

This is where the idea of scope comes in;

scope

As we all know, JS has the concept of global scope, function scope, block scope;

Variables defined in the global context should be stored in the global context, while variables defined in the function context should be stored in the function scope.

export class Scope { private parent: Scope | null; private content: { [key: string]: Var }; public invasive: boolean; constructor(public readonly type: ScopeType, parent? : Scope) { this.parent = parent || null; this.content = {}; Public var(rawName: string, value: any): Boolean {let scope: scope = this; // while (scope.parent!) == null && scope.type ! == 'function') { scope = scope.parent; } scope.content[rawName] = new Var('var', value); return true; } public const(rawName: string, value: any): Boolean {if (! this.content.hasOwnProperty(rawName)) { this.content[rawName] = new Var('const', value); return true; } else {// return false; } } /** * */ public let(rawName: string, value: any): boolean { if (! this.content.hasOwnProperty(rawName)) { this.content[rawName] = new Var('let', value); return true; } else {// return false; }} / * * * * find variables from the scope/public search (rawName: string) : Var | null {/ / 1. For the first from the current scope and the if (this. Content. hasOwnProperty (rawName)) {return this. The content [rawName]; } else if (this.parent) {return this.parent.search(rawName);} else if (this.parent) {return this.parent.search(rawName); } else { return null; } } public declare(kind: KindType, rawName: string, value: any): boolean { return ({ 'var': () => this.var(rawName, value), 'const': () => this.const(rawName, value), 'let': () => this.let(rawName, value) })[kind](); }}

When you encounter a BlockStatement, you need to form an instance of Scope, because variables such as const and let define block-level scopes, and their values are stored in the current block-level Scope.

In this case, the var variable in the BlockStatement still needs to be defined in the upper scope until the function scope is encountered. Therefore, when defining the var variable, there will be the following processing:

public var(rawName: string, value: any): boolean { let scope: Scope = this; // While (scope.parent! == null && scope.type ! == 'function') { scope = scope.parent; } scope.content[rawName] = new Var('var', value); return true; }

The entry function

Now that you have the ability to read and execute JS, let’s define an entry function and output the result:

Export function execute(code: string, externalApis: any = {}) {// global scope = new scope ('root'); scope.const('this', null); for (const name of Object.getOwnPropertyNames(defaultApis)) { scope.const(name, defaultApis[name]); } for (const name of Object.getOwnPropertyNames(externalApis)) { scope.const(name, externalApis[name]); } // exports const $exports = {}; const $module = { exports: $exports }; scope.const('module', $module); scope.var('exports', $exports); const rootNode = acorn.parse(code, { sourceType: 'script' }); const astPath: AstPath<ESTree.Node> = { node: rootNode, evaluate, scope } evaluate(astPath); // const ModuleExport = scope.search('module'); return moduleExport ? moduleExport.getVal().exports : null; }

The entry function execute takes two arguments:

  • Code is the code converted to a string;
  • ExternalApis for some"Built-in"Object;

The output of an entry function, via a custom module.exports object;

What can be done now

The interpreter is currently a prototype, it can only do some simple JavaScript interpretation:

  • For example, test cases written by Run Run;
  • Do some simple code execution in a small program like environment;

If the JS after the packaging, into the small program to run, its running effect is as follows:

  • Sample code:
const interpreter = require('./interpreter'); // Interpret.Execute (' wx.showModal({title: 'Example 1 ', success: function() {wx.showToast({title:' Click button '}); }}); `, { wx: wx }); // Interpreter.execute (' setTimeout(function() {wx.showToast({title: 'Countdown complete'}); }, 1000); `, { wx: wx });
  • The effect is as follows:



conclusion

In the process of realizing JS interpretation, it is also a process of further understanding of the JS language. We will continue to optimize the interpreter, for example:

  • Handling variable lift;
  • Provide more ES6 + handling;
  • , etc.

I first cast a brick to attract jade, welcome to exchange!