React 进阶必备:从函数式组件看 Hooks 设计
大厂技术 坚持周更 精选好文
从函数式说起
React 在现有的三大主流框架中是非常“函数式”的语言,小到 setState,render 函数的设计,大到函数组件,周边组件 Redux 等,都蕴含了一定的函数式风格。因此要了解 React,我们就需要了解一定的函数式编程。
函数式编程作为声明式编程的一种形式,与命令式编程相对,来源于范畴论,最早是为了解决数学问题而诞生。范畴论认为,同一个范畴的所有成员,就是不同状态的"变形",通过态射,一个成员可以变形成另一个成员。
我们可以把物件理解为集合,态射理解为函数,通过函数,来规定范畴中成员之间的关系,函数扮演管道的角色,一个值进去,一个新值出来,没有其他副作用,也就是所谓的 y = f(x)。
函数式风格包含了多种特性,典型的如函数一等公民、纯函数、副作用、柯里化、组合等,这里我们主要以基础的纯函数和副作用做进一步解释。
纯函数
纯函数 ——输入输出数据流全是显式的。
显式的意思是,函数与外界交换数据只有一个唯一渠道——参数和返回值;函数从函数外部接受的所有输入信息都通过参数传递到该函数内部;函数输出到函数外部的所有信息都通过返回值传递到该函数外部。
相同的输入,永远得到相同的输出,而且没有任何副作用,与环境变量无关,可以在任意地方调用。
//splice是不纯的函数
let arr = [1,2,3,4,5];
arr.splice(0,3); //[1,2,3]
arr.splice(0,3); //[4,5]
//slice是纯函数
arr = [1,2,3,4,5];
arr.slice(0,3); //[1,2,3]
arr.slice(0,3); //[1,2,3]
副作用
在计算机科学中,函数副作用指当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响。例如修改全局变量(函数外的变量),修改参数或改变外部存储。
典型的副作用:
-
发送一个http请求
-
new Date() / Math.random();
-
console.log / IO
-
DOM查询 (外部数据)
然而,只有纯函数而无副作用的程序,在程序运行完毕后,不留一丝痕迹,仅仅只是空耗 CPU 而已,因此,副作用是必要的。在函数式语言中,为了保证函数的尽可能纯粹性,副作用使用函子进行统一管理。这里我们不去深究函子的原理,我们需要记住的是:尽可能保持函数的纯粹性,将副作用收拢统一管理。
追溯历史 - Hook 诞生背景
React 组件
React 组件根据书写形式分为函数组件和类组件。
class ComponentA extends React.Component {
constructor(props) {
super(props);
this.state = { displayContent: 'Hello World' }
}
render() {
return <div>{this.state.displayContent}</div>
}
}
function ComponentFunctionA = (props) => <div>{props.displayContent}</div>
函数组件定位为展示组件,自身无状态,无生命周期。
类组件定位为容器组件,自身管理状态与生命周期。
问题
函数组件想管理状态、生命周期
随着需求变更,在未作出良好设计的情况下。常会出现本是函数的组件处于功能内聚性等因素想管理状态的情况。
解决方案:
-
改为类组件
-
提升状态至上层容器
然而,改为类组件需要一定的人力成本;而数据提升至上层容器,既有可能会增加 React 组件层级结构,又丧失了组件功能的内聚性,都不能认为是良好的解决方案。
类组件生命周期带来逻辑分离
class DemoA extends React.PureComponent {
constructor(props) {
super(props);
this.listener = () => {/* do something */};
}
componentDidMount() {
document.addEventListener('click', this.listener);
}
componentWillUnmount() {
document.removeEventListener('click', this.listener);
}
}
这里只是一个简单的例子,展示了由于生命周期的存在,需要我们将一个事件的监听和取消逻辑分别置于不同生命周期内。一旦逻辑复杂,极易导致遗漏这类对事件,数据的清理工作,从而造成内存泄漏甚至逻辑错误。
逻辑抽象复用
方案 | 优点 | 缺点 |
---|---|---|
Mixin | 使用简单方便 灵活 | 维护性差容易 mixin 覆盖 |
HOC | 解决了class不能使用 mixin 问题 | 拥有额外组件层级属性来源不定,容易属性覆盖 |
Render Props | 明确来源,解决了属性覆盖问题 | 额外组件层级可读性差 |
Hook | 消除额外组件层级可读性高,适用逻辑抽象数据来源输出明确 | 依赖管理闭包问题 |
class 本身的问题,比如不能很好的压缩,在热重载时会出现不稳定的情况
解决问题 - Hook 设计
通过 Hook 方式,React 为函数组件引入了可管理副作用的 useState、useEffect、useMemo 等 Hook,以保证函数尽可能纯粹的基础上,有效解决了上述几个之前组件开发的痛点。
这篇文章中以 useState 的数据存储和 useEffect 的副作用管理为例子展开。
由于函数组件有着纯函数的特点,本身不负责数据存储和副作用处理。因此我们首先要解决的问题就是数据的存储和副作用管理。
useState
在 JavaScript 中,解决数据存储主要有以下几个方案。
-
class 成员变量
-
全局状态
-
Dom
-
localStorage 等本地存储方案
-
闭包
其中,类的成员变量是 class 采用的数据存储方案;
考虑到尽可能规避副作用的影响,我们排除全局状态、本地存储和 DOM 的方案;相对而言,闭包可以满足我们数据存储和可靠性的要求。
DEMO 演化
参考 React.useState 的使用方案,应该返回一个 state 数据字段和一个更新 state 的 dispatch。
function Demo () {
const [count, setCount] = useState(0)
return <div onClick={() => { setCount(count++); }}>{count}<div>
}
根据闭包的定义和使用的返回值,我们可以很轻易的定义出以下方法:
var useState = (initState) => {
let data = initState;
const dispatch = (newData) => {
data = newData;
}
return [data, dispatch];
}
在初始化阶段,我们可以验证上面的基础 useState 可以运行。然而在每次渲染的过程中,函数都会被重新调用而重新初始化,这并不是我们期望的。因此我们需要一个数据结构对每次执行的 state 进行存储,同时还需要区分初始化和更新状态的不同执行方式。
type Hook {
memorizedState: any;
}
var useState = (initState) => {
// 根据不同生命周期判断
if (mounted) {
mountedState(initState);
}
if (updated) {
updatedState(initState);
}
}
var mountedState = (initState) => {
const hook = createNewHook();
// 初始化渲染
hook.memoizedState = initalState;
return [hook.memorizedState, dispatchAction]
}
var createNewHook = () => {
return {
memorizedState: null
}
}
function dispatchAction(action){
// 使用数据结构存储所有的更新行为,以便在 rerender 流程中计算最新的状态值
storeUpdateActions(action);
// 执行 fiber 的渲染
scheduleWork();
}
// 第一次之后每一次执行 useState 时实际调用的方法
function updateState(initialState){
// 获取当前正在工作中的 hook
const hook = updateWorkInProgressHook();
// 根据 dispatchAction 中存储的更新行为计算出新的状态值,并返回给组件
updateMemorizedState();
return [hook.memoizedState, dispatchAction];
}
到这里我们有两个问题
-
对于同一个 state,在 mounted 和 updated 的不同状态下,hook 是如何共享的
-
dispatchAction 中的 storeUpdateActions 和 updateState 中的 updateMemorizedState 是如何运作的
针对第一个问题,对于一个组件而言,Hook 是相对于组件存在的。因此,React 组件存储的 ReactNode 十分适合该场景,在当前版本下,我们将其挂载于 FiberNode 节点下。
type FiberNode {
memorizedState: any;
}
而针对第二个问题, 需要我们考虑一些复杂场景问题。
在我们实际场景中,普遍存在着一个更新周期中多次调用的 re-render 行为
以一个例子描述:
function Demo () {
const [count, setCount] = useState(0);
return <div onClick={() => {
setCount(count++);
setCount(count++)
setCount(count++)
}}>{count}<div>
}
实际上组件不会渲染3次,而是根据最后的状态渲染。这意味着在调用 dispatch 更新的时候,我们并不是直接进行更新逻辑,而是将其存储进行update时统一的调度更新,根据执行的有序性,我们采用队列存储一个 hook 的多次调用。
type Queue {
last: Update,
dispatch: any,
lastRenderedState: any
}
type Update {
action: any,
next: Update
}
type Hook {
memorizedState: any,
queue: Queue;
}
function mountState(initState) {
const hook = mountWorkInProgressHook();
hook.memorizedState = initState;
const queue = (hook.queue = {
last: null,
dispatch: null,
lastRenderedState: null
});
// 闭包绑定 queue,实现共享
const dispatch = dispatchAction.bind(null, queue);
queue.dispatch = dispatch;
return [hook.memorizedState, dispatch]
}
function dispatchAction(queue, action) {
const update = {
action,
next: null,
};
// 处理队列更新
let last = queue.last;
if (last === null) {
update.next = update;
} else {
// ... 更新循环链表
}
// 执行 fiber 的渲染
scheduleWork();
}
function updateState(initialState){
// 获取当前正在工作中的 hook
const hook = updateWorkInProgressHook();
// 根据 dispatchAction 中存储的更新行为计算出新的状态值,并返回给组件
(function doReducerWork(){
let newState = null;
do{
// 循环链表,执行每一次更新
}while(...)
hook.memoizedState = newState;
})();
return [hook.memoizedState, hook.queue.dispatch];
}
此外,在真实的应用场景中,我们会根据逻辑进行状态分割。需要在一个组件内多次使用一个 Hook,因此需要记录所有使用的 Hook 信息。这方面,与之前存储同一组更新相同的 Hook 多次调用相同,可采用链表形式进行存储。
type Hook = {
memoizedState: any, // 上一次完整更新之后的最终状态值
queue: UpdateQueue<any, any> | null, // 更新队列
next: any // 下一个 hook
}
这里我们可以看一个例子:
const Demo = () => {
const [count, setCount] = useState(0);
const [time, setTime] = useState(Date.now());
return <div onClick={() => {
setCount(count++);
setTime(Date.now());
}}>{count}-{time}</div>
}
在 ReactNode 中存储的节点形式:
const fiber = {
//...
memoizedState: {
memoizedState: 0,
queue: {
last: {
action: 1
},
dispatch: dispatch,
lastRenderedState: 0
},
next: {
memoizedState: 1603594106044,
queue: {
// ...
},
next: null
}
},
//...
}
整个链表在 mounted 的时候构建,在 update 时按照顺序执行。因此不能在条件循环等场景下使用。
useEffect
有了 useState 设计经验,useEffect 可以同比借鉴。在mount时创建一个 hook 对象,新建一个 effectQueue,以单向链表的方式存储每一个 effect,将 effectQueue 绑定在 fiberNode 上,并在完成渲染之后依次执行该队列中存储的 effect 函数。
type EffectQueue{
lastEffect: Effect
}
type FiberNode{
memoizedState: any, // 用来存放某个组件内所有的 Hook 状态
updateQueue: EffectQueue
}
type Effect {
create: any;
destory: any;
deps: Array;
next: any;
}
与 useState 不同的一点是,useEffect 拥有一个 deps 依赖数组。当依赖数组变更的时候,一个新的副作用函数会被追加至链尾。
function useEffect(fn, dependencies) {
if (mounted) {
mounteEffect(fn, dependencies)
}
if (updated) {
updateEffect(fn, dependencies)
}
}
function mountEffect(fn, deps) {
const hook = mountWorkInProgressHook();
hook.memorizedState = pushEffect(xxxTag, fn, deps)
}
function updateEffect(fn, deps) {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
// 依赖改变则触发销毁重置
if(currentHook!==null){
const prevEffect = currentHook.memoizedState;
const destroy = prevEffect.destroy;
if (nextDeps!== null){
if(areHookInputsEqual(deps, prevEffect.deps)){
pushEffect(xxxTag, create, destroy, deps);
return;
}
}
}
hook.memoizedState = pushEffect(xxxTag, create, deps);
}
function pushEffect(tag, create, destroy, deps) {
const effect = {
create,
destory,
deps,
next: null
};
// 构建 effect 队列
const updateQueue = fiberNode.updateQueue = fiberNode.updateQueue || newUpdateQueue();
if (updateQueue.lastEffect) {
const firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
updateQueue.lastEffect = effect;
} else {
updateQueue.lastEffect = effect.next = effect;
}
return effect;
}
我们可以看到,在 useEffect 阶段,实际并没有对 effect 进行执行,仅仅是构建一条 effect 执行存储链表,而真正 create 和 destroy 的执行,在于 commit 阶段,该部分内容不在本次分享范围,感兴趣的小伙伴可以自行了解
相关链接:ReactFiberCommitWork[1]
总结
通过查找定位问题 -> 得出需求 -> 实现设计 -> 设计演进的步骤,我们一步步了解了 Hook 设计的初衷和设计的一步步完善,在日常工作中,我们也应该借鉴这种思维模式,完善自身对于业务痛点的认识以真正采取相应的解决措施
参考资料
ReactFiberCommitWork: https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberCommitWork.new.js#L375
❤️ 谢谢支持
喜欢的话别忘了 分享、点赞、在看 三连哦~。
点击下方名片,关注 ELab团队 ,获取 一手的大厂技术文章。