什么是闭包
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。
MDN
对闭包的定义是这样的,可以说 JavaScript
每个函数都是一个闭包。闭包包含函数本身和函数的作用域,对于定义在全局的函数,它就包含函数本身和函数内部变量还有全局变量的引用
闭包常见的考题
比较常见的闭包题目如下:
for (var i = 0; i < 10; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
// 类似这种题目,有个比较明显的特征,循环体用的是 var
// 然后大部分人都能想到这里有个闭包问题
// 1 秒后 输出 10 个 10
通常还有第二问,上面这道题,如果希望按顺序输出 0 - 9
有什么改造方法
// 改动最小的方案, var 改成 let 就可以
// let 是块级作用域
for (let i = 0; i < 10; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
// 使用 iife 实现块级作用域
// 这里其实也是使用了闭包
// _i 保存这对 iife _i 这个变量的引用
for (var i = 0; i < 10; i++) {
((_i) => {
setTimeout(() => {
console.log(_i);
}, 1000);
})(i);
}
// 当然,也可以利用 setTimeout rest 参数解决这个问题,可以看下 setTimeout 的函数签名,这个是支持传参给回调函数的
function setTimeout(handler: TimerHandler, timeout?: number, ...arguments: any[]): number
for (var i = 0; i < 10; i++) {
setTimeout((_i) => {
console.log(_i);
}, 1000, i);
}
react 和闭包问题
看到上面那种题目,很多人总以为闭包和自己没啥关系似的,一个是现在的 let
和 const
大家都在使用了,再不用就配个 no-var
的 es-lint
,想用 var
就报警报了,所以上面那种问题日常编码中大概率不会遇到,但是我们真不会遇到闭包带来的问题么?
useState 闭包
function Counter() {
const [count, setCount] = useState(0);
const log = () => {
setCount(count + 1);
setTimeout(() => {
console.log(count);
}, 3000);
};
return (
<div>
<p>You clicked {count} times</p>
<button onClick={log}>Click me</button>
</div>
);
}
class Counter extends Component {
state = { count: 0 };
log = () => {
this.setState({
count: this.state.count + 1,
});
setTimeout(() => {
console.log(this.state.count);
}, 3000);
};
render() {
return (
<div>
<p>You clicked {this.state.count} times</p>
<button onClick={this.log}>Click me</button>
</div>
);
}
}
上面这两个组件,都是 React
的组件,一个是函数组件一个是类组件,如果快速点击三次,看看结果,看看打印在控制台的结果是怎样的
-
类组件输出是
3 3 3
,因为类组件3
秒后执行setTimeout
的回调的时候,this.state.count
变成3
了,这个没问题 -
正当你以为函数组件也是输出
3 3 3
的时候,其实实际是输出0 1 2
,为什么呢? -
这时候看下联想一下和本文章相关的问题,是不是能想象到,这里也是一个闭包呢?每次渲染都需要重新执行一次函数组件,
setTimeout
的回调函数通过作用域链去获取到本次渲染的state
和prop
-
从闭包的角度上想,确实是对的,我们来拆解一下函数组件的渲染过程
-
三次点击,共
4
次渲染,count
从0
变为3
-
页面第一次渲染,页面看到 的
count = 0
-
第一次点击,事件处理器获取的
count = 0
,count
变成1
,第二次渲染,渲染后页面的看到count = 1
-
第二次点击,事件处理器获取的
count = 1
,count
变成2
, 第三次渲染,渲染后页面看到count = 2
-
第三次点击,事件处理器获取的
count = 2
,count
变成3
, 第四次渲染,渲染后页面看到count = 3
-
-
每次渲染,调用函数组件,回调函数通过闭包去获取本次渲染的
state
和prop
,这个导致了输出和类组件不一样。但是如果希望和类组件一样都是输出3 3 3
,实现方式也可以参考类组件的this
,this
在整个类组件生命周期中,都指向自身。函数组件其实有个类似的useRef
,通过useRef
包裹的值,返回一个被包裹的对象,这个对象在整个生命周期是不会改变的,通过这个可以模拟this
,就可以简单的实现输出3 3 3
,如下:
function Counter() {
const count = useRef(0);
const log = () => {
count.current++;
setTimeout(() => {
console.log(count.current);
}, 3000);
};
return (
<div>
<p>You clicked {count.current} times</p>
<button onClick={log}>Click me</button>
</div>
);
}
useState 闭包在 useEffect 中
接下来我们看下 useEffect
和 useState
结合起来有多少有哪些问题
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
}, []);
return <h1>{count}</h1>;
}
这个组件写的很简单,实现的功能也简单,就是每一秒中, setState
一次,按照期望应该组件 count
会从 0
开始无限递增,但是实际上,页面变成 1
之后,页面就不变了,难道是定时器坏了?其实并不是。
当尝试在定时器回调中输出 count
,发现每次定时器执行的时候,count
的值都是 0
,所以定时器每次执行的都是 setCount(0 + 1)
,所以才发现页面一直都是 1
这里也是一个闭包问题,setInterval
在组件渲染完之后,初始化了一次,然后定时器的回调函数每次执行的时候,通过作用域链去获取到的 count
都是页面首次渲染后的 count
,所以获取到的才是 0
这里要实现原本的效果也很简单
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// setCount(count + 1);
setCount((c) => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
把 setCount
改一下调用方式,通过回调函数来调用,回调函数返回的是最新的 state
,当编码中有遇到需要获取最新的 state
的时候,类似这种,可以使用回调函数,也可以使用 useRef
缓存一下 state
的值,下面示范一下怎么通过回调函数获取最新的 state
并且不重新渲染组件
let latestState;
setCount((c) => {
latestState = c;
return c;
});
调用 State Hook 的更新函数并传入当前的 state 时,React 将跳过子组件的渲染及 effect 的执行。
如上面代码和 react
官方文档的说明,如果传入一个同样的 state
,该次子组件不会重新渲染,通过这个方式可以获取到最新的 state
,如上述代码中的 latestState
。
但是这样写不太直观,也不好理解,可以用 useRef
来处理一下 ,当 state
改变的之后,改变 useRef
的值就好
总结
闭包其实在日常生活中用处非常多,并不只限于某些题目中,闭包是作用域链带来的一个副作用,但是利用好闭包可以做很多事情