最近在做一个需要,需要实现一个字符串形式的“函数表达式”,以双括号“{{…}}”为语法特征。例如:

1
2
3
{
title:"{{formData.x.y === 'us' ? '美元':'人民币'}}"
}

并且函数表达式内置了一些关键词:

  • $self - 代表当前字段实例,用来获取当前字段的值和 schema 配置
  • $values - 代表整个表单数据
  • $form - 代表当前 Form 实例,可以用来触发表单校验等操作
  • $deps - 获取 dependencies 中依赖项的值,和 dependencies 顺序一致
  • $fetch - 使用封装的 request 方法,发送 http 请求

使用了关键词的表达式如下:

1
2
3
{
select_options: "{{$self.select_options.map(item => ({ ...item, disabled: $deps[0].map(it => it.bank_card_usage).includes(item.value) }))}}";
}

函数表达式可能由后端下发或者用户输入,因此为防止 XSS 注入,函数表达式的作用域需要限制下。

实现

new Function 语法

引用自:深入 JS new Function 语法 « 张鑫旭-鑫空间-鑫生活

实现函数表达式之前,我们先复习下 new Function 语法:

1
let func = new Function([arg1, arg2, ...argN], functionBody);

最后一个参数一定是函数体,其余参数都作为传给函数体的参数。

例如:

1
2
3
let sum = new Function("a", "b", "return a + b");

console.log(sum(1, 2)); // 结果是 3

平常进行 JS 或者 Node.js 开发的时候,我们是没有任何理由使用 new Function 构造函数的,因为没必要,直接使用 function 或者 () => {} 箭头函数写法就好了。

那是不是表示 new Function 语法是个鸡肋特性呢?

不!绝不是!

new Function 语法有个特别厉害的特性,使其成为 JavaScript 这门语言中无可替代的重要角色。。

什么特性呢?

那就是函数体的数据格式是字符串,这可是个不得了的东西啊!

解析函数表达式

利用 new Function 的语法特性,我们就可以将字符串变成可执行的代码:

1
2
3
4
5
const compile = (expression: string, scope = {}) => {
return new Function("$root", `with($root) { return (${expression}); }`)(
scope
);
};

compile 函数有两个参数,第一个参数为要执行的字符串表达式,第二个参数为执行时的作用域,将 $self 等内置关键词传入 scope 供函数执行时调用。

compile 实现时使用了 with 关键词,帮助我们简化代码,如果不使用 with 关键词,scope 里所有的元素都需要依次传入 new Function 中:

1
2
3
4
5
const compile = (expression, scope = {}) => {
const keys = Object.keys(scope);
const values = keys.map((key) => scope[key]);
return new Function(...keys, `return (${expression});`)(...values);
};

不过,with 在性能和语义方面都有些问题(具体问题见:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/with),至于用不用,自己考虑吧

限制作用域

现在字符串得以执行了,接下来需要解决的就是函数的作用域问题了。

Function 构造函数创建的函数不会创建当前环境的闭包,它们总是被创建于全局环境,因此在运行时它们只能访问全局变量和自己的局部变量,不能访问它们被 Function 构造函数创建时所在的作用域的变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var x = 10;

function createFunction1() {
var x = 20;
return new Function("return x;"); // 这里的 x 指向最上面全局作用域内的 x
}

function createFunction2() {
var x = 20;
function f() {
return x; // 这里的 x 指向上方本地作用域内的 x
}
return f;
}

var f1 = createFunction1();
console.log(f1()); // 10
var f2 = createFunction2();
console.log(f2()); // 20

因此,如果仅仅是限制表达式访问创建时所在的作用域的变量,new Function 就足够了,如果还想要限制访问全局作用域,那么就要实现一个沙箱了。

沙箱

引用自:浅析 JavaScript 沙箱机制

什么是沙箱?

在计算机安全中,沙箱(Sandbox)是一种用于隔离正在运行程序的安全机制,通常用于执行未经测试或不受信任的程序或代码,它会为待执行的程序创建一个独立的执行环境,内部程序的执行不会影响到外部程序的运行

常见的沙箱应用场景:

  • 执行 JSONP 请求回来的字符串时或引入不知名第三方 JS 库时,可能需要创造一个沙箱来执行这些代码。
  • Vue 模板表达式的计算是运行在一个沙盒之中的,在模板字符串中的表达式只能获取部分全局对象,这一点官方文档有提到,详情可参阅源码
  • 在线代码编辑器,如 CodeSanbox 等在线代码编辑器在执行脚本时都会将程序放置在一个沙箱中,防止程序访问/影响主页面。

够用的沙箱实现:

Proxy 中的 getset 方法只能拦截已存在于代理对象中的属性,对于代理对象中不存在的属性这两个钩子是无感知的。因此这里我们使用 Proxy.has() 来拦截 with 代码块中的任意变量的访问,并设置一个白名单,在白名单内的变量可以正常走作用域链的访问方式,不在白名单内的变量会继续判断是否存在沙箱自行维护的上下文对象中,存在则正常访问,不存在则直接报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
const withedCode = (code: string) => {
return new Function("$root", `with($root) { return (${code}); }`);
};

// 可访问全局作用域的白名单列表
const access_white_list = ["Math", "Date"];

const Sandbox = (code: string, scope = {}) => {
// 执行上下文对象的代理对象
const scopeProxy = new Proxy(scope, {
has: (target, prop) => {
// has 可以拦截 with 代码块中任意属性的访问
if (access_white_list.includes(prop)) {
// 在可访问的白名单内,可继续向上查找
return target.hasOwnProperty(prop);
}

if (!target.hasOwnProperty(prop)) {
throw new Error(`Invalid expression - ${prop}! You can not do that!`);
}

return true;
},
});

withedCode(code).call(scopeProxy, scopeProxy);
};

// 执行上下文对象
const scope = {
func: (variable) => {
console.log(variable);
},
foo: "foo",
};

// 待执行程序
const code = `
Math.random()
location.href = 'xxx'
func(foo)
`;

console.log(Sandbox(code, scope));

最终实现版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const scope = {
$self: "xxxx",
};

const ExpRE = /^\s*\{\{([\s\S]*)\}\}\s*$/;

const withedCode = (code: string) => {
return new Function("$root", `with($root) { return (${code}); }`);
};

// 可访问全局作用域的白名单列表
const access_white_list = ["Math", "Date"];

const Sandbox = (source: string, scope = {}) => {
const matched = source.match(ExpRE);
if (!matched) return source;
const code = matched[1];

// 执行上下文对象的代理对象
const scopeProxy = new Proxy(scope, {
has: (target, prop) => {
// has 可以拦截 with 代码块中任意属性的访问
if (access_white_list.includes(prop)) {
// 在可访问的白名单内,可继续向上查找
return target.hasOwnProperty(prop);
}

if (!target.hasOwnProperty(prop)) {
throw new Error(`Invalid expression - ${prop}! You can not do that!`);
}

return true;
},
});

withedCode(code).call(scopeProxy, scopeProxy);
};