48

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

 4 years ago
source link: https://www.tuicool.com/articles/fYNZn2R
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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. 在组件之间复用状态逻辑很难

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

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

    miEv2yb.png!web

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

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

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

  3. 难以理解的 Class

    • this 指针问题。

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

    • class 不能很好的压缩。

    • 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 : 主要用于需要内部状态,以及包含副作用的复杂的组件

复制代码

classAppextendsReact.Component{
constructor(props){
super(props);
this.state = {
//...
}
}
//...
}
  1. 函数组件 Function Component :主要用于纯组件,不包含状态,相当于一个模板函数

复制代码

functionFooter(links){
return(
<footer>
<ul>
{links.map(({href, title})=>{
return<li><ahref={href}>{title}</a></li>
})}
</ul>
</footer>
)
}

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

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

复制代码

// 计数器
functionCounter(){
letstate = {count:0}

functionclickHandler(){
setState({count: state.count+1})
}

return(
<div>
<span>{count}</span>
<buttononClick={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. 类实例属性

复制代码

classA(){
constructor(){
this.count =0;
}
increment(){
returnthis.count ++;
}
}
consta = new A();
a.increment();

  1. 全局变量

复制代码

constglobal= {count:0};

functionincrement(){
returnglobal.count++;
}

  1. DOM

复制代码

constcount= 0;
const$counter= $('#counter');
$counter.data('count',count);

funciton increment(){
constnewCount = parseInt($counter.data('count'), 10) + 1;
$counter.data('count',newCount);
returnnewCount;
}
  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()

// 替代 shouldComponent
useMemo()

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

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)

// demo
const [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>
            )
        }
    }
} 

// CompA
export default WithOptimizeLog(CompA)

//CompB
export 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 校验:

use

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

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

我们会尽快补齐

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

小结

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

参考文档


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK