Skip to content

作用域与提升

一、理解作用域

JS引擎在代码执行前会对其进行编译,这个过程中需要收集并维护代码中存在的所有变量,并实施一套规则来确定当前执行的代码对变量的访问权限,这套规则就是作用域,用于确定在何处以及如何查找变量

声明

  • 变量声明:以 var a = 2 为例,会分为两个步骤:

    • var a :编译阶段,在作用域中声明一个变量并命名为 a
    • a = 2:执行阶段,在作用域内查找变量a,找到的话对其赋值
  • 函数声明:以 function foo(){...} 为例,并不会有线程专门用来将一个函数值分配给 foo,编译器会在代码生成的同时处理声明和值的定义

作用域嵌套

实际情况中需要同时顾及多个作用域。当一个块(或函数)嵌套在另一个块(或函数)中时,就发生了作用域的嵌套。在当前作用域无法找到某个变量时,引擎会在外层嵌套的作用域中继续查找,直到找到该变量或者抵达最外层(也就是全局)作用域为止

父级作用域的同名变量无法访问,但全局变量会自动成为全局对象(例如浏览器中的window)的属性,因此可以通过window.a这种形式访问同名全局变量

异常情况

  • 在作用域内未找到变量

    • 查找变量的目的是为了使用,例如console.log(a),会报错ReferenceError
    • 查找变量的目的是为了赋值,例如 a = 2,会自动隐式地创建一个全局变量,然后赋值(严格模式下依旧会报错)
  • 在作用域内找到变量,但未正确使用,例如对字符串进行函数调用,则报错 TypeError

二、词法作用域

作用域主要有两种工作模型,一种是大多数编程语言使用的最普遍的词法作用域,另一种叫动态作用域

什么是词法作用域

词法作用域这个概念的来源是编译器的第一个工作阶段词法化(检查和解析源代码的字符),作用域是由书写代码时变量声明的位置来决定的

修改词法作用域

  • evalsetTimeoutsetIntervalnew Function可以根据传入的字符串执行代码,若其中包含有变量声明,则会修改词法作用域环境,严格模式下这种形式的调用会生成一个独立的作用域,无法影响其他作用域

  • with可以将一个对象处理为一个完全隔离的词法作用域,但是这个块内部正常的 var 声明并不会被限制在这个块的作用域中,而是被添加到 with 所处的作用域中,严格模式禁用with

引擎在对编译阶段的性能优化依赖于作用域的静态分析,预先确定变量的位置才能更快地查找和使用。但出现eval等可能会影响作用域的代码后,由于参数是动态的,引擎无法判断最终的作用域状态,所以会影响性能

三、函数作用域与块作用域

函数作用域

函数是 JavaScript 中最常见的作用域单元,声明在一个函数内部的变量无法在外部访问。

首先我们要区分函数声明表达式,最简单的方法是看 function 关键字出现在声明中的位置,如果 function 是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。

两者最重要的区别是它们的名称标识符将会绑定在何处,函数表达式的变量名会隐藏在自身,外部无法访问,如下几个示例都是函数表达式

js
let foo = function () {};
(function bar() {...})();  // bar无法在全局中访问到
setTimeout(function baz() {}, 1000);

判断 function 的位置看的不仅仅是一行代码,而是整个声明范围,所以回调函数也是函数表达式

块作用域

代码块(通常指{...})内的作用域被称为块作用域,块作用域的存在可以更好地避免变量污染以及利于垃圾回收,然而表面上看 JavaScript 并没有块作用域的相关功能,在其中通过 var 声明变量会泄漏到外层,除非我们深入了解

js
if (true) {
    var a = 1;
}
console.log(a);  // 1

with()

该作用域中如果对正确的对象属性进行访问,是和外部作用域分隔开的

try/catch

catch 分句会创建一个块作用域,其中的变量 err 只在 catch 内有效

js
try {
    undefined();
} catch (err) {
    console.log('err:' + err); // 此处可以正常访问到 err
}
console.log(err); // ReferenceError: err is not defined

虽然这种形式的代码十分丑陋,但事实上这是很多工具都在使用的代码转换方式,使得块作用域能在更早的环境中兼容

ES6

ES6之后的版本中,引入的 letconst 关键字,真正实现了块作用域

js
if (true) {
    let a = 1;
}
console.log(a); // ReferenceError: a is not defined

四、提升

变量声明提升

引擎会在解释 JavaScript 代码之前首先对其进行编译。编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来,也就是说,包括变量和函数在内的所有声明都会在代码执行前进行处理

js
console.log(a); // undefined
var a = 2;

上述代码之所以没有报错,是因为它在编译阶段被处理为如下逻辑

js
var a;
console.log(a); // undefined
a = 2;

声明和赋值被分开,并且声明被移动到了最上面,这个过程就叫提升

函数声明提升

函数声明是不会被拆分为声明和赋值两个过程的,会一起提升,所以函数在声明前就可以调用

js
foo(); // foo
function foo() {
    console.log('foo');
}

函数表达式不会被提升,即使具名也不提升

js
bar(); // ReferenceError: bar is not defined
foo(); // TypeError: foo is not a function
var foo = function bar() {
    console.log('foo');
};

提升的优先级

对同一个变量名重复声明时,函数声明优先级更高,会忽略变量声明

js
foo(); // 2 第一行代码运行时,忽略了 var foo 的声明,foo 被优先提升为第二个函数声明
var foo = function () {
    console.log('1');
};
function foo() {
    console.log('2');
}
foo(); // 1 第二行代码中 foo 被重新赋值为第一个函数表达式,因此此处输出 1

同样都是函数声明的话,后面的优先级更高,会覆盖之前的声明

js
foo(); // 2
function foo() {
    console.log('1');
}
function foo() {
    console.log('2');
}
foo(); // 2

声明提升的终点

  • 使用 var 的声明会无视块级作用域提升到当前作用域

    js
    console.log(a); // undefined
    if (true) {
        console.log(a); // undefined
        var a = 1;
    }
    console.log(a); // 1
  • 没有关键字的默认声明不会在编译阶段提升,而是在执行代码时提升,提升的终点与 var 相同

    js
    console.log(a); // ReferenceError: a is not defined
    if (true) {
        console.log(a); // ReferenceError: a is not defined
        a = 1;
    }
    console.log(a); // 1
  • 函数声明在块级作用域内的表现比较怪异,个人理解可以还原为如下过程:

    • 编译阶段在块级作用域内外分别提升一次,块级作用域外的声明初始值是 undefined,块内变量初始值为一个函数

    • 无论在块内对变量做任何更改都不会影响到块外的同名变量,只有在执行函数声明那一行代码后,会复制当前块内作用域的变量值给块外变量

      js
      console.log(window.a, a); // undefined,undefined
      if (true) {
          console.log(window.a, a); // undefined [Function: a]
          a = 50;
          console.log(window.a, a); // undefined 50
          function a() {}
          console.log(window.a, a); // 50 50
          a = 100;
          console.log(window.a, a); // 50 100
      }
      console.log(window.a, a); // 50 50

      应当尽量避免在块级作用域内进行函数声明

五、闭包

什么是闭包

一般情况下函数内部声明的变量是无法在外部被访问到的,首先查看一段经典的示例代码

js
function foo() {
    var a = 2;
    function bar() {
        console.log(a);
    }
    return bar;
}
var baz = foo();
baz(); // 2

上例中,函数 baz 被定义在函数 foo 的作用域外,但却能访问到 foo 内部的变量,这就是闭包。无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

一般情况下函数被执行后,垃圾回收机制就会将该函数内部的作用域销毁以释放内存空间,而闭包的存在会阻止垃圾回收,使得上例中的 foo 函数的内部作用域依旧存在。

闭包的场景

闭包是基于词法作用域书写代码产生的自然结果,不需要为了利用它们而有意识地创建闭包。闭包的创建和使用在代码中随处可见

回调函数

js
function wait(message) {
    setTimeout(function timer() {
        console.log(message);
    }, 100);
}
wait('Hello, closure!');

在函数中使用 setTimeout 传入一个回调函数是很常见的场景,这也是闭包的一种,内部函数 timer 在执行的时候,已经不处于定义它的 wait 函数内了,但 timer 依旧保持了对 wait 内部作用域的引用,可以访问到变量 message ,由此可知,只要使用了回调函数,实际上就是在使用闭包

循环

for 循环是最常见的闭包示例,对于以下代码,我们期望的结果是输出 1-5 ,但实际上会输出五次 6

js
for (var i = 1; i <= 5; i++) {
    setTimeout(function timer() {
        console.log(i);
    }, 0);
}

仔细想想这个结果是显而易见的,回调函数是在循环结束后才执行,所以输出的结果是变量 i 最终的值

之所以会期望输出 1-5 是因为我们会错误地假设:回调函数每次循环迭代在运行的时候都会捕获一次 i 的副本,让后续 i 的变化影响不到回调函数中的副本变量

但实际情况是回调函数引用的 i 始终是同一个变量,循环结构让我们误以为背后还有更复杂的机制在起作用,但实际上没有

那到底如何才能达到我们假设的情况呢,这要具备两个条件:独立作用域、副本变量

js
for (var i = 1; i <= 5; i++) {
    (function () { // 使用一个函数包裹起来形成独立作用域
        var j = i; // 拷贝当前循环中 i 的值
        setTimeout(function timer() {
            console.log(j);
        }, 0);
    })();
}

上述代码还可以进行优化,使用形参来拷贝变量

js
for (var i = 1; i <= 5; i++) {
    (function (j) {
        setTimeout(function timer() {
            console.log(j);
        }, 0);
    })(i);
}

let 声明的变量天然就具备我们设想的这两点,每次循环 let 都会创建独立作用域并且在其中声明变量

js
for (let i = 1; i <= 5; i++) {
    setTimeout(function timer() {
        console.log(i);
    }, 0);
}

模块

例如 jQuery 中的 $ 标识符,可以通过 $ 调用模块的函数方法,这也是闭包的应用方式

模块具有以下特点:

  • 为创建内部作用域而调用了一个包装函数
  • 包装函数会将内部函数返回,这样就形成了一个闭包

六、动态作用域

首先查看一段示例代码:

js
var a = 1;
function foo() {
    console.log(a);
}
function bar() {
    var a = 2;
    foo();
}
bar();

JS只有词法作用域,词法作用域在代码的书写阶段就已经决定,因此无论在何处调用,foo 函数的调用结果都是输出 1

假设JS具有动态作用域,理论上来说上面的例子就该输出 2

根本区别在于词法作用域只关注函数在何处声明,而动态作用域关注函数在何处调用