前段时间研究了一波React的渲染流程,内部机制的源码,阅读到一半的时候React终于推出了16.8.x的版本,主要带来的更新就是Hooks的新功能。相信已经有很多的使用教程或者源码阅读文章。那么我也来一个属于自己的阅读有感的文章,做一个记录吧。
其实React文档中也有说明了Hooks的提出主要是为了解决什么问题的。
- React团队认为组件之间复用状态逻辑很难。
就以前React为了将一个组件的逻辑抽离复用,不和渲染代码混用在一个class的做法,比较推介的是用高阶组件,将状态逻辑抽离出来,通过不同的样式组件传入带有状态逻辑的高阶组件中,增强样式组件的功能,从而达到复用逻辑的功能。再早期就会使用mixin去实现。
缺点:增加嵌套层级,代码会更加难以理解。
- 复杂组件变得难以理解
在使用class组件的时候,我们少不了在生命周期函数中添加一些操作,例如调用一些函数,或者去发起请求,在销毁组件的时候为了防止内存溢出,我们可能还需要对一些事件移除。那么这个时候我们就需要在componentDidMount,componentDidUpdate中可能会调用相同的函数获取数据,componentWillUnmount中移除事件等。
确实,在使用class组件的时候,会有很多在生命周期中处理的事情,无论是获取dom的一些高度,或者发起请求,这些因为和组件有很强的耦合性,也很难通过高阶组件的方式抽离出来,而Hook将组件中关联的部分拆分成更小的函数,每个函数功能更加单一。
- Hook就是一个以纯函数的方式存在的class组件
以前我们使用纯函数组件时都有一个标准,就是这个组件并不具备自身的生命周期使用,以及自己独立的state。只是单纯的返回一个组件。或者是根据传入的props组装组件。但随着Hook的发布,React团队是想将React更加偏向函数式编程的方式编写组件,让本来存函数组件变得可以使用class组件的一些特性。
接下来我们按照文档的教学,一边看文档一边看源码,了解Hook背后的原理,以及加入一些自己的思考。
本篇文章并非一篇Hooks的教学文章,更多是了解背后的代码实现。
下面是一个官方的DEMO:
import React, {useState, useEffect, Component} from 'react';
import ReactDOM from 'react-dom';
function Example(props) {
// 声明一个新的叫做 “count” 的 state 变量
const [count, setCount] = useState(0);
return (
<div>
<h1>{props.name}</h1>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
class App extends Component {
render() {
return <Example name='lamho'/>
}
}
ReactDOM.render(
<App />,
document.getElementById('root')
)
Hook - useState
useState其实是等价于setState的,只是useState需要在函数组件中使用而已。
const [count, setCount] = useState(0);
像代码中的useState,传入的参数就是值,返回一个数组,第一个为key,第二个为修改该key的值的函数。换句话说,每一个state的声明和赋值都需要通过调用useState函数来完成。而设置代码中的count变量的值,都必须使用useState返回的setCount。
count和setCount都是自定义的名字,但是建议语义化。
然后在DEMO中的button点击事件中,我们调用了setCount对count+1。
<button onClick={() => setCount(count + 1)}>
Click me
</button>
到这里其实就是一个基本的流程。
- 声明state。
- 使用state的值进行渲染。
- 修改state的值。
- 更新组件。
现在我们来看看源码了。
首先在render阶段时候,因为首次渲染,会对ReactElement进行解析,成为一个FIber树。在workLoop函数中,我们看到纯函数的Fiber节点是长这个样子的。
如果ReactElement是一个function的情况下,那么Fiber节点的tag标识是为2,在beginWork函数中,会进入IndeterminateComponent函数中对function类型的Fiber节点实例化。
在断点的过程中,发现有一个名为renderWithHooks函数。在函数中我发现了几点:
- HooksDispatcherOnMountInDEV这个全局变量,保存了所有Hook的Api。
- 执行了function类型的Fiber节点的实例化。
var children = Component(props, refOrContext);
将function的内容执行完后得出ReactElement对象。
基本上这就是function类型的Fiber的render阶段会做的事情。那么在DEMO中我们有用到useState,那么具体从源码上是如何调用的呢?
首先react-dom中有一个全局变量ReactCurrentDispatcher$1。在render阶段的最开始,会将ReactCurrentDispatcher$1赋值为一个全局变量ContextOnlyDispatcher。
var ContextOnlyDispatcher = {
readContext: readContext,
useCallback: throwInvalidHookError,
useContext: throwInvalidHookError,
useEffect: throwInvalidHookError,
useImperativeHandle: throwInvalidHookError,
useLayoutEffect: throwInvalidHookError,
useMemo: throwInvalidHookError,
useReducer: throwInvalidHookError,
useRef: throwInvalidHookError,
useState: throwInvalidHookError,
useDebugValue: throwInvalidHookError
};
而这个时候useState之类的Hook如果调用会出现如下报错:
而在renderWithHooks函数中,在调用function类型的Fiber节点,将其实例化前,会有一段代码。
if (nextCurrentHook !== null) {
ReactCurrentDispatcher$1.current = HooksDispatcherOnUpdateInDEV;
} else if (hookTypesDev !== null) {
ReactCurrentDispatcher$1.current = HooksDispatcherOnMountWithHookTypesInDEV;
} else {
// 将ReactCurrentDispatcher$1.current指向为HooksDispatcherOnMountInDEV
ReactCurrentDispatcher$1.current = HooksDispatcherOnMountInDEV;
}
以下是HooksDispatcherOnMountInDEV代码。这才是真正的Hook代码。
HooksDispatcherOnMountInDEV = {
readContext: function (context, observedBits) {
return readContext(context, observedBits);
},
useCallback: function (callback, deps) {
currentHookNameInDev = 'useCallback';
mountHookTypesDev();
return mountCallback(callback, deps);
},
useContext: function (context, observedBits) {
currentHookNameInDev = 'useContext';
mountHookTypesDev();
return readContext(context, observedBits);
},
useEffect: function (create, deps) {
currentHookNameInDev = 'useEffect';
mountHookTypesDev();
return mountEffect(create, deps);
},
useImperativeHandle: function (ref, create, deps) {
currentHookNameInDev = 'useImperativeHandle';
mountHookTypesDev();
return mountImperativeHandle(ref, create, deps);
},
useLayoutEffect: function (create, deps) {
currentHookNameInDev = 'useLayoutEffect';
mountHookTypesDev();
return mountLayoutEffect(create, deps);
},
useMemo: function (create, deps) {
currentHookNameInDev = 'useMemo';
mountHookTypesDev();
var prevDispatcher = ReactCurrentDispatcher$1.current;
ReactCurrentDispatcher$1.current = InvalidNestedHooksDispatcherOnMountInDEV;
try {
return mountMemo(create, deps);
} finally {
ReactCurrentDispatcher$1.current = prevDispatcher;
}
},
useReducer: function (reducer, initialArg, init) {
currentHookNameInDev = 'useReducer';
mountHookTypesDev();
var prevDispatcher = ReactCurrentDispatcher$1.current;
ReactCurrentDispatcher$1.current = InvalidNestedHooksDispatcherOnMountInDEV;
try {
return mountReducer(reducer, initialArg, init);
} finally {
ReactCurrentDispatcher$1.current = prevDispatcher;
}
},
useRef: function (initialValue) {
currentHookNameInDev = 'useRef';
mountHookTypesDev();
return mountRef(initialValue);
},
useState: function (initialState) {
currentHookNameInDev = 'useState';
mountHookTypesDev();
var prevDispatcher = ReactCurrentDispatcher$1.current;
ReactCurrentDispatcher$1.current = InvalidNestedHooksDispatcherOnMountInDEV;
try {
return mountState(initialState);
} finally {
ReactCurrentDispatcher$1.current = prevDispatcher;
}
},
useDebugValue: function (value, formatterFn) {
currentHookNameInDev = 'useDebugValue';
mountHookTypesDev();
return mountDebugValue(value, formatterFn);
}
};
在我们DEMO的代码中会调用react提供的useState函数。我们看看useState的代码是怎样的。注意上面提到的HooksDispatcherOnMountInDEV变量中的useState是在react-dom中的代码,并非react的代码,但是在DEMO中我们调用的是react提供的useState。
在react文件中提供的useState的代码是这样的。
function useState(initialState) {
var dispatcher = resolveDispatcher();
return dispatcher.useState(initialState);
}
最终resolveDispatcher函数返回的ReactCurrentDispatcher.current。ReactCurrentDispatcher和ReactCurrentDispatcher$1是互相引用的关系。所以最终是调用react-dom的useState。好了说回重点,useState是如何工作的。
其实真正执行useState工作的是mountState函数,而这个函数主要做了几件事。
function mountState(initialState) {
// mountWorkInProgressHook这个函数是构建出这个hook对应的存储数据以及队列等信息。
var hook = mountWorkInProgressHook();
// 如果传入useState的参数是一个函数,那么先执行函数。
if (typeof initialState === 'function') {
initialState = initialState();
}
// 为hook赋值memoizedState和baseState为传入的值
hook.memoizedState = hook.baseState = initialState;
// 创建hook的队列对象
var queue = hook.queue = {
last: null,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: initialState
};
// 闭包注册一个可以修改当前state值的函数。
// 传入当前的Fiber节点,以及队列信息
var dispatch = queue.dispatch = dispatchAction.bind(null,
// Flow doesn't know this is non-null, but we do.
currentlyRenderingFiber$1, queue);
// 将值和函数返回
return [hook.memoizedState, dispatch];
}
mountWorkInProgressHook函数内容
function mountWorkInProgressHook() {
var hook = {
memoizedState: null,
baseState: null,
queue: null,
baseUpdate: null,
next: null
};
// 做好每个Hook的顺序
if (workInProgressHook === null) {
// This is the first hook in the list
firstWorkInProgressHook = workInProgressHook = hook;
} else {
// Append to the end of the list
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
其实看到这里就很清晰的知道useState的工作流程是怎样的了。以我们现在看到内容做个简单的总结。
- 函数组件会有特殊的处理方式。
- 在render阶段,在将函数Fiber内容实例化的时候会去处理全局中的Hooks对象的指向
- 最终useState是调用内部函数mountState去设置state的。
- 在mountState中会对传入的参数如果是函数会对其先执行,得出返回值再继续运行。
- 在mountState中会对创建一个闭包事件,将当前的Hook所在的Fiber节点以及Hook队列对象作为参数绑定在函数中,并返回。
Hook - 更新state
在demo中我们为button元素绑定了一个onClick事件,经过React的合成事件最终触发了之前说到的dispatchAction函数。在useState的时候返回的setCount函数中,会提前绑定好了当前的Fiber节点,以及一个queue的参数。
最终会将新传入的state的值保存在queue中的last对象上。
而当前的Fiber节点中的memoizedState对象中的queue是对queue的一个引用。所以现在Fiber节点中已经带有新的state的记录,之后会将Fiber节点传入到scheduleWork函数中。
这里基本上是和正常的class组件一样的,只是处理的方式不一样而已,正常的class组件在时间阶段都是根据新的state来修改Fiber中备用树的state里面的值,而Hook就是利用闭包返回的函数,修改自己Fiber节点中memoizedState.queue中的值,在下一次渲染中进行更新。
在执行renderWithHooks函数中时,发现当前的函数组件并非第一次渲染,所以会将使用HooksDispatcherOnUpdateInDEV这个全局对象执行setState,而并非像第一次那样使用HooksDispatcherOnMountInDEV全局对象进行渲染。
useState最终调用的是updateReducer,而并非mountState函数。
从代码可以发现当我们在事件中多次触发setCount函数,其实也只会触发一次render,因为在之前的queue(队列)对象中,会保持一个关系,如果队列中存在last对象,那么将会将新的state存到last对象的next对象中,以此类推。所以就形成了一个多次state的调用链。
然后触发updateReducer的时候有这么一段代码:
do {
var updateExpirationTime = _update.expirationTime;
if (updateExpirationTime < renderExpirationTime) {
// Priority is insufficient. Skip this update. If this is the first
// skipped update, the previous update/state is the new base
// update/state.
if (!didSkip) {
didSkip = true;
newBaseUpdate = prevUpdate;
newBaseState = _newState;
}
// Update the remaining priority in the queue.
if (updateExpirationTime > remainingExpirationTime) {
remainingExpirationTime = updateExpirationTime;
}
} else {
// Process this update.
if (_update.eagerReducer === reducer) {
// If this update was processed eagerly, and its reducer matches the
// current reducer, we can use the eagerly computed state.
_newState = _update.eagerState;
} else {
var _action2 = _update.action;
_newState = reducer(_newState, _action2);
}
}
prevUpdate = _update;
_update = _update.next;
} while (_update !== null && _update !== first);
就是会使用最后一次set的值。这个和class的setState原理是一样。
最后会将新的值赋值到Fiber节点中,并返回新的值,值得注意的是,事件将会复用之前的闭包生成的事件。
这个闭包事件是如何复用的呢?其实在第一次生成闭包函数的时候,不单单只是返回而已,还会将函数赋值在queue的dispatch中,这样就可以做到复用闭包函数了。
之后就是正常的渲染流程了,和class组件并没有什么区别了。
useEffect原理
在官网中说道useEffect是componentDidMount,componentDidUpdate和componentWillUnmount这三个函数的组合。
在一次渲染的时候会执行useEffect函数,而这个函数最终生成一个effect对象,effect中的create就是保持useEffect传入的回调函数。最终会暂时保存在componentUpdateQueue全局对象中,然后对函数组件实例化成ReactElement之后,将会把componentUpdateQueue赋值到函数组件的updateQueue对象中。
如果在一个函数组件中有多个useEffect函数,那么将会是以下的样子。
最后effect对象将会保存在Fiber节点的updateQueue对象中。
在进入commit阶段,将Fiber节点都渲染到页面上后,就会开始执行Fiber节点中的updateQueue中所保存的函数了。
接着在每一次渲染完组件后,如果是class组件将会调用class组件本来的生命周期函数,但是如果是函数组件,那么将会找到updateQueue中的注册函数逐个调用。
最终会调用commitHookEffectList函数,去触发注册的生命周期函数。执行的方式就是执行完每一个注册事件后,会查找是否存在next,如果存在就继续调用,所有注册函数都执行完毕。
useEffect中如何在组件卸载时执行对应的动作?
在官网的demo中有说道一个例子:
这个组件需要在卸载的时候移除某一些事件绑定,那么官网中的说明是在执行useEffect传入的函数中return一个函数,return的函数在组件卸载的时执行,那么通过代码很容易就知道它内部是如何实现的。
什么时候执行呢?在组件卸载的时候还是调用commitHookEffectList函数。
会将之前return的函数在组件卸载的时候进入这个判断中,然后执行它。
useEffect性能优化
在官网中有一个例子,在class组件中,我们非常常用的一个操作,就是在生命周期中需要做一些判断,当满足条件才会执行一些操作。
componentDidUpdate(prevProps, prevState) {
if (prevState.count !== this.state.count) {
document.title = `You clicked ${this.state.count} times`;
}
}
但是在我们使用useEffect的时候每一次渲染都会触发,如果我们的函数组件中,存在某些操作需要满足特定条件才会在useEffect中触发,那么如何去做呢?
官网告诉了我们,useEffect可以接受第二个参数,第二个参数其实就是代表当传入的参数和当前的同名参数不相等时,执行useEffect。
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]); // 仅在 count 更改时更新
那么代码中是如何实现的呢?
首先在实例我们的函数组件的时候,传入的第二个参数会保存在deps中。
在非第一次的渲染中,执行useEffect,最终是执行updateEffectImpl函数,而这个函数中就会对传入的第二个参数中(数组)的每一项的值和当前存在的每一项值进行对比,如果不相等,则返回false,那么会赋值给全局中的sideEffectTag,在之后的renderWithHooks函数中,会将sideEffectTag赋值到函数组件中的effectTag中。
最后因为对Fiber节点赋值了effectTag,所以在渲染后会触发commitHookEffectList函数,具体代码的调用栈非常长,就不一一细说。
下面是我总结了一下整个函数组件的渲染过程,以及上面说到了useState和useEffect的执行过程。
总结:
- useEffect的执行时机都是每次渲染后触发,无论是首次渲染还是更新渲染。
- useEffect只会有当然组件是函数组件才会执行,不能再非函数组件中使用。
- useEffect可以在同一函数组件中使用多次,按调用顺序执行,这样我们可以将生命周期中需要做的事情更小粒度的去编写代码。
- 在useEffect传入的函数中,return一个函数,用作函数组件卸载时需要执行的操作。
- 控制useEffect什么时候执行可以传入第二参数,而且第二个参数必须是数组!react会对这次传入的数组中的每一项和上一次数组中的每一项进行对比,当发现不一样时会做对应记录,在渲染后就会触发对应符合触发的useEffect函数。
- useEffect的触发是采用异步方式执行的。因为有可能存在多个useEffect的函数,如果像class组件那样在commit阶段最后触发的话,很容易导致阻塞线程。所以React利用setTimeout的方式,将useEffect异步执行。
原文https://zhuanlan.zhihu.com/p/68842478