前言
在通关 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