前言
大家好,我是 luckyCover,今天带来 JavaScript 三座大山中的闭包与作用域。在了解闭包前,我们得先弄清楚什么是作用域,以及作用域链的形成,理解后也就理解了闭包的底层机制了。
作用域
字面意思理解"作用域",“作用”泛指某个东西在环境内的影响,“域”指的是某个范围;那么"作用域"可以理解为某个东西在某个范围(环境)内产生的影响。
下面我们从代码层面来理解:
function func() {
let a = 1;
}
console.log(a); // 会报错 a 未定义
上边例子就可以体会到作用域的概念,变量 a 在全局作用域中没有声明,所以在全局作用域中取值会报错。
由此,我们可以这么理解:作用域就是一个独立的地盘,地盘内声明的变量不会外泄、暴露出去。也就是说作用域最大的作用就是隔离变量,不同作用域下同名变量不会冲突。
全局作用域
在代码的任何位置都能访问到的对象拥有全局作用域。具体如下:
最外层函数和最外层函数外面定义的变量拥有全局作用域
let a = 1
console.log(a); // 1 最外层函数外部
function func() {
console.log(a); // 1 最外层的函数
}
func()
- 所有
未定义直接赋值的变量自动声明为全局作用域
function func() {
a = 1; // 未定义直接赋值,自动声明为拥有全局作用域
}
console.log(a); // 1
- 所有
window 对象的属性拥有全局作用域,比如window.name,window.age等等
全局作用域的弊端:污染命名空间,容易引起命名冲突,试想一下你和你的同事分别负责编写自己的 js 文件,所有变量声明都放在全局作用域的情况下,是不是极可能出现变量命名相同,从而引发意外的错误。
补充:像 for、while、if、switch 语法后跟的花括号{},在里边声明的变量是不会创建一个新的作用域的,而是保留在它们已经存在的作用域中。如下:
if(true) {
// if 语句不会创建一个新的作用域
var name = 'Bill' // 声明变量依然保留在已经存在的作用域,也就是全局作用域
}
console.log(name); // Bill
块级作用域
ES6 引入了块级作用域,可以通过新增的 let 和 const 声明,所声明的变量在指定块的作用域外无法被访问。块作用域在如下情况被创建:
- 在函数内部
- 在一个代码块内(花括号
{}包裹内部)
let 和 const 声明的变量不会被提升到代码块顶部,你需要手动设置其在代码的位置
function func() {
console.log(a); // 报错
let a = 11; // 但如果是 var 声明的会提升,上面就会打印 undefined
}
let 和 const 禁止重复声明
function func() {
let a = 11;
let a = 11;
console.log(a); // 报错,a 重复定义了
}
函数作用域
函数作用域是指在函数内部声明的变量,局部作用域一般只在固定的代码片段内可以访问到,比如函数内部。
function func() {
let name = 'Bill'
return function func2() {
console.log(name)
}
}
console.log(name) // 报错
func()() // Bill
作用域链
自由变量
当前作用域没有定义的变量称为自由变量。
function func() {
let name = 'Bill'
function func2() {
console.log(name)
}
}
上面代码中 name 就是一个自由变量,不存在于 func2 的作用域中,于是向它的上级作用域 func 中寻找。
那么什么是作用域链呢?
如果当前作用域不存在我们要访问的变量,那么会向上级作用域寻找,一直找到全局作用域为止,这么一个寻找的过程,就称为作用域链。
自由变量的取值
要到创建函数时的“域”中去取值,而不是调用时,这也是所谓的静态作用域。
静态作用域
静态作用域也叫词法作用域,它是根据代码编写的位置来决定作用域的。也就是代码在编写时就确定好了作用域,如果函数有嵌套关系,那是可以通过分析代码可以得出的,不需要运行。这种链的好处是可以直观的知道变量之间的引用关系,因此静态作用域也叫静态词法作用域。
动态作用域
动态作用域是根据调用栈来确定作用域的,也就是函数调用时才确定作用域,我们无法直观的根据函数嵌套来分析出对象间的引用关系。
接下来说说大家容易混淆的点,作用域和执行上下文。
作用域与执行上下文
我们知道 JavaScript 是解释型语言,其执行主要分为两个阶段:解析阶段和执行阶段,各阶段所做的事情也不一样:
解析阶段:
- 词法分析
- 语法分析
- 作用域规则确定
执行阶段:
- 创建执行上下文
- 执行函数代码
- 垃圾回收
从上边我们可以看出,作用域是在解析阶段就确定好了,也就是函数定义时,而执行上下文是在代码执行阶段也就是函数执行前才创建的。执行上下文最明显的就是 this 的指向是运行时确定的。而作用域访问的变量是解析时确定的,因此两者的执行时机不同。
大家记住下面这句话:
作用域和执行上下文最大的区别是:执行上下文是运行时确定,随时可能发生改变;而作用域是解析时确定,并且不会改变。
闭包
说了这么多作用域相关的内容,终于要进入本篇的重中之重“闭包”了,相信我,只要上边内容理解得不错,闭包并不难。
下面我们带着以下几个问题去探索闭包:
- 什么是闭包?
- 闭包是怎么来的?
- 闭包是为了解决什么问题?
- 闭包有哪些缺陷?
- 闭包有哪些应用场景?
我们会从上面的五个问题作为切入点去真正的了解闭包,随后还会教大家如何在面试中答好闭包。
我们先看看闭包是怎么来的,从而才能给闭包下定义,知道什么是闭包。
闭包怎么来的
我们先来看一个例子:
function func() {
let name = 'Bill'
let age = 20
return function func2() {
console.log(name)
}
}
const newFunc = func()
这里我们称 func 为父函数,func2 为子函数。
父函数的执行会返回一个子函数,理论上执行完父函数其执行上下文(作用域在里边)就会销毁,但由于子函数内部使用了父函数作用域内的变量(上边子函数 func2 使用了父函数 func 的 name 变量)。那么大家可以想想,父函数执行完后,其作用域到底销毁不销毁呢?
答案是肯定要销毁的,父函数中很可能存在子函数没有引用的变量,比如上边的 age 变量,那么如果不销毁,因为子函数没执行结束(上边子函数保存到了 newFunc 中暂未调用)就一直常驻内存,这肯定会影响性能的。但是要销毁父作用域不能影响子函数,所以要再创建一个对象,把子函数内引用父作用域中的变量打个包,让子函数带走。
这个特殊的对象就是 [[Scopes]],用这个存放函数打包带走的环境(可能是一堆变量与对象的引用)。而且这个对象还得是一个栈,因为子函数下还可能存在子函数,每次打包都要放到一个新包,所以要设置成一个栈结构,就像叠罗汉一样。
销毁父作用域后,把用到的变量包起来,然后打包给子函数,放到子函数的特殊对象([[Scopes]])上,这就是闭包的机制。
下面我们具体看看 [[Scopes]] 对象:
function func() {
let name = 'Bill'
return function func2() {
console.log('111');
}
}
const newFunc = func();
newFunc();
上面代码返回的子函数 func2 并没有外部引用,发现其[[Scopes]]对象中有一个 Global 属性,也就是不管如何栈底永远有一个全局作用域。
我们再改为引用了父函数的 name 变量,看看其[[Scopes]]对象啥样:
function func() {
let name = 'Bill'
return function func2() {
console.log(name);
}
}
const newFunc = func();
newFunc();
发现有一个 Closure 对象,里边存储的正是 name 变量。
通过上边两段代码示例,我们可以得出:
- 闭包最少会包含
全局作用域 - 闭包是返回函数时扫描函数内的标识符引用,把用到的非本作用域的变量打成
Closure 包,放到[[Scopes]]里
什么是闭包
我们可以下个定义:
闭包是在函数创建的时候,让函数打包带走的是根据函数内的外部引用来过滤作用域链剩下的链。它是函数创建时生成的作用域链的子集,是打包的外部环境。
过滤规则:
- 全局作用域不会被过滤掉,一定包含,不管在何处的函数都能访问到
- 其余作用域会根据是否内部有变量被当前函数所引用,过滤掉那些未被引用的变量。
我们可以简单理解什么是闭包: 闭包是指一个函数能够“记住”并访问其定义时的词法作用域,即使该函数在其词法作用域之外执行。
闭包解决了什么问题
闭包主要解决了父函数执行完后作用域销毁导致子函数无法访问父作用域中的变量的问题。
其实就是延长了作用域的生命周期
闭包的缺点
前面我们知道父函数销毁时打的包会放到内部函数的[[Scopes]]对象上,这个对象是放在堆内存中的,这就导致一个隐患:如果一个很大的对象被子函数引用的话,本来父函数调用完就能销毁,但现在的引用却通过闭包保存到了堆中,而且还一直用不到(子函数一直不调用),那这块堆内存就无法使用,严重到一定程度就造成内存泄漏。这也是为什么说闭包要少用,就是要尽量少打包一些东西到堆内存中。
闭包的应用场景
- 数据封装和私有隐藏
function checkAccount(initBalance) {
let balance = initBalance
return {
deposit(amount) {
balance += amount
return balance
},
getBalance() {
return balance
}
}
}
const count = checkAccount(100)
count.deposit(50)
console.log(count.balance) // 无法直接访问,实现了数据保护
console.log(count.getBalance()) // 150
- 回调函数和异步处理
function setupCounter() {
let count = 0;
document.getElementById('btn').addEventListener('click', function() {
// 这个回调函数是一个闭包,可以访问count
count++;
console.log(`按钮被点击了 ${count} 次`);
});
}
// 在Ajax调用中保持状态
function fetchUserData(userId) {
const loadingState = '加载中...';
fetch(`/api/users/${userId}`)
.then(response => response.json())
.then(function(data) {
// 这个回调可以访问loadingState
console.log(loadingState, data);
});
}
- 模块化开发(闭包是实现模块化开发的基石)
const Calculator = (function() {
let memory = 0; // 私有状态
function add(a, b) {
return a + b;
}
function store(value) {
memory = value;
}
function recall() {
return memory;
}
// 只暴露公共接口
return {
add,
store,
recall
};
})();
console.log(Calculator.add(2, 3)); // 5
Calculator.store(100);
console.log(Calculator.recall()); // 100
// Calculator.memory 无法访问
- 缓存
function createMemoizedFunction(fn) {
const cache = {}; // 缓存对象
return function(...args) {
const key = JSON.stringify(args);
if (cache[key]) {
console.log('从缓存返回结果');
return cache[key];
}
console.log('计算新结果');
const result = fn.apply(this, args);
cache[key] = result;
return result;
};
}
- 防抖 / 节流(闭包在性能优化的场景下很有用)
// 防抖函数:在事件频繁触发时,只执行最后一次
function debounce(fn, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// 节流函数:在指定时间内只执行一次
function throttle(fn, interval) {
let lastTime = 0;
return function(...args) {
const now = Date.now();
if (now - lastTime >= interval) {
fn.apply(this, args);
lastTime = now;
}
};
}
// 使用示例
const debouncedSearch = debounce(function(query) {
console.log('搜索:', query);
}, 300);
const throttledScroll = throttle(function() {
console.log('滚动处理');
}, 100);
- 函数柯里化和部分应用
// 柯里化函数
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
} else {
return function(...moreArgs) {
return curried.apply(this, args.concat(moreArgs));
};
}
};
}
// 使用示例
function addThree(a, b, c) {
return a + b + c;
}
const curriedAdd = curry(addThree);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
// 部分应用
const addFive = curriedAdd(2)(3); // 固定前两个参数
console.log(addFive(5)); // 10
不了解函数柯里化没关系,只要知道也是闭包的使用场景就行。
其他的还有 函数工厂,状态管理,迭代控制等等,感兴趣自行了解。
面试如何讲解闭包?
面试时你就围绕下面四点来回答闭包:
- 闭包什么时候创建
闭包并不是在函数调用时,而是在函数定义(创建)时就随之产生的,当一个函数在内部定义了另一个函数,并且这个内部函数引用了外部函数作用域中的变量时,JavaScript 引擎会通过静态词法作用域分析,识别出那些被引用的外部变量,并“打包”形成一个闭包。所以,闭包是在函数创建时,从其所在的作用域链中“过滤”出所需的外部引用而形成的一个环境。
归纳:让函数打包带走的是根据函数内的外部引用来过滤作用域剩下的链。它是在函数创建的时候生成的作用域链的子集,是打包的外部环境。
- 闭包保存什么数据
闭包主要通过静态作用域分析来识别哪些变量被内部使用了,然后只保留这些变量(当内部函数返回时,会扫描本作用域使用的所有外部变量,把用到的变量打包到 Closure 对象中,放到返回函数的[[Scopes]]对象里)
- 闭包保存在哪里
闭包将其所引用的外部变量“打包”成一个 Closure 对象,然后将其放到函数的 [[Scopes]] 对象中。由于 JavaScript 中的函数本质就是一个对象,这个特殊对象和闭包函数本身关联,因此会一起被持久化保存在了堆内存中(返回的函数会从作用域链过滤出用到的引用形成闭包链放到堆中)。
- 闭包和作用域链的关系
当我们调用内部函数时,JavaScript 引擎会查找用到的变量,它会沿着内部函数定义时确定的作用域链去查找。虽然外部函数的执行上下文已经不存在,但它的变量对象被闭包引用着,所以无法被销毁,仍然存活在内存中。因此内部函数的每次执行都能查找并修改同一个变量。
归纳:作用域链决定了函数定义时能访问哪些变量,闭包则是保存了函数定义时的作用域链,以便需要时帮你恢复这个作用域链。
所以没有作用域链,闭包就无从谈起。闭包就是利用了作用域链的“定义时“。
以上就是本篇的所有内容了,欢迎留言,对你有帮助的话就点个赞嘿嘿,大家一起加油~