重新演绎 You don't know JS<2>: 远离那些欺骗词法作用域的函数

158 阅读7分钟
原文链接: boxueio.com

了解了JS引擎的工作方式之后,这一节,我们先通过一些代码更具体的认识JavaScript中的作用域。然后,来看两个欺骗JS作用域的方法,了解它们的目的并不是鼓励我们使用这些技术。而是,当你了解这些方法的复杂性之后,才会更加认同我们提出的观点:尽可能避免去使用它们。

由词法确定的作用域

首先,来看下面的代码:

function foo(a) {
    var b = a * 2;

    function bar(c) {
        console.log(a, b, c);
    }

    bar(b * 3);
}

foo(2);

在这个例子中,存在几个作用域呢?我们在注释中标记出了作用域的边界:

/* Global scope */
function foo(a) {
    /* Begin foo scope */
    var b = a * 2;

    function bar(c) {
        /* Begin bar scope */
        console.log(a, b, c);
        /* End bar scope */
    }

    bar(b * 3);
    /* End foo scope*/
}

foo(2);

这里要说明的是,函数的参数是属于对应函数作用域的。

于是:

  • 在Global scope中,只有一个符号,就是函数foo
  • 在Foo scope中,有符号abbar
  • 在Bar scope中,只有一个符号c

在JS里,作用域的嵌套关系一定是完整的。绝不可能存在一个作用域同时被两个不相关的作用域包围。换句话说,也不可能存在一个跨越两个作用域的函数。

并且,在上一节我们也提到了,对符号的查询,是从当前作用域逐步向外层作用域进行的。例如,在bar的作用域内,当JS引擎查询符号a/b/c的时候:它可以直接在bar的作用域内,找到c,在foo的作用域里找到ab。并且,一旦引擎找到了匹配,就不会再到外层作用域中继续查找了

这里,还有一点要说明的是,符号的逐级向上查询的规则,对类对象的属性,以及属性的属性这样的符号,是不生效的。它只对诸如a/b/c这样的一级符号生效。

另外,如果我们的JS代码在浏览器里执行,在全局空间中定义的变量,还可以通过window的属性来访问。通过这种方式,我们可以避免同名局部变量覆盖掉全局变量的情况:

var a = 1;

function foo(a) {
    var b = a * 2;

    function bar(c) {
        console.log(window.a); // Only works in browser
        console.log(a, b, c);
    }

    bar(b * 3);
}

最后,要再次强调一点的是,函数的作用域是静态的,是和词法相关的概念,当我们编写好一个函数之后,这个函数的作用域就已经完成确定了。这与函数在什么地方被调用没有任何关系

如何欺骗JS的词法作用域

尽管作用域是一个静态的概念,JS也提供了一些方法,允许我们在运行时动态修改它。但这些方法在JS社区中的口碑并不好,稍后我们就看到,这些方法不仅不容易用对,还会显著降低JS代码的执行性能。

总之,一句话:理解它们,是为了远离它们。

使用eval

第一种方法,是调用eval函数,它接受一个字符串参数。JS引擎会把它的参数直接作为JS代码放在调用eval的位置,这样我们就可以在运行时,通过eval向特定的作用域安插任何内容了,也就达到了我们修改某个作用域的效果。来看下面这个例子:

function foo(a) {
    console.log(a, b);
}

var b = 2;
foo(1);

执行一下,就会看到,结果是1和2,此时log中访问的b,是全局作用域中的变量。接下来,我们修改一下foo,让它接受另外一个参数,允许我们向它的作用域内,插入任意代码:

function foo(str, a) {
    eval(str);

    console.log(a, b);
}

var b = 2;
foo("var b = 3;", 1);

现在,重新执行一下,结果就变成1和3了。JS引擎会直接在eval调用的地方,插入代码var b = 3;,这样,foo的作用域里,就多了一个变量b。于是,调用log时,b的RHS查询就会直接使用当前作用域中的版本,而不会再到外围作用域中查找了。

但是,当eval工作在strict模式的时候,插入的代码会带有自己的作用域,而不会污染到它所在的外围作用域,因此下面的代码,会发生ReferenceError

function foo(str, a) {
    "use strict";
    eval(str);

    console.log(a, b); // ReferenceError: b is not defined.
}

foo("var b = 3;", 1);

另外,除了eval之外,JS中还有一些允许我们以字符串的形式在运行时执行代码函数,例如setIntervalFunction等等,尽管它们的工作方式略有差异,但是我们的态度是相同的:记着,不要使用这种会修改函数作用域的方式使用它们。

with

第二个会在运行时修改作用域方法,是通过with关键字。和eval不同的是,with对作用域的修改是间接的,这并不是with的核心功能。最初,我们只是使用它避免编写重复类对象而已:


var obj = {
    a: 1,
    b: 2,
    c: 3
};

obj.a = 1;
obj.b = 2;
obj.c = 3;

在上面这个例子中,为了避免反复使用obj访问它的属性,我们就可以这样:

with(obj) {
    a = 4;
    b = 5;
    c = 6;
}

console.log(obj);

这时,obj的值就变成{ a: 4, b: 5, c: 6 }了。但事情并没有想象的这么简单,一些IDE甚至会在你使用with的时候给你一个提示,告诉你with会带来一些容易让人困惑的结果。我们先来看下面这个例子。

首先,定义一个函数:

function foo(obj) {
    with(obj) {
        a = 2;
    }
}

var o1 = { a: 1; }
var o2 = { b: 1; }

其次,分别把o1o2传递给它:

foo(o1);
console.log(o1.a); // 2

foo(o2);
console.log(o2.a); // undefined

直到现在,所有的结果都还如我们预期,o1.a是2,o2.a是undefined。但是,当我们接下来打印全局变量a的时候,你认为是什么结果呢?

console.log(a);

你可能会觉得,当然也是undefined啊,全局作用域里哪来的变量a。但如果你实际执行下就会发现,结果是2。很奇怪对不对?而这,就是with的功劳。

简单来说,with接受一个对象参数,并且会为这个对象创建一个自己独立的作用域。因此,给它传递o1的时候,由于o1包含属性a,我们就修改了o1.a的值;当我们给它传递o2的时候,由于o2中并不存在属性a,我们会得到一个undefined

但是,这里with还带来了一个副作用,由于witho2创建了一个独立的作用域,当JS引擎在这个作用域里执行a = 2的时候,会对变量a进行LHS查询,显然,当前作用域里没有a,上一级作用域,也就是foo中,也没有,于是按照我们上一节提到过的规则,JS引擎会在全局作用域自动创建一个变量a。因此,我们在全局作用域执行console.log(a);的时候,就会看到2,而不是undefined了。

看到这,你是不是觉得已经足够让你意外了。但故事还没结束,在with创建的独立作用域里,还有一个特例:用var定义的变量,并不属于这个作用域。于是,看下面的代码:

function foo(obj) {
    with(obj) {
        a = 2;
        var c = 10;
    }

    console.log(c);
}

var o1 = {
    a: 1
};

foo(o1);
console.log(o1.a);

这次,在with的作用域里,我们用var定义了变量c。就像我们刚才说过的,c并不属于with作用域,而是属于它的外层作用域,也就是foo,因此,我们可以在foo里,直接访问到这个变量。

现在,你应该能彻底理解我们在一开始提到的关于这些修改作用域方法的评价了。即便你可以在大脑清醒的时候用对它们,也很难保证在之后的修改中不引入问题。因此,要说它们最大的用处,应该也就是帮助我们理解作用域这个概念了。

所以,我们的结论就是:记着作用域的概念,然后忘了这些函数吧

What's next?

以上,就是这一节的内容,通过这些例子,我们应该对JS中的作用域概念有一个比较清楚的认识了。下一节,我们将围绕函数作用域,来看一些JS中的惯用方法,以及容易发生错误的场景。