【内推字节】欢迎简历chengxinsong@bytedance.com

React Hook 源码解读(一)

React Hook 源码解读(一)

一、前言

  • 背景:React Hook 新特性随 React v16.8.0 版本正式发布,React Hook 还在快速的发展和更新迭代过程中,很多 Class Component 支持的特性,React Hook 还并未支持,但这丝毫不影响社区的学习热情。
  • 特点:
    • 上手简单,使用容易
    • 转变类组件写法的理念和思想
    • 参考 Hook 一些规则和 eslint 插件辅助

二、设计的背景和初衷

了解整个 React Hook 的设计,大部分代码都在适配 React Fiber 架构理念上,这也是源码晦涩难懂的主要原因。我们今天先不看这个,先看纯碎的 React Hook 架构。

React Hook 产生的初衷,解决痛点问题:

  • 组件之间复用状态逻辑很难
    • 类组件方案:render props 和高阶组件
    • 缺点:难理解,存在过多嵌套形成嵌套地狱。
  • 复杂组件难以理解
    • 生命周期中充斥各种状态逻辑和副作用
    • 副作用难以理解
  • 难以理解的 Class
    • this 指针问题
    • 组件预编译技术会在 class 中遇到优化失效的 case
    • class 不能很好的压缩
    • class 在热重载会出现不稳定的情况。

三、设计方案

官网一段介绍:
为了解决上述问题,Hook 是你在非 class 情况下可以使用更多的 React 特性,从概念上讲,React 组件一直更像函数,而 Hook 则拥抱函数,同事没有牺牲 React 的原则,Hook 提供了问题解决方案,无需学习复杂的函数式编程和响应式编程。

1、设计目标和原则

Hook 主要是为了解决以下问题:

  • 无 Class 的复杂性
  • 无生命周期的困扰
  • 优雅复用
  • 对齐 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 中,可以存储持久化状态的无非几种方法:

  • 类实例属性
    class A(){
      constructor(){
          this.count = 0;
      }
      increment(){
          return this.count ++;
      }
    }
    const a = new A();
    a.increment();
    
  • 全局变量

    const global = {count:0};
    
    function increment(){
       return global.count++;
    }
    
  • 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;
    }
    
  • 闭包

    const Counter = function(){
        let count = 0;
        return {
            increment: ()=>{
                return count ++;
            }
        }
    }()
    
    Counter.increment();
    
  • 其他全局存储:indexDB、LocalStorage 等等

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

可靠无副作用

方案 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 函数的实现,分别叫做 mountState 和 updateState。

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,在不同时间调用 mountState 和 updateState 时,如何实现 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];
}

到这一步,更新逻辑的共享,我们就已经解决了。


感谢你的阅读,本文由 sau交流学习社区 版权所有。
如若转载,请注明出处:sau交流学习社区-power by saucxs(程新松)(/page/901.html)
交流咨询
    官方QQ群
    群号663940201,欢迎加入!
    sau交流学习社区交流群

微信群
欢迎加入微信群
微信公众号
欢迎关注微信公众号

图文推荐

微信群
saucxs聊天机器人
saucxs
hi ,欢迎来到sau交流学习社区,欢迎与我聊天,问我问题哦!