面试官:讲讲作用域和闭包吧

541 阅读9分钟

作用域

浅显的理解: 作用域就是变量的可用范围 ​

为什么要有作用域: 目的是防止不同范围的变量之间互相干扰 ​

JS 中包括 2级 作用域

  1. 全局作用域

    1. 不属于任何函数的外部范围称为全局作用域
    2. 保存在全局作用域的变量称为全局变量,创建后一直存在,除非手动删除
    3. 任何作用域内都可以访问到全局变量(当然,能否正常访问到另说。比如读取之前还未定义的 let const class 的变量就会在运行时抛错,不允许在设定初始值之前读取)
  2. 函数作用域

    1. 一个函数内的范围称为函数作用域
    2. 保存在函数作用域内的变量称为局部变量形参也是函数内的局部变量

函数作用域其实是 JS 引擎在调用函数时才临时创建的一个作用域对象。这个对象中保存函数的局部变量,函数调用完,函数作用域对象就释放了。

全局变量的特点:可反复使用但容易造成全局污染

var a = 10;
function fun1() {
	a = 100; // 在 fun1 中可以访问
}

function fun2() {
	a = 3 // 在 fun2 中也可以访问
}
fun1();
console.log(a) // 100
a = 111;
console.log(a) // 111
fun2();
console.log(a) // 3

局部变量的特点:不会被外部变量影响造成污染 但是无法反复使用(我想维持住一个变量,但是又不想被外部变量访问到时无法实现)

var total = 10;
function pay (m) {
	var total = 100;
  total -= m
  console.log(total)
}
fun1(10) // 90
fun1(10) // 90。 第二次调用仍是返回 90
console.log(total); // 10。 外部的 total 与 pay 内部的 toal 互不影响

只有函数的 { },才能形成作用域

  • 但也不是所有的 { } 都能形成作用域
  • 也不是所有的 { } 内的数据都能是局部变量
var xiaoming = { // 比如这个对象的 { } 就不是作用域
  name: 'xiao ming' // 对象中的属性,也不是局部变量
} 
if (false) {
  var a = 10
}
​
console.log(a) // 会输出 undefined 而不是抛错 Uncaught ReferenceError: a is not defined
​
{
  var b = 1
}
console.log(b) // 同样会输出 undefined

块级作用域

这里值得注意的是,ES6 中的 const、let 会在 { } 中形成一个“块级作用域”,如下:

{
  let x = 0
}

console.log(x) // Uncaught ReferenceError: x is not defined

但其实这并不是真正的块级作用域。

有两种说法:

  1. 这是因为新增的语法定义的变量会有一个单独的词法环境保存。
  2. 这其实是JS在let时使用了一个闭包来保存这个变量与其执行程序。

我个人而言可能会更加倾向于第一种

作用域链

作用域链是什么:每个函数在定义时就已经规划好了自己专属的一个查找变量的路线图,称为作用域链

作用域链如何形成: 一个函数可用的所有作用域串联起来,就形成了当前函数的作用域链

函数内部在查找一个变量时是由内向外查找的,一旦在某个作用域中查找到了自己想要到变量,则停止查找,并获取该变量的值。 特别的是,在函数的整个作用域链中没有找到想要的变量,但存在赋值行为,则会在全局创建该变量。

// 3. 在全局作用域中仍没有查找到 y,但因为存在赋值关系,会创建 y。 var y
  function f1() {
    const x = 1             // 在 f1 作用域内查找 y 也没有找到
    function f2() {
      y = 2                 // 1. 在 f2 作用域内查找 y 没有找到
    }
    f2()
  }
  // console.log(y)         // 抛错 Uncaught ReferenceError: y is not defined
  f1()
  console.log(y)            // 2

JS 中,作用域和作用域链都是对象结构,👇

作用域是对象.png

总结一下作用域对象查找规则:

  1. 由内部作用域向外部作用域查找
  2. 如果在某一个作用域对象中找到了该数据,则返回该数据并结束查找;
  3. 如果是访问并且一直找到顶级作用域仍找不到,则抛错 Uncaught ReferenceError: y is not defined
  4. 如果是赋值,并且一直找到顶级作用域仍找不到,则在顶级作用域下创建该变量,并返回赋值

闭包

什么是闭包

闭包就是一个对象,闭包是每次调用外层函数时,临时创建的函数作用域对象。之所以外层函数作用于对象能保留下来是因为被内层函数对象的作用域引用者,无法释放。 ​

闭包的使用场景是:使一个函数保存一个既可反复使用,又不会被外界污染的专属局部变量时,就用闭包 使用闭包三步曲:

  1. 用外层函数包裹要保护的变量和使用变量的内层函数
  2. 在外层函数内部返回内层函数对象
  3. 调用外层函数将结果(返回的内层函数对象)赋值给一个变量
function mother() {                // 1. 外层函数包裹内部变量与函数
  let total = 1000                  // 1.1 内部变量
  return function (money) {        // 2. 返回内部函数
    total -= money                 // 1.2 内部函数
    console.log(`用了${money}元,还剩${total}元`)
  }
}
​
const pay = mother()               // 3. 调用外层函数将返回的内层函数赋值给变量 pay
pay(10) // 用了10元,还剩990元

闭包实现的原理

我们还是用上面的这段代码。 JS 底层执行在遇到 function时会将其翻译成 new Function然后将其赋值给 mother变量,我们上图为证(为了能更直观的看到使用了a

js会翻译function.png

我们用图来说明mother这整段代码的执行过程

  1. mother 定义时

yuque_diagram.jpg

  1. mother 调用时,因为是函数对象,所以临时创建了一个函数作用域对象,然后将函数内部的变量添加到这个临时的函数作用域对象内

2.jpg

  1. mother 调用时2,这一步是到了给 total 赋值total = 1000,于是临时函数作用域对象内的 total就变成了 1000

3.jpg

  1. mother 返回时,首先遇到的是return,标志着这个函数即将结束,在函数 mother 的作用域中创建一个内部的返回值Return value它的值为 undefined

4.jpg

  1. 但是因为 return 后面还有一个 function ,所以在 return 之前还需要先创建一个函数对象(假如它的内存地址为0x2136),这个函数对象没有name 是一个匿名函数,其作为一个返回值,Return value拿到这个匿名函数的引用作为即将返回的值

5.jpg

在下图中,我们可以看到这个被返回的匿名函数的 [[Scopes]]很有趣,它是一个数组的结构,并且里面第 0 位的内容是 Closure(mother)这个对象内包含着 total: 1000,这预示着我们将来想要在这个函数中想访问某个变量,会在这个 [[Scopes]] 从 0 开始找起,一直找到顶级作用域 Global(window)。

5.5.png

  1. mother 执行结束并返回

6.jpg 首先 mother 函数已经执行完毕了这时候将 mother 与其分配的临时作用域断开,但这个临时的作用域中还有变量被那个返回的内层的函数所引用着,所以它还不能销毁(注意:没有被引用的变量将不再存在在这个临时作用域中) ​

  1. 变量 pay 调用时

7.jpg

我们以运行时的作用域对象佐证上面的这张图

7.7.png

我们在第二次调用时,total就变成了 990 。这样,我们的 total 就只能被 pay 函数调用时内部所使用,外部无法访问到 pay 函数内到 total 变量,这样就保证了我们的数据安全无污染,即解决了全局变量数据污染的问题,也解决了函数中的局部变量数据不能重用的问题。

由于闭包藏很很深,几乎找不到,所以会造成内存泄漏,为了防止内存泄漏,我们只需要将不需要使用的闭包及时释放即可——将保存的内层函数对象的变量赋值为 null。

function mother() {
  let total = 1000
  return function(money) {
    total -= money
    console.log(`用来${money}元,还剩${total}元`)
  }
}
let pay = mother()
pay(10)
pay = null // 不用了将其释放

总结:

作用域 : 第一种是全局作用域。 不属于任何函数的外部范围称为全局作用域。其中全局作用域下 var声明的变量和function定义的函数都将挂载在 window 对象上。任何作用域都可以访问到全局作用域中的变量(注意let const class 方式设定初始值之前访问会抛错),并且全局作用域中的变量一旦定义,一般情况下都将一直存在(直到宿主环境关闭)。 全局作用域中定义的变量被称为全局变量。全局变量的优点是可以反复使用,缺点是会存在全局污染。 ​

第二种是函数作用域,一个函数内的范围称为函数作用域(注意,箭头函数不算)。保存在函数作用域内的变量称为局部变量。形参也是函数内的局部变量。 在函数中定义的变量为称为局部变量。局部变量的优点是不会存在污染,缺点是无法重复使用。

作用域链: 每个函数在定义时就已经规划好了自己专属的一个查找变量的路线图,称为作用域链。一个函数可用的所有作用域串联起来,就形成了当前函数的作用域链

作用域链查找规则:

  1. 由内部作用域向外部作用域查找
  2. 如果在某一个作用域对象中找到了该数据,则返回该数据并结束查找;
  3. 如果是访问并且一直找到顶级作用域仍找不到,则抛错 Uncaught ReferenceError: a is not defined
  4. 如果是赋值,并且一直找到顶级作用域仍找不到,则在顶级作用域下创建该变量,并返回赋值

闭包: 在需要使一个函数保存一个既可反复使用,又不会被外界污染的专属局部变量的情况下,就要用闭包。

闭包就是一个对象,闭包是每次调用外层函数时,临时创建的函数作用域对象。之所以外层函数作用于对象能保留下来是因为被内层函数对象的作用域引用者,无法释放。

闭包使用的三步骤:

  1. 用外层函数包裹要保护的变量和使用变量的内层函数
  2. 在外层函数内部返回内层函数对象
  3. 调用外层函数将结果(返回的内层函数对象)赋值给一个变量

将保存的内层函数对象的变量赋值为 null 就可以解决内存泄漏的问题。