/ JavaScript

不完美沙箱 - 变量定义及查找

时隔N久,继续生产,年底实在忙……

上一篇发表之后,有网友指出这个沙箱过于复杂,事实上一些更简单的代码也能完成同样的效果,例如:

var proxy = {
    document: new DocumentProxy()
};
function(window) {
    // 开发者提交的代码
}.call(proxy, proxy);

通过call和函数参数来劫持thiswindow这两个指向全局对象的变量,以达到屏蔽全局对象的目的。

但是这个方式虽然简单易懂,却有一个非常重要的环节无法照顾到,即全局环境下LexicalEnvironment和VariableEnvironment和ThisBinding均是全局对象,这一特性意味着在全局环境下执行的代码将同时满足以下4个条件:

  1. 通过var x = 3;定义的变量,可以使用console.log(x);输出。
  2. 通过this.x = 3;定义的属性,可以使用console.log(this.x);输出。
  3. 通过this.x = 3;定义的属性,可以使用console.log(x);输出。
  4. 通过var x = 3;定义的变量,可以使用console.log(this.x);输出。

其中第1点和第2点是任何环境下的代码都可以满足的,因此通过call和函数参数来支持全局对象没有问题。但是第3和第4点正是全局环境的特征,并不能简单地通过单层的函数代码来控制。而通过一个with语句则可以得到满足第3点,而第4点则需要一些tricky的手段来实现了,本文将会重点从标准出发来阐述这2点的推理过程。

问题本质

首先,抛开沙箱这一目标,从本质上来看一下,以下几个语句分别是什么作用:

  1. var x = 3;
  2. this.x = 3;
  3. x;
  4. this.x;

只要有基本的javascript基础的开发者,都会很顺利地回答出来:

  1. 定义一个变量,变量名为x,其值为3。
  2. this对象上定义一个属性,属性名为x,其值为3。
  3. 获取变量名为x的变量。
  4. this对象上获取属性名为x的属性。

因此,从这4句代码中,我们可以总结而得,第1节中提到的4个条件,其实质是处理以下几个问题:

  • 变量定义。
  • 变量查找。
  • this对象确定。
  • 属性查找。

由于事实上,我们希望定义的变量本身就在this对象上,而非其原型链上的其他对象上,因此这里不会涉及到属性查找这一环节,真正需要处理的是变量的定义和查找,以及this绑定的问题。

而接下去的章节,就将来分析一下这3个过程在标准中是如何定义的。

变量定义

继续打开ECMAScript 5的HTML版本,搜索Variable关键字,并不会有太多的结果,一一过目,最终与变量定义有关的内容会集中在Declaration Binding Instantiation一章中,这一章概述了当进入一个函数时,函数名、形参、变量是如何进行初始化绑定的,其中内容非常多,因此只抽取与变量相关的部分摘录:

Every execution context has an associated VariableEnvironment. Variables and functions declared in ECMAScript code evaluated in an execution context are added as bindings in that VariableEnvironment’s Environment Record. For function code, parameters are also added as bindings to that Environment Record.

Which Environment Record is used to bind a declaration and its kind depends upon the type of ECMAScript code executed by the execution context, but the remainder of the behaviour is generic. On entering an execution context, bindings are created in the VariableEnvironment as follows using the caller provided code and, if it is function code, argument List args:

1. Let env be the environment record component of the running execution context’s VariableEnvironment.

...其他内容

8. For each VariableDeclaration and VariableDeclarationNoIn d in code, in source text order do

  1. Let dn be the Identifier in d.

  2. Let varAlreadyDeclared be the result of calling env’s HasBinding concrete method passing dn as the argument.

  3. If varAlreadyDeclared is false, then

    1. Call env’s CreateMutableBinding concrete method passing dn and configurableBindings as the arguments.
    2. Call env’s SetMutableBinding concrete method passing dn, undefined, and strict as the arguments.

这一段告诉我们,一个变量是绑定在VariableEnvironment上的(env对象),并且同名的变量只会绑定一次(通过HasBinding判断)。

变量查找

关于变量查找,很多开发者都明白存在一个称为“作用域链”的数据结构,变量是在作用域链上自下向上进行查找。而在ECMAScript 5中,作用域链的概念已经不复存在,相对应地引入了LexicalEnvironment这一概念,在Identifier Resolution一章中,对变量的查找过程进行了描述:

Identifier resolution is the process of determining the binding of an Identifier using the LexicalEnvironment of the running execution context.
During execution of ECMAScript code, the syntactic production PrimaryExpression : Identifier is evaluated using the following algorithm:

  1. Let env be the running execution context’s LexicalEnvironment.
  2. If the syntactic production that is being evaluated is contained in a strict mode code, then let strict be true, else let strict be false.
  3. Return the result of calling GetIdentifierReference function passing env, Identifier, and strict as arguments.

The result of evaluating an identifier is always a value of type Reference with its referenced name component equal to the Identifier String.

这一段说明查找一个变量,是通过将LexicalEnvironment及变量名称作为参数,传递给GetIdentifierReference函数来进行的,而GetIdentifierReference函数的说明在标准中有如下描述:

The abstract operation GetIdentifierReference is called with a Lexical Environment lex, an identifier String name, and a Boolean flag strict. The value of lex may be null. When called, the following steps are performed:

  1. If lex is the value null, then

    1. Return a value of type Reference whose base value is undefined, whose referenced name is name, and whose strict mode flag is strict.
  2. Let envRec be lex’s environment record.

  3. Let exists be the result of calling the HasBinding(N) concrete method of envRec passing name as the argument N.

  4. If exists is true, then

    1. Return a value of type Reference whose base value is envRec, whose referenced name is name, and whose strict mode flag is strict.
  5. Else

    1. Let outer be the value of lex’s outer environment reference.
    2. Return the result of calling GetIdentifierReference passing outer, name, and strict as arguments.

简而言之,ECMAScript 5定义了一种自内向外的变量查找过程,如果使用大家熟悉的javascript代码来描述,则可以这样表达:

function LexcicalEnvironment(outer) {
    this.outer = outer || null;
    // 存放变量名-变量关系
    this.environmentRecords = {};
}

function GetIdentifierReference(env, name) {
    while (env) {
        if (env.environmentRecords[name]) {
            return env.environmentRecords[name];
        }
        env = env.outer;
    }
}

至此,我们可以得出以下结论:

  • 变量的定义是通过VariableEnvironment进行的。
  • 变量的查找是通过LexicalEnvironment进行的。
  • ThisBinding规则已经有非常多的文献进行了介绍,也应当作为开发者的基本认识,便不再赘述。

因此回到最初的问题,来分析一下为何通过一个with语句,可以使得this.x =3;定义的属性,能通过console.log(x);的形式访问。

LexicalEnvironment及VariableEnvironment的创建

为了解释上一节最后提出的问题,有必要再回顾一下我们上一篇得到的代码在执行过程中,LexicalEnvironment和VariableEnvironment是如何变化的:

new function() {
    with (this) {
        this.x = 3;
        console.log(x);
    }
};

在这段代码执行的过程中,我们可以很简单地看到,其中包含了一个函数的调用和一个with语句,同时肉眼不可见的自然有一个全局环境。根据上一篇中引用的标准:

  1. 进入函数代码时,会创建一个LexicalEnvironment和一个VariableEnvironment,他们指向同一个对象,由NewDeclarativeEnvironment生成。
  2. 进入with语句时,会创建一个LexicalEnvironment,该LexicalEnvironment的environmentRecords由with语句的对象提供,由NewObjectEnvironment生成。

因此,加上最外层的全局对象,我们不难得到这样的一个图:

LexicalEnvironment及VariableEnvironment关系图

图中自外向内依次为全局环境、new function以及with语句生成的相关环境,并根据颜色,在下文中用R、G、B给予代替:

  1. 在进入全局代码时,生成了一个LexicalEnvironment和一个VariableEnvironment,均为global对象,并且this也为global对象。
  2. 在进入函数代码时,生成一个LexicalEnvironment和一个VariableEnvironment,这个对象由引擎生成且不可通过编码的方式访问,这里以env为指代。同时在函数中,this指向new运算符的结果,使用obj为指代。
  3. 在进入with语句时,生成一个新的LexicalEnvironment,该LexicalEnvironment使用this,即obj对象为environment records组件,在图中简单地描述该LexicalEnvironment即为obj对象。同时with语句不会改变this对象,因此this依旧指向obj对象。

这便是with语句的奇特之处,简单来说,with语句是唯一可以指定使用确定对象形成LexicalEnvironment的语句,也正因为这个特性,让我们有办法将this和LexicalEnvironment统一为一个对象。

在上图表示的结构中,根据标准定义的查找变量的方式,当执行console.log(x);的时候,会从B层的LexicalEnvironment中查找变量名为x的变量,而该LexicalEnvironment的environment records正好是obj对象,与this指向的对象相同,而obj对象上自然因为之前this.x = 3;这一语句,定义了一个名为x的属性,因此顺利地拿到了对应的值。

回到变量定义

在正确地解释了为何this.x = 3;定义属性后可以通过console.log(x);输出之后,唯一需要解决的问题就是以上过程的反向,即如果使用var x = 3;定义变量后,现在还没有办法使得console.log(this.x);可以正确地输出值。

重新审视上面这张LexicalEnvironment和VariableEnvironment的关系图,当我们进入函数时,在搜索到var x = 3;这一语句后,发生了什么:

  1. 找出当前执行环境里的VariableEnvironment,由于with语句并不会改变VariableEnvironment,因此找到的VariableEnvironment是图中G部分的对象,即env对象。
  2. 判断env对象的environment records中是否有名为x的绑定,由于没有在函数中定义任何变量,自然不会有。
  3. 由于没有相关的绑定,则在env的environment recrods添加一个绑定,变量名为x,值为undefined。
  4. 等待函数被执行并运行到var x = 3;这一句时,再对x赋值。

由此可见,非常遗憾地,x被定义到了由函数代码生成的VariableEnvironment对象,即env对象中,而env和我们希望的obj自然不是同一个对象,且没有任何编码方式可以访问到env对象,也无法通过修改引用来使得objenv指向同一对象。

因此,从这一结论上看,希望通过改变变量定义时的行为来达到目的,似乎是不可行的,只能另寻他法。

再次将目光回到var x = 3;这一语句上,这一语句究竟代表了什么,真的是定义一个名为x的变量并赋值为3这么简单的一句话吗?

作为一个javascript的开发者,你是否听过javascript有一个被称为Hosting Behavior的机制,即所有的var定义会被提前到函数开始这一通俗的言论?而本文前半部分摘录的Declaration Binding Instantiation相关内容,也正是对这个机制地阐释。事实上,变量的定义是在进入函数时进行的,而不是函数代码执行过程中进行的,换句更通俗的话说,var x = 3;这一简单的语句,包括了好多个步骤:

  1. 进入函数时,定义一个名为x的变量,值为undefined。
  2. 开始按行执行代码。
  3. 当执行到var x = 3;时,给x赋值,值为3。

再仔细分解,就容易得出这样一个结论:var x = 3;事实上是var x;x = 3;的整合。

因此,既然var x;这一部分已经被变量定义的过程所限制,无从下手给予变更,那么进而着眼于x = 3;这一语句,寻求突破口便成了最自然地举动。在此分析一下,x = 3;这一语句是一个怎么样的过程:

  1. 在当前的LexicalEnvironment中查找名为x的变量。
  2. 如果存在,则将对应的值修改为3。
  3. 如果不存在,则获取当前LexicalEnvironment的outer属性指定的外层LexicalEnvironment,回到第1步,直到不存在外层LexicalEnvironment。

亦即是说,x = 3;这一语句是由变量查找变量值修改这两个逻辑整合而成。至此,你是否又看到了一个熟悉的词汇,并因此而倍感兴奋?是的,在绕了一圈之后,变量定义的问题又变成了变量查找,而上一节中我们已经证明了变量查找是一个可控的过程。

到这一步,问题再一次被简化,我们需要做的是,当查找x变量时,可以在this对象中获得该变量,即在图中B部分的LexicalEnvironment中得到结果,即保证在obj对象中拥有x这一属性。

至于如何让obj对象拥有x属性,那自然是需要定义一下这个属性了,这是必须的无奈之举。好在如果抱着宁可错杀一万,不可漏杀一个的心态,从开发者提交的代码中提取定义的变量名并不是一个非常困难的事,通过简单的正则表达式提取var关键字以及其后的标识符即可,例如/var\s+([^\s]+)\s/g这样的正则就已经在很大程度上可以满足要求。即便提取了本不是变量定义的部分也不要紧,多出来的这些也很少会影响到逻辑。

虽然依旧避免不了对开发者的代码进行扫描,但至少不再需要进行词法、语法的分析,也不需要创建一个javascript的虚拟机来试运行,只需一个简单的正则,让问题变得简单很多,最终的代码可能是如下形式,以供大家讨论:

// new function负责创建一个新的、独立的this对象
new function() {
    // 将用户提交的代码中的所有变量名抽取出来,形成varList数组
    // 通过在this对象中定义对应的属性,来达到更完善地模拟全局环境
    (function(o) {
        for (var i = 0; i < varList.length; i++) {
            o[varList[i]] = undefined;
        }
    }(this));

    // with负责创建一个可控的LexicalEnvironment
    // 保证变量查找和this对象下的属性查找会落在一个对象上
    with (this) {
        // 用户提交的代码
    }
};