1. 1. 作用域 Scope
  2. 2. 词法作用域
  3. 3. 函数作用域、块级作用域
    1. 3.1. 变量提升
      1. 3.1.1. 基本概念
      2. 3.1.2. 函数声明
      3. 3.1.3. 临时死区 temporal dead zone
      4. 3.1.4. 最佳实践
  4. 4. 闭包
Table of Contents ▼

JavaScript 起手式之作用域 Scope

作用域 Scope

谈到作用域的时候,希望大家不要有任何负担,这是一个非常简单的概念。难不在概念本身,而在于清楚地知道在一些设计模式和模块中,作用域扮演的重要角色。

什么是作用域:定义变量可访问性的一套规则。当定义了很多变量,我们终极关心的是这个变量的值是什么,在什么地方可以访问,这正是作用域干的事情。

如果觉得上诉说法不放心,为节省时间,推荐直接查阅英文维基百科):

In computer programming, the scope of a name binding – an association of a name to an entity, such as a variable – is the region of a computer program where the binding is valid: where the name can be used to refer to the entity. Such a region is referred to as a scope block. In other parts of the program the name may refer to a different entity (it may have a different binding), or to nothing at all (it may be unbound).

作用域有两种主要的工作模型:一种是大多数编程语言(包括 JavaScript)采用的词法作用域(Lexical Scope),另外一种是动态作用域(Bash,Perl …)

词法作用域

JavaScript 采用词法作用域,词法作用域的特点是作用域在词法分析(Lexing)阶段确定(不考虑 eval, with),即在查找变量时,依据的是代码的书写位置,和调用位置无关。看下面代码:

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

function bar(){
  var a = 3;
  foo();
}

var a = 2;

bar(); // 2
  1. 执行 foo ,查找 foo 的作用域,没有变量 a
  2. 沿着作用域链,查找 foo 的上一层作用域(全局作用域),发现变量 a = 2
  3. 输出结果 2

如果是动态作用域呢?
动态作用域是基于调用栈的,在函数调用时确定:foo 中查找不到 a;沿着调用栈查找,foo 是在 bar 中调用,于是在 bar 的作用域中查找 a,找到值为 3 的变量 a

词法作用域是理解 JavaScript 中非常重要的概念;无论作用域嵌套了多少层,调用多复杂,你只需要先找函数定义的地方,然后根据代码的书写顺序,一层一层往外找。最后借用mqyqingfeng的一个例子:

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f();
}
checkscope();
var scope = "global scope";
function checkscope(){
    var scope = "local scope";
    function f(){
        return scope;
    }
    return f;
}
checkscope()();

可以通过 evalwith 欺骗词法,但不推荐在实际开发中使用,感兴趣可阅读《你不知道的JavaScript 上卷》2.2 章。

函数作用域、块级作用域

对于学过 C、Java 等块作用域的同学来说,这个地方其实没有知识点;但 JavaScript 的块作用域,可以牵扯出闭包,ES6,this 等知识点,所以在此罗嗦一下,希望大家有自己的理解。先看一段经典代码:

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

这段代码会连续打印10次“10”。原因很简单,setTimeout 中的函数并不会马上执行,1s 后放入任务队列,然后等待执行(想一想你是否清楚 setTimeout 的执行过程),真正执行 setTimeout 的 function 时,i = 10

如果想输出 0~9,es6之前我们通常使用闭包来解决,而现在我们直接使用 es6 支持的块作用域来解决:

for(var i = 0; i < 10; i++) {
  let j = i;
  setTimeout(() => console.log(j), 1000);
}

在循环体中通过 let 声明块级作用域变量(本质上这是将块转换为一个可以被关闭的作用域),保证了setTimeou中的函数执行时是独立的变量,而不是引用的同一个变量。

再精简一点就是下面这个样子了:

for(let i = 0; i < 10; i++) {
  setTimeout(() => console.log(i), 1000);
}

每次迭代,i 都是被重新声明的全新变量,只是其初始值是上一次迭代的值。

最后看看闭包的解决方式:

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

思考: 分别用 setTimeoutsetInterval 改写上面的代码,每隔 1s 打印一次。

变量提升

基本概念

变量提升听起来是一个很高级的东西,但需要注意这并不是一个好的实践方式。

大一学习C语言的时候,老师对我们的代码格式有非常严格的要求,int i = 0 这样代码是不被允许的

console.log(a); // undefined
var a = 2;
foo(); // foo
function foo(){
  return 'foo';
}

由于变量提升,上面的代码实际会被理解成下面的形式:

function foo(){
  return 'foo';
}
var a;
console.log(a);
a = 2;
foo();

函数声明

上面的代码你注意的一点是,函数声明和变量声明都会被提升,但是函数是先于变量的;思考下面的代码:

foo() // 1
var foo;

function foo(){
  console.log(1);
}

foo = function(){
  console.log(2);
}

虽然 var foofunction foo()... 之前,但是由于函数声明被提升到变量之前,所以var foo 反而因为重复声明被忽略。

临时死区 temporal dead zone

console.log(a) // ReferenceError: b is not defined
let a = 2;

当我们使用 let 或者const 就出现了所谓的临时死区。临时死区并非否决了变量提升,只是在临时死区内,变量是无法访问的。

通过对比 varlet 的解析过程,来看看到底区别在哪:


let的声明和初始化阶段之间的就是临时死区,当我们在初始化之前去访问let声明的变量,就会抛出 ReferenceError。但这和变量提升是两个维度,所以不要被一些表面的东西混淆。

思考:let 尚且如此,请问 const, class 怎么样呢

最佳实践

这些概念看起来花哨,但你只需要保持良好的代码风格就不用处处小心,“哦,这个地方是不是有变量提升,重复声明在哪被覆盖了…”。

  • 永远先声明,再使用变量
  • 一个作用域内不要重复声明

闭包

闭包我觉得难的不是概念,而在于合理运用。所以这里我不打算过多解释闭包,理解闭包的精华在于作用域模块,这些之后我会以实际案例再分享出来,这里摘录书中的一段解释作为定义就好了:

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时 就产生了闭包 《你不知道的JavaScript(上卷)》第一部分第5章