直播预约通道开启!2021腾讯数字生态大会邀您共探产业发展新机遇! 了解详情
写点什么

源码解析 React Hook 构建过程:没有设计就是最好的设计

2019 年 7 月 17 日

源码解析React Hook构建过程:没有设计就是最好的设计

2018 年的 React Conf 上 Dan Abramov 正式对外介绍了React Hook,这是一种让函数组件支持状态和其他 React 特性的全新方式,并被官方解读为这是下一个 5 年 React 与时俱进的开端。从中细品,可以窥见React Hook的重要性。今年 2 月 6 号,React Hook 新特性随 React v16.8.0 版本正式发布,整个上半年 React 社区都在积极努力地拥抱它,学习并解读它。虽然官方声明,React Hook还在快速的发展和更新迭代过程中,很多Class Component支持的特性,React Hook还并未支持,但这丝毫不影响社区的学习热情。


React Hook上手非常简单,使用起来也很容易,但相比我们已经熟悉了 5 年的类组件写法,React Hook还是有一些理念和思想上的转变。React 团队也给出了使用 Hook 的一些规则eslint插件来辅助降低违背规则的概率,但规则并不是仅仅让我们去记忆的,更重要的是要去真正理解设计这些规则的原因和背景。


本文是我个人在学习 React Hook 的过程中,通过学习官方文档、阅读源码、浏览其他优秀同行撰写的经验文章,再结合自己的思考,通过逆向思维从 React Hook 希望解决的问题出发,复盘了 React Hook 的核心架构设计和创造的过程。非常适合希望对 React Hook 有更深了解,但又不愿意去读晦涩的源码的同学。


文章中的代码很多只是伪代码,重点在解读设计思路,因此并非完整的实现。很多链表的构建和更新逻辑也一并省略了,但并不影响大家了解整个 React Hook 的设计。事实上React Hook的大部分代码都在适配React Fiber架构的理念,这也是源码晦涩难懂的主要原因。不过没关系,我们完全可以先屏蔽掉React Fiber的存在,去一点点构建纯粹的 React Hook 架构。


设计的背景和初衷

React Hook 的产生主要是为了解决什么问题呢?官方的文档里写的非常清楚,这里只做简单的提炼,不做过多陈述,没读过文档的同学可以先移步阅读React Hook简介


总结一下要解决的痛点问题就是:


  1. 在组件之间复用状态逻辑很难

  2. 之前的解决方案是:render props 和高阶组件。

  3. 缺点是难理解、存在过多的嵌套形成“嵌套地狱”。


  4. 复杂组件变的难以理解

  5. 生命周期函数中充斥着各种状态逻辑和副作用。

  6. 这些副作用难以复用,且很零散。

  7. 难以理解的 Class

  8. this 指针问题。

  9. 组件预编译技术(组件折叠)会在 class 中遇到优化失效的 case。

  10. class 不能很好的压缩。

  11. class 在热重载时会出现不稳定的情况。


设计方案

React 官网有下面这样一段话:


为了解决这些问题,Hook 使你在==非 class 的情况下可以使用更多的 React 特性==。 从概念上讲,React 组件一直更像是函数。而 Hook 则拥抱了函数,同时也没有牺牲 React 的精神原则。Hook 提供了问题的解决方案,无需学习复杂的函数式或响应式编程技术


设计目标和原则

对应第一节所抛出的问题,React Hook 的设计目标便是要解决这些问题,总结起来就以下四点:


  1. 无 Class 的复杂性

  2. 无生命周期的困扰

  3. 优雅地复用

  4. 对齐 React Class 组件已经具备的能力


设计方案

无 Class 的复杂性(去 Class)

React 16.8 发布之前,按照是否拥有状态的维护来划分的话,组件的类型主要有两种:


  1. 类组件 Class Component:主要用于需要内部状态,以及包含副作用的复杂的组件



class App extends React.Component{ constructor(props){ super(props); this.state = { //... } } //...}
复制代码


  1. 函数组件 Function Component:主要用于纯组件,不包含状态,相当于一个模板函数


function Footer(links){    return (        <footer>            <ul>            {links.map(({href, title})=>{                return <li><a href={href}>{title}</a></li>            })}            </ul>        </footer>    )}
复制代码


如果设计目标是==去 Class==的话,似乎选择只能落在改造Function Component,让函数组件拥有Class Component一样的能力上了。


我们不妨畅想一下最终的支持状态的函数组件代码:


// 计数器function Counter(){    let state = {count:0}        function clickHandler(){        setState({count: state.count+1})       }        return (        <div>            <span>{count}</span>            <button onClick={clickHandler}>increment</button>        </div>    )}
复制代码


上述代码使用函数组件定义了一个计数器组件Counter,其中提供了状态state,以及改变状态的setState函数。这些 API 对于Class component来说无疑是非常熟悉的,但在Function component中却面临着不同的挑战:


  1. class 实例可以永久存储实例的状态,而函数不能,上述代码中 Counter 每次执行,state 都会被重新赋值为 0;

  2. 每一个Class component的实例都拥有一个成员函数this.setState用以改变自身的状态,而Function component只是一个函数,并不能拥有this.setState这种用法,只能通过全局的 setState 方法,或者其他方法来实现对应。


以上两个问题便是选择改造Function component所需要解决的问题。


解决方案

在 JS 中,可以存储持久化状态的无非几种方法:


  1. 类实例属性


class A(){    constructor(){        this.count = 0;    }    increment(){        return this.count ++;    }}const a = new A();a.increment();

复制代码


  1. 全局变量


const global = {count:0};
function increment(){ return global.count++;}

复制代码


  1. DOM


const count = 0;const $counter = $('#counter');$counter.data('count', count);
funciton increment(){ const newCount = parseInt($counter.data('count'), 10) + 1; $counter.data('count',newCount); return newCount;}
复制代码


  1. 闭包


const Counter = function(){    let count = 0;    return {        increment: ()=>{            return count ++;        }    }}()
Counter.increment();
复制代码


  1. 其他全局存储:indexDB、LocalStorage 等等


Function component对状态的诉求只是能存取,因此似乎以上所有方案都是可行的。但作为一个优秀的设计,还需要考虑到以下几点:


  1. 使用简单

  2. 性能高效

  3. 可靠无副作用


方案 2 和 5 显然不符合第三点;方案 3 无论从哪一方面都不会考虑;因此闭包就成为了唯一的选择了。


闭包的实现方案

既然是闭包,那么在使用上就得有所变化,假设我们预期提供一个名叫useState的函数,该函数可以使用闭包来存取组件的 state,还可以提供一个 dispatch 函数来更新 state,并通过初始调用时赋予一个初始值。


function Counter(){    const [count, dispatch] = useState(0)        return (        <div>            <span>{count}</span>            <button onClick={dispatch(count+1)}>increment</button>        </div>    )}

复制代码


如果用过 redux 的话,这一幕一定非常眼熟。没错,这不就是一个微缩版的 redux 单向数据流吗?


给定一个初始 state,然后通过 dispatch 一个 action,再经由 reducer 改变 state,再返回新的 state,触发组件重新渲染。


知晓这些,useState的实现就一目了然了:


function useState(initialState){    let state = initialState;    function dispatch = (newState, action)=>{        state = newState;    }    return [state, dispatch]}
复制代码


上面的代码简单明了,但显然仍旧不满足要求。Function Component在初始化、或者状态发生变更后都需要重新执行useState函数,并且还要保障每一次useState被执行时state的状态是最新的。


很显然,我们需要一个新的数据结构来保存上一次的state和这一次的state,以便可以在初始化流程调用useState和更新流程调用useState可以取到对应的正确值。这个数据结构可以做如下设计,我们假定这个数据结构叫 Hook:


type Hook = {  memoizedState: any,   // 上一次完整更新之后的最终状态值  queue: UpdateQueue<any, any> | null, //更新队列};
复制代码


考虑到第一次组件mounting和后续的updating逻辑的差异,我们定义两个不同的useState函数的实现,分别叫做mountStateupdateState


function useState(initialState){    if(isMounting){        return mountState(initialState);    }        if(isUpdateing){        return updateState(initialState);    }}
// 第一次调用组件的useState时实际调用的方法function mountState(initialState){ let hook = createNewHook(); hook.memoizedState = initalState; return [hook.memoizedState, dispatchAction]}
function dispatchAction(action){ // 使用数据结构存储所有的更新行为,以便在rerender流程中计算最新的状态值 storeUpdateActions(action); // 执行fiber的渲染 scheduleWork();}
// 第一次之后每一次执行useState时实际调用的方法function updateState(initialState){ // 根据dispatchAction中存储的更新行为计算出新的状态值,并返回给组件 doReducerWork(); return [hook.memoizedState, dispatchAction];}
function createNewHook(){ return { memoizedState: null, baseUpdate: null }}
复制代码


上面的代码基本上反映出我们的设计思路,但还存在两个核心的问题需要解决:


  1. 调用storeUpdateActions后将以什么方式把这次更新行为共享给doReducerWork进行最终状态的计算。

  2. 同一个 state,在不同时间调用mountStateupdateState时,如何实现hook对象的共享。


更新逻辑的共享

更新逻辑是一个抽象的描述,我们首先需要根据实际的使用方式考虑清楚一次更新需要包含哪些必要的信息。实际上,在一次事件 handler 函数中,我们完全可以多次调用dispatchAction



function Count(){ const [count, setCount] = useState(0); const [countTime, setCountTime] = useState(null); function clickHandler(){ // 调用多次dispatchAction setCount(1); setCount(2); setCount(3); //... setCountTime(Date.now()) } return ( <div> <div>{count} in {countTime}</div> <button onClick={clickHandler} >update counter</button> </div> )}
复制代码


在执行对setCount的 3 次调用中,我们并不希望 Count 组件会因此被渲染 3 次,而是会按照调用顺序实现最后调用的状态生效。因此如果考虑上述使用场景的话,我们需要同步执行完clickHandler中所有的dispatchAction后,并将其更新逻辑顺序存储,然后再触发 Fiber 的 re-render 合并渲染。那么多次对同一个dispatchAction的调用,我们如何来存储这个逻辑呢?


比较简单的方法就是使用一个队列Queue来存储每一次更新逻辑Update的基本信息:


type Queue{    last: Update,   // 最后一次更新逻辑    dispatch: any,    lastRenderedState: any  // 最后一次渲染组件时的状态}
type Update{ action: any, // 状态值 next: Update // 下一次Update}
复制代码


这里使用了单向链表结构来存储更新队列,为什么要用单向链表而不用数组呢?这个问题应该是一道经典的数据结构的面试题,留给大家自己去思考。


有了这个数据结构之后,我们再来改动一下代码:


function mountState(initialState){    let hook = createNewHook();    hook.memoizedState = initalState;        // 新建一个队列    const queue = (hook.queue = {        last: null,        dispatch: null,        lastRenderedState:null    });        //通过闭包的方式,实现队列在不同函数中的共享。前提是每次用的dispatch函数是同一个    const dispatch = dispatchAction.bind(null, queue);    return [hook.memoizedState, dispatch]}

function dispatchAction(queue, action){ // 使用数据结构存储所有的更新行为,以便在rerender流程中计算最新的状态值 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 对象即可。对于一个 React 组件而言,唯一对应的全局存储自然就是 ReactNode,在React 16x 之后,这个对象应该是FiberNode。这里为了简单起见,我们暂时不研究 Fiber,我们只需要知道一个组件在内存里有一个唯一表示的对象即可,我们姑且把他叫做fiberNode


type FiberNode {    memoizedState:any  // 用来存放某个组件内所有的Hook状态}
复制代码


现在,摆在我们面前的问题是,我们对Function component的期望是什么?我们希望的是用Function componentuseState来完全模拟Class componentthis.setState吗?如果是,那我们的设计原则会是:


一个函数组件全局只能调用一次 useState,并将所有的状态存放在一个大 Object 里


如果仅仅如此,那么函数组件已经解决了去Class的痛点,但我们并没有考虑优雅地复用状态逻辑的诉求。


试想一个状态复用的场景:我们有多个组件需要监听浏览器窗口的resize事件,以便可以实时地获取clientWidth。在Class component里,我们要么在全局管理这个副作用,并借助 ContextAPI 来向子组件下发更新;要么就得在用到该功能的组件中重复书写这个逻辑。


resizeHandler(){    this.setState({        width: window.clientWidth,        height: window.clientHeight    });}
componentDidMount(){ window.addEventListener('resize', this.resizeHandler)}
componentWillUnmount(){ window.removeEventListener('resize', this.resizeHandler);}

复制代码


ContextAPI 的方法无疑是不推荐的,这会给维护带来很大的麻烦;ctrl+c ctrl+v就更是无奈之举了。


如果Function component可以为我们带来一种全新的状态逻辑复用的能力,那无疑会为前端开发在复用性和可维护性上带来更大的想象空间。


因此理想的用法是:


const [firstName, setFirstName] = useState('James');const [secondName, setSecondName] = useState('Bond');
// 其他非state的Hook,比如提供一种更灵活更优雅的方式来书写副作用useEffect()

复制代码


综上所述,设计上理应要考虑一个组件对应多个 Hook 的用法。带来的挑战是:


我们需要在fiberNode上存储所有 Hook 的状态,并确保它们在每一次re-render时都可以获取到最新的正确的状态


要实现上述存储目标,直接想到的方案就是用一个 hashMap 来搞定:


{    '1': hook1,    '2': hook2,    //...}
复制代码


如果用这种方法来存储,会需要为每一次 hook 的调用生成唯一的 key 标识,这个 key 标识需要在 mount 和 update 时从参数中传入以保证能路由到准确的 hook 对象。


除此方案之外,还可以使用 hook.update 采用的单向链表结构来存储,给 hook 结构增加一个 next 属性即可实现:



type Hook = { memoizedState: any, // 上一次完整更新之后的最终状态值 queue: UpdateQueue<any, any> | null, // 更新队列 next: any // 下一个hook}

const fiber = { //... memoizedState: { memoizedState: 'James', queue: { last: { action: 'Smith' }, dispatch: dispatch, lastRenderedState: 'Smith' }, next: { memoizedState: 'Bond', queue: { // ... }, next: null } }, //...}
复制代码


这种方案存在一个问题需要注意:


整个链表是在mount时构造的,所以在update时必须要保证执行顺序才可以路由到正确的 hook。


我们来粗略对比一下这两种方案的优缺点:


方案优点缺点
hashMap查找定位hook更加方便对hook的使用没有太多规范和条件的限制影响使用体验,需要手动指定key
链表API友好简洁,不需要关注key需要有规范来约束使用,以确保能正确路由


很显然,hashMap 的缺点是无法忍受的,使用体验和成本都太高了。而链表方案缺点中的规范是可以通过 eslint 等工具来保障的。从这点考虑,链表方案无疑是胜出了,事实上这也正是React团队的选择。


到这里,我们可以了解到为什么 React Hook 的规范里要求:


只能在函数组件的顶部使用,不能再条件语句和循环里使用


function Counter(){    const [count, setCount] = useState(0);    if(count >= 1){        const [countTime, setCountTime] = useState(Date.now());    }}
// mount 阶段构造的hook链为{ memoizedState: { memoizedState: '0', queue: {}, next: null}
// 调用setCount(1)之后的update 阶段,则会找不到对应的hook对象而出现异常

复制代码


至此,我们已经基本实现了 React Hooks 去Class的设计目标,现在用函数组件,我们也可以通过useState这个 hook 实现状态管理,并且支持在函数组件中调用多次 hook。


无生命周期的困扰

上一节我们借助闭包、两个单向链表(单次 hook 的 update 链表、组件的 hook 调用链表)、透传 dispatch 函数实现了 React Hook 架构的核心逻辑:如何在函数组件中使用状态。到目前为止,我们还没有讨论任何关于生命周期的事情,这一部分也是我们的设计要解决的重点问题。我们经常会需要在组件渲染之前或者之后去做一些事情,譬如:


  • Class componentcomponentDidMount中发送ajax请求向服务器端拉取数据。

  • Class componentcomponentDidMountcomponentDidUnmount中注册和销毁浏览器的事件监听器。


这些场景,我们同样需要在 React Hook 中予以解决。React 为Class component设计了一大堆生命周期函数:


  • 在实际的项目开发中用的比较频繁的,譬如渲染后期的:componentDidMountcomponentDidUpdatecomponentWillUnmount

  • 很少被使用的渲染前期钩子componentWillMountcomponentWillUpdate

  • 一直以来被滥用且有争议的componentWillReceiveProps和最新的getDerivedStateFromProps

  • 用于性能优化的shouldComponentUpdate


React 16.3 版本已经明确了将在 17 版本中废弃componentWillMountcomponentWillUpdatecomponentWillReceiveProps这三个生命周期函数。设计用来取代componentWillReceivePropsgetDerivedStateFromProps也并不被推荐使用


真正被重度使用的就是渲染后和用于性能优化的几个,在 React hook 之前,我们习惯于以 render 这种技术名词来划分组件的生命周期阶段,根据名字componentDidMount我们就可以判断现在组件的 DOM 已经在浏览器中渲染好了,可以执行副作用了。这显然是技术思维,那么在 React Hook 里,我们能否抛弃这种思维方式,让开发者无需去关注渲染这件事儿,只需要知道哪些是副作用,哪些是状态,哪些需要缓存即可呢?


根据这个思路我们来设计 React Hook 的生命周期解决方案,或许应该是场景化的样子:


// 用来替代constructor初始化状态useState()
// 替代 componentDidMount和componentDidUpdate以及componentWillUnmount// 统一称为处理副作用useEffect()
// 替代shouldComponentuseMemo()

复制代码


这样设计的好处是开发者不再需要去理清每一个生命周期函数的触发时机,以及在里面处理逻辑会有哪些影响。而是更关注去思考哪些是状态,哪些是副作用,哪些是需要缓存的复杂计算和不必要的渲染。


useEffect

effect的全称应该是Side Effect,中文名叫副作用,我们在前端开发中常见的副作用有:


  • dom 操作

  • 浏览器事件绑定和取消绑定

  • 发送 HTTP 请求

  • 打印日志

  • 访问系统状态

  • 执行 IO 变更操作


在 React Hook 之前,我们经常会把这些副作用代码写在componentDidMountcomponentDidUpdatecomponentWillUnmount里,比如:


componentDidMount(){    this.fetchData(this.props.userId).then(data=>{        //... setState    })        window.addEventListener('resize', this.onWindowResize);        this.counterTimer = setInterval(this.doCount, 1000);}
componentDidUpdate(prevProps){ if (this.props.userID !== prevProps.userID) { this.fetchData(this.props.userID); }}
componentWillUnmount(){ window.removeEventListener('resize', this.onWindowResize); clearInterval(this.counterTimer);}

复制代码


这种写法存在一些体验的问题:


  1. 同一个副作用的创建和清理逻辑分散在多个不同的地方,这无论是对于新编写代码还是要阅读维护代码来说都不是一个上佳的体验。

  2. 有些副作用可能要再多个地方写多份。


第一个问题,我们可以通过 thunk 来解决:将清理操作和新建操作放在一个函数中,清理操作作为一个 thunk 函数被返回,这样我们只要在实现上保障每次 effect 函数执行之前都会先执行这个 thunk 函数即可:


useEffect(()=>{    // do some effect work    return ()=>{        // clean the effect    }})
复制代码


第二个问题,对于函数组件而言,则再简单不过了,我们完全可以把部分通用的副作用抽离出来形成一个新的函数,这个函数可以被更多的组件复用。



function useWindowSizeEffect(){ const [size, setSize] = useState({width: null, height: null}); function updateSize(){ setSize({width: window.innerWidth, height: window.innerHeight}); } useEffect(()=>{ window.addEventListener('resize', updateSize); return ()=>{ window.removeEventListener('resize', updateSize); } }) return size;}
复制代码


useEffect 的执行时机

既然是设计用来解决副作用的问题,那么最合适的时机就是组件已经被渲染到真实的 DOM 节点之后。因为只有这样,才能保证所有副作用操作中所需要的资源(dom 资源、系统资源等)是 ready 的。


上面的例子中描述了一个在 mount 和 update 阶段都需要执行相同副作用操作的场景,这样的场景是普遍的,我们不能假定只有在 mount 时执行一次副作用操作就能满足所有的业务逻辑诉求。所以在 update 阶段,useEffect 仍然要重新执行才能保证满足要求。


这就是 useEffect 的真实机制:


Function Component函数(useState、useEffect、…)每一次调用,其内部的所有 hook 函数都会再次被调用。


这种机制带来了一个显著的问题,就是:


父组件的任何更新都会导致子组件内 Effect 逻辑重新执行,如果 effect 内部存在性能开销较大的逻辑时,可能会对性能和体验造成显著的影响。


React 在PureComponent和底层实现上都有过类似的优化,只要依赖的 state 或者 props 没有发生变化(浅比较),就不执行渲染,以此来达到性能优化的目的。useEffect同样可以借鉴这个思想:



useEffect(effectCreator: Function, deps: Array)
// democonst [firstName, setFirstName] = useState('James');const [count, setCount] = useState(0);
useEffect(()=>{ document.title = `${firstName}'s Blog`;}, [firstName])

复制代码


上面的例子中,只要传入的firstName在前后两次更新中没有发生变化,effectCreator函数就不会执行。也就是说,即便调用多次setCount(*),组件会重复渲染多次,但只要 firstName 没有发生变化,effectCreator函数就不会重复执行。


useEffect 的实现

useEffect 的实现和 useState 基本相似,在mount时创建一个 hook 对象,新建一个 effectQueue,以单向链表的方式存储每一个 effect,将 effectQueue 绑定在 fiberNode 上,并在完成渲染之后依次执行该队列中存储的 effect 函数。核心的数据结构设计如下:


type Effect{    tag: any,           // 用来标识effect的类型,    create: any,        // 副作用函数    destroy: any,       // 取消副作用的函数,    deps: Array,        // 依赖    next: Effect,       // 循环链表指针}
type EffectQueue{ lastEffect: Effect}
type FiberNode{ memoizedState:any // 用来存放某个组件内所有的Hook状态 updateQueue: any }
复制代码


deps 参数的优化逻辑就很简单了:


let componentUpdateQueue = null;function pushEffect(tag, create, deps){    // 构建更新队列    // ...}
function useEffect(create, deps){ if(isMount)( mountEffect(create, deps) )else{ updateEffect(create, deps) }}
function mountEffect(create, deps){ const hook = createHook(); hook.memoizedState = pushEffect(xxxTag, create, deps); }
function updateEffect(create, deps){ const hook = getHook(); if(currentHook!==null){ const prevEffect = currentHook.memoizedState; if(deps!==null){ if(areHookInputsEqual(deps, prevEffect.deps)){ pushEffect(xxxTag, create, deps); return; } } } hook.memoizedState = pushEffect(xxxTag, create, deps);}
复制代码


useEffect 小结

  1. 执行时机相当于componentDidMountcomponentDidUpdate,有 return 就相当于加了componentWillUnmount

  2. 主要用来解决代码中的副作用,提供了更优雅的写法。

  3. 多个 effect 通过一个单向循环链表来存储,执行顺序是按照书写顺序依次执行。

  4. deps 参数是通过循环浅比较的方式来判断和上一次依赖值是否完全相同,如果有一个不同,就重新执行一遍 Effect,如果相同,就跳过本次 Effect 的执行。

  5. 每一次组件渲染,都会完整地执行一遍清除、创建 effect。如果有 return 一个清除函数的话。

  6. 清除函数会在创建函数之前执行。


useMemo

useEffect中我们使用了一个deps参数来声明 effect 函数对变量的依赖,然后通过areHookInputsEqual函数来比对前后两次的组件渲染时deps的差异,如果浅比较的结果是相同,那么就跳过 effect 函数的执行。


仔细想想,这不就是生命周期函数shouldComponentUpdate要做的事情吗?何不将该逻辑抽取出来,作为一个通用的 hook 呢,这就是useMemo这个 hook 的原理。


function mountMemo(nextCreate,deps) {  const hook = mountWorkInProgressHook();  const nextDeps = deps === undefined ? null : deps;  const nextValue = nextCreate();  hook.memoizedState = [nextValue, nextDeps];  return nextValue;}
function updateMemo(nextCreate,deps){ const hook = updateWorkInProgressHook(); const nextDeps = deps === undefined ? null : deps; // 上一次的缓存结果 const prevState = hook.memoizedState; if (prevState !== null) { if (nextDeps !== null) { const prevDeps = prevState[1]; if (areHookInputsEqual(nextDeps, prevDeps)) { return prevState[0]; } } } const nextValue = nextCreate(); hook.memoizedState = [nextValue, nextDeps]; return nextValue;}

复制代码


但 useMemo 和shouldComponentUpdate的区别在于 useMemo 只是一个通用的无副作用的缓存 Hook,并不会影响组件的渲染与否。所以从这点上讲,useMemo 并不能替代shouldComponentUpdate,但这丝毫不影响 useMemo 的价值。useMemo 为我们提供了一种通用的性能优化方法,对于一些耗性能的计算,我们可以用 useMemo 来缓存计算结果,只要依赖的参数没有发生变化,就达到了性能优化的目的。


const result = useMemo(()=>{    return doSomeExpensiveWork(a,b);}, [a,b])
复制代码


那么要完整实现shouldComponentUpdate的效果应该怎么办呢?答案是借助React.memo:


const Button = React.memo((props) => {  // 你的组件});
复制代码


这相当于使用了 PureComponent。


到目前为止,除了getDerivedStateFromProps,其他常用的生命周期方法在 React Hook 中都已经有对应的解决方案了,componentDidCatch官方已经声明正在实现中。这一节的最后,我们再来看看getDerivedStateFromProps的替代方案。


这个生命周期的作用是根据父组件传入的 props,按需更新到组件的 state 中。虽然很少会用到,但在 React Hook 组件中,仍然可以通过在渲染时调用一次"setState"来实现:


function ScrollView({row}) {  let [isScrollingDown, setIsScrollingDown] = useState(false);  let [prevRow, setPrevRow] = useState(null);
if (row !== prevRow) { // Row 自上次渲染以来发生过改变。更新 isScrollingDown。 setIsScrollingDown(prevRow !== null && row > prevRow); setPrevRow(row); }
return `Scrolling down: ${isScrollingDown}`;}
复制代码


如果在渲染过程中调用了"setState",组件会取消本次渲染,直接进入下一次渲染。所以这里一定要注意"setState"一定要放在条件语句中执行,否则会造成死循环。


优雅地复用

React 组件化开发方式,本质上就是组件的复用,开发一个应用就像搭积木一样把各种组件有机地堆叠在一起。但这是整个组件层面的复用,是一种粗粒度的复用。在不同的组件内部,我们仍然会经常做一些重复劳动,这些重复劳动可能包含以下几种:


  • 状态及其逻辑的重复。比如 loading 状态,计数器等。

  • 副作用的逻辑重复。比如有同一个 ajax 请求、多个组件内对同一个浏览器事件的监听、同一类 dom 操作或者宿主 API 的调用等。


React Hook 的设计目标中很重要的一点就是:


如何让状态及其逻辑和副作用逻辑具备真正的复用性而不需要使用reder-propsHOC


React 中的代码复用

使用过早期版本 React 的同学可能知道Mixins API,这是官方提供的一种比组件更细粒度的逻辑复用能力。在 React 推出基于 ES6 的Class Component的写法后,就被逐渐’抛弃’了。Mixins虽然可以非常方便灵活地解决AOP类的问题,譬如组件的性能日志监控逻辑的复用:


const logMixin = {    componentWillMount: function(){        console.log('before mount:', Date.now());    }        componentDidMount: function(){        console.log('after mount:', Date.now())    }}
var createReactClass = require('create-react-class');const CompA = createReactClass({ mixins: [logMixin], render: function(){ //... }})
const CompB = createReactClass({ mixins: [logMixin], render: function(){ //... }})
复制代码


但这种模式本身会带来很多的危害,具体可以参考官方的一片博文:《Mixins Considered Harmful》


React 官方在 2016 年建议拥抱HOC,也就是使用高阶组件的方式来替代mixins的写法。minxins API 仅可以在create-react-class手动创建组件时才能使用。这基本上宣告了 mixins 这种逻辑复用的方式的终结。


HOC非常强大,React 生态中大量的组件和库都使用了HOC,比如react-reduxconnect API:


class MyComp extends Component{    //...}export default connect(MyComp, //...)
复制代码


HOC实现上面的性能日志打印,代码如下:


function WithOptimizeLog(Comp){    return class extends Component{        constructor(props){            super(props);                   }                componentWillMount(){            console.log('before mount:', Date.now());        }                componentDidMount(){            console.log('after mount:', Date.now());        }                render(){            return (                <div>                    <Comp {...props} />                </div>            )        }    }} 
// CompAexport default WithOptimizeLog(CompA)
//CompBexport defaultWithOptimizeLog(CompB);
复制代码


HOC虽然强大,但因其本身就是一个组件,仅仅是通过封装了目标组件提供一些上层能力,因此难以避免的会带来嵌套地狱的问题。并且因为HOC是一种将可复用逻辑封装在一个 React 组件内部的高阶思维模式,所以和普通的React组件相比,它就像是一个魔法盒子一样,势必会更难以阅读和理解。


可以肯定的是HOC模式是一种被广泛认可的逻辑复用模式,并且在未来很长的一段时间内,这种模式仍将被广泛使用。但随着React Hook架构的推出,HOC模式是否仍然适合用在Function Component中?还是要寻找一种新的组件复用模式来替代HOC呢?


React 官方团队给出的答案是后者,原因是在React Hook的设计方案中,借助函数式状态管理以及其他 Hook 能力,逻辑复用的粒度可以实现的更细、更轻量、更自然和直观。毕竟在 Hook 的世界里一切都是函数,而非组件。


来看一个例子:


export default function Article() {    const [isLoading, setIsLoading] = useState(false);    const [content, setContent] = useState('origin content');        function handleClick() {        setIsLoading(true);        loadPaper().then(content=>{            setIsLoading(false);            setContent(content);        })    }
return ( <div> <button onClick={handleClick} disabled={isLoading} > {isLoading ? 'loading...' : 'refresh'} </button> <article>{content}</article> </div> )}
复制代码


上面的代码中展示了一个带有 loading 状态,可以避免在加载结束之前反复点击的按钮。这种组件可以有效地给予用户反馈,并且避免用户由于得不到有效反馈带来的不断尝试造成的性能和逻辑问题。


很显然,loadingButton 的逻辑是非常通用且与业务逻辑无关的,因此完全可以将其抽离出来成为一个独立的LoadingButton组件:


function LoadingButton(props){    const [isLoading, setIsLoading] = useState(false);        function handleClick(){        props.onClick().finally(()=>{            setIsLoading(false);        });        }        return (        <button onClick={handleClick} disabled={isLoading} >            {isLoading ? 'loading...' : 'refresh'}        </button>    )}
// 使用function Article(){ const {content, setContent} = useState(''); clickHandler(){ return fetchArticle().then(data=>{ setContent(data); }) } return ( <div> <LoadingButton onClick={this.clickHandler} /> <article>{content}</article> </div> )}
复制代码


上面这种将某一个通用的 UI 组件单独封装并提取到一个独立的组件中的做法在实际业务开发中非常普遍,这种抽象方式同时将状态逻辑和 UI 组件打包成一个可复用的整体。


很显然,这仍旧是组件复用思维,并不是逻辑复用思维。试想一下另一种场景,在点击了 loadingButton 之后,希望文章的正文也同样展示一个 loading 状态该怎么处理呢?


如果不对 loadingButton 进行抽象的话,自然可以非常方便地复用 isLoading 状态,代码会是这样:


export default function Article() {    const [isLoading, setIsLoading] = useState(false);    const [content, setContent] = useState('origin content');        function handleClick() {        setIsLoading(true);        loadArticle().then(content=>{            setIsLoading(false);            setContent(content);        })    }
return ( <div> <button onClick={handleClick} disabled={isLoading} > {isLoading ? 'loading...' : 'refresh'} </button> { isLoading ? <img src={spinner} alt="loading" /> : <article>{content}</article> } </div> )}
复制代码


但针对抽象出 LoadingButton 的版本会是什么样的状况呢?


function LoadingButton(props){    const [isLoading, setIsLoading] = useState(false);        function handleClick(){        props.onClick().finally(()=>{            setIsLoading(false);        });        }        return (        <button onClick={handleClick} disabled={isLoading} >            {isLoading ? 'loading...' : 'refresh'}        </button>    )}
// 使用function Article(){ const {content, setContent} = useState('origin content'); const {isLoading, setIsLoading} = useState(false); clickHandler(){ setIsLoading(true); return fetchArticle().then(data=>{ setContent(data); setIsLoading(false); }) } return ( <div> <LoadingButton onClick={this.clickHandler} /> { isLoading ? <img src={spinner} alt="loading" /> : <article>{content}</article> } </div> )}
复制代码


问题并没有因为抽象而变的更简单,父组件 Article 仍然要自定一个 isLoading 状态才可以实现上述需求,这显然不够优雅。那么问题的关键是什么呢?


答案是耦合。上述的抽象方案将isLoading状态和button标签耦合在一个组件里了,这种复用的粒度只能整体复用这个组件,而不能单独复用一个状态。解决方案是:


// 提供loading状态的抽象export function useIsLoading(initialValue, callback) {    const [isLoading, setIsLoading] = useState(initialValue);
function onLoadingChange() { setIsLoading(true);
callback && callback().finally(() => { setIsLoading(false); }) }
return { value: isLoading, disabled: isLoading, onChange: onLoadingChange, // 适配其他组件 onClick: onLoadingChange, // 适配按钮 }}
export default function Article() { const loading = useIsLoading(false, fetch); const [content, setContent] = useState('origin content');
function fetch() { return loadArticle().then(setContent); }
return ( <div> <button {...loading}> {loading.value ? 'loading...' : 'refresh'} </button> { loading.value ? <img src={spinner} alt="loading" /> : <article>{content}</article> } </div> )}
复制代码


如此便实现了更细粒度的状态逻辑复用,在此基础上,还可以根据实际情况,决定是否要进一步封装 UI 组件。譬如,仍然可以封装一个 LoadingButton:


// 封装按钮function LoadingButton(props){    const {value, defaultText = '确定', loadingText='加载中...'} = props;    return (        <button {...props}>            {value ? loadingText: defaultText}        </button>    )}
// 封装loading动画function LoadingSpinner(props) { return ( < > { props.value && <img src={spinner} className="spinner" alt="loading" /> } </> )}// 使用
return ( <div> <LoadingButton {...loading} /> <LoadingSpinner {...loading}/> { loading.value || <article>{content}</article> } </div>)
复制代码


状态逻辑层面的复用为组件复用带来了一种全新的能力,这完全有赖于React Hook基于Function的组件设计,一切皆为函数调用。并且,Function Component也并不排斥HOC,你仍然可以使用熟悉的方法来提供更高阶的能力,只是现在,你的手中拥有了另外一种武器。


自定义 Hook 的实现原理

上述例子中的 useIsLoading 函数被称之为自定义Hook,它所做的仅仅是将部分hook代码提取到一个独立的函数中,就像我们把可复用的逻辑提取到一个独立的函数中一样。


从上文中我们了解到,Hook 队列需要存储在组件对应的 FiberNode 上才可以,那么自定义 hook 也会对应一个 FiberNode 吗?自定义 Hook 对入参和结果有什么要求呢?


我们对自定义 Hook 的定义是逻辑的复用,而不是组件的复用,因此它不应该像Function Component一样直接返回组件树,自然也就没有一个独立的 FiberNode 来对应了。如果没有独立存储,那自定义 hook 函数内部调用的 useState、useEffect 等 hook 函数的数据结构应该如何存储呢?


答案是绑定在调用这个自定义 hook 的Function Component对应的FiberNode上,被抽离出来的自定义 Hook 逻辑,在实际执行的过程中,就好像 copy 了一份自定义 Hook 代码,替换了原来的调用代码,这就是自定义 Hook 的本质。


因此自定义 Hook 在使用时也需要遵循 Hook 规范,需要在函数顶部调用 hook,不能写在条件语句和循环里。除此之外,由于规范允许在自定义 Hook 中调用 hook 函数,但不允许在普通的 function 中调用,因此需要一种规范或者机制来保障开发者不会犯错。


React 团队给出的方案是命名规范和 eslint 校验:


  1. 自定义 Hook 必须以use开头,以便可以通过命名规范来区分。比如:‘useIsLoading’

  2. 使用 ESLINT 插件来确保当开发者犯错时可以进行提示


对齐 React Class 组件已经具备的能力

在本文撰写的时间点上,仍然有一些Class Component具备的功能是React Hook没有具备的,譬如:生命周期函数componentDidCatchgetSnapshotBeforeUpdate。还有一些第三方库可能还无法兼容 hook,官方给出的说法是:


我们会尽快补齐


未来可期,我们只需静静地等待。


小结

武侠小说中有”无招胜有招“的境界,在设计领域也有”没有设计就是最好的设计“的论断。React Hook抛弃Class,拥抱函数式编程,使用 JS 语言独特的闭包来存储状态,这种设计就像是日本设计师深泽直人倡导的无意识设计一样,对于 Javascript 程序员而言,使用的时候不需要多余的思考,一切皆函数,一切都那么自然、优雅和顺理成章。因本人能力的局限性,文中难免有解读不正确之处,盼望大家可以交流指正(笔者 github 博客地址:https://github.com/shanggqm/blog)


参考文档


2019 年 7 月 17 日 18:069759

评论 1 条评论

发布
用户头像
if(last === null){
update.next = update; // 这里应该是queue.next = update 吧
}
2019 年 08 月 04 日 15:31
回复
没有更多了
发现更多内容

架构师训练营第 1 期 - 第九周总结

Todd-Lee

极客大学架构师训练营

网络冲浪信任危机频发,区块链能否破局?

CECBC区块链专委会

区块链 征信透明

输入法B端市场转化:百度推动产业智能化的新路径

脑极体

JVM运行时数据区,你真得好好学一学

Simon郎

JVM Java 分布式

大专学历Java开发7年,从年初被裁到四面美团点评成功上岸,闭关七个月,入职那一天我哭了!

Java架构追梦

Java 阿里巴巴 面试 美团 java架构

《使用C ++的数据结构和程序设计》限时免费下载

计算机与AI

c++

给,你们想要的内存溢出MAT排查工具

田维常

内存溢出

React 灵魂 23 问

局外人

Java 前端 React

架构师训练营第 1 期 - 第九周作业

Todd-Lee

极客大学架构师训练营

DataOps系列丨DataOps的组织架构与挑战

DataPipeline数见科技

大数据

Appium常用操作之「Toast提示信息获取」

清菡

消灭微服务的坏味道 之 循环依赖

码猿外

微服务 循环依赖 坏味道

架构师系列之6: python实现一致性hash

桃花原记

LeetCode 热题 - 递归

哈希说

LeetCode

git使用与原理剖析及其私服搭建

程序员Fox

git

接口的幂等性的多重考虑,你会了吗?

moon聊技术

Java 接口

Spring Cloud Config 实现分布式配置中心

AI乔治

Java 架构 微服务 Spring Cloud

用 Python 实现定时自动化收取蚂蚁森林能量

Python小二

Python

《ZooKeeper分布式过程协同技术详解》.pdf

田维常

电子书

第五周 - 作业

leo

极客大学架构师训练营

区块链的新信任模式将重塑传统金融业

CECBC区块链专委会

区块链 资产流动性

石、火、水:从OriginOS透视移动系统进化论

脑极体

Maven-技术专题-Setting文件结构解析

李浩宇/Alex

阿里大牛八年打造,编程宝典,从初学到编程进阶—深入学习—实战

Java~~~

Java 阿里巴巴 程序员 架构 编程语言

看“区块链”如何为外贸企业融资

CECBC区块链专委会

区块链 银行

DataOps系列丨DataOps理念与设计原则

DataPipeline数见科技

大数据

实现2nm工艺突破,台积电为何能给“摩尔定律”续命?

脑极体

第五周-笔记

leo

极客大学架构师训练营

结合实战和源码来聊聊Java中的SPI机制?

冰河

Java spi 服务发现

五、一致性哈希算法

Geek_28b526

Nacos实战及其源码分析

程序员Fox

Spring Cloud nacos spring cloud alibaba

技术为帆,纵横四海- Lazada技术东南亚探索和成长之旅

技术为帆,纵横四海- Lazada技术东南亚探索和成长之旅

源码解析React Hook构建过程:没有设计就是最好的设计-InfoQ