JavaScript中的变量作用域

再次修改这篇文章,已经时隔三年了。最近在重新复习JavaScript基础知识,因此本文将从头修改,并整理JavaScript中的作用域相关知识,包括:变量提升、变量作用域、作用域链和闭包等。

<!--more-->

参考

1. 作用域的概念

作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。

基于以上定义,我们实际上要学习的是这种查询变量值的规则,于是整篇文章可以从下面这行代码开始

var a = 2;

JavaScript引擎会认为这里有两个完全不同的操作,一个声明操作由编译器在编译时处理,另一个查询操作由引擎在运行时处理

  • 首先编译器会在当前作用域中声明一个变量
  • 然后在运行时引擎会在作用域中查找该变量,如果能找到则对其进行复制

1.1. 变量声明

参考

我们知道,在JavaScript中通过varfunction等方式可以声明变量或函数。这里需要了解JavaScript中变量声明提升的问题。

所谓变量声明提升,指的是:包含变量和函数在内的所有声明都会在任何代码执行前首先被处理,观察下面的代码

var a = 2
// 等价于
var a // 定义声明在编译阶段进行
a = 2 // 赋值声明会被留在原地,等待执行阶段

每个函数作用域都会进行变量声明提升的操作,需要注意的下面两点

函数声明会提升,但函数表达式不会

// 正常执行
foo()
function foo(){//...}

// 报 TypeError 错误
bar()
var bar = function(){//...}

函数声明提升优先级大于变量声明提升,即:函数声明和变量声明都会被提升,但是是函数会首先被提升,然后才是变量。

foo() // 1
var foo
// 相当于函数声明提升会覆盖同名的变量声明提升
function foo(){
    console.log(1)
}
foo = function(){
    console.log(2)
}
// 如果存在多个提升,则后提升的函数声明会覆盖先提升的函数声明,变量亦然
// 因此,建议不要在同一个作用域中进行重复声明

下面通过一个例子来理解变量声明提升

alert(a);//function a(){alert(4);}
var a = 1;
alert(a);//1
function a(){alert(2);}
alert(a);//1
var a = 3;
alert(a);//3
function a(){alert(4);}
alert(a);//3

通过运行代码可以看见:

  • 第一次弹出的是function a(){alert(4);}整个代码块;
  • 第二次弹出的是1(由于其前面那句的赋值表达式改变了仓库中的值,从一个函数块变为了1);
  • 第三次弹出的仍然是1;
  • 第四次弹出的是3;
  • 第五次弹出的仍然是3;

可以看见,虽然声明了函数,但是并没有调用它,在代码运行的过程中声明的函数被覆盖,且在整个程序结束之后,a为一个数字而非函数了,当使用a()时会报错。

具体来说,JavaScript引擎的工作可分成“预解析”与“逐行运行代码”两部分

  • 预解析:根据var function等声明寻找找所有的变量及函数,在正式运行代码前所有的变量都赋值为undefined,而所有的函数都只是函数块(不会调用函数),并将变量放入"仓库"中,在该解析过程中
    • 如果遇见与变量名相同的函数,则保留函数;
    • 如果是重名函数,则后声明的函数会覆盖前面声明的函数;
    • 如果只是两个变量重名,由于提前赋值均为undefined,因此并没有什么区别。
  • 逐行运行代码:按程序逐行运行代码,遇见变量则从“仓库”中取出并使用,当遇见赋值表达式的时候,存放在仓库中的变量值会被改变

1.2. 变量查询

引擎执行的查找有两种形式:LHSRHS

  • 当变量出现在赋值操作的左侧时进行LHS查询(即查找的目的是为变量进行赋值),如a=2
  • 当变量作为赋值操作的源头时进行RHS查找(即查找的目的是获取变量的值),如console.log(a)

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域继续查找,知道找到该变量或者抵达全局作用域为止。

对于LHSRHS,上面的查找过程均适用,他们的区别在于当变量还没有声明时(即查询在所有嵌套的作用域中都找不到所需的变量),这两种查找的行为是不一样的

  • 如果RHS,则引擎会抛出ReferenceError的错误
  • 如果LHS
    • 非严格模式下,全局作用域会创建一个具有该名称的变量,并将其返回给引擎
    • 严格模式下会抛出ReferenceError错误

1.3. 词法作用域

在编程语言中,作用域一般可分为词法作用域和动态作用域,JavaScript采用的是词法作用域

  • 词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写 代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域 不变(大部分情况下是这样的)。
  • 动态作用域在程序运行时确定变量的值,换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套。

(最初接触“词法域”的概念是在Lua中:若将一个函数写在另一个函数之内,那么这个位于内部的函数可以访问外部函数中的局部变量。。现在在JS中居然又碰见了词法作用域,可谓是感叹万分呀!毕竟看Lua纯粹只是当时的一点兴趣罢了。)

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

上面代码存在3个作用域:

  • 全局作用域,其中包含foo一个标识符
  • 包含着foo函数所创建的作用域,其中有a,b,bar三个标识符
  • 包含着bar函数所创建的作用域,其中包含c一个标识符

作用域查找会在找到第一个匹配的标识符停止,内部作用域的标识符遮蔽了外部作用域的标识符。

词法作用域只会查找一级标识符,也就是说,如果查找foo.bar.baz,词法作用域只会视图查找foo标识符,找到这个变量以后,对象属性访问规则则会分别接管对bar和baz属性的访问。

无论函数在哪里被调用,也无论它何时被调用,它的词法作用域都只由函数被声明时所处的位置决定。

在JavaScript中可以通过witheval等方式欺骗词法,在运行时修改或创建新的作用域,以此来欺骗其他在书写时定义的词法作用域。

但是由于JavaScript引擎会在编译阶段对作用域查找进行性能优化,如果使用上述手段欺骗引擎,则可能导致程序运行效率的下降,因此不建议使用。

上面提到,JavaScript采用的是词法作用域,接下来我们看看JavaScript中几种具体的词法作用域:全局作用域、函数作用域及块级作用域。

2. JavaScript中的作用域

2.1. 全局作用域

所谓全局作用域,指的是不再任何函数内声明的变量的作用域,全局作用域中的数据内容包括:

  • 普通变量声明
  • 函数声明
  • this(指向全局对象window),

此外,全局变量也可以看作是全局对象的属性。

2.2. 函数作用域

关于函数作用域,《JavaScript权威指南》给出的定义是

变量在声明他们的函数体内以及这个函数所嵌套的任意函数体内都是有定义的(作用域链),且声明的变量在函数体内都是可见的(即变量声明提前)。

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

函数作用域中的数据内容除了上面全局作用域中的数据之外,还包括this、函数实参、arguments,以及不在该函数中声明的自由变量

根据前面了解的“声明提升”,在执行代码之前,解析器会把这段代码将要用到的所有变量都事先拿出来,有的直接赋值了,有的先用undefined占个空。

  • 变量、函数表达式声明,默认赋值为undefined占位;
  • this——赋值;
  • 函数语句声明——赋值;

函数作用域是JavaScript中最重要的作用域之一,我们后面学到的闭包就与其密切相关。

自由变量

在函数中使用的变量x,却没有在该函数作用域中声明,对于该函数作用域来说,x就是一个自由变量。

根据词法作用域的规则,函数在定义的时候(不是调用的时候),就已经确定了函数体内部自由变量的作用域

var a = 10;
function fn(){
    console.log(a); // 此处的a就是一个自由变量
}

function foo(f){
    var a = 20; // 此处的a是foo作用域内的一个变量,对于f而言并不是一个自由变量
    f();
}
foo(fn); // 10

也就是说,不管函数在何处调用,其内部的自由变量都是由该函数定义时的词法作用域决定的。

理解函数内部自由变量的规则,是学习闭包的基础。

this

函数内部的this取何值,是在函数真正被调用执行的时候确定的,因为函数定义的时候根本确定不了。

  • 如果函数作为构造函数调用,那么其中的this就代表它即将new出来的对象,在原型链中,this代表的也都是当前对象的值;但是如果直接把构造函数当作普通函数调用,则其的this会变成window
  • 如果函数作为对象的一个属性时,并且作为对象的一个方法被调用时,函数中的this指向该对象;但是如果方法函数被赋值到了另一个变量中,并没有作为obj的一个属性被调用,那么this的值就是window,此时就无法在函数中使用this获取原对象的属性
  • 当一个函数被call和apply调用时,this的值就取传入的对象的值
  • 全局环境下,this永远是window,普通函数在调用时,其中的this也都是window
  • 闭包函数中的this也是window。

形参

尽管JavaScript中的变量类型可以分为基础类型和引用类型,但JavaScript中函数参数的都是按值进行的传递

  • 原始类型的处理方式是,将参数的值完全复制一份,按值传递,
    • 对于传递过来的变量进行修改,不会影响到原变量。
  • 引用类型的处理方式是,将参数的地址复制一份,按值传递地址(更准确的说是引用复制传递)
    • 对于变量的成员进行修改时,会直接影响原变量;
    • 而如果对传递过来的变量进行重新赋值,则不会影响原变量,并且此后再修改变量的成员,也不会影响原变量。
var obj = {
    x: 1
}
function foo(o){
    o.x = 100
}
function foo2(o) {
    o = 100
}

console.log(obj) // {x:1}
foo(obj)
console.log(obj) // {x:100}
foo2(obj)
console.log(obj) // // {x:100},并没有修改obj指向的内存地址的数据

这种设计的意义在于:

  • 按值传递的类型,复制一份存入栈内存,这类类型一般不占用太多内存,而且按值传递保证了其访问速度。
  • 按共享传递的类型,是复制其引用,而不是整个复制其值(C 语言中的指针),保证过大的对象等不会因为不停复制内容而造成内存的浪费。

2.3. 块级作用域

参考:

在ES5及之前的版本,JavaScript只支持函数作用域,如在iffor等语句块中的变量声明,也会被声明提前,不过我们可以通过一些手段变相实现块级作用域

"use strict";
// 通过IIFE创建一个块级作用域,避免污染全局变量
~function(){
    var i = 1;
    alert(i);//1
}();    
!function(){
    var i = 1;
    alert(i);//1
}();
+function(){
    var i = 1;
    alert(i);//1
}();
alert(i);//报错

ES6改变了现状,引入了let关键字,它为其声明的变量隐式地附加到所在的块作用域。块级作用域有以下几个技巧

  • 由于闭包函数的存在,引擎可能会保存父级作用域中的大数据(即使闭包函数没有使用这个变量),使用块级作用域可以消除这种顾虑
  • 在for循环中,let实际上将计数器重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值

此外const关键字也可以用来创建块级作用域,区别在于其声明的变量值是固定的,之后任何试图修改值的操作都会引起错误。

需要注意的是letconst声明的变量都不会进行声明提升,这是非常有趣的一点。

在代码块内,使用let/const命令声明变量之前,该变量都是不可用的,在变量声明之前属于该变量的“死区”。这在语法上,称为“暂时性死区”(temporal dead zone,简称 TDZ)。

有些人认为块作用域不应该完全作为函数作用域的替代方案。两种功能应该同时存在,开 发者可以并且也应该根据需要选择使用何种作用域,创造可读、可维护的优良代码。

2.4. 作用域小结

本章节整理了JavaScript中三种作用域:全局作用域、函数作用域与块级作用域,对于作用域而言,可以做如下理解。

作用域只是一个抽象的概念,其中没有变量。要通过作用域对应的执行上下文环境来获取变量的值。同一个作用域下,不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值。所以,如果要查找一个作用域下某个变量的值,就需要找到这个作用域对应的执行上下文环境,再在其中寻找变量的值。

牢牢记住,作用域中变量的值是在执行过程中产生的确定的,而作用域却是在函数创建时就确定了。

作用域内部声明的变量(包括参数)会覆盖掉外部的同名变量,这正是我们需要的。那么,程序是如何确定作用域下的某个上下文中所使用的自由变量呢?

前面提到过:函数在定义的时候(不是调用的时候),就已经确定了函数体内部自由变量的作用域。

在fn函数中,取自由变量x的值时,要到创建fn函数的那个作用域中取,无论fn函数将在哪里调用。 如果跨了一步,还没找到呢?接着跨!一直跨到全局作用域为止。要是在全局作用域中都没有找到,那就是真的没有了。

这个一步一步“跨”来寻找自由变量的路线,就是我们常说的“作用域链”。接下来让我们来看看作用域链

3. 作用域链

在了解作用域链之前,首先需要了解函数的声明和调用流程。

3.1. 函数的创建和调用

创建函数

在创建函数的时候,会创建一个预先包含全局变量对象及外部作用域的初始作用域链,这个作用域链保存在函数内部的[[Scope]]属性中;

调用函数

  • 在函数每次被调用时,会创建一个执行环境,这里可以大致地将执行环境理解为函数作用域。执行环境定义了变量或函数能够有权访问的其他数据,并决定了他们各自的行为;
  • 完成执行环境创建之后,会复制函数的[[Scope]]属性中的对象,并构建起该执行环境的实际作用域链
  • 完成作用域链创建之后,执行环境会创建一个表示函数内部变量的变量对象,并通过参数来初始化这个函数的变量对象,所有在该执行环境中定义的变量和函数都保存在这个变量对象中,作为该对象的属性和方法。
    • 需要注意的是每个函数在调用的时候会自动取得this和arguments这两个变量,而该函数的变量对象不包含这两个特殊变量,取而代之的是一个arguments属性,该属性引用参数类数组对象
  • 完成变量对象创建之后,该变量对象会被推入执行环境作用域链的前端,因此,作用域链的本质是一个指向变量对象的指针列表。

执行完毕

在一般的情况下,当函数执行完毕之后,局部变量对象就会被删除,内存中仅仅保留着作用域链末端的全局变量对象,但是在闭包函数的情况下就不一样了。

小结

  • 函数在创建时,会通过[[Scope]]维持其创建时的作用域链变量对象的引用
  • 函数在运行时,
    • 对于在该函数内部声明的变量,遵循常规的函数作用域,此时包括变量声明提升等特性
    • arguments,该数据引用参数类数组对象
    • this,由当前函数的执行方式推断并获得
    • 自由变量,通过[[Scope]]对应的作用域链获得
  • 函数在运行完成后,
    • 如果未产生闭包,局部变量对象被删除
    • 如果产生闭包,局部变量对象被闭包的[[Scope]]引用,无法删除,当闭包函数执行时,将重复步骤二,这样就形成了“作用域链”

3.2. 作用域链

当从全局域到调用一个函数时,会创建一个全新的上下文,并生成该函数的作用域,当运行函数内部的代码时,遇见变量优先从当前函数的作用域寻找变量:

  • 如果有,则使用其值
  • 如果没有,则从该函数的[[Scope]]引用的作用域中逐步向上寻找
  • 如果直至全局作用域中也没有找到该变量,则程序抛出异常
var a = 1;
var b = 1;
function f(){
    alert(a);// 当前函数作用域包含a变量的声明,因此直接使用,返回undefine
    alert(b);//1
    // alert(c);//报错
    var a = 2;//不会更改全局仓库里面的值,如果没有前面的var关键字,则会修改全局仓库里面的值
}
f();
alert(a);//1

可以看见,当调用f()时,

  • 由于首先进行的预解析,因此访问a并不会报错,而是一个undefined;
  • 由于b元素是上一个作用域(全局作用域变量)的变量,因此其值1;
  • 由于c并没有声明,且又不存在于作用链中,因此会报错。

需要注意的是,形参也可以看做是局部变量声明,修改上述示例

var a = 1;
var b = 1;
function f(a){
         // 形参也可以看做是局部变量声明
    alert(a);//undefine
    a = 2; 
}
f(); //不传参数
alert(a); // 1 修改内部变量并不会影响外部变量

4. 闭包

千呼万唤始出来,相信读到这里,你对于JavaScript中的函数作用域已经有了比较深刻的理解,让我们最后整理一下闭包相关的知识。

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。换句话说,闭包使得函数可以继续访问定义时的 词法作用域。

即使定义闭包的外部函数已经执行完毕,由于闭包仍旧保持对其词法作用域的引用,因此只要闭包在其他地方调用,都可以观察到闭包(即访问定义时的自由变量),下面列举了常见的三种形式

// 以函数返回值形式调用闭包
function foo(a) {
    var b = a * 2
    function bar() {
        console.log(b)
    }
    return bar 
}

let baz = foo(2)
baz()

// 以回调函数形式调用闭包
function foo(fn){
    var b = 4
    function bar() {
        console.log(b)
    }
    fn(bar)
}

function baz(fn){
    fn()
}
foo(baz)

// 以外部变量形式调用闭包
var baz
function foo(a) {
    var b = a * 2
    function bar() {
        console.log(b)
    }
    baz = bar
}
foo(2)
baz()

上面提到,函数内部的自由变量是从该函数的[[Scope]]作用域链上获取到的,而[[Scope]]是在定义函数时赋值的,无论通过何种手段将内部函数传递到所在的词法作用域以外的地方执行时,这个查找规则也是生效的,因此这个函数可以访问到其作用域链上的所有变量。

理解闭包,弄清楚环境上下文和作用域链是十分必要的,总之,牢记下面这句话

函数在定义的时候(不是调用的时候),就已经确定了函数体内部自由变量的作用域。

4.1. 循环和闭包

下面是一个比较常见的面试题

for(var i = 0; i < 5; ++i){
    setTimeout(() => {
        console.log(i)
    }, i*100);
}

上面的代码本意是每隔100ms输出不同的i值,最后的输出结果却是5个相同的值:5。

如果从JavaScript事件队列的机制来解释,即先执行同步代码,再执行异步代码,定时器生效时访问到的i变量的值已经变成了5,因此最后输出的都是5。

但是究竟是什么导致代码的行为与语义上所暗示的不一样呢?因为我们试图假设循环中每个迭代在运行时都会给自己“捕获”一个i的副本,但根据作用域的原理,i变量会进行声明提升,因此实际上只有一个i,且被封闭在与for同级的全局作用域中。这样,即使定时器使用了5个闭包函数,他们共享的是同一个i变量,所以最后输出的值相同。

换句话说,要解决上面的问题,我们需要在每次循环中都生成一个新的闭包作用域,然后在作用域中保存每次循环不同的i值,这样保证每个闭包访问到不同的变量。

可以使用下面几种方式来解决这个问题

// 通过IIFE来创建新的作用域
// 因为变量提升只会在提升到当前作用域的顶部,因此可以使用一个变量来保存每次循环中的值
for (var i = 0; i < 5; ++i) {
    (function () {
        var j = i
        setTimeout(() => {
            console.log(j)
        }, j * 100);
    })()
}
// 与上面这种方式基本相同,通过形参保留
for(var i = 0; i < 5; ++i){
    (function(i){
        setTimeout(() => {
            console.log(i)
        }, i * 100);
    })(i)
}
// 通过let在每次循环时重新声明i
// 既然上面的问题是由于变量声明提升导致共享一个变量导致的,使用let在块级作用域中保存每次循环的值更加简单
for(let i = 0; i < 5; ++i){
    setTimeout(() => {
        console.log(i)
    }, i*100);
}

4.2. 匿名函数中的this

JS中的匿名函数的执行环境具有全局性,所以其this通常指向window(这是书上的原话,且貌似是ES3时代的故意设计,具体看这里,暂时没有深究。)。在闭包函数中调用this就可能出现问题,比如下面这个例子:

var o = {};
function out(){
  var x = (function(){
    return this;
  })();
  return x;
}
o.f = out;
alert(o.f());//object Window 而不是 o

4.3. 闭包的应用

通过函数作用域封装内部变量和数据,然后通过闭包暴露相关接口,闭包可以访问内部变量,而其他地方无法访问,这样就可以封装模块,模块有两个主要特征:

  • 为创建内部作用域而调用了一个包装函数;
  • 包装函数的返回 值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包

通过闭包封装模块,并提供访问私有变量的模块,这是闭包很常用的使用场景。

闭包也用于在对象中创建私有变量。对于在构造函数中定义的所有变量和方法,并不是公有属性和方法,因此无法被实例对象所访问,可以通过闭包的方式访问这些变量,并对他们进行操作。

function Bird(){
  var num = 0;
  function fin(){
    alert("private function");
  }

  this.foo = function(){
    num++;
    fin();
  }
}
var o = new Bird();
o.foo()

4.4. 垃圾回收和闭包

正常情况下当函数执行完毕,函数的整个内部作用域都被销毁,垃圾回收机制会回收不被使用的内存空间。

但是如果在函数执行时产生了闭包,情况就有所不同了,由于闭包的[[scope]]属性保留了对于当前上下文作用域链的引用,因此当前函数执行完毕,其运行上下文产生的变量对象也不会被回收(如果跟普通的函数一样调用结束就被销毁,则就无法找到其中的数据内容了)。

JavaScript的垃圾回收机制这篇文章中提到,目前浏览器基本都是基于“标记-清除”的方式进行垃圾回收,对于无法访问到的变量,则会进行清除,因此闭包实际上并不会造成内存泄漏。

不过,滥用闭包可能会导致一些无用变量占用的内存无法及时得到释放,可能影响应用的性能,因此在使用时也需要斟酌一番。

5. 小结

本文主要介绍了JavaScript中的作用域

  • 首先从作用域的概念开始,了解JavaScript中使用的词法作用域
  • 然后了解JavaScript中三种具体的作用域:全局作用域、函数作用域和块级作用域,并给出了作用域的理解
  • 从函数的声明、运行到执行完毕的流程中,了解每个函数运行时的一些细节,通过自有变量的查询理解作用域链
  • 最后从函数定义时的[[scope]]作用域链来理解闭包,以及闭包常见的一些问题

此外本文还进行了一些扩展,如this的推算规则、形参传值等,由于篇幅有限,并没有过多深入介绍。

从业三年,写过很多行JavaScript代码,现在基本上已经不会再犯语法上的错误了,但每次复习基础知识,都会有更深的理解,这大概就是“温故而知新”吧~