本文转载自微信公众号「神光的编程秘籍」,作者神说要有光。转载本文请联系神光的编程秘籍公众号。
React 是实现了组件的前端框架,它支持 class 和 function 两种形式的组件。
class 组件是通过继承模版类(Component、PureComponent)的方式开发新组件的,这是 class 本身的特性,它支持设置 state,会在 state 改变后重新渲染,可以重写一些父类的方法,这些方法会在 React 组件渲染的不同阶段调用,叫做生命周期函数。
function 组件不能做继承,因为 function 本来就没这个特性,所以是提供了一些 api 供函数使用,这些 api 会在内部的一个数据结构上挂载一些函数和值,并执行相应的逻辑,通过这种方式实现了 state 和类似 class 组件的生命周期函数的功能,这种 api 就叫做 hooks。
hooks 挂载数据的数据结构叫做 fiber。
那什么是 fiber 呢?
我们知道,React 是通过 jsx 来描述界面结构的,会把 jsx 编译成 render function,然后执行 render function 产生 vdom:
在 v16 之前的 React 里,是直接递归遍历 vdom,通过 dom api 增删改 dom 的方式来渲染的。但当 vdom 过大,频繁调用 dom api 会比较耗时,而且递归又不能打断,所以有性能问题。
后来就引入了 fiber 架构,先把 vdom 树转成 fiber 链表,然后再渲染 fiber。
vdom 转 fiber 的过程叫做 reconcile,是可打断的,React 加入了 schedule 的机制在空闲时调度 reconcile,reconcile 的过程中会做 diff,打上增删改的标记(effectTag),并把对应的 dom 创建好。然后就可以一次性把 fiber 渲染到 dom,也就是 commit。
这个 schdule、reconcile、commit 的流程就是 fiber 架构。当然,对应的这个数据结构也叫 fiber。
hooks 就是通过把数据挂载到组件对应的 fiber 节点上来实现的。
fiber 节点是一个对象,hooks 把数据挂载在哪个属性呢?
我们可以 debugger 看下。
准备这样一个函数组件(代码没啥具体含义,就是为了调试 hooks):
function App() {
const [name, setName] = useState("guang");
useState('dong');
const handler = useCallback((evt) => {
setName('dong');
},[1]);
useEffect(() => {
console.log(1);
});
useRef(1);
useMemo(() => {
return 'guang and dong';
})
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p onClick={handler}>
{name}
</p>
</header>
</div>
);
}
在函数打个断点,运行到这个组件就会断住。
我们看下调用栈:
上一个函数是 renderWithHooks,里面有个 workingInProgress 的对象就是当前的 fiber 节点:
fiber 节点的 memorizedState 就是保存 hooks 数据的地方。
它是一个通过 next 串联的链表,展开看一下:
链表一共六个元素,这和我们在 function 组件写的 hooks 不就对上了么:
这就是 hooks 存取数据的地方,执行的时候各自在自己的那个 memorizedState 上存取数据,完成各种逻辑,这就是 hooks 的原理。
这个 memorizedState 链表是什么时候创建的呢?
好问题,确实有个链表创建的过程,也就是 mountXxx。链表只需要创建一次,后面只需要 update。
所以第一次调用 useState 会执行 mountState,后面再调用 useState 会执行 updateState。
我们先集中精力把 mount 搞明白。
mountXxx 是创建 memorizedState 链表的过程,每个 hooks api 都是这样的:
它的实现也很容易想到,就是创建对应的 memorizedState 对象,然后用 next 串联起来,也就是这段代码:
当然,创建这样的数据结构还是为了使用的,每种 hooks api 都有不同的使用这些 memorizedState 数据的逻辑,有的比较简单,比如 useRef、useCallback、useMemo,有的没那么简单,比如 useState、useEffect。
为什么这么说呢?我们看下它们的实现再说吧。
先看这几个简单的:
useRef
每个 useXxx 的 hooks 都有 mountXxx 和 updateXxx 两个阶段,比如 ref 就是 mountRef 和 updateRef。
它的代码是最简单的,只有这么几行:
mountWorkInProgressHook 刚才我们看过,就是创建并返回 memorizedState 链表的,同理,下面那个 updateWorkInProgressHook 是更新的。
这些不用管,只要知道修改的是对应的 memorizedState 链表中的元素就行了。
那 ref 在 memorizedState 上挂了什么呢?
可以看到是把传进来的 value 包装了一个有 current 属性的对象,冻结了一下,然后放在 memorizedState 属性上。
后面 update 的时候,没有做任何处理,直接返回这个对象。
所以,useRef 的功能就很容易猜到了:useRef 可以保存一个数据的引用,这个引用不可变。
这个 hooks 是最简单的 hooks 了,给我们一个地方存数据,我们也能轻易的实现 useRef 这个 hooks。
再来看个稍难点的:
useCallback
useCallback 在 memorizedState 上放了一个数组,第一个元素是传入的回调函数,第二个是传入的 deps(对 deps 做了下 undefined 的处理)。
更新的时候把之前的那个 memorizedState 取出来,和新传入的 deps 做下对比,如果没变,那就返回之前的回调函数,也就是 prevState[0]。
如果变了,那就创建一个新的数组,第一个元素是传入的回调函数,第二个是传入的 deps。
所以,useCallback 的功能也就呼之欲出了:useCallback 可以实现函数的缓存,如果 deps 没变就不会创建新的,否则才会返回新传入的函数。
这段逻辑其实也不难,就是多了个判断逻辑。
再来看个和它差不多的:
useMemo
useMemo 也在 memorizedState 上放了个数组,第一个元素是传入函数的执行结果,第二个元素是 deps(对 deps 为 undefined 的情况做了下处理)。
更新的时候也是取出之前的 memorizedState,和新传入的 deps 做下对比,如果没变,就返回之前的值,也就是 prevState[0]。
如果变了,创建一个新的数组放在 memorizedState,第一个元素是新传入函数的执行结果,第二个元素是 deps。
所以,useMemo 的功能大家也能猜出来:useMemo 可以实现函数执行结果的缓存,如果 deps 没变,就直接拿之前的,否则才会执行函数拿到最新结果返回。
实现逻辑和 useCallback 大同小异。
这三个 hooks 难么?给大家一个对象来存储数据,大家都能写出来,并不难。
因为它们是没有别的依赖的,只是单纯的缓存了下值而已。而像 useState、useEffect 这些就复杂一些了,主要是因为需要调度。
useState
state 改了之后是要触发更新的调度的,React 有自己的调度逻辑,就是我们前面提到的 fiber 的 schedule,所以需要 dispatch 一个 action。
(不展开讲,简单看一下)
这里详细讲要涉及到调度,就先不展开了。
useEffect
同样的,effect 传入的函数也是被 React 所调度的,当然,这里的调度不是 fiber 那个调度,而是单独的 effect 调度:
(不展开讲,简单看一下)
hooks 负责把这些 effect 串联成一个 updateQueue 的链表,然后让 React 去调度执行。
所以说,useState、useEffect 这种 hooks 的实现是和 fiber 的空闲调度,effect 的调度结合比较紧密的,实现上更复杂了一些。
这里没有展开讲,因为这篇文章的目的是把 hooks 的主要原理理清楚,不会太深入细节。
大家可能还听过自定义 hooks 的概念,那个是啥呢?
其实就是个函数调用,没啥神奇的,我们可以把上面的 hooks 放到 xxx 函数里,然后在 function 组件里调用,对应的 hook 链表是一样的。
只不过一般我们会使用 React 提供的 eslint 插件,lint 了这些函数必须以 use 开头,但其实不用也没事,它们和普通的函数封装没有任何区别。
总结
React 支持 class 和 function 两种形式的组件,class 支持 state 属性和生命周期方法,而 function 组件也通过 hooks api 实现了类似的功能。
fiber 架构是 React 在 16 以后引入的,之前是 jsx -> render function -> vdom 然后直接递归渲染 vdom,现在则是多了一步 vdom 转 fiber 的 reconcile,在 reconcile 的过程中创建 dom 和做 diff 并打上增删改的 effectTag,然后一次性 commit。这个 reconcile 是可被打断的,可以调度,也就是 fiber 的 schedule。
hooks 的实现就是基于 fiber 的,会在 fiber 节点上放一个链表,每个节点的 memorizedState 属性上存放了对应的数据,然后不同的 hooks api 使用对应的数据来完成不同的功能。
链表自然有个创建阶段,也就是 mountXxx,之后就不需要再 mount 了,只需要 update。所以每个 useXx 的实现其实都是分为了 mountXxx 和 updateXxx 两部分的。
我们看了几个简单的 hooks:useRef、useCallback、useMemo,它们只是对值做了缓存,逻辑比较纯粹,没有依赖 React 的调度。而 useState 会触发 fiber 的 schedule,useEffect 也有自己的调度逻辑。实现上相对复杂一些,我们没有继续深入。
其实给我们一个对象来存取数据,实现 useRef、useCallback、useMemo 等 hooks 还是很简单的。对于需要调度的,则复杂一些。
对于自定义的 hooks,那个就是个函数调用,没有任何区别。(lint 的规则不想遵守可以忽略)
所有 hooks api 都是基于 fiber 节点上的 memorizedState 链表来存取数据并完成各自的逻辑的。
所以,hooks 的原理简单么?只能说有的简单,有的不简单。