前言
在通关 JS 的道路上你一定也遇到过两个难缠的大 BOSS:this的指向和闭包,而且不能逃课。
在使用 React 时,如果你习惯编写 class 组件,就绕不开this;如果你使用函数组件,就一定会用到 React Hooks,而 React Hooks 又大量使用闭包。(闭包真的无处不在呢!)

本文将用代码示例,仿写一个简化版的 useState Hook,浅析闭包在 React 中的应用。当然本文的代码示例不代表 React 源码的真实情况。
前置条件
想要彻底理解本文内容需要你:
- 对 JS 闭包有大概了解。不太了解也没关系,我会在本文回顾。
- 浏览过 React Hooks 文档,最好是使用过 React Hooks。
Let's get our hands dirty!
“闭包”是什么?
请允许我引用来自《你不知道的 JavaScript》对闭包的定义:
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
第一次读到这句话的时候,我深刻地体会到了什么叫“每一个字我都认识,但是我完全不知道它说了什么”的绝望。现在回看,其实《你不知道的 JavaScript》的章节设计得很好,想要真的理解闭包这个概念,推荐熟读前面词法作用域部分,了解了作用域,如何查找变量,闭包问题也就引刃而解了。
对于我个人来说,开悟的那一刻是当我把闭包的英文closure转换成动词词组close over来理解的那一刻。close就是打包了当前作用域,而over是超越了自己的作用域,在作用域之外也可以执行。

顺便提一句,很多概念直接通过英文来理解会容易很多,另一个例子是作用域的英文:scope。技术书能读英文原版就读原版,比方说《JavaScript 忍者秘籍》,很好的一本书,但是中文翻译了个啥???
废话就说这么多,请看一个经典的代码示例:
function addOne() {
let num = 1;
return function inner() {
num = num + 1;
return num;
};
}
const outer = addOne();
console.log(outer()); // 2
console.log(outer()); // 3
console.log(outer()); // 4
console.log(outer()); // 5
num = 999; // 报错
让我们一起看看在上面的代码片段发生了什么?
- 当我们在调用
outer函数的时候,实际调用的是addOne的内部的inner函数。 inner函数引用了其作用域之外的num的值。inner函数在自己的作用域(即addOne函数)之外(即outer函数)中执行。- 我们通过在
addOne函数中返回inner函数(close),并在外部调用(over)实现了一个经典的闭包(closure)。
而通过打印到控制台的结果,发现实现了两个效果:
num为私有变量,尝试在全局修改变量的值会报错。私有变量也是闭包的常用场景之一。- 反复调用
outer函数可以实现数字的等差累加(+1),也就是说outer是一个有状态的函数(stateful function)。
闭包与有状态函数(stateful function)
有状态函数就是利用闭包,使得函数拥有内部状态,这样每一次调用函数会基于上一次的结果产生不一样的结果。(可以对比纯函数的概念理解)
看到这里你是不是想到了什么?React 不就是利用 React Hooks 对组件进行状态管理嘛?!
仿写 useState
编写 useState 函数
那让我们进入正题,从仿写useState开始。
根据useState的结构,可以得出如果编写一个useState函数的话,需要返回一个数组,包含一个值和一个相关的 setter 函数:
function myUseState(initValue) {
let _value = initValue;
const state = _value;
const setState = (newValue) => {
_value = newValue;
};
return [state, setState];
}
让我们看看上面的代码片段发生了什么:
useState函数返回了一个数组,这个数组包含两个变量:state和一个 setter 函数setState。useState函数通过内部变量_value记住了我们传入的初始值,setState引用了这个值,并做修改。
在真实的 React 中,我们是通过React.useState来调用这个 hook,那我们需要调整一下上面这个函数的写法,将函数封装在 React 中。
我们先写个简单的练手,把前文的outer函数改写一下:
const outer = (function addOne() {
let num = 1;
return function inner() {
num = num + 1;
return num;
};
})();
console.log(outer()); // 2
console.log(outer()); // 3
console.log(outer()); // 4
console.log(outer()); // 5
利用 IIFE 将代码封装后和之前的输出结果一致,同理就可以把myUseState函数改写为:
const MyReact = (function () {
function myUseState(initValue) {
let _value = initValue;
const state = _value;
const setState = (newValue) => {
_value = newValue;
};
return [state, setState];
}
return { myUseState };
})();
让我们测试一下使用:
const [count, setCount] = MyReact.myUseState(1);
console.log(count); //1
setCount(count + 1);
console.log(count); //1
为什么setCount失效了?让我们来分析一下调用setCount(count + 1)的时候发生了什么:
setCount是在外部调用了setState函数(over)setState可以访问和修改myUseState函数内部_value的值(close)setState是一个闭包,并且当我们在调用setCount(count + 1)时,确实改变了_value的值- 但
count拿到的值是返回数组的第一项,并不持续追踪_value值的变化
让我们再回到闭包的概念:
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
很容易以为误闭包记住的是值,但实际上闭包 close(记住)的是作用域。那我们只需要把state也修改为_value的闭包,让 state 也记住它的作用域就可以了:
const MyReact = (function () {
function myUseState(initValue) {
let _value = initValue;
const state = () => _value; //state 为 _value 的闭包
const setState = (newValue) => {
_value = newValue;
};
return [state, setState]; //外部可以调用 state
}
return { myUseState };
})();
对应打印到控制台的代码也要修改:
const [count, setCount] = MyReact.myUseState(1);
console.log(count()); //1
setCount(count() + 1);
console.log(count()); //2
编写函数组件
让我们继续:
在真实的 React Hook 的使用场景中,我们是在一个函数组件中调用 Hook,让我们边调整边完善:
function MyComponent() {
const [count, setCount] = MyReact.myUseState(1);
return {
//此处应该为 JSX
};
}
假设返回的 JSX 是一个可以展示count的值的标签,以及一个按钮,每次点击之后count的值加 1。
为了简化案例,我们把 JSX 改写为打印到控制台,这样 return 返回的对象就包含两个方法:
- 一个
render方法,在控制台输出count的值; - 一个
click方法,模拟按钮,每次调用改变count的值。
function MyComponent() {
const [count, setCount] = MyReact.myUseState(1);
return {
render: () => console.log(count),
click: () => setCount(count + 1),
};
}
这样我们就构建好了一个函数组件。
编写渲染方法
通常我们使用ReactDOM.render(要渲染的内容,在哪里渲染)将函数组件渲染到页面,那么我们的仿写还差一个渲染方法:
const MyReact = (function () {
function myUseState(initValue) {
...
return [state, setState];
}
function myRender(Component){
const _c = Component();
_c.render();//在控制台打印状态
return _c;
}
return { myUseState, myRender };
})();
由于在编写函数组件的时候,我们返回的render方法是() => console.log(count),直接在控制台打印状态,所以我们对myUseState要稍作修改:
const MyReact = (function () {
let _val; //放在最外层
function myUseState(initVal) {
const state = _val || initVal;//不需要使用函数
const setState = (newVal) => {
_val = newVal;
};
return [state, setState];
}
...
return { myUseState, myRender };
})();
现在完整的代码为:
const MyReact = (function () {
let _value; //在 MyReact 最外层声明变量
function myUseState(initValue) {
const state = _val || initVal; //不需要使用函数
const setState = (newValue) => {
_value = newValue;
};
return [state, setState];
}
function myRender(Component) {
const _c = Component();
_c.render(); //在控制台打印状态
return _c;
}
return { myUseState, myRender };
})();
function MyComponent() {
const [count, setCount] = MyReact.myUseState(1);
return {
render: () => console.log(count),
click: () => setCount(count + 1),
};
}
我们来查看一下调用效果:
var App = MyReact.myRender(MyComponent); //1
App.click();
var App = MyReact.myRender(MyComponent); //2
简要分析一下在这个代码片段里发生了什么:
MyReact.myRender方法对我们编写的组件MyReactComponent进行调用,直接将count的值打印到控制台,同时也暴露了click方法;- 使用
App.click调用暴露的方法,在非setCount的词法作用域调用setCount方法(over),修改了之前的_val值(close); - 当我们再次利用
myRender方法获取count值时,count拿到的是修改后_val的值,就从1变成了2。
做得不错!
但往往我们在一个函数组件中使用不止一个useState:
function MyComponent() {
const [count, setCount] = MyReact.myUseState(1);
const [text, setText] = MyReact.myUseState('freeCodeCamp');
return {
render: () => console.log({ count, text }), //同时展现两个变量
click: () => setCount(count + 1),
type: (newText) => setText(newText), //假设有一个输入框
};
}
打印控制台就会出现诡异的效果:
var App = MyReact.myRender(MyComponent); //{"count": 1, "text": 1}
App.click();
var App = MyReact.myRender(MyComponent); //{"count": 2, "text": 2}
这是因为我们只有_val这一个值来存储变量,这个时候我们就需要引入数组:
const MyReact = (function () {
let hooks = [];
let idx = 0;
function myUseState(initVal) {
const state = hooks[idx] || initVal;
const _idx = idx; //记住这个值
const setState = (newVal) => {
hooks[_idx] = newVal; //调用 setState 修改的是创建 state 同索引所指向的值
};
idx++; //这样才能在 hook 数组中记录下一个值
return [state, setState];
}
function myRender(Component) {
idx = 0; //每一次渲染的时候,索引要回到 0
const C = Component();
C.render();
return C;
}
return { myUseState, myRender };
})();
重新调用:
var App = MyReact.myRender(Component); //{"count": 1, "text": 'freeCodeCamp'}
App.click();
var App = MyReact.myRender(Component); //{"count": 2, "text": 'freeCodeCamp'}
App.setText('awesome!');
var App = MyReact.myRender(Component); //{"count": 2, "text": 'awesome!'}
这里要稍微解释一下为什么在myRender函数中,一定要将idx的值重新设置为0。
因为每一次调用MyReact.myRender都是独立的,如果不将索引设置为0的话,多次调用useState会将在上次索引上继续索引,这样上次索引的记录就不会被重写,每次渲染Component时就会返回同样的状态值和 setter 函数。
总结
探索闭包和 React Hooks 之间的关系是一段有趣的旅程。
你也可以尝试仿写其他 hook 方法。
希望这篇文章可以启发你,在以后的打怪之路上优雅地理解和利用闭包。

参考资料
Swyx - Deep dive: How do React hooks really work?
Rudi Yardley - React hooks: not magic, just arrays