javascript - 闭包

闭包的概念

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。

也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

痛点

JS 的作用域分为函数作用域,全局作用域,块作用域。因 JS 的特殊性,在函数作用域内,外层作用域是无法获取到函数内的变量和函数的:

1
2
3
4
5
6
function result() {
var count = 0;
console.log(count)
}
// 无法访问 result 函数中的 count
console.log(count) // ReferenceError

解决方案

假如现在希望在函数外访问 result 函数内的 count 呢?可以做到吗?

可以!把代码改为如下形式就可以了

1
2
3
4
5
6
7
8
9
10
11
function result() {
var count = 0;
function getCount() {
console.log(count)
return count;
}
return getCount
}

var getCount = result()
getCount() // 0

上述代码,(count变量 + getCount函数)就形成了一个闭包,使得外层作用域可以通过某种形式进行读取

实际场景

  • 面向对象
    在 ES6 之前,JS 都没有“类”的语法的,导致很难写出面向对象的模式,但是,勉强可以借助闭包来实现:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    function Student() {
    var name = ''
    var age = 0
    function getName() {
    return name
    }
    function setName(newName) {
    name = newName
    }
    function sayHi() {
    return 'hi, ' + name
    }

    return {
    getName: getName,
    setName: setName,
    sayHi: sayHi
    }
    }

    var stu1 = Student()
    var stu2 = Student()
    stu1.setName('yigger01')
    stu2.setName('yigger02')
    console.log(stu1.sayHi()) // hi, yigger01
    console.log(stu2.sayHi()) // hi, yigger02

虽然并不是特别严谨,但总算是达到了隐藏变量和方法的效果,通过这样的“封装”,外部无法直接读取/写入 name 变量,只能通过暴露出来的方法进行变更。

另外,值得一提的是,在一些编程语言中,一个函数中的局部变量仅存在于此函数的执行期间。一旦 setName() 执行完毕,你可能会认为 name 变量将不能再被访问。然而,因为代码仍按预期运行,所以在 JavaScript 中情况显然与此不同。

闭包的循环“陷阱”

其实也称不上是陷阱,如果不了解闭包特性的人,踩坑一点也不奇怪,早期用 JQ 一顿写代码的时候也经常遇到过,直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function caculate() {
var nums = ['one', 'two', 'three'];

for (var i = 0; i < nums.length; i++) {
setTimeout(function() {
console.log(i, nums[i])
}, 100)
}
}

caculate()
// 输出:
// 3, undefined
// 3, undefined
// 3, undefined

原因是什么?

因为 i 是用 var 声明的,由于变量的提升,实际上 i 是属于 caculate 的函数作用域,以上代码等同于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function caculate() {
var nums = ['one', 'two', 'three'];
var i
for (i = 0; i < nums.length; i++) {
setTimeout(function() {
console.log(i, nums[i])
}, 100)
}
}

caculate()

// 实际调用栈:
1 次循环:
i = 0, 由于 setTimeout,挂起执行`console.log(i, nums[i]`
2 次循环:
i = 1, 由于 setTimeout,挂起执行`console.log(i, nums[i]`
3 次循环:
i = 2, 由于 setTimeout,挂起执行`console.log(i, nums[i]`
// 执行完最后一次循环后,i++ ----> i = 3

由于以上代码是立即执行的,所以一瞬间就完成了,可以假设 需要 1ms 的时间

... 随后 cpu 喝了杯咖啡,过去了 99ms ...

随后开始执行定时器中的代码

由于闭包的特性,函数执行完成以后还保留着 i 的引用,而此时的 i = 3

所以,最后实际执行的结果:
console.log(3, nums[3]) // 3, undefined

解决方案

  • 方案 1:使用 let 关键字

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    function caculate() {
    var nums = ['one', 'two', 'three'];
    for (var i = 0; i < nums.length; i++) {
    let j = i // let 不会存在变量提升
    setTimeout(function() {
    console.log(j, nums[j])
    }, 100)
    }
    }
    caculate()
  • 方案 2:消除对 i 的引用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    function caculate() {
    var nums = ['one', 'two', 'three'];
    for (var i = 0; i < nums.length; i++) {
    // 匿名作用域,j 是 i 的复制,不是引用
    (function(j) {
    setTimeout(function() {
    console.log(j, nums[j])
    }, 100)
    })(i)
    }
    }
    caculate();

方案还有挺多,由于 es6 的基本都兼容主流浏览器了,一般都会写成方案 1

1
2
3
4
5
for (let i = 0; i < nums.length; i++) {
setTimeout(function() {
console.log(i, nums[i])
}, 100)
}

总结

  1. 闭包的形成条件:内部函数引用外部函数的变量/参数
  2. 闭包的结果:内部函数的使用外部函数的那些变量和参数仍然会保存
  3. 返回的函数并非孤立的函数,而是连同周围的环境打了一个包,成了一个封闭的环境包,共同返回出来 —-> 闭包
  4. 函数的作用域,取决于声明时而不取决于调用时

参考资料:
https://juejin.cn/post/6844903777808416776
https://www.cnblogs.com/rubylouvre/p/3345294.html
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Closures
https://segmentfault.com/a/1190000003818163

javascript - 作用域与变量提升

函数作用域

函数作用域还是比较简单,但凡写过 JS 的小伙伴也知道下面的示例代码为什么会输出这种结果,因为变量 count 是在函数作用域内定义的,那么 count 的作用范围则仅在 myFunction 内生效,所以外部是无法访问到 count 变量的。

1
2
3
4
5
6
7
function myFunction() {
var count = 1;
count++;
console.log(count); // 2
}
myFunction();
console.log(count); // a is not defined

块作用域

对于后端的开发者来说,特别是用 ruby 语言的,块作用域应该是用得比较多,因为在 ruby 中,可以往函数传递一个块来进行执行。但是 js 就 相对来说没这么复杂,甚至还很少显式的用到块作用域。

1
2
3
4
5
6
7
var right = true
if (right) {
var count = 0;
count ++;
console.log(count); // 1
}
console.log(count); // ?

对于 ? 处会打印什么?在我第一次看到这段代码的时候,我也会直觉性的认为他会打印 count is undefined,因为我认为 count 的包含在 if 的块作用域内的,然而事实是 ? 处的 count 也打印了 1,问题在于我忽略了变量提升这茬事了,稍后也会描述到,现在可以简单理解为在 if 块内用 var 声明的变量,最终也会等同于在 if 外层声明

let

基于上述的块作用域提到的示例代码片段,产生了与预期不一致的行为 – 预期是 count 仅在 if 块内可用,如何才能产生预期的情况?很简单,把 var 改为 let 即可。

let 是 es6 引入的新的关键字,提供了除 var 以外的另一种变量声明的方式,let 最有用的地方在于声明的变量仅在块内起作用

再次执行上述代码,发现与预期情况一致了:count 仅在 if 作用域内生效

1
2
3
4
5
6
7
var right = true
if (right) {
let count = 0;
count ++;
console.log(count); // 1
}
console.log(count); // Uncaught ReferenceError: count is not defined

除此以外 ,也可以 显示的定义块作用域,如下代码也能产生预期效果:

1
2
3
4
5
6
7
8
9
var right = true
if (right) {
{
let count = 0;
count ++;
console.log(count); // 1
}
console.log(count); // Uncaught ReferenceError: count is not defined
}

const

除了 let 的声明 ,const 也是 es6 引入的新的变量声明方式,最初学习的时候,我认为用 const 方式声明的变量后,随后就不能被改动了。但慢慢我认识到这是错误的。const 实际保证的,不是变量的值不能改变,而是变量指向的内存地址不能改动。因此,在使用复合对象类型的时候需要尤其注意,比如对象和数组
示例片段一:

1
2
3
const count = 0
count++ // count 不可变,报错
console.log(count)

示例片段二:

1
2
3
4
5
6
const arr = [0]
arr.push(1)
console.log(arr) // [0, 1]

const arr1 = [0]
arr1 = [2,3] // 报错

片段三:

1
2
3
4
5
6
const stu = {
name: 'yigger',
sex: 1
}
stu.age = 18
console.log(stu) // {name: "yigger", sex: 1, age: 18}

片段四:

1
2
3
4
5
6
7
8
9
10
const stu = {
name: 'yigger',
sex: 1
}
stu.age = 18

// 无法重新赋值,报错
stu = {
site: 'yigger.cn'
}

try/catch

另外,值得注意的是,try/catch 也会创建一个块作用域,其中声明的变量仅在 catch 内部有效。

1
2
3
4
5
6
try {
null();
} catch (e) {
console.log(e) // TypeError: null is not a function
}
console.log(e) // Uncaught ReferenceError: e is not defined

变量提升

在最初写 JS 代码的时候,以下代码让我感到非常困惑,我还一度误以为 JS 是自带多线程执行代码的,否则怎么可以在变量/函数声明之前进行调用呢? 后来才慢慢了解到,原来 JS 有变量提升的机制。

1
2
3
4
5
6
7
sayHi() // hi, yigger
console.log(myName) // undefined

var myName = 'yigger'
function sayHi() {
console.log('hi, yigger')
}

首先,JavaScript是单线程语言,所以执行肯定是按顺序执行。但是并不是逐行的分析和执行,而是一段一段地分析执行,会先进行编译阶段然后才是执行阶段。

在编译阶段阶段,代码真正执行前的几毫秒,会检测到所有的变量和函数声明,所有这些函数和变量声明都被添加到名为Lexical Environment的JavaScript数据结构内的内存中,所以这些变量和函数能在它们真正被声明之前使用。

  • var 变量提升
1
2
3
4
5
6
7
8
9
10
console.log(myName) // undefined
var myName = 'yigger'
console.log(myName) // yigger

// 由于变量提升,以上代码等同于

var myName;
console.log(myName) // undefined
myName = 'yigger'
console.log(myName) // yigger

代码这么写就清晰了许多,具体原因是当 JavaScript 在编译阶段会找到 var 关键字声明的变量会添加到词法环境中,并初始化一个值 undefined,在之后执行代码到赋值语句时,会把值赋值到这个变量

  • let/const 的声明会变量提升吗?
    答案是会的,事实上所有的声明(function, var, let, const, class)都会被“提升”,那你可能会问 ,既然会提升,下述代码理应会输出 undefined,而不是 error
1
2
console.log(myName) // Uncaught ReferenceError: Cannot access 'myName' before initialization
let myName = 'yigger'

这是因为在编译阶段,JavaScript引擎遇到变量 myName 并将它存到词法环境中,但因为使用 let 关键字声明的,JavaScript引擎并不会为它初始化值(而 var 会初始为 undefined),所以在编译阶段,此刻的词法环境像这样:

1
2
3
lexicalEnvironment = {
myName: <uninitialized>
}

如果我们要在变量声明之前使用变量,JavaScript引擎会从词法环境中获取变量的值,但是变量此时还是uninitialized状态,所以会返回一个错误ReferenceError。

完。

参考链接: