JavaScript 的作用域和闭包

JavaScript 的作用域和闭包的杂谈。

作用域

作用域(scope)是将名字和实体进行绑定,可以通过名字访问实体的一个机制。按照规定的语法,我们可以创建作用域,并在上面定义一些名字及它们对应的实体(比如变量、函数等),在这个作用域的代码可以通过定义的名字访问到对应的实体。

js 使用的作用域是 词法作用域,也称静态作用域。查找变量的时候,会在当前作用域查找,如果没找到,再往上一层作用域查找,直到找到或到达到最高层的全局作用域时结束。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
var a = 'A'

function printABC() {
  var b = 'B'
  function print(c) {
    console.log(a, b, c)
  }
  print('C')
}

printABC()
// A B C

在 print 函数内,可以访问到 print 函数作用域的形参 c,printABC 函数作用域中定义的变量 b,以及全局作用域的 a。这里形成了一个 printC 作用域 -> printABC 函数作用域 -> 全局作用域 的作用域嵌套。 printC 里的代码如果要用到某个变量,就会从里往外一个个查找该变量。

词法作用域是在声明时确定的

词法作用域是在声明时确定的,而不是在执行时确认。我们看看下面的 js 的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var a = 'outer'
function printA() {
  var a = 'inner'
  realPrintA()
}

function realPrintA() {
  console.log(a)
}

printA()

如果对作用域的绑定行为不了解,可能根据直觉认为输出结果是 outer。但事实上,正确答案是 inner。该结果验证了 js 的作用域是语法作用域,即作用域是在函数定义时确定的,和执行无关。realPrintA 函数在全局作用域下定义,所以 printA 函数中的代码能访问的作用域就只有两个:realPrintA 函数作用域 和 全局作用域。printA 函数作用域下的 a 标识符无法被其访问。于是我们最终拿到的是全局作用域下的 a 标识符绑定的字符串值 ‘outer’。

和词法作用域相对的是 静态作用域,它的作用域只有一个,会在程序执行的过程中发生变化。我们常用的 Bash 脚本语言用的就是静态作用域。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/bin/bash

a="outer"
function printA() {
  a="inner" # 或 local a="inner"
  realPrintA
}

function realPrintA() {
  echo $a
}

printA

将这段代码拷贝到一个文件中,然后在类 unix 系统的终端下输入 bash 文件名 并回车,就能得到运行结果。和 js 不同,输出结果为 inner。Bash 只会维护一个作用域,执行 a="inner" 其实修改的是全局的 a,并没有创建一个新的作用域。值得一提的是,Bash 在函数中可以通过 local 声明局部变量,但依旧没有创建新作用域,你可以看作只是临时替换掉全局的变量,并会在函数执行完后恢复为原来的值。

提升

提升是老生常谈了,这里大致讲一下。

执行代码的时候,代码中的 变量声明函数声明 会优先执行。因为效果等价于将声明提升到作用域顶部,于是称为 提升(hoisting)。

执行一段 js 代码分为 编译器编译js 引擎解释 两个步骤。编译时的一个工作是扫描代码将所有的声明找出来并加入到当前的作用域中,这就是提升发生的根本原因。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
console.log(b)
var b = 2
printA()

function printA() {
  console.log('a')
}

// 输出结果为:
// undefined
// a

因为提升的原因,这段代码等价于:

1
2
3
4
5
6
7
8
function printA() {
  console.log('a')
}
var b // 此时值为 undefined

console.log(b)
b = 2
printA()

为什么要变量提升?

函数提升还能理解,但为什么变量也会发生提升呢?js 的作者 Brendan Eich 曾在推特 坦言了导致变量提升的原因:

  • 函数要提升
  • 不支持块作用域的特性
  • 开发 js 的时间很短(10天)

变量提升这个特性并不是作者想要的,是开发时间紧迫不得不做的妥协。好在 ES6 推出了 let/const 声明关键字,解决了 var 声明变量提升导致的一些奇怪行为。

var 的问题

关于 var 的一些反常规的行为。

  • 即使当前作用域下一个变量名已经存在,也能使用该 var 再次声明它而不报错(let/const 可以解决这个问题)。
1
2
3
var a = 1
var a = 2 // 这样写不会报错
console.log(a) // 输出:2
  • 如果被赋值的变量在所有的嵌套作用域中没有找到,会声明一个全局作用域的变量(这个其实不算是var 的问题,而是 js 的问题。暂时无法解决,只能小心地使用变量)
1
2
3
4
5
6
function setA() {
  a = 1
}

setA()
console.log(a) // 输出:1
  • 不能形成块作用域(会导致作用域污染)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
for (var i = 0; i < 3; i++) {
  console.log(i)
}

console.log('i:', i)

// 输出结果:
// 0
// 1
// 2
// i: 3

var 不支持块作用域,导致 i 污染了外部的作用域。解决方案是使用 let/const,它们可以形成块作用域。此外还可以使用立即执行函数表达式。

let 提升了吗?

let 的声明提升了,但和 var 不同的是,它被加入到了 TDZ(Temporal Dead Zone,临时死区)。如果一个变量处于 TDZ 中,它在被初始化(即第一次赋值)前,如果被尝试读取它的值,就会抛出 ReferenceError 错误。一些文章认为 let 没有发生提升,因为一眼看过去确实很像是这样,但这是错误的。我们看看下面的代码:

1
2
3
4
5
let x = 'outer';
(function() {
    console.log(x);
    let x = 'inner';
}());

如果我们认为 let 没有发生变量提升,那么在声明局部的 x 变量前,这个 x 应该是全局的,最终程序的输出结果应该为 outer。但实际运行这段代码抛出了 ReferenceError 错误。let 声明的变量提升到函数作用域的顶部,当尝试获取该值时,因为 TDZ 机制抛出了错误。

const 同理也发生了提升。

闭包

闭包(closure),是 函数和其关联的环境绑定 的一种组合,常见于函数是第一公民的编程语言。关联的环境不仅仅可以是函数作用域,也可以是全局作用域和块作用域,只是函数作用域更常见。可以说,声明函数的时候,闭包就发生了。

js 支持闭包。在 js 中,最为常见的利用通过在一个外部函数中返回一个函数,我们可以拿到了一个绑定了外部函数作用域的函数。即使这个函数在当前作用域之外调用,也能够拿到闭包时绑定的外部函数作用域。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
function createCounter() {
  let count = 0
  function addAndPrint() {
    count++
    console.log(count)
  }
  return addAndPrint
}

const counter = createCounter()
counter() // 1
counter() // 2

const otherCounter = createCounter()
otherCounter() // 1

可以看到,每次当执行 createCounter 时,都会拿到一个绑定了含有 count 变量的作用域的函数,类似面向对象语言中通过类创建实例。

闭包经常和立即执行函数表达式(IIFE)一起使用,创建含有一个唯有自己可以访问作用域的的唯一函数。防止多次点击按钮发送重复请求的闭包写法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const fetchData = (function () {
  let loading = false
  return function() {

    if (loading == true) return
    loading = true
    
    api_method()
      .then(res => {
        // 请求成功处理
      })
      .catch(err => {
        // 请求失败处理
      })
      .finally(() => {
        loading = false
      })
  }
})()

通过将 loading 放入 fetchData 函数的关联作用域中,防止被其他的代码错误修改。

闭包的作用

闭包的强大之处在于给函数绑定了一个私有的作用域,通过这个特性我们能做很多东西。

1. 创建具有私有属性的对象(模块模式)

模块模式,就是将变量和函数封装在一个作用域里的一种模式。通过闭包,我们可以创建拥有私有变量的对象,实现类似面向对象语言中类中私有属性的写法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
function createPoint() {
  let x = 0
  let y = 0
  return {
    printPos: function() {
      console.log({ x, y })
    },
    moveTo(tx, ty) {
      x = tx
      y = ty
    }
  }
}

const point = createPoint()
point.printPos() // { x: 0, y: 0 }
point.moveTo(3, 4)
point.printPos() // { x: 3, y: 4 }

2. 实现函数柯里化

柯里化的一种实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const curriedAdd = (function() {
  let addedNums = []
  function f() {
    if (arguments.length == 0) {
      const sum = addedNums.reduce((acc, cur) => acc + cur, 0)
      console.log(sum)
      return sum
    }
    addedNums.push(...arguments)
    return f
  }
  return f
})()

curriedAdd(1,5)(7)() // output: 13

一些面试题

1. 下面的代码输出为?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
var result = [];
var a = 3;
var total = 0;
function foo(a) {
  var i = 0
  for (; i < 3; i++) {
    result[i] = function() {
      total += i * a;
      console.log(total);
    }
  }
}

foo(1);
result[0]();
result[1]();
result[2]();

虽说在一般形式的闭包是在函数内返回一个函数,但只要把内部的函数传递出去了,也是可以的。这段代码中,我们在执行 foo(1) 时创建了三个函数,传入到一个数组中,需要注意的是,这三个函数引用的同一个词法环境。这个词法环境的局部变量有 a=1, i=3。依次执行三个函数,total 依次为 3, 6, 9。所以结果输出为:

1
2
3
3
6
9

1. 下面的代码会怎么输出?

这是一道很经典的面试题。

1
2
3
4
5
for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i)
  }, i * 1000)
}

该代码会每隔 1 秒输出一个 3。

这题主要考察了 var 声明的变量不支持块作用域的问题。执行玩循环后,因为块作用域不存在,产生了一个全局的 i,此时它的值为 i。 setTimeout 方法传入的回调函数,声明时绑定的外部作用域是全局作用域。定时器在指定时间执行回调函数的时候,会拿到全局作用域的 i 的值 3 并输出。

如果我们希望依次输出 0,1,2。那该如何做呢?一个最简单的方法是,使用支持块作用域的 let:

1
2
3
4
5
for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i)
  }, i * 1000)
}

另一个方式就是使用 IIFE 创建的函数作用域替代没能发挥作用的块作用域。我们只是希望能创建作用域,保存每次循环的 i 的值。既然块作用域不堪重用,我们何不使用函数作用域呢?

1
2
3
4
5
6
7
for (var i = 0; i < 3; i++) {
  (function (j) {
    setTimeout(() => {
      console.log(j)
    }, j * 1000)
  })(i)
}

参考