前言
最近在看<<你不知道的JavaScript>>系列书籍,这套书在Github
上是star
数超过8W的开源书籍。
久仰大名,前不久趁着618打折,买了一整套。目前读了一下上册,重点正是JS
的几个主要难点之一:作用域与闭包,this
、原型与原型链。
作者循序渐进,由浅入深讨论了让人困惑的这些难题,加上幽默的讲解方式并不会让人感到无聊。
我整理了一下自己看书时的笔记,方便复习查阅。本篇为作用域部分,本文主要以问答的方式进行。
什么是作用域
作用域是什么
作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用
LHS
查询;如果目的是获取变量的值,就会使用RHS
查询。赋值操作符会导致LHS
查询。=
操作符或调用函数时传入参数的操作都会导致关联作用域的赋值操作。
LHS
和RHS
的含义是“赋值操作的左侧或右侧”并不一定意味着就是“=赋值操作符的左侧或右侧”。赋值操作还有其他几种形式,因此在概念上最好将其理解为“赋值操作的目标是谁(LHS
)”以及“谁是赋值操作的源头(RHS
)”。
JavaScript 的作用域有何特点
JavaScript
引擎首先会在代码执行前对其进行编译,在这个过程中,像var a = 2
这样的声明会被分解成两个独立的步骤:
var a
在其作用域中声明新变量(最优先)a = 2
会查询(LHS
查询)变量a
并对其进行赋值。
LHS
和RHS
查询都会在当前执行作用域中开始,如果有需要(也就是说它们没有找到所需的标识符),就会向上级作用域继续查找目标标识符,这样每次上升一级作用域(一层楼),最后抵达全局作用域(顶层),无论找到或没找到都将停止。
不成功的
RHS
引用会导致抛出ReferenceError
异常。不成功的LHS
引用会导致自动隐式
地创建一个全局变量(非严格模式下),该变量使用LHS
引用的目标作为标识符,或者抛出ReferenceError
异常(严格模式下)。
词法作用域
什么是词法作用域
词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。
词法作用域有何特征
词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。
函数作用域和块作用域
什么是函数作用域
函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(其实在嵌套的作用域中也可以使用)。
函数是
JavaScript
中最常见的作用域单元。本质上,声明在一个函数内部的变量或函数会
在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。但函数不是唯一的作用域单元。
为什么“隐藏”变量和函数是一个有用的技术?
最小特权原则,也叫最小授权或最小暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的
API
设计。
这个原则可以延伸到如何选择作用域来包含变量和函数。如果所有变量和函数都在全局作用域中,当然可以在所有的内部嵌套作用域中访问到它们。但这样会破坏前面提到的最小特权原则,因为可能会暴漏过多的变量或函数,而这些变量或函数本应该是私有的,正确的代码应该是可以阻止对这些变量或函数进行访问的。
“隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突。
1 | function doSomething(a) { |
如何规避变量命名冲突
- 在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴漏在顶级的词法作用域中。
- 从众多模块管理器中挑选一个来使用。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显式地导入到另外一个特定的作用域中。
匿名函数表达式有哪些缺点
- 1.匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
- 2.如果没有函数名,当函数需要引用自身时只能使用已经过期的
arguments.callee
引用,比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身。- 3.匿名函数省略了对于代码可读性/可理解性很重要的函数名。一个描述性的名称可以让代码不言自明。
如何区分函数声明和函数表达式?
- 最简单的方法是看
function
关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。
1 | function foo(){...} //函数声明 |
- 如果
function
是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。- 函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。
为什么要使用立即执行函数
1 | var a = 2; |
在任意代码片段外部添加包装函数,可以将内部的变量和函数定义“隐藏”起来,外部作用域无法访问包装函数内部的任何内容。
虽然这种技术可以解决一些问题,但是它并不理想,因为会导致一些额外的问题。
- 首先,必须声明一个具名函数
foo()
,意味着foo
这个名称本身“污染”了所在作用域(在这个例子中是全局作用域)。- 其次,必须显式地通过函数名
foo()
调用这个函数才能运行其中的代码。
立即执行函数(
IIFE
),函数可以不需要函数名,并且能够自动运行,这正好可以解决上面的两个问题。
解决方法1
2
3
4
5
6var a = 2;
(function foo(){ // foo函数名也可以删除
var a = 3;
console.log( a ); // 3
})();
console.log( a ); // 2
块作用域
表面上看 JavaScript
并没有块作用域的相关功能。
with
with
关键字。它不仅是一个难于理解的结构,同时也是块作用域的一个例子(块作用域的一种形式),用with
从对象中创建出的作用域仅在with
声明中而非外部作用域中有效。
try / catch
try / catch
的catch
分句会创建一个块作用域,其中声明的变量仅在catch
内部有效。
1 | try { |
let
let
关键字可以将变量绑定到所在的任意作用域中(通常是{...}
内部)。换句话说,let
为其声明的变量隐式地了所在的块作用域。
1 | var foo = true; |
使用 let 进行的声明不会在块作用域中进行提升。
1 | console.log( bar ); // ReferenceError! |
const
const
,同样可以用来创建块作用域变量,但其值是固定的(常量)。之后任何试图修改值的操作都会引起错误。
1 | var foo = true; |
块作用域有哪些好处
- 减少变量命名冲突
- 垃圾收集
1 | function process(data) { |
- let循环
1 | for (let i=0; i<10; i++) { |
for
循环头部的let
不仅将i
绑定到了for
循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。
等价于
1 | { |
变量提升
什么是变量提升
我们习惯将
var a = 2;
看作一个声明,而实际上JavaScript
引擎并不这么认为。它将var a
和a = 2
当作两个单独的声明,第一个是编译阶段的任务,而第二个则是执行阶段的任务。
这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。可以将这个过程形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的最顶端,这个过程被称为提升。
包括变量和函数在内的所有声明都会在任何代码被执行前首先被编译器处理。
1 | var a = 2; |
ReferenceError和TypeError的区别?
- 如果
RHS
查询在所有作用域中都查不到所需变量,引擎就会抛出ReferenceError
异常。 - 如果
RHS
查询找到了所需变量,但是对该变量的值进行非法操作,比如试图对一个非函数类型的值进行函数调用,比如调用null
或undefined
,那么引擎就会抛出TypeError
异常。
1 | foo();//TypeError |
变量提升的一些规则
JavaScript
里先有声明再有赋值。- 所有声明(变量+函数)都会在任何代码被执行前,首先被编译器处理。
- 声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。
- 每个作用域都会进行提升操作。
- 即使是具名的函数表达式,名称标识符在被赋值之前也无法在所在作用域中使用。
- 函数声明和变量声明都会被提升,函数会首先被提升,然后才是变量。
- 尽管重复的
var
声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。 - 尽可能避免在块内部声明函数。
附录:动态作用域
词法作用域的定义过程发生在代码的书写阶段(这里假设代码没有使用eval
和with
)。
1 | function foo(){ |
这段代码说明JavaScript
中的作用域就是词法作用域。
- 词法作用域让
foo()
函数中的a
通过RHS(Retrieve His Source)
引用到了全局作用域中的a
,因此输出2。
词法作用域只关心函数在何处声明!
动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用的。也就是说,作用域链是基于调用栈的,而不是代码中的作用域嵌套。
1 | //注意,以下代码仅仅是在假设动态作用域存在的情况下 |
为什么会这样?
- 当
foo()
函数无法找到a
的变量引用时,就会顺着调用栈在调用foo()
函数的地方查找a
,而不是在嵌套的词法作用域中向上查找。由于foo()
函数是在bar()
函数中调用的,因此,引擎会检查bar()
函数的作用域,并在其中找到值为3的变量a
。
词法作用域和动态作用域的区别?
主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(
this
也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。
JavaScript
只有词法作用域,那为什么还要了解动态作用域?
- 因为
JavaScript
中的this
机制在某种程度上与动态作用域相似。