给你一段js字符串,你有多少种方式执行?

349 阅读10分钟

给你一段js字符串,你有多少种方式执行?

提起动态执行 js,实际上就是如何把字符串解释成可执行的代码。在一些特定场景下,动态执行 JS 显得尤为必要。例如,在低代码开发工具中,为了实现更灵活的功能定制和快速开发,需要动态执行 JavaScript 代码。当程序需要根据用户的特定输入或系统的不同状态来生成和执行代码时,动态执行能力就发挥了关键作用。

在 js 中,有非常常见的两种方式可以执行 js 字符串:evalnew Function。除此之外,你还可以自己实现编译器来执行。下面我们一起来看看吧。

eval 和 new Function

eval 函数在 JavaScript 中可以执行作为代码的字符串。其语法为eval(code),其中code是一个包含要执行的 JavaScript 代码的字符串。例如:let result = eval("1 + 2"); // 返回 3

但是eval的方式很不安全,它可以访问在当前作用域,这意味着它可以访问并修改当前作用域中的变量。

let a = 1;
eval('a = 100');
console.log(a); // 100

let a = 1;
new Function('a = 100;console.log(a)')(); // 100
console.log(a); // 1

一般我们会使用 new Function 作为执行 js 字符串的首选方式。

with 语句

我们来回顾一下with语句,with 语句可以创建一个临时的作用域,让您可以在代码中更轻松地访问特定对象的属性和方法。当在临时作用域访问不到变量后,会继续沿着作用域链向上查找。如:

with(console){
  log('====log')
  warn('====warn')
  error('====error')
}

with 语句会引入一个新的作用域,这可能导致变量的作用域被混淆,以及增加了寻找作用域链的步骤,因此在严格模式下已被禁止使用。

我们可以利用with语句的特点,建立一个沙盒环境。比如:

const code = `with(sandbox){
  let a = 10;
  a = foo(a);
  log(a);
}`
const foo = (num) => num++

const sandbox = { foo, ...console }

new Function('sandbox', code)(sandbox);

再进一步从安全方面考虑,按照现在的设计如果在传进去的sandbox中访问不到变量的话,还是会继续沿着作用域链寻找。如果我们不希望用户访问到window中的某些变量的话,我们要怎么做呢?

这时候我们就可以引入 ES6 中的 Proxy 来去作一层对象代理拦截。

这里要注意,设置 Proxy 时 has 函数的返回值要始终返回 true,否则被认为在当前作用域中找不到变量就会默认向上查找。

const code = `with(sandbox){
  let a = 10;
  a = foo(a);
  log(a,document);
}`;
const foo = (num) => ++num;

const sandbox = { foo, ...console };

// 新增白名单及代理
const whiteList = Object.keys(sandbox);
const proxy = new Proxy(sandbox, {
  get(target, props, receiver) {
    if (whiteList.includes(props)) {
      return target[props];
    } else {
      return undefined;
    }
  },
  // 查询作用域内变量的时候会被调用,如果不设置为true,则会继续往上作用域查找
  has: () => true,
});

new Function('sandbox', code)(proxy);

当然,目前比较常用的实现沙盒环境的方式,还有iframe。这里就不展开了。

自己编译

除了利用已有 Api 进行 js 代码的执行,我们还可以自己去编写一个 js 编译器去实现,这听起来很酷吧,让我们一起来试试吧。

首先在开工之前,我们需要了解代码字符串的执行流程: 简单来说是经过词法分析和语法分析生成 AST,然后由编译器进行分析,生成 IR 或汇编代码,最后转化成机器码,供计算机直接运行。

在这里我们并不需要实现整套流程,我们使用 js 进行编写编译器,实际上是在遍历 AST 的过程中对每一种节点进行不同的逻辑处理。

在弄清楚了目的之后,我们就可以开始编写代码了。

开始

首先是如何生成 AST 呢?我们可以利用 acorn 来去生成。 babel parser 底层用的 acorn,只不过 babel 在其基础上拓展了更丰富的功能。

我们通过一下代码就可以获取 js 字符串的 AST:

const ast = acorn.parse(code, Options);

同时,我需要去创建一个编译器:


const WindowVarMap: { [key: string]: any } = {
  console,
  setTimeout,
  setInterval,
  clearTimeout,
  clearInterval,
  encodeURI,
  Number,
  Math,
  Date,
  String,
  RegExp,
  Array,
  JSON,
  Promise,
};

class Interpreter {
  scope: Scope;
  constructor() {
    this.scope = new Scope('block');
    for (const name of Object.getOwnPropertyNames(WindowVarMap)) {
      this.scope.const(name, WindowVarMap[name]);
    }
  }
  run(ast: Program, sandbox?: Record<string, any>) {
    for (const [key, value] of Object.entries(sandbox || {})) {
      this.scope.const(key, value);
    }
    return this._excute(ast, this.scope);
  }
}

为了实现沙盒环境,在编译器初始化的时候会创建一个块状作用域,这是最外层的作用域,并且我会将我想要注入的 window 的变量。那么,模拟作用域链需要作些什么呢:

在实现作用域前,先实现一个变量。什么是变量,简单来说,变量就是实现了 gettersetter 的对象。

export class ScopeVar implements Var {
  kind: string;
  value: any;
  constructor(kind: Kind, value: any) {
    this.kind = kind;
    this.value = value;
  }
  get() {
    return this.value;
  }
  set(value: any): boolean {
    if (this.kind === 'const') {
      return false;
    } else {
      this.value = value;
      return true;
    }
  }
}

上面这段简单代码就已经完成了 可变变量不可变变量 的实现。

接下来我们来想想,一个作用域需要什么?

  • 类型:块作用域、函数作用域。。。。
  • 声明变量的能力
  • 获取变量的能力
  • 作用域链

因此我们可以先构造一个基本的数据结构:


export class Scope {
  private content: { [key: string]: Var };
  private parent?: Scope;
  type: any;

  constructor(type: 'block' | 'function', parent?: Scope) {
    this.type = type;
    this.parent = parent;
    this.content = {};
  }

声明变量能力的实现也很简单,我们只需要分别实现varletconst三种声明方式即可:

 let(key: string, value: any) {
    const _value = new ScopeVar('let', value);
    if (Object.hasOwn(this.content, key)) {
      throw SyntaxError(`Identifier '${key}' has already been declared`);
    } else {
      this.content[key] = _value;
      return true;
    }
  }
  const(key: string, value: any) {
    const _value = new ScopeVar('const', value);
    if (Object.hasOwn(this.content, key)) {
      throw SyntaxError(`Identifier '${key}' has already been declared`);
    } else {
      this.content[key] = _value;
      return true;
    }
  }

var 声明比较特殊,它具有可以重复声明、声明后会覆盖同名变量(函数作用域除外,因为对于函数作用域来说,函数内部的变量声明不会影响外部作用域。)

var(key: string, value: any) {
  const _value = new ScopeVar('var', value);
  let scope: Scope | undefined = this;

  while (Boolean(scope?.parent) && scope?.type !== 'function') {
    scope = scope?.parent;
  }

  // 覆盖同名变量的值
  scope.content[key] = _value;
  return true;
}

最后剩下的是获取变量,核心思想就是从当前作用域开始,不断向上查找:

find(key: string) {
  let scope: Scope | undefined = this;
  // 先找当前作用域
  if (Object.hasOwn(scope.content, key)) {
    return scope.content[key];
  }
  while (scope?.parent) {
    if (Object.hasOwn(scope.content, key)) {
      return scope.content[key];
    }
    scope = scope?.parent;
  }

  return undefined;
}

编译一段简单代码

我们的目标是要编译一段简单的变量声明、使用的代码:

let a = 1;console.log(a);

说干就干,主函数如下:

const Options: acorn.Options = {
  ecmaVersion: 'latest',
  sourceType: 'script', // 代码类型,可选值还有'module'
  locations: true,
};
const code = `let a = 1;console.log(a);`
const ast = acorn.parse(code, Options);
new Interpreter().run(ast, {});


// interpreter.js
run(ast: Program, sandbox?: Record<string, any>) {
  for (const [key, value] of Object.entries(sandbox || {})) {
    this.scope.const(key, value);
  }
  return this._excute(ast, this.scope);
}

接下来我们所需要的就是完善编译器内 run 方法。

为了直观的知道 AST 结构,我们可以去 astexplorer.net/ 进行查看。

声明变量

由图可知,我们先从 AST 最外层的 Program 节点开始编译:

 _excute(node: (Node & { type: string }) | undefined | null, scope: Scope) {
  if (!node) return;
  return this[node.type]?.(node, scope);
}

Program(node: Program, scope: Scope) {
  const { body } = node;
  body.forEach((ele) => {
    this[ele.type]?.(ele, scope);
  });
}

首先我们来实现声明语句的编译,从图中可以知道,我们需要关注的方法有 VariableDeclarationVariableDeclaratorIdentifierLiteral

这里为了代码简洁,我们省略 VariableDeclarator的实现,否则我还得把 kind 传进去,破坏了函数结构。

这里的 Identifier 我们也可以暂时不实现,因为这里是声明变量,只需要使用一个字符串作为 key 来存储变量即可。

Literal(node: Literal, scope: Scope) {
  return node.value;
}
VariableDeclarator(_node: VariableDeclarator, scope: Scope) {}
VariableDeclaration(node: VariableDeclaration, scope: Scope) {
  const { declarations, kind } = node;
  declarations.forEach((declaration) => {
    const { id } = declaration;
    const { name } = id;
    const init = this._excute(declaration?.init, scope);
    scope[kind](name, init);
  });
}

这样,我们就成功的往作用域内声明了一个变量。我们可以此时打印一下 scope 进行查看:

其中变量 a 就是被我们声明且赋值好的新变量。

调用console.log函数

console.log(a)这段代码涉及到了 ExpressionStatementCallExpressionMemberExpression 3个 node。

首先是ExpressionStatement node,这个 node 非常常见,所有的表达式都会被认为是一个 ExpressionStatement node,它只是一层壳,我们并不需要对它进行处理,关键的地方在于表达式中的逻辑:

ExpressionStatement(node: ExpressionStatement, scope: Scope) {
  this._excute(node.expression, scope);
}

这里的 node.expression 指向的是 CallExpression,顾名思义,就是调用表达式。调用语句我们需要关注的是 调用了谁 以及 参数 ,在 AST 中就是 CallExpressionNode.callee 以及 CallExpressionNode.arguments:

// 调用语句
CallExpression(node: CallExpression, scope: Scope) {
  const callee = this._excute(node.callee, scope);
  const myArguments = node.arguments.map((arg) => {
    return this._excute(arg, scope);
  });
  return callee(...myArguments);
}
  • 调用了谁

这里的 callee 指向了 MemberExpression,该 node 表示通过点表达式来进行链式调用的表达式。由于这里是链式调用,因此 AST 中会有左右之分。其中 object 就是左边的对象 console ,property 就是右边的值 log:

MemberExpression(node: MemberExpression, scope: Scope) {
  const object = this._excute(node.object, scope);
  // 看下是不是变量
  if (node.computed) {
    const property = this._excute(node.property, scope);
    return object[property];
  } else {
    return object[node.property?.name];
  }
}

简单来说就是在 console 对象中取出了 log 的值,它是一个函数。

  • 参数

参数就简单了,直接遍历编译一遍 node.arguments 即可,最后将参数和调用函数结合即可。

至此,我们的编译器已经实现了去编译一段简单代码的能力。

用vitest实现单测

在实际项目中,编写单元测试可以极大地提高代码的质量和可维护性。这里我使用 vitest 进行单元测试。首先,根据我们上面实现的例子写一个测试示例如下:

test('declare variable "a" equal to 1', () => {
  const code = 'let a = 1; console.log(a)';
  // ...
});

我们在上面的例子中去实现变量a的声明和赋值的时候,实际上我们是通过console.log或者debugger的方式去检测我们结果的正确性,但是在单测中这种方式就不可行了。这里我们可以模拟实现一个简单的 js module ,通过 export 语句来实现值的获取,最终实现测试目的。

这里我们把两种常用的导出语句都实现一遍:

test('declare variable "a" equal to 1 with export defualt', () => {
  const code = 'let a = 1; export default a;';
  const module = interpreter(code);
  expect(module.defaultExports).toBe(1);
});

test('declare multiple variables with export', () => {
  const code = 'export const a = 1,b = 2; export const c = 3;';
  const module = interpreter(code);
  expect(module.exports.a).toBe(1);
  expect(module.exports.b).toBe(2);
  expect(module.exports.c).toBe(3);
});

说干就干,首先一个具有导出功能的 js 模块,其中是自带 exports 属性的。

class Module {
  defaultExports: Record<string, any>;
  exports: Record<string, any>;
  name: string;
  constructor() {
    this.exports = {};
    this.defaultExports = {};
    this.name = String(Date.now());
  }
}
  • ExportDefaultDeclaration

ExportDefaultDeclaration 其实比较简单,我们可以通过 ExportDefaultDeclarationNode.declaration 拿到最终导出的值,最后赋值给 module.defaultExports 既可。

// 导出语句
  ExportDefaultDeclaration(node: ExportDefaultDeclaration, scope: Scope) {
    this.module.defaultExports = this._excute(node.declaration, scope);
  }
  • ExportNamedDeclaration

ExportDefaultDeclaration 一样,我们只需要拿到最终导出的值即可。但是别忘了,通过export导出的时候,可以同时导出多个:

export const a = 1,b = 2;
export let c = 3;

为此我们必须改造一下 VariableDeclaration,因为我们之前的实现中,VariableDeclaration是没有返回值,这样我们就没法拿到我们需要导出的变量,我们需要将声明的变量作为返回值进行返回:

 VariableDeclaration(node: VariableDeclaration, scope: Scope) {
      const { declarations, kind } = node;
+     const resultForExportNamed: Record<string, any> = {};
      declarations.forEach((declaration) => {
        const { id } = declaration;
        const { name } = id;
        const init = this._excute(declaration?.init, scope);
        scope[kind](name, init);
+       resultForExportNamed[name] = init;
      });
+     return resultForExportNamed;
  }
  ExportNamedDeclaration(node: ExportNamedDeclaration, scope: Scope) {
    const exportNamedObject = this._excute(node.declaration, scope);
    for (const name of Object.getOwnPropertyNames(exportNamedObject)) {
      this.module.exports[name] = exportNamedObject[name];
    }
  }

做完这一切之后,我们的单元测试就完成啦。

总结

这篇文章主要介绍了如何动态地去执行 js 代码,并且从安全层面考虑,简单实现了沙盒。再进一步地去介绍如何自行去实现一个js编译器,单元测试等。

当然,这只是个玩具编译器~,感兴趣的可以再去丰富更多的功能。。

仓库地址:github.com/1360151219/…

参考文章