你不知道的JavaScript--作用域

前言

最近在看<<你不知道的JavaScript>>系列书籍,这套书在Github上是star数超过8W的开源书籍。

久仰大名,前不久趁着618打折,买了一整套。目前读了一下上册,重点正是JS的几个主要难点之一:作用域与闭包,this、原型与原型链。

作者循序渐进,由浅入深讨论了让人困惑的这些难题,加上幽默的讲解方式并不会让人感到无聊。

我整理了一下自己看书时的笔记,方便复习查阅。本篇为作用域部分,本文主要以问答的方式进行。

什么是作用域

作用域是什么

作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用 LHS查询;如果目的是获取变量的值,就会使用 RHS 查询。赋值操作符会导致 LHS 查询。 操作符或调用函数时传入参数的操作都会导致关联作用域的赋值操作。

LHSRHS 的含义是“赋值操作的左侧或右侧”并不一定意味着就是“=赋值操作符的左侧或右侧”。赋值操作还有其他几种形式,因此在概念上最好将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头(RHS)”。

JavaScript 的作用域有何特点

JavaScript 引擎首先会在代码执行前对其进行编译,在这个过程中,像 var a = 2 这样的声明会被分解成两个独立的步骤:

  1. var a 在其作用域中声明新变量(最优先)
  2. a = 2 会查询(LHS 查询)变量 a 并对其进行赋值。

LHSRHS 查询都会在当前执行作用域中开始,如果有需要(也就是说它们没有找到所需的标识符),就会向上级作用域继续查找目标标识符,这样每次上升一级作用域(一层楼),最后抵达全局作用域(顶层),无论找到或没找到都将停止。

不成功的 RHS 引用会导致抛出 ReferenceError 异常。不成功的 LHS 引用会导致自动隐式
地创建一个全局变量(非严格模式下),该变量使用 LHS 引用的目标作为标识符,或者抛出 ReferenceError 异常(严格模式下)。

词法作用域

什么是词法作用域

词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。

词法作用域有何特征

词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。

函数作用域和块作用域

什么是函数作用域

函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(其实在嵌套的作用域中也可以使用)。

函数是 JavaScript 中最常见的作用域单元。本质上,声明在一个函数内部的变量或函数会
在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。但函数不是唯一的作用域单元。

为什么“隐藏”变量和函数是一个有用的技术?

最小特权原则,也叫最小授权或最小暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的 API 设计。

这个原则可以延伸到如何选择作用域来包含变量和函数。如果所有变量和函数都在全局作用域中,当然可以在所有的内部嵌套作用域中访问到它们。但这样会破坏前面提到的最小特权原则,因为可能会暴漏过多的变量或函数,而这些变量或函数本应该是私有的,正确的代码应该是可以阻止对这些变量或函数进行访问的。

“隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突。

1
2
3
4
5
6
7
8
9
10
function doSomething(a) {
function doSomethingElse(a) {
return a - 1;
}
var b;
b = a + doSomethingElse( a * 2 );
console.log( b * 3 );
}
doSomething( 2 ); // 15
// b 和 doSomethingElse(..) 都无法从外部被访问,而只能被 doSomething(..) 所控制。

如何规避变量命名冲突

  • 在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴漏在顶级的词法作用域中。
  • 从众多模块管理器中挑选一个来使用。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显式地导入到另外一个特定的作用域中。

匿名函数表达式有哪些缺点

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

如何区分函数声明和函数表达式?

  • 最简单的方法是看function关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。
1
2
3
function foo(){...}  //函数声明
(function foo(){...})() //函数表达式
var test = function(){...} //函数表达式
  • 如果function是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。
  • 函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。

为什么要使用立即执行函数

1
2
3
4
5
6
7
var a = 2;
function foo(){
var a = 3;
console.log(a);//3
}
foo();
console.log(a);//2

在任意代码片段外部添加包装函数,可以将内部的变量和函数定义“隐藏”起来,外部作用域无法访问包装函数内部的任何内容。

虽然这种技术可以解决一些问题,但是它并不理想,因为会导致一些额外的问题。

  • 首先,必须声明一个具名函数foo(),意味着foo这个名称本身“污染”了所在作用域(在这个例子中是全局作用域)。
  • 其次,必须显式地通过函数名foo()调用这个函数才能运行其中的代码。

立即执行函数(IIFE),函数可以不需要函数名,并且能够自动运行,这正好可以解决上面的两个问题。

解决方法

1
2
3
4
5
6
var a = 2;
(function foo(){ // foo函数名也可以删除
var a = 3;
console.log( a ); // 3
})();
console.log( a ); // 2

块作用域

表面上看 JavaScript 并没有块作用域的相关功能。

with

with关键字。它不仅是一个难于理解的结构,同时也是块作用域的一个例子(块作用域的一种形式),用with从对象中创建出的作用域仅在with声明中而非外部作用域中有效。

try / catch

try / catchcatch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效。

1
2
3
4
5
6
7
try {
undefined(); // 执行一个非法操作来强制制造一个异常
}
catch (err) {
console.log( err ); // 能够正常执行!
}
console.log( err ); // ReferenceError: err not found

let

let 关键字可以将变量绑定到所在的任意作用域中(通常是 {...} 内部)。换句话说,let为其声明的变量隐式地了所在的块作用域。

1
2
3
4
5
6
7
var foo = true;
if (foo) {
let bar = foo * 2;
bar = something( bar );
console.log( bar );
}
console.log( bar ); // ReferenceError

使用 let 进行的声明不会在块作用域中进行提升。

1
2
console.log( bar ); // ReferenceError!
let bar = 2;

const

const ,同样可以用来创建块作用域变量,但其值是固定的(常量)。之后任何试图修改值的操作都会引起错误。

1
2
3
4
5
6
7
8
9
var foo = true;
if (foo) {
var a = 2;
const b = 3; // 包含在 if 中的块作用域常量
a = 3; // 正常 !
b = 4; // 错误 !
}
console.log( a ); // 3
console.log( b ); // ReferenceError!

块作用域有哪些好处

  • 减少变量命名冲突
  • 垃圾收集
1
2
3
4
5
6
7
8
9
10
11
12
13
function process(data) {
// 在这里做点有趣的事情
}
// 在这个块中定义的内容可以销毁了!
// 可以让引擎清楚地知道没有必要继续保存 someReallyBigData 了
{
let someReallyBigData = { .. };
process( someReallyBigData );
}
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
console.log("button clicked");
}, /*capturingPhase=*/false );
  • let循环
1
2
3
4
for (let i=0; i<10; i++) {
console.log( i );
}
console.log( i ); // ReferenceError

for 循环头部的 let 不仅将 i 绑定到了for循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。

等价于

1
2
3
4
5
6
7
{
let j;
for (j=0; j<10; j++) {
let i = j; // 每个迭代重新绑定!
console.log( i );
}
}

变量提升

什么是变量提升

我们习惯将 var a = 2; 看作一个声明,而实际上 JavaScript 引擎并不这么认为。它将 var aa = 2当作两个单独的声明,第一个是编译阶段的任务,而第二个则是执行阶段的任务。

这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。可以将这个过程形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的最顶端,这个过程被称为提升。

包括变量和函数在内的所有声明都会在任何代码被执行前首先被编译器处理。

1
2
3
4
5
6
7
var a = 2;
//分解为两个阶段
//第一个声明:定义声明,在编译阶段进行
var a;

//第二个声明:赋值声明,原地待命,等待执行阶段
a = 2;

ReferenceError和TypeError的区别?

  • 如果RHS查询在所有作用域中都查不到所需变量,引擎就会抛出ReferenceError异常。
  • 如果RHS查询找到了所需变量,但是对该变量的值进行非法操作,比如试图对一个非函数类型的值进行函数调用,比如调用nullundefined,那么引擎就会抛出TypeError异常。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
foo();//TypeError
bar();//ReferenceError
var foo = function bar(){
// do something
};

// 变量提升后可以理解为
var foo;
foo();//TypeError,找到foo,但是此时调用是非法操作
bar();//ReferenceError,在全局作用没有找个这个函数
foo = function(){
var bar = ...self...
// do something
};

变量提升的一些规则

  • JavaScript里先有声明再有赋值。
  • 所有声明(变量+函数)都会在任何代码被执行前,首先被编译器处理。
  • 声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。
  • 每个作用域都会进行提升操作。
  • 即使是具名的函数表达式,名称标识符在被赋值之前也无法在所在作用域中使用。
  • 函数声明和变量声明都会被提升,函数会首先被提升,然后才是变量。
  • 尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。
  • 尽可能避免在块内部声明函数。

附录:动态作用域

词法作用域的定义过程发生在代码的书写阶段(这里假设代码没有使用evalwith)。

1
2
3
4
5
6
7
8
9
function foo(){
console.log(a);//2
}
function bar(){
var a = 3;
foo();
}
var a = 2;
bar();

这段代码说明JavaScript中的作用域就是词法作用域。

  • 词法作用域让foo()函数中的a通过RHS(Retrieve His Source)引用到了全局作用域中的a,因此输出2。

词法作用域只关心函数在何处声明!

动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用的。也就是说,作用域链是基于调用栈的,而不是代码中的作用域嵌套。

1
2
3
4
5
6
7
8
9
10
//注意,以下代码仅仅是在假设动态作用域存在的情况下
function foo(){
console.log(a);//3,(而不是2!)
}
function bar(){
var a = 3;
foo();
}
var a = 2;
bar();

为什么会这样?

  • foo()函数无法找到a的变量引用时,就会顺着调用栈在调用foo()函数的地方查找a,而不是在嵌套的词法作用域中向上查找。由于foo()函数是在bar()函数中调用的,因此,引擎会检查bar()函数的作用域,并在其中找到值为3的变量a

词法作用域和动态作用域的区别?

主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。( this 也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。

JavaScript只有词法作用域,那为什么还要了解动态作用域?

  • 因为JavaScript中的this机制在某种程度上与动态作用域相似。

感谢打赏,错误之处欢迎指正交流(`・ω・´) !~~



文章目录
  1. 1. 前言
  2. 2. 什么是作用域
    1. 2.1. 作用域是什么
    2. 2.2. JavaScript 的作用域有何特点
  3. 3. 词法作用域
    1. 3.1. 什么是词法作用域
    2. 3.2. 词法作用域有何特征
  4. 4. 函数作用域和块作用域
    1. 4.1. 什么是函数作用域
    2. 4.2. 为什么“隐藏”变量和函数是一个有用的技术?
    3. 4.3. 如何规避变量命名冲突
    4. 4.4. 匿名函数表达式有哪些缺点
    5. 4.5. 如何区分函数声明和函数表达式?
    6. 4.6. 为什么要使用立即执行函数
    7. 4.7. 块作用域
      1. 4.7.1. with
      2. 4.7.2. try / catch
      3. 4.7.3. let
      4. 4.7.4. const
    8. 4.8. 块作用域有哪些好处
  5. 5. 变量提升
    1. 5.1. 什么是变量提升
    2. 5.2. ReferenceError和TypeError的区别?
    3. 5.3. 变量提升的一些规则
  6. 6. 附录:动态作用域
    1. 6.1. 词法作用域和动态作用域的区别?
|