请结合源码说说Hook为什么要顺序调用

1,054 阅读3分钟

引言

面试的时候,经常会问到hook的使用原则

  1. 只能在React函数中调用Hook
  2. 不要在循环、条件或嵌套函数中调用hook
    关于第二点的原因,是因为要确保Hooks在每次渲染的时候都保持同样的执行顺序。
    要是面试官深入问你,为啥要确保同样的调用顺序呢?这时没看过源码的你是不是就抓瞎了,下面跟着我的总结,不用深入源码,只要抓住几个点也能让你对达如流。

场景举例

运行环境:React 16.8 React-dom:16.8

import React, { useState } from 'react'
let isMounted = false //设置一个变量,用于记录是否已挂载
const MytestDemo = () => {
    console.log("isMounted is", isMounted);
    let name, age, career, setName, setCareer;
    if(!isMounted){
    // eslint-disable-next-line  //注意要添加eslint忽略否则react会编译不通过
    [name, setName] = useState('张三');
    isMounted = true
    }

    [age] = useState(16);
    [career, setCareer] = useState('前端')
     // 打印对应的career值关注前后变化
  console.log("career", career);
    return (
        <>
            {name ? <div>姓名:{name}</div> : null}
            {age ? <div>年龄:{age}</div> : null}
            {career ? <div>职业:{career}</div> : null}
            <button onClick={() => { setName('李四') }}>更改姓名</button>
        </>
    )
}

export default MytestDemo

运行结果

news.gif

1654154025(1).jpg 从图上我们可以看到第一次运行的时候,career这个变量里面是能正确读取到‘前端’这个值的,而当我们点击修改按钮的时候,career读取的值确是16了,为什么呢?因为第一次遍历的时候,会构建一个链表记录hook的调用顺序,而第二次调用的时候,会按之前的对应顺序去取值,而这时候hook对应顺序已经改变,所以取值不一样了。

源码解析

下面我们从UseState源码层面来解析这一个过程
对应文件:ReactFiberHooks.new.js

首次渲染过程

image.png 这个流程中mountState的主要工作是初始化Hooks,这里面有一个mountWorkInProgressHook方法需要我们关注下,他里面定义了Hooks的结构形式。

mountWorkInProgressHook源码:

function mountWorkInProgressHook(): Hook {
//这里定义了hook的结构,是一个对象,还有一个next指针
  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    baseQueue: null,
    queue: null,

    next: null,
  };

  if (workInProgressHook === null) {
    // This is the first hook in the list
    //把hook当成链条头节点处理
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    //链表不为空,把hook追加到链表尾部
    workInProgressHook = workInProgressHook.next = hook;
  }
  //返回结果
  return workInProgressHook;
}

更新过程

image.png 这个过程中,updateState函数是返回了updateReducer的调用结果,而upDateReducer的作用,涉及的 代码有点多,可以忽略,总结下来就是:按顺序去遍历之前构建好的链表,并取出对应的值进行渲染, 所以如果前后两次调用的顺序发生了变化,取值就会发生混乱。

updateState源码

function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, (initialState: any));
}

updateReducer源码

function updateReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;

  if (queue === null) {
    throw new Error(
      'Should have a queue. This is likely a bug in React. Please file an issue.',
    );
  }

  .....

  if (baseQueue !== null) {
    // We have a queue to process.
   ......

    hook.memoizedState = newState;
    hook.baseState = newBaseState;
    hook.baseQueue = newBaseQueueLast;

    queue.lastRenderedState = newState;
  }

  // Interleaved updates are stored on a separate queue. We aren't going to
  // process them during this render, but we do need to track which lanes
  // are remaining.
  const lastInterleaved = queue.interleaved;
  if (lastInterleaved !== null) {
    let interleaved = lastInterleaved;
    do {
      const interleavedLane = interleaved.lane;
      currentlyRenderingFiber.lanes = mergeLanes(
        currentlyRenderingFiber.lanes,
        interleavedLane,
      );
      markSkippedUpdateLanes(interleavedLane);
      interleaved = ((interleaved: any).next: Update<S, A>);
    } while (interleaved !== lastInterleaved);
  } else if (baseQueue === null) {
    // `queue.lanes` is used for entangling transitions. We can set it back to
    // zero once the queue is empty.
    queue.lanes = NoLanes;
  }

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

场景回顾

初始化阶段的对应关系如下:

image.png

更新阶段的对应关系如下:

image.png

当我们点击更改姓名按钮,去进行页面更新时,react还是会遍历初始化阶段构建的hook链表,然后按照顺序去取出对应的值,然而这时候useState(name)的这个由于我们设置了一个if判断所以没有调用,这时取值顺序已经发生了变化。

总结

React初始化阶段会构建一个hook链表,更新阶段会根据useState的执行顺序去遍历链表取值,如果前后执行顺序不一致,就会导致取出的值不对应,所以我们再写hoos的时候要确保Hooks在每次渲染的时候都保持同样的执行顺序