【JavaScript】作用域 - 笔记

756 阅读12分钟

什么是作用域

作用域是为了在我们使用变量引用之后,更方便的寻找到这些变量而制定的一套规则。

简单来说,作用域就是变量的使用范围,且同一个作用域内的变量是唯一的。

作用域嵌套

作用域在实际的使用中,会互相的嵌套,所以我们通常需要顾及多个作用域。

当一个块或者函数嵌套在另一个块或者函数中,就发生了作用域的嵌套。在当前作用域无法找到某个变量时,引擎会在外层的作用域中寻找,逐级递增出去,直到找到该变量或者已经抵达全局作用域。

最外层的作用域是全局作用域。

LHS和RHS

在理解作用域的时候,我们还需要对LHSRHS有所了解。因为在变量还未声明的情况下,LHSRHS的查询方式是不一样的。

在未使用**“严格模式”**的情况下,LHS在未找到目标变量时,会创建一个对应名称的变量然后使用。而RHS只要未查询到目标变量,就会直接报错。

当变量出现在赋值操作的左侧时进行 LHS 查询, 出现在右侧时进行 RHS 查询。讲得更准确一点, RHS 查询与简单地查找某个变量的值别无二致, 而 LHS 查询则是试图找到变量的容器本身, 从而可以对其赋值。 从这个角度说, RHS 并不是真正意义上的“赋值操作的右侧”, 更准确地说是“非左侧”。

简单理解。LHS查找的是容器,RHS查找的是容器里面的内容。

例如: var a = 1

a是容器,我们要将=1这个赋值,赋值到容器a上面,这个操作并不需要a原本容器里面是什么,无论是什么都覆盖掉即可。

console.log(a) 这里的操作,需要将a容器里面的值取出来然后打印出来。

词法作用域

作用域共有两种主要的工作模型。分别是最普遍的词法作用域和比较少见的动态作用域。

这里我们只讨论词法作用域。

JavaScript的作用域,就是词法作用域。

大部分标准语言编译器的第一个工作阶段叫词法化。词法作用域就是定义在词法阶段的作用域。 所以词法作用域就是由你写代码时的变量和作用域决定的。

随便举个例子:

function foo(a){
    var b = a*2;
    function bar (c){
        console.log(a,b,c)
    }
    bar(b*3);

}
foo(2); //2,4,12

上述例子中,出现了三个作用域,分别是:

  1. 全局作用域
  2. foo()方法内部的作用域
  3. bar()方法内部的作用域

JavaScript的作用域是严格包含的,没有任何函数可以部分地同时出现在两个父级函数中。

欺骗词法

JavaScript中,有两种机制可以让代码在运行的时候来“修改”(或者说欺骗)词法作用域。

需要注意的是,欺骗词法作用域会导致性能下降。

eval

JavaScript中的eval()函数可以接受一个字符串为参数,并将其中的内容视为书写时就存在于某个位置中的代码。

举个例子:

function foo(str,a){
    eval(str);
    console.log(a,b);
}
var b = 2;
foo( 'var b = 3;', 1 ); // 1,3
console.log(b); //2

可以看到,这个例子中,eval()的参数为var b = 3。在全局作用域中,本身已经将b变量的值声明为2。但是通过eval()方法,将foo()方法中所调用到的b参数的值,改为了3

在调用完eval()之后,我们在全局作用域中,再次打印b参数的值,发现依旧是2

那么我们再看一个例子:

function foo(str){
    eval(str);
    console.log(a);
}

foo('var a = 2'); // 2

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

结合两个例子我们可以看到,eval()方法的参数传入的声明,只会在调用对应方法的时候有效。实际作用域中并不会永久性的生成或者改变对应的声明。 可以理解为临时声明。

with

JavaScript中,还可以使用with关键字来欺骗词法作用域。

with通常被当做重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。

例子:

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

var obj = {
	a:1
}

foo(obj);

console.log(obj) // {a:2}

在上述例子中,我们可以看到,调用with声明之后,修改的内容会泄露到全局作用域上。、

with声明实际上是根据你传递的对象,凭空创建了一个全新的词法作用域。

小结

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段 基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它 们进行查找。 JavaScript 中有两个机制可以“欺骗”词法作用域: eval(..) 和 with 。前者可以对一段包 含一个或多个声明的“代码”字符串进行演算,并借此来修改已经存在的词法作用域(在 运行时)。后者本质上是通过将一个对象的引用当作作用域来处理,将对象的属性当作作 用域中的标识符来处理,从而创建了一个新的词法作用域(同样是在运行时)。 这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认 为这样的优化是无效的。使用这其中任何一个机制都将导致代码运行变慢。不要使用它们。

函数作用域和块作用域

JavaScript的作用域,主要由函数作用域块作用域组成。

函数中的作用域

JavaScript 具有基于函数的作用域,每创建一个函数,就会创建一个对应的作用域。

函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用以及复用。

隐藏内部实现

对函数的传统认知就是先声明一个函数,然后再向里面添加代码。但反过来想也可以带来 一些启示:从所写的代码中挑选出一个任意的片段,然后用函数声明对它进行包装,实际 上就是把这些代码“隐藏”起来了。

实际上,“隐藏”这个操作,远比我们想象的作用更大。

隐藏部分变量或者函数,符合最小授权或暴露原则。避免过多的变量向外泄露。

我们需要遵守的一个原则是,尽量让变量或者函数,只让其在需要使用的范围内出现。

规避冲突

“隐藏”所带来的另一个好处,是可以避免同名标识符之间的冲突,避免变量的值被意外覆盖。

毕竟程序员烦恼的事情之一,是如何给众多相似且重复的变量命名。

  1. 全局命名空间 变量冲突的一个典型例子存在于全局作用域中。当程序中加载了多个第三方库时,如果它们没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引发冲突。 这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴漏在顶级的词法作用域中。 例如:
var MyReallyCoolLibrary = {
    awesome: "stuff",
    doSomething: function() {
        // ...
    },
    doAnotherThing: function() {
        // ...
    }
}
  1. 模块管理 另外一种避免冲突的办法和现代的模块机制很接近,就是从众多模块管理器中挑选一个来使用。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显式地导入到另外一个特定的作用域中。 显而易见,这些工具并没有能够违反词法作用域规则的“神奇”功能。它们只是利用作用域的规则强制所有标识符都不能注入到共享作用域中,而是保持在私有、无冲突的作用域中,这样可以有效规避掉所有的意外冲突。 因此,只要你愿意,即使不使用任何依赖管理工具也可以实现相同的功效。

函数作用域

“隐藏”变量或函数,这个技术虽然可以解决一些问题,但是并不理想。

首先必须声明一个具名函数,其次我们必须显式的通过函数名去调用这个函数,才可以运行其中的代码。

为此,JavaScript提供了可以同时解决这两个问题的方案。

var a = 2;
( function foo(){ // <-- 添加这一行
    var a = 3;
    console.log( a ); // 3
})(); // <-- 以及这一行
console.log( a ); // 2

使用这个写法,函数会被当做函数表达式,而不是一个标准的函数声明来处理。该写法也被称为自动执行函数表达式。

区分函数声明和表达式最简单的方法是看 function 关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。如果 function 是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。

(function foo(){ .. }) 作为函数表达式意味着 foo 只能在 .. 所代表的位置中被访问,外部作用域则不行。 foo 变量名被隐藏在自身中意味着不会非必要地污染外部作用域。

匿名和具名

没有名称标识符的函数表达式,称为匿名函数表达式。反之,有名称标识符的函数表达式,称为具名函数表达式

匿名函数表达式书写起来简单快捷,很多库和工具也倾向鼓励使用这种风格的代码。但是 它也有几个缺点需要考虑。

  1. 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
  2. 如果没有函数名,当函数需要引用自身时只能使用已经过期的 arguments.callee 引用, 比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身。
  3. 匿名函数省略了对于代码可读性 / 可理解性很重要的函数名。一个描述性的名称可以让 代码不言自明。

给函数表达式命名是一个最佳实践。

块级作用域

for (var i=0; i<10; i++) {
    console.log( i );
}

对于for循环,想必大家都不陌生。

我们在 for 循环的头部直接定义了变量 i ,通常是因为只想在 for 循环内部的上下文中使用 i ,而忽略了 i 会被绑定在外部作用域(函数或全局)中的事实。这就是块作用域的所带来的好处。并且变量的声明应该距离使用的地方越近越好,并最大限度地本地化。

块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从在函数中隐藏信息扩展为在块中隐藏信息。

闭包

闭包是基于词法作用域书写代码时所产生的自然结果,并不需要为了利用它们而有意识的创建闭包。

function foo() {
    var a = 2;
    function bar() {
        console.log( a );
    }
    return bar;
}
var baz = foo();
baz(); // 2 —— 朋友,这就是闭包的效果。

在上述例子中,bar()函数被正常的执行,但是它是在自己定义的词法作用域外执行的。 在foo()方法执行之后,引擎的垃圾回收器正常情况下会将该方法销毁以释放内存。但是因为闭包的存在,bar()方法调用到了foo()的词法作用域,所以垃圾回收器并没有将foo()的内部销毁。

闭包就是在定义的词法作用域以外的地方被调用。

小结

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。 如果没能认出闭包,也不了解它的工作原理,在使用它的过程中就很容易犯错,比如在循环中。但同时闭包也是一个非常强大的工具,可以用多种形式来实现模块等模式。

结语

作用域的使用在我们的日常开发中随处可见,灵活的应用和明确的了解自己所写的代码的作用域,可以提到开发的效率。

同时,正确的使用相关知识,也可以提到自己的代码质量。

本篇内容关于闭包的内容较少,主要是因为几个方面:

  • 闭包的概念如果想描述清楚,实属困难。
  • 要验证闭包的种种,需要代入大量的例子和分析,和作用域同一篇章的话,篇幅会过长。

所以本文仅仅只是简单地提到了闭包的一些内容。

希望我的文章能被你们所喜欢,也希望若有不足之处,大佬们能一一点出,谢谢。

参考

本文内容,为学习《你不知道的JavaScript》上卷的第一部分【作用域与闭包】后所产出的笔记文章。有兴趣的小伙伴可以直接查看原书籍。