- 作者:老汪软件技巧
- 发表时间:2024-09-05 15:01
- 浏览量:
从JavaScript执行原理到作用域与闭包
闭包是JavaScript中非常重要的一个概念,MDN中提到闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合。换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在 JavaScript 中,闭包会随着函数的创建而被同时创建。看起来非常晦涩难懂,不易理解。今天写下这篇文章就是想从原理上深入探索,彻底弄明白什么是闭包。
JavaScript执行三阶段与变量提升
先来看看这段代代码,明明console.log(a)在声明赋值之前,却没有出现语法错误而终止执行,JavaScript作为一种解释性语言,它并不只是简单的读取一行代码就执行一行代码。
console.log(a) // 输出结果 undefined
var a = '赋值成功'
console.log(a) // 输出结果 赋值成功
整个JavaScript的执行过程,可以简单归纳为三个步骤:语法检查、预编译、执行
graph LR
语法检查 --> 预编译 --> 执行
语法检查阶段
当JavaScript代码被加载到浏览器环境或Node.js环境中时,首先进行语法检查,检查代码是否符合 JavaScript 的语法规则,比如括号是否正确闭合,发现并报告语法错误,阻止代码的进一步执行
预编译阶段
预编译是发生在函数执行的前一刻,或全局代码执行前,这里值得注意⚠️全局代码执行前和每个函数执行前都会经历预编译阶段,而不仅仅是只执行一次全局预编译。预编译阶段JavaScript引擎会创建函数作用域,分配变量、函数名以及参数等内存空间,并赋予默认值,此阶段涉及变量提升和函数提升。到这里可以解释上面的代码为什么输出会是undefined了。这个阶段的主要任务如下:
看看这段代码,函数和var声明的变量被提升了,let和const声明的变量无法被提升:
console.log(a) // 输出undefined
console.log(b) // 输出[Function: b]
// console.log(c) // 语法错误
// console.log(d) // 语法错误
var a = 1
function b() {
console.log('函数执行了')
}
// let c = 2
// const d = 3
执行阶段
此阶段JavaScript引擎会按照代码的顺序,逐行解释执行代码,完成代码的实际功能,使用var、let、const声明的变量将被赋予实际的值
GO、AO与作用域链
搞懂了JavaScript执行的三个阶段,接下来我们来看看作用域是怎么确定的。
GO与AO全局对象GO
全局对象GO(Global Object) 是在全局作用域中自动创建的,它代表了全局作用域。在浏览器中是window对象;在Node.js中是global对象。它包含了全局变量、内置函数和用户定义的全局变量和函数。它在代码执行之前就已经存在,其确定主要分为以下3个步骤:
创建GO对象找变量声明,将变量声明作为GO对象的属性名,值赋予undefined找全局里的函数声明 ,将函数名作为GO对象的属性名,值赋予函数体
举个例子:
console.log(a) // output:[Function: a]
var a = 1
console.log(a) // output:1
function a(){}
// a() // 这里a已经是数字型,出现语法错误
console.log(a) // output:1
这段代码中GO的创建和代码执行的过程是这样的:
graph LR
创建GO --> 变量声明a:undefined --> 函数声明a:function --> 输出a --> 赋值a:1 --> 输出1 --> 再次输出1
激活对象AO
激活对象AO(Activation Object) 是在函数被调用时创建的,它代表了函数作用域。它包含了函数的局部变量、参数、this关键字的引用以及函数内部声明的函数。当一个函数被调用时,一个新的执行上下文被创建,其中包括一个新的AO。这个AO会随着函数的执行而存在,函数执行完毕后,AO通常会被销毁,除非函数形成了闭包。其确定主要分为以下4个步骤:
创建AO对象到函数体作用域里找形参和变量声明,将形参和变量声明作为AO对象的属性名,值为undefined将实参赋值给形参在函数体内找函数声明,将函数名作为AO对象的属性名,值赋予函数体
举个例子:
function func(a) {
console.log(a) // output:[Function: a]
var a = 1
console.log(a) // output:1
function a() {}
console.log(a) // output:1
var b = function () {} //函数表达式
console.log(b) // output:[Function: b]
function c() {}
var c = a
console.log(c) // output:1
}
func(2)
AO的创建和代码执行的过程是这样的:
AO、GO和函数代码的执行总顺序
在这里,我们可以理解为JavaScript在全局代码预编译阶段创建GO,在函数调用后、函数内部代码执行前创建AO,执行完毕后决定销毁AO与否。整个流程是这样的:
graph LR
创建GO --> 函数调用 --> 创建AO --> 执行函数代码 --> 销毁AO
这里AO的创建晚于GO,也就是说AO中的新值,在其作用域内会覆盖掉GO
作用域链
从上面的学习,我们知道,对于每个函数,JavaScript引擎会创建一个与之关联的作用域对象AO,这个作用域对象包含函数内部声明的所有局部变量、函数参数以及声明的变量(⚠️注意:let和const声明的变量具有块级作用域,而var声明的变量具有函数级作用域,这里只拿var做分析)
此外,JavaScript引擎还会构建一个作用域链(Scope Chain),它是一个从当前作用域向上查找父级作用域的链表。当在函数内部访问一个变量时,JavaScript会首先在当前作用域中查找,如果找不到,则继续向上在作用域链中查找,直到找到全局作用域。我们可以把作用域链理解为一个保存了当前函数AO、父级函数AO和GO对象的链表。
graph LR
当前函数AO --> 父级函数AO --> GO
我们直接看图,这里展示的是test2执行前一刻的作用域链:
函数的作用域链可以干成一个数组,自身AO排在数组的最前面,越往下这是父级函数的AO,直到最后到达全局对象GO。
步入正题——闭包闭包到底是什么
说白了,闭包就是一个内部函数访问了它外部函数的变量。闭包会创建一个包含外部函数作用域变量的环境,并将其保存在内存中,这意味着,即使外部函数已经执行完毕,闭包仍然可以访问和使用外部函数的变量。
看图更容易理解,还是这个图,当test1()被执行完后,其AO对象本该被销毁,但其还在test2的作用域链上,可以被test2操控,形成闭包了,不会被销毁。
来段代码看看,这里,我们通过返回的add和reduce方法操作了calculateOne的局部变量num,形成闭包:
// 函数定义
function calculateOne(num) {
var n = num
// 自加
function add() {
n++
console.log(n)
}
// 自减
function reduce() {
n--
console.log(n)
}
return {
add: add,
reduce: reduce,
}
}
// 代码执行
var test = calculateOne(100)
test.add() // 101
test.add() // 102
test.reduce() // 101
可见,我们常用的定时器、事件监听器、Ajax请求这些都是闭包,它们使用了回调函数访问了外部作用域。
闭包的优缺点闭包的优点
变量封装和隐私保护JavaScript本身不支持变量私有化,通过闭包可以将变量封装在函数内部,避免全局污染,保护变量不被外部访问和修改。
变量生命周期延长闭包让函数内部的变量在函数执行完后仍然存在,可以在函数外部继续使用。
实现模块化闭包可以创建私有变量和私有方法,通过返回函数来暴露接口,隐藏内部实现细节,实现模块化的封装和隐藏,提高代码的可维护性和安全性。
保持状态闭包可以捕获外部函数的变量,并在函数执行时保持其状态。在事件处理、回调函数等场景中经常用到。
柯里化通过闭包实现函数柯里化,创建函数更加灵活。
闭包带来的问题
内存占用:闭包会导致外部函数的变量无法被垃圾回收,从而增加内存占用。如果滥用闭包,会导致内存泄漏问题。
性能损耗:闭包涉及到作用域链的查找过程,会带来一定的性能损耗。
增加代码的复杂性和调试难度闭包内部可以访问外部作用域的变量,这可能会增加代码复杂度,使得代码的调试变得更加困难,特别是在大型项目中,需要仔细跟踪变量的作用域和生命周期。
闭包的常见应用场景
结合自动执行函数使用: 结合使用可以用来创建私有变量、私有函数、实现模块化、配置对象等,比如下面实现了模块的封装:
var myModule = (function () {
var privateVar = 'Private data'
function privateFunction() {
console.log('Private function called')
}
return {
publicFunction: function () {
privateFunction()
return privateVar
},
}
})()
console.log(myModule.publicFunction())
// output:
// Private function called
// Private data
柯里化(Currying) 柯里化是一种函数的转换,它是指将一个函数从可调用的f(a, b, c)转换为可调用的f(a)(b)(c)。柯里化不会调用函数。它只是对函数进行转换。
function curry(f) {
// 柯里化转换
return function (a) {
return function (b) {
return f(a, b)
}
}
}
// 使用
function sum(a, b) {
return a + b
}
let curriedSum = curry(sum)
console.log(curriedSum(1)(2)) // 3
节流和防抖
// 节流
function throttle(func, delay) {
let timer = null
return function () {
if (!timer) {
timer = setTimeout(() => {
func.apply(this, arguments)
timer = null
}, delay)
}
}
}
// 防抖
function debounce(func, delay) {
let timer = null
return function () {
clearTimeout(timer)
timer = setTimeout(() => {
func.apply(this, arguments)
}, delay)
}
}
另外,闭包在事件回调处理、迭代器封装、发布订阅者模式等都有着广泛的应用。
解除闭包
闭包在不需要的时候需要解除,否则会占用内存,影响性能,通常我们可以通过解除引用的方法来解除闭包:
// 函数定义
function calculateOne(num) {
var n = num
// 自加
function add() {
n++
console.log(n)
}
// 自减
function reduce() {
n--
console.log(n)
}
return {
add: add,
reduce: reduce,
}
}
// 代码执行
var test = calculateOne(100)
test.add() // 101
test.add() // 102
test.reduce() // 101
// 解除闭包
test = null
console.log(test) // null