【万字长文】React Hooks的黑魔法

1,870 阅读14分钟

React 的 logo 是一个原子图案, 原子组成了物质的表现。类似的, React 就像原子般构成了页面的表现; 而 Hooks 就如夸克, 更接近 React 的本质, 但是直到 4 年后的今天才被设计出来。 —— Dan in React Conf(2018)

正片开始

React在18年推出了hooks这一思想,以一种新的思维模式去构建web App。我们都知道,React认为,UI视图是数据的一种视觉映射,即UI = F(data),F需要负责对输入数据进行加工、并对数据的变更做出响应。 React给UI的复用提供了极大的便利,但是对于逻辑的复用,在Class Component中并不是那么方便。在有Hooks之前,逻辑的复用通常是使用HOC来实现,使用HOC会带来一些问题:

  • 嵌套地狱,每一次HOC调用都会产生一个组件实例
  • 可以使用类装饰器缓解组件嵌套带来的可维护性问题,但装饰器本质上还是HOC

在有Hooks之前,Function Component只能单纯地接收props、事件,然后返回jsx,本身是无状态的组件,依赖props来响应数据(状态)的变更,而上面提到的依赖都是从Class Component传入的,所以在有Hooks之前Function Component是要完全依赖Class Component存在的。但是这上面这些在Hooks出现之后全部都被打破。 本文不会介绍hooks的使用方式,详见 官方文档

在使用了Hooks之后,你肯定会对Hooks的原理非常感兴趣,本文要讲述的就是React Hooks的“黑魔法”,主要内容有:

  1. Hooks的结构
  2. Hooks是如何区分是首次渲染(mount)还是更新(update)的
  3. useState是如何使Function Component有状态的?
    • useState的原理
    • useState如何触发更新
  4. useEffect、useCallback、useMemo、useRef的实现
  5. 使用hooks的注意事项

Hooks的结构

一个Hook结构如下:

export type Hook = {
  memoizedState: any,

  baseState: any,
  baseUpdate: Update<any, any> | null,
  queue: UpdateQueue<any, any> | null,

  next: Hook | null,
};

我们都知道,在React v16版本推出了Fiber架构,每一个节点都对应一个Fiber结构。一个Function Component中可以有很多个Hooks执行,最终形成的结构如下:

上面这个图到这里还看不懂没关系,下面让我们开始深入Hooks的原理。

Hooks如何区分Mount和Update

要知道这个问题的答案,首先需要了解React的Fiber架构。React定义了Fiber结构来描述一个节点,通过Fiber节点上的child、return、sibling指针构成整个App的结构。Fiber类型定义如下:

export type Fiber = {|
  tag: WorkTag,
  key: null | string,
  elementType: any,
  type: any,
  stateNode: any,
  return: Fiber | null,
  child: Fiber | null,
  sibling: Fiber | null,
  index: number,
  ref: null | (((handle: mixed) => void) & {_stringRef: ?string}) | RefObject,
  pendingProps: any, // This type will be more specific once we overload the tag.
  memoizedProps: any, // The props used to create the output.
  updateQueue: UpdateQueue<any> | null,
  memoizedState: any,
  dependencies: Dependencies | null,
  mode: TypeOfMode,
  effectTag: SideEffectTag,
  nextEffect: Fiber | null,
  firstEffect: Fiber | null,
  lastEffect: Fiber | null,
  expirationTime: ExpirationTime,
  childExpirationTime: ExpirationTime,
  alternate: Fiber | null,
  // ...
|};

用React Conf上的例子简单说明一下 Fiber 结构:List组件下面有四个子节点:一个button和三个Item。

这个组件转换成Fiber tree结构如下:

一个 Function Component 最终也会生成一个 Fiber 节点挂载到 Fiber tree 中。React 还使用了 Double Buffer 的思想,在 React 内部会维护两棵 Fiber tree,一棵叫 Current,一棵叫 WorkInProgress,current 是最终展示在用户界面上的结构,workInProgress 用于后台进行diff更新操作。两棵树在更新结束的时候会互相调换,即 workInProgress 在更新之后会变为 current 展示在用户界面上,current 会变成 workInProgress 用于下次 update。两棵树之间对应的节点是通过Fiber结构上的 alternat e属性链接的,即 workInProgress.alternate = current,current.alternate = workInProgress

那这和Hooks有什么关系?其实React在初始渲染的时候,只会生成一棵workInProgress树,当整棵树构建完成之后,由workInProgress变为current,在下一次更新的时候才会生成第二棵树。所以当Function Component对应的Fiber节点发现自己的alternate属性为null,说明是第一次渲染。在React的源码中就是renderWithHooks函数中的这句(Function Component的mount和update过程会执行renderWithHooks):

export function renderWithHooks( // 判断是mount还是update:fiber.memoizedState是否有值
  current: Fiber | null,
  workInProgress: Fiber,
  Component: any,
  props: any,
  refOrContext: any,
  nextRenderExpirationTime: ExpirationTime,
): any {
  // ...
  nextCurrentHook = current !== null ? current.memoizedState : null;

  ReactCurrentDispatcher.current =
    nextCurrentHook === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;

  // ...
}

从代码中能看到current 等于 null的情况下,nextCurrentHook = null,导致下面的dispatcher取得是HooksDispatcherOnMount,这个HooksDispatcherOnMount就是在初始渲染Mount阶段对应的Hooks,HooksDispatcherOnUpdate显然就是在更新阶段时该调用的Hooks。

useState让函数组件有状态

上面知道了Hooks是如何区分Mount和Update之后,接下来分析useState的实现原理。

useState如何触发更新

state在Function Component中就是一个普通的常量,不存在诸如数据绑定之类的逻辑,更新都是通过useStatedispatch(useState返回的数组的第二个元素),触发了组件rerender,进而更新组件。

dispatch方法接收到最新的state后(就是第三个参数action),生成一个update,添加到queue的最后,用last指针指向这最新的一次更新,然后调用scheduleWork方法,这个scheduleWork就是触发react更新的起始方法,在Class Component中调用this.setState时最终也是执行了这个方法开始更新。

function dispatchAction<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A, // 最新的state
) {

  const alternate = fiber.alternate;
  if (
    fiber === currentlyRenderingFiber ||
    (alternate !== null && alternate === currentlyRenderingFiber)
  ) {
    // ...
  } else {
    const currentTime = requestCurrentTime();
    const suspenseConfig = requestCurrentSuspenseConfig();
    const expirationTime = computeExpirationForFiber(
      currentTime,
      fiber,
      suspenseConfig,
    );

    const update: Update<S, A> = {
      expirationTime,
      suspenseConfig,
      action,
      eagerReducer: null,
      eagerState: null,
      next: null,
    };

    // Append the update to the end of the list.
    const last = queue.last;
    if (last === null) {
      // This is the first update. Create a circular list.
      update.next = update;
    } else {
      const first = last.next;
      if (first !== null) {
        // Still circular.
        update.next = first;
      }
      last.next = update;
    }
    queue.last = update;

    // ...

    scheduleWork(fiber, expirationTime);
  }
}

useState的原理

上面提到了Hooks的结构,每一次Hook的执行都会生成一个Hook结构,首次渲染的时候执行useState,会将传过来的initialState挂在Hook的memoizedState属性上,后续再获取状态就是从memoizedState属性上获取了。

mount阶段的useState:mountState

useState最终会返回一个有两个元素的数组,第一个元素是state,第二个元素是 修改state的方法 dispatch。 这里dispatch方法也需要关注一下:queue.dispatch = dispatchAction.bind(null, currentlyRenderingFiber, queue ),dispatch是dispatchAction方法通过bind传入当前的 Fiber 和 queue属性作为前两个参数生成的,所以每个useState都有自己的dispatch方法,这个dispatch方法是固定作用在指定的Fiber上的(通过闭包锁定了前两个参数)

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue = (hook.queue = {
    last: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  });
  const dispatch: Dispatch<BasicStateAction<S>> = (queue.dispatch = (dispatchAction.bind(
    null, currentlyRenderingFiber, queue ));
  return [hook.memoizedState, dispatch];
}

queue属性用一个Update结构记录了每次调用dispatch时候传过来的state,形成了一个链表结构,其last属性指向最新的一个state Update结构。Update结构如下,我们需要关注的有:

  • action:最新传过来的state值
  • next:指向后面生成的Update结构

其他属性涉及到的内容不在本次讨论范围

update阶段的useState:updateReducer

update阶段,React首先会从Hooks上获取到 last、baseState、baseUpdate 属性,各个值的含义如下:

  • last:queue上挂载的最新一次的Update,里面包含了最新的state,在dispatch方法执行的时候挂载到queue上的
  • baseState:上一次更新后的state值。当dispatch传入的是一个函数的时候,这个值就是函数执行时传入的参数
  • baseUpdate:上一次更新生成的Update结构,作用是找到本次rerender的第一个为处理的Update节点(baseUpdate.next),即下面代码中的first代表第一个未处理的update
const hook = updateWorkInProgressHook();
const queue = hook.queue;

// The last update in the entire queue
const last = queue.last; // 新的值
// The last update that is part of the base state.
const baseUpdate = hook.baseUpdate; // 旧的值,next属性指向新的Update
const baseState = hook.baseState; // 旧的值

// Find the first unprocessed update.
let first;
if (baseUpdate !== null) {
  if (last !== null) {
    last.next = null;
  }
  first = baseUpdate.next;
} else {
  first = last !== null ? last.next : null;
}

找到第一个未处理的update之后就需要循环对所有新增update进行处理,这里的变量newState虽然名字叫newState,在每次执行reducer之前的值都是 就的state值,所以当useState传入的值为一个函数的时候,我们可以获取到上一次的state,因为旧的state值是有缓存的。 当处理完所有update之后就更新hooks对应的baseState、baseUpdate、memoizedState的值。

if (first !== null) {
  let newState = baseState;
  let newBaseState = null;
  let newBaseUpdate = null;
  let prevUpdate = baseUpdate;
  let update = first;
  let didSkip = false;
  do {
      // ...
    const action = update.action;

    newState = reducer(newState, action);

    prevUpdate = update;
    update = update.next;
  } while (update !== null && update !== first);

  if (!didSkip) {
    newBaseUpdate = prevUpdate;
    newBaseState = newState;
  }

  if (!is(newState, hook.memoizedState)) {
    markWorkInProgressReceivedUpdate();
  }

  hook.memoizedState = newState;
  hook.baseUpdate = newBaseUpdate;
  hook.baseState = newBaseState;

  queue.lastRenderedState = newState;
}

// reducer函数:
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  return typeof action === 'function' ? action(state) : action;
}

最终仍然返回的是一个数组结构,包含了最新的state和dispatch方法

const dispatch: Dispatch<A> = (queue.dispatch: any);
return [hook.memoizedState, dispatch];

这样Function Component中获取到的state就是最新的值,Function Component的更新实际上就是重新执行一次函数,得到 jsx,剩下 reconcile 和 commit 阶段的就与 class Component是一样的了

useEffect的实现

我们知道useEffect传入的函数是在绘制之后才执行的,所以当执行function component执行的时候肯定不是 useEffect 的第一个函数参数执行的时候,那在执行useEffect的时候都做了什么呢?

执行useEffect时是在为后面做准备。useEffect会生成一个Effect结构,Effect结构如下:

type Effect = {
  tag: HookEffectTag,
  create: () => (() => void) | void,
  destroy: (() => void) | void,
  deps: Array<mixed> | null,
  next: Effect,
};

create就是我们传入的参数,我们如果想取消一个副作用的话是通过create执行返回的结果(仍然是一个函数),React会在内部调用这个函数。因为create函数是在 绘制之后执行的,所以这个时候Effect的destory是null,在后面真正执行create的时候会赋值destory

生成了Effect结构之后就要将其挂载到Hooks的memorizedState上,React不光将Effect挂载到了Hook结构上,也将其直接和Fiber挂钩:

React会将所有的effect形成一个环形链表,保存在FunctionComponentUpdateQueue上,其lastEffect指向最新生成的effect。 为什么要做一个环形链表保存所有的effect? 我认为主要是:

  • 环形链表可以很方便的找到头结点(第一个effect),可以处理所有的effect
  • 保存所有的effect是因为effect可能有需要unmount的时候销毁的操作,保存之前的effect可以调用其destory方法销毁,避免内存泄露

最终处理上面保存的effects的函数在commit阶段的commitHookEffectList函数中,代码如下,主要工作就是:循环处理所有的effect,判断需要销毁还是需要执行,循环终止条件就是重新回到环形链表的第一个节点。删减后的代码如下:

// ReactFiberCommitWork.js
function commitHookEffectList(
  unmountTag: number,
  mountTag: number,
  finishedWork: Fiber,
) {
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  let lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & unmountTag) !== NoHookEffect) {
        // Unmount
        const destroy = effect.destroy;
        effect.destroy = undefined;
        if (destroy !== undefined) {
          destroy();
        }
      }
      if ((effect.tag & mountTag) !== NoHookEffect) {
        // Mount
        const create = effect.create;
        effect.destroy = create();

      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

useCallback和useMemo的实现

useCallbackuseMemo是十分相似(useCallback可以通过useMemo实现),所以这里我们看一下useMemo的实现: useMemo传入一个函数fn和一个数组dep,当dep中的值没发生变化的时候,就会一直返回之前函数fn执行后返回的值,所以我们首先执行函数fn,将返回的结果和依赖数组dep保存起来就好了, React 内部是这么处理的:

const nextValue = nextCreate(); // 传入的函数Fn
hook.memoizedState = [nextValue, nextDeps];
return nextValue;

当触发re-render的时候再次执行 useMemo,React会从 hook.memoizedState 上面取出之前保存的dep,也就是hook.memoizedState的第二个元素。比较之前的dep和新传入的dep的每个元素是否相同(浅比较),如果相同则返回原来保存的值(hook.memoizedState的第一个元素),不相同则重新执行 函数Fn 重新生成返回值并保存。

const prevState = hook.memoizedState;
if (prevState !== null) {
  if (nextDeps !== null) {
    const prevDeps: Array<mixed> | null = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];
    }
  }
}
const nextValue = nextCreate();
hook.memoizedState = [nextValue, nextDeps];
return nextValue;

useCallback原理相同,只不过保存的值不同,一个是函数,一个是函数的执行结果。

useRef的实现

useRef的实现是最简单的了,我们先回顾一下useRef的作用:使用useRef生成一个对象之后可以通过 current 属性获取到一个值,这个是不会随着re-render而改变,只能我们自己手动改变。当然也可以传递给组件的 ref 属性使用。经常被类比为 Class Component的实例属性,比如 this.xxx = xxx

在开头部分我们知道 Hooks 是基于 fiber 结构的,fiber 是 react 内部维护的结构,会在整个react生命周期中存在,所以useRef最简单的实现就是 挂载到 fiber 上,作为fiber的一个属性存在,这样通过 fiber 就一直能获取到值。

但是真正设计的时候肯定不能这样来,因为 Hooks 是有自己的结构的,所以就把 useRef 的值挂载到 Hook 结构的 memorizedState上 就可以了,所以你看到的 useRef 的结构是这样的:

将 useRef的值挂载到 对象的current属性上,从current属性上能获取到值

使用hooks的注意事项

使用React.memo减少不必要的组件渲染

React.memo is equivalent to PureComponent, but it only compares props. (You can also add a second argument to specify a custom comparison function that takes the old and new props. If it returns true, the update is skipped.)

使用 React.memo包裹组件之后,当父组件传过来的props不变时,子组件不会re-render。举个例子:

const ChildComponent = React.memo((props) => {
    return (
        <div>{props.name}</div>
    )
}, compare)

React.memo是对新旧Props进行浅比较,也可以自定义compare函数比较nextPropsprevProps,浅比较就会带来问题:每次Function Component执行内部的对象都会重新生成,这个时候如果传给子组件的是一个对象的话,其实还是会造成刷新。例:

function ParentComponent() {
    const [state, setState] = useState(0)
    function handleClickButton() {
       console.log(state)
        // balabalabala
    }
    const someProps = {
        name: 'xxx'
    }
    return (
        <div>
            <input />
            <ChildExpensiveComponent onPress={handleClickButton} someProps={someProps}/>
        </div>
    )
}

所以这里我们想到用 useMemouseCallback 来保存值,这样只要依赖不变值就不变。所以下面代码这种改变之后确实不会触发不必要的刷新了

function ParentComponent() {
    const [state, setState] = useState(0)
    const handleClickButton  = useCallback(() => {
        // balabalabala
        console.log(state) // 0
    }, [])
    const memoProps = useMemo(() => {
        return {
            name: 'xxx'
        }
    }, [])
    return (
        <div>
            <p>{state}</p>
            <ChildExpensiveComponent onPress={handleClickButton} someProps={memoProps}/>
        </div>
    )
}

但是Hooks是通过closure实现的,除useRef之外,其他的Hooks都会存在capture values的特点。上面例子中handleClickButton每次执行,无论state如何变化,打印的state值将一直是0。 因为useCallback通过闭包保存了一开始的state的值,这个值不会像Class Component一样每次都会取到最新的值。

那我们是不是给handleClick加个dependence就行了,像这样:

const handleClickButton  = useCallback(() => {
    // balabalabala
    console.log(state) // 0
}, [state])

但是当你的state频繁发生变化的时候,handleClickButton其实会频繁改变,这样的话你的子组件通过React.memo实现的优化就失效了。

所以当依赖经常变动时,盲目使用useCallbackuseMemo可能会导致性能不升反降。 上面我们已经了解了,在react内部,useCallback执行会生成一个Hook结构,将函数和deps保存在这个Hook结构的memoizedState上,在每次rerender的时候,react会去比较prevDepsnextDeps,相等会返回保存的值/函数,不相等会重新执行,所以当deps频繁改变的时候,会多了一个比较deps是否改变的操作,也会浪费性能。这里有一个来自React官网的例子:

function Form() {
  const [text, updateText] = useState('');

  const handleSubmit = useCallback(() => {
    console.log(text);
  }, [text]); // 每次 text 变化时 handleSubmit 都会变

  return (
    <>
      <input value={text} onChange={(e) => updateText(e.target.value)} />
      <ExpensiveTree onSubmit={handleSubmit} /> // 很重的组件
    </>
  );
}

解决这种问题React官方也给了一种方式,就是使用useRef

function Form() {
  const [text, updateText] = useState('');
  const textRef = useRef();

  useEffect(() => {
    textRef.current = text; // Write it to the ref  });

  const handleSubmit = useCallback(() => {
    const currentText = textRef.current; // Read it from the ref   
    alert(currentText);
  }, [textRef]); // Don't recreate handleSubmit like [text] would do

  return (
    <>
      <input value={text} onChange={e => updateText(e.target.value)} />
      <ExpensiveTree onSubmit={handleSubmit} />
    </>
  );
}

当使用Hooks时不要把思维仍然局限在class Component的定式中,useRef通常给人的感觉是和class Component的createRef类似的作用,但 useRef 除了在 ref 使用之外,还可以用来保存值,上面这个例子是一个非常好的例子,帮我们更好的去使用Hooks避免一些性能的浪费。

推荐阅读

reactjs.org/docs/hooks-…

reactjs.org/docs/hooks-…

zhuanlan.zhihu.com/p/142735113

juejin.cn/post/684490…

medium.com/@dan_abramo…

dev.to/tylermcginn…

欢迎关注我的个人技术公众号,不定期分享各种前端技术~