不完美沙箱 - 模拟全局对象

上一章中,经过对标准的查询、翻阅、解析,终于在绕了一个大圈之后,明白了全局对象的特征,并明确了模拟一个全局对象需要的内容,还为此编写了简单的测试用例。本文将从第1个测试用例着手,放眼与全局作用域的VariableEnvironmentThisBinding以及window别名这三者的一致性,来推导出一个沙箱的原型。

所谓纰漏

在开始这一章以前,首先不得在这里道个歉,原因很简单,我对标准进行了误读,且在没有测试的情况下就开始了这一系列的博客。事实上,以我的思路,是无法制作出一个理想中的沙箱的,虽然可以控制住第三方代码的作用区域,但是无法做到完全的透明,其主要问题出在这里:

var x = 3;  
console.log(this.x); // 打印出3  

事实上,仅仅依靠Javascript本身,是没有办法在非全局环境下使上面这段代码得到正确的结果的。

因此,在这里,不得不先把标准给改上一改,放弃绝对透明性这一要求,事实上也确实很少有开发者会通过var声明变量并使用this.xwindow.x来访问,不得以之下只能放弃这个需求,将var声明和this.x声明隔离开来。

事实上(开始狡辩),对标准的误读也是学习标准的一种过程,不会犯错的人也很难有太大的进步。我思考良久,最终的决定是不删除这一系列的博文,而是使之继续下去,在修改测试用例的情况下,继续来制作沙箱。当然这个难度要简单不少了。

这一系列已然变成了一个“边研究边写”的“研究笔记”,随着这一系列的进行,必然会出现越来越多的问题,事实上这个沙箱也不见得有可用性。还请以“标准对研究进行指导”这样的角度来看待这个问题吧,得出一个“无角”这样的结论,姑且也算一个结论……

理解VariableEnvironment和ThisBinding

依旧沿用上一章的思路,打开ECMAScript 5的HTML版本,通过CTRL+F组合键打开搜索框,查询VariableEnvironment,可以看到搜索的结果并不多,同样搜索ThisBinding,则发现多数内容与VariableBinding的搜索结果重叠,说明这两者经常放在一块描述。

首先是对VariableEnvironment一词的解释,在Execution Context一章的表格中有如下描述:

Identifies the Lexical Environment whose environment record holds bindings created by VariableStatements and FunctionDeclarations within this execution context.

对ThisBinding的描述也颇为简单:

The value associated with the this keyword within ECMAScript code associated with this execution context.

当然这并不是我们需要的内容,仅仅以此来对VariableEnvironment有一个基本的认知。VariableEnvironment是用于在文法环境中保存通过变量声明语句函数声明创建的对象之用的,即var xfunction fn() {}创建的对象会被保留在VariableEnvironment中。而ThisBinding则特指this关键字指向的对象。

而我们真正需要知道的是,在各种代码的执行环境下,VariableEnvironment和ThisBinding分别会是什么,通过在标准中的搜索,我们可以很容易地找到相关的内容,在Establishing an Execution Context一章中,分别描述了全局代码函数代码eval代码执行时的VariableEnvironment和ThisBinding:

Initial Global Execution Context中对全局代码执行时的描述:

  1. Set the VariableEnvironment to the Global Environment.
  2. Set the LexicalEnvironment to the Global Environment.
  3. Set the ThisBinding to the global object.

Entering Eval Code一章中对eval代码中VariableEnvironment的描述:

  1. If there is no calling context or if the eval code is not being evaluated by a direct call (15.1.2.1.1) to the eval function then,

    1. Initialize the execution context as if it was a global execution context using the eval code as C as described in 10.4.1.1.
  2. Else,

    1. Set the ThisBinding to the same value as the ThisBinding of the calling execution context.
    2. Set the LexicalEnvironment to the same value as the LexicalEnvironment of the calling execution context.
    3. Set the VariableEnvironment to the same value as the VariableEnvironment of the calling execution context.
  3. If the eval code is strict code, then

    1. Let strictVarEnv be the result of calling NewDeclarativeEnvironment passing the LexicalEnvironment as the argument.
    2. Set the LexicalEnvironment to strictVarEnv.
    3. Set the VariableEnvironment to strictVarEnv.
  4. Perform Declaration Binding Instantiation as described in 10.5 using the eval code.

可以看到,如果使用的是indirect eval,则与全局代码相同。如果使用普通的eval,则继续使用当前所在执行环境的VariableEnvironment和ThisBinding。如果是严格模式则有更复杂的过程,好在他虽然麻烦,但我们不用他就行了嘛,破坏严格模式还是很容易的一件事,现在先不作讨论。

Entering Function Code一章中描述了函数内代码执行时VariableEnvironment的情况:

  1. If the function code is strict code, set the ThisBinding to thisArg.
  2. Else if thisArg is null or undefined, set the ThisBinding to the global object.
  3. Else if Type(thisArg) is not Object, set the ThisBinding to ToObject(thisArg).
  4. Else set the ThisBinding to thisArg.
  5. Let localEnv be the result of calling NewDeclarativeEnvironment passing the value of the [[Scope]] internal property of F as the argument.
  6. Set the LexicalEnvironment to localEnv.
  7. Set the VariableEnvironment to localEnv.
  8. Let code be the value of F’s [[Code]] internal property.
  9. Perform Declaration Binding Instantiation using the function code code and argumentList as described in 10.5.

其中第1-3步说明在函数代码执行时,其ThisBinding指向一个叫thisArg的东西,而第5、第7步则说明其VariableEnvironment是一个全新创建的环境,仅与当前的作用域挂钩。

至于这个thisArg是什么,相信很多编写Javascript的代码工程师很非常了解,我在javascript中的对象查找一文中也专门抽一节讲述了this的查找规则。对于已经是现成知识的内容,也就不必再辛辛苦苦去翻阅标准了,还请时刻牢记标准是指导作用,而不是强迫查询

进一步分析

对于标准的查阅也至此为止,既然已经知道了各种代码执行时的VariableEnvironment和ThisBinding,不如先总结一下各自的优劣:

  • 全局代码:这个必然不行,因为我们就是要防止代码在全局下执行。
  • eval代码:eval的效果几乎透明,要么直接把代码放在全局下执行,要么和当前环境没有区别,因此eval显然也不是什么合理的选择。
  • 函数代码:VariableEnvironment是个全新创建的,而ThisBinding根据函数调用时的规则有所变化。

到此,我们已经有了控制var声明变量的作用域的方式,其实这个非常简单,只要有一个新的作用域生成就行,最方便的自然是一个自调用函数表达式:

(function() {
    // 放入开发者提交的代码
}());

但是以上代码至少有2个缺陷:

  1. this.x = 3window.x = 3就足以声明全局变量,即使是君子也很容易以这种形式不小心污染了全局。
  2. this.x = 3声明属性后,无法使用console.log(x)获得正确的输出。

为了解决这2个问题,我们需要控制住this。对于第1点,只要随便有和个this对象即可,而对于第2点,则需要让ThisBinding变得和VariableEnvironment有一定关系(变量查找会找到当前this对象中)就行了,于是我们还是回顾一下ThisBinding有哪些情况:

  • 直接调用,则this为全局对象。
  • 方法调用,则this为函数所属对象。
  • 构造器调用,则this为构造后的实例对象。
  • call/apply调用,则this任意指定。

粗略一看,似乎后两者更有实用价值,特别是call/apply调用有最大的自由度。然而这里却遇到一个棘手的问题,VariableEnvironment根本是一个不可能通过Javascript访问到的对象,因此即使使用call来指定this对象,也不可能将VariableEnvironment传递过去。

于是到这一步,似乎通过修改this为VariableEnvironment的方法并不是很现实,至少不可能主动获取VariableEnvironment并将之像普通对象一样传递。而事实也是如此,用“不可能”来形容“将this修改为VariableEnvironment”这一需求也不为过,如果哪位有什么方法,还望不吝赐教。

此路不通,在此时便只能回头。回头看看刚才摘录的标准相关内容,仔细地查找每一个陌生名词,经过无数的弯路(如果把走过的弯路都写出来,我可以写小说了,所以省略吧),我们发现在讲述VariableEnvironment的时候,总是有一个名词在附近晃悠,就像打一打就能升级但又逃得飞快的的天使波波羊一样,它就是LexicalEnvironment

对于LexicalEnvironment,Execution Contexts一章中有非常明确的解释:

The LexicalEnvironment and VariableEnvironment components of an execution context are always Lexical Environments. When an execution context is created its LexicalEnvironment and VariableEnvironment components initially have the same value. The value of the VariableEnvironment component never changes while the value of the LexicalEnvironment component may change during execution of code within an execution context.

这段话绝对是令人振奋的,他说明VariableEnvironment没有什么特别,其实VariableEnvironment和LexicalEnvironment根本是一个东西。那么我们又可以将原先对准VariableEnvironment的矛头再一次指向LexicalEnvironment,从标准里找一找,有没有什么办法修改LexicalEnvironment,让它和this变成一样呢?

在标准中搜索LexicalEnvironment,并不会有很多的结果,经过细致的查阅和过滤,发现在2个地方有单独提到LexicalEnvironment而没有讲VariableEnvironment,这显然是突破口,因此必须牢牢抓住:

标准中The with Statement一章有如下的描述:

The production WithStatement : with ( Expression ) Statement is evaluated as follows:

  1. Let val be the result of evaluating Expression.
  2. Let obj be ToObject(GetValue(val)).
  3. Let oldEnv be the running execution context’s LexicalEnvironment.
  4. Let newEnv be the result of calling NewObjectEnvironment passing obj and oldEnv as the arguments.
  5. Set the provideThis flag of newEnv to true.
  6. Set the running execution context’s LexicalEnvironment to newEnv.
  7. Let C be the result of evaluating Statement but if an exception is thrown during the evaluation, let C be
  8. (throw, V, empty), where V is the exception. (Execution now proceeds as if no exception were thrown.)
  9. Set the running execution context’s Lexical Environment to oldEnv.
  10. Return C.

另外The try Statement一章中对catch块的描述:

The production Catch : catch ( Identifier ) Block is evaluated as follows:

  1. Let C be the parameter that has been passed to this production.
  2. Let oldEnv be the running execution context’s LexicalEnvironment.
  3. Let catchEnv be the result of calling NewDeclarativeEnvironment passing oldEnv as the argument.
  4. Call the CreateMutableBinding concrete method of catchEnv passing the Identifier String value as the argument.
  5. Call the SetMutableBinding concrete method of catchEnv passing the Identifier, C, and false as arguments. Note that the last argument is > . immaterial in this situation.
  6. Set the running execution context’s LexicalEnvironment to catchEnv.
  7. Let B be the result of evaluating Block.
  8. Set the running execution context’s LexicalEnvironment to oldEnv.
  9. Return B.

经过不小的折腾,终于找到了2个可以额外改变LexicalEnvironment的语法,即withcatch

再仔细看一看标准中的描述,with可以指定任何一个对象,通过这个对象创建一个叫NewObjectEnvironment的东西,并把这个东西当做LexicalEnvironment。而catch则比较悲剧,只能在原有的LexicalEnvironment基础上增加一个catch的异常对象,形成新的LexicalEnvironment。

对于我们的需求,两者的优劣显而易见,with是有望符合我们的要求的,而catch则因为机制本身受限过多,基本不用抱以希望。那么现在的问题就是如何利用with语句,来创建一个和this相同的LexicalEnvironment。

解决问题

其实再仔细研读标准中对LexicalEnvironment的描述,通过简单的总结,答案已经非常明显:

  • LexicalEnvironment和VariableEnvironment其实是一个东西,变量会存在里面。
  • 不严格地说,with可以指定任何对象使之变成LexicalEnvironment。
  • this是一个对象。

于是不难看出,只要通过with指定this作为LexicalEnvironment,问题也就迎刃而解了。加之前文对this的理解,我们很容易就得到3种方法来创建一个干净、全新的this:

  1. 将一个函数附着在空对象上,使用方法调用的形式来生成。
  2. 使用new方式,形成一个空的实例。
  3. 使用callapply,主动传递一个干净的对象作为this。

每个方法都不难,个人还是选择了第2种方法,没什么原因,写着方便而已,所以有了最后的代码:

new function() {  
    with(this) {
        // 嵌入开发者提交的代码
    }
};

最后再把上一章中的测试代码拿过来执行一下:

测试代码已经经过了修改,由于其中第1部分是无法做到的(参见本文第一段的声明),因此只测试thiswindow和读取变量的相通性,再次致以歉意!

// 测试var, this, window三者是否相通
function equals(x, y) {  
    console.log(x === y);
}

new function() {  
    // 动持window,无需多言
    this.window = this;

    with (this) {
        this.x = 4;
        equals(x, 4);
        equals(window.x, 4);

        window.y = 5;
        equals(y, 5);
        equals(this.y, 5);
    }
};
console.log(window.x);  
console.log(window.y);  

在浏览器中运行,可以在console中得到4个true的结果,这个简单的沙箱还是能起到一定的作用的。

事实上,这里本身就是一个问题,如果将测试用例封装为函数,再直接调用函数,则this已经指向了window,这是标准所规定的ThisBinding方式,根本无从拦截,除非使用类似虚拟机的工具来完成。但是个人认为,能做到这一步已经不错,进一步只能给予开发者一定的限制,比如不得直接调用函数,必须以this.fn()的形式调用。虽然会麻烦(其实这个沙箱已经是越做洞越多了),却也是无奈之举。