Modal.success 中 hook 无法实时更新问题

最近同事问了我一个问题,为啥 Modal.success 中的 hook 无法实时更新,代码如下

import React, { useState } from "react"
import { Modal,  Button } from 'antd'

function App() {
    const [num, setNum] = useState(0)

    const success = () => {
        setNum(100)

        Modal.success({
            content: <span>num: {num}</span>,
            onOk: () =>  {}
        })
    }

    return <Button onClick={success}>Success</Button>
}

export default App

看起来代码没有任何问题,但是点击按钮后展示如下
在这里插入图片描述

也就是说弹窗中展示的是初始值0,并不是setNum设置的最新值100,这就非常奇怪,useState不是可以实时更新num的值吗,为什么这里失效了,初步怀疑是由于setNum异步执行导致,于是修改代码如下

import React, { useState } from "react"
import { Modal, Button } from 'antd'
function App() {
    const [num, setNum] = useState(0)
    const success = () => {
        setNum(100)
        Modal.success({
            content: <span>num: {num}</span>,
            onOk: () => { 
                setTimeout(() => {
                    console.log(num);
                }, 1000);
            }
        })
    }
    return <Button onClick={success}>Success</Button>
}
export default App

在 onOk 中设置一个定时器,一秒后再获取 num,发现得到的值还是0,排除异步因素,那为什么会出现这种情况呢?

于是花费了一些时间对这个问题进行研究,下面是对这个问题的解析,你可以自己先思考一下,然后再继续浏览,看看我们想法是否一致。

要解释这个问题需要具备两个知识点:

  1. useState的执行原理
  2. 闭包的原理

首先大致说一下 useState 原理:在 React 内部,初始化的时候会将传入的 num 挂载到一个全局的 hook 对象上,调用 setNum 的时候会更新 hook 对象上挂载的 num 值,然后触发重新渲染,继而执行 updateState,在updateState的时候从全局的 hook 对象上拿到最新的 num 值,然后返回最新的 num 和 setNum,此时组件中的num 就从0变成了100。

为了方便理解,我写了一个极简的 useState 如下,当然和源码相比还非常简陋,但是基本原理是一样的。

const hook = {};

function useState(initialState) {
    if (!hook.state) {
        hook.state = initialState;
    }
    
    function setState(newState) {
        hook.state = newState;
        render();  // 重新渲染
    }
    return [hook.state, setState];
}

从上面的 useState 中可以看出,num之所以变化,是由于 setState 会触发重新渲染组件,进而将 最新值渲染到页面,至此 useState 的原理已经解释完毕。

接下来就是闭包的原理,对于闭包的解释网上有很多文章这里就不再详细介绍,需要注意的是针对闭包的定义大致有两种,以上面的 App 组件为例,红皮书认为 success 是闭包,Chrome 开发团队认为外层的 App 是闭包,因为日常开发都是以 Chrome为主,所以我采纳 Chrome 开发团队的观点。

接下来就是问题解释了,因为 success 函数引入了外层 App 中的 num 变量,所以 App 形成了闭包,App 执行完毕后不会被垃圾回收,仍会以闭包的形成存在于内存中,闭包中的 num 就等是0,所以当点击按钮执行 success 方法,在 success 内部无论是以同步还是异步获取的 num 始终是0,这也就解释了上面的问题。

深入思考下,如果我们关闭弹窗重新点击,那么弹窗中显示的是多少呢?答案是100,原理和第一次点击一样,不同的是第一次点击时候会执行 setNum(100) 触发重新渲染,生成一个新的闭包,新闭包中的num变成了100罢了。

既然知道问题的根源,那么如何规避这个问题呢?

最简单的方法就是将值挂载到一个对象上面,如下

import React from "react"
import { Modal, Button } from 'antd'

const obj = {
    num: null
}

function App() {
    obj.num = 0
    
    const success = () => {
        obj.num = 100

        Modal.success({
            content: <span>num: {obj.num}</span>,
            onOk: () => { }
        })
    }
    return <Button onClick={success}>Success</Button>
}
export default App

因为之前 num 是一个基本类型,所以每次闭包中的值都会重新拷贝,虽然引用类型也会被拷贝,但是只会拷贝 指针,堆内存中的值却始终只有一份,所以无论通过哪个指针都可以获取最新的值。

其实 React 官方也给我们提供现成的 hook,useRef 也可以解决这个问题,下面是 useRef 的源码,其实和我们的处理方法原理差不多。

function mountRef<T>(initialValue: T): {|current: T|} {
  const hook = mountWorkInProgressHook();
  const ref = {current: initialValue};
  hook.memoizedState = ref;
  return ref;
}

function updateRef<T>(initialValue: T): {|current: T|} {
  const hook = updateWorkInProgressHook();
  return hook.memoizedState;
}

至此,这个问题已经基本回答完毕。

但是既然讲的了 useState,就顺手分析一波源码,加深下理解。

在 React 内部 useState 其实是对应了两个函数,分别是 mountStateupdateState,顾名思义,就是初始化和更新时调用的,App 组件第一次 render 的时候会执行 useState(0) 对应的就是执行 mountState,源码如下

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    // $FlowFixMe: Flow doesn't like mixed types
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue = (hook.queue = {
    pending: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  });
  const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchAction.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));
  return [hook.memoizedState, dispatch];
}

主要是通过 mountWorkInProgressHook 生成了一个 hook,然后将初始值挂载到 hook 上面,之后创建了一个队列 queue,并 queue 挂载到 hook 上,最后返回初始值和 dispatch。dispatch 其实就是 dispatchAction,所以我们看下 dispatchAction 的源码

function dispatchAction<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
) {

  const eventTime = requestEventTime();
  const lane = requestUpdateLane(fiber);

  const update: Update<S, A> = {
    lane,
    action,
    eagerReducer: null,
    eagerState: null,
    next: (null: any),
  };

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

  const alternate = fiber.alternate;
  if (
    fiber === currentlyRenderingFiber ||
    (alternate !== null && alternate === currentlyRenderingFiber)
  ) {
    // This is a render phase update. Stash it in a lazily-created map of
    // queue -> linked list of updates. After this render pass, we'll restart
    // and apply the stashed updates on top of the work-in-progress hook.
    didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate = true;
  } else {
    if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
    ) {
      // The queue is currently empty, which means we can eagerly compute the
      // next state before entering the render phase. If the new state is the
      // same as the current state, we may be able to bail out entirely.
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        let prevDispatcher;
        if (__DEV__) {
          prevDispatcher = ReactCurrentDispatcher.current;
          ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
        }
        try {
          const currentState: S = (queue.lastRenderedState: any);
          const eagerState = lastRenderedReducer(currentState, action);
          // Stash the eagerly computed state, and the reducer used to compute
          // it, on the update object. If the reducer hasn't changed by the
          // time we enter the render phase, then the eager state can be used
          // without calling the reducer again.
          update.eagerReducer = lastRenderedReducer;
          update.eagerState = eagerState;
          if (is(eagerState, currentState)) {
            // Fast path. We can bail out without scheduling React to re-render.
            // It's still possible that we'll need to rebase this update later,
            // if the component re-renders for a different reason and by that
            // time the reducer has changed.
            return;
          }
        } catch (error) {
          // Suppress the error. It will throw again in the render phase.
        } finally {
          if (__DEV__) {
            ReactCurrentDispatcher.current = prevDispatcher;
          }
        }
      }
    }

    scheduleUpdateOnFiber(fiber, lane, eventTime);
  }
}

这个函数主要是生成一个 update 对象,然后挂载到传入的 queue 队列上面,然后将传入的值挂载到 update 的 eagerState 上,这样一来,更新的时候 hook 就可以通过调用 queue 上的 update 上的 eagerState 来获取每次传入的值了,最后调用 scheduleUpdateOnFiber 重渲染,这个方法非常复杂,就不展开了,这里只需要知道这个方法会触发重新渲染,进而执行 updateState 就行了,下面是updateState的源码:

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

updateState 其实调用的是 updateReducer,传入了一个内置的 reducer 也就是这里的basicStateReducer。

function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  // $FlowFixMe: Flow doesn't like mixed types
  return typeof action === 'function' ? action(state) : action;
}

这个方法很简单,没啥看的,我们继续看 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;
  queue.lastRenderedReducer = reducer;

  const current: Hook = (currentHook: any);

  // The last rebase update that is NOT part of the base state.
  let baseQueue = current.baseQueue;

  // The last pending update that hasn't been processed yet.
  const pendingQueue = queue.pending;
  if (pendingQueue !== null) {
    // We have new updates that haven't been processed yet.
    // We'll add them to the base queue.
    if (baseQueue !== null) {
      // Merge the pending queue and the base queue.
      const baseFirst = baseQueue.next;
      const pendingFirst = pendingQueue.next;
      baseQueue.next = pendingFirst;
      pendingQueue.next = baseFirst;
    }
    current.baseQueue = baseQueue = pendingQueue;
    queue.pending = null;
  }

  if (baseQueue !== null) {
    // We have a queue to process.
    const first = baseQueue.next;
    let newState = current.baseState;

    let newBaseState = null;
    let newBaseQueueFirst = null;
    let newBaseQueueLast = null;
    let update = first;
    do {
      const updateLane = update.lane;
      if (!isSubsetOfLanes(renderLanes, updateLane)) {
        // Priority is insufficient. Skip this update. If this is the first
        // skipped update, the previous update/state is the new base
        // update/state.
        const clone: Update<S, A> = {
          lane: updateLane,
          action: update.action,
          eagerReducer: update.eagerReducer,
          eagerState: update.eagerState,
          next: (null: any),
        };
        if (newBaseQueueLast === null) {
          newBaseQueueFirst = newBaseQueueLast = clone;
          newBaseState = newState;
        } else {
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }
        // Update the remaining priority in the queue.
        // TODO: Don't need to accumulate this. Instead, we can remove
        // renderLanes from the original lanes.
        currentlyRenderingFiber.lanes = mergeLanes(
          currentlyRenderingFiber.lanes,
          updateLane,
        );
        markSkippedUpdateLanes(updateLane);
      } else {
        // This update does have sufficient priority.

        if (newBaseQueueLast !== null) {
          const clone: Update<S, A> = {
            // This update is going to be committed so we never want uncommit
            // it. Using NoLane works because 0 is a subset of all bitmasks, so
            // this will never be skipped by the check above.
            lane: NoLane,
            action: update.action,
            eagerReducer: update.eagerReducer,
            eagerState: update.eagerState,
            next: (null: any),
          };
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }

        // Process this update.
        if (update.eagerReducer === reducer) {
          // If this update was processed eagerly, and its reducer matches the
          // current reducer, we can use the eagerly computed state.
          newState = ((update.eagerState: any): S);
        } else {
          const action = update.action;
          newState = reducer(newState, action);
        }
      }
      update = update.next;
    } while (update !== null && update !== first);

    if (newBaseQueueLast === null) {
      newBaseState = newState;
    } else {
      newBaseQueueLast.next = (newBaseQueueFirst: any);
    }

    // Mark that the fiber performed work, but only if the new state is
    // different from the current state.
    if (!is(newState, hook.memoizedState)) {
      markWorkInProgressReceivedUpdate();
    }

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

    queue.lastRenderedState = newState;
  }

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

这个方法首先通过 updateWorkInProgressHook 获取 hook,之后获取 hook 上的队列 queue,这个队列是一个单链结构,并通过 do while 来循环这个单链,获取初始化时挂载到 update上 的 eagerState 属性,传入到上面内置的 basicStateReducer 中,生成新的 newState,然后将 newState 重新挂载到hook上,之后返回新的值dispatch,这样 App 组件中的 num 是通过 setNum 设置的值了,至此源码分析结束。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值