返回介绍

状态的更新

发布于 2025-01-10 12:49:04 字数 9784 浏览 0 评论 0 收藏 0

之前我们已经看懂了 React 的事件委托机制,那么不如在一次点击事件中尝试修改组件的状态来更新我们的页面。

首先康康 setState 是如何工作的,我们知道 this.setState 是 React.Component 类中的方法:

/**
 * @description 更新组件 state
 * @param { object | Function } partialState 下个阶段的状态
 * @param { ?Function } callback 更新完毕之后的回调
 */
Component.prototype.setState = function (partialState, callback) {
  if (!(
    isObject(partialState) ||
    isFunction(partialState) ||
    isNull
  )) {
    console.warn('setState 的第一个参数应为对象、函数或 null')
    return
  }
  this.updater.enqueueSetState(this, partialState, callback, 'setState')
}

看起来核心步骤就是触发挂载在实例上的一个 updater 对象。默认的, updater 会是一个展位的空对象,虽然实现了 enqueueSetState 等方法,但是这些方法内部都是空的。

// 我们初始化这个默认的 update,真正的 updater 会被 renderer 注入
this.updater = updater || ReactNoopUpdateQueue
export const ReactNoopUpdateQueue = {
  /**
   * 检查组件是否已经挂载
   */
  isMounted: function (publishInstance) {
    // 初始化 ing 的组件就别挂载不挂载了
    return false
  },

  /**
   * 强制更新
   */
  enqueueForceUpdate: function (publishInstance, callback, callerName) {
    console.warn('enqueueForceUpdate', publishInstance)
  },

  /**
   * 直接替换整个 state,通常用这个或者 setState 来更新状态
   */
  enqueueReplaceState: function (
    publishInstance,
    completeState,
    callback,
    callerName
  ) {
    console.warn('enqueueReplaceState', publishInstance)
  },

  /**
   * 修改部分 state
   */
  enqueueSetState: function (
    publishInstance,
    partialState,
    callback,
    callerName
  ) {
    console.warn('enqueueSetState', publishInstance)
  }
}

还记得我们在 render 的过程中,是通过执行 Component.render() 来获得一个类组件的实例,当 React 得到了这个实例之后,就会将实例的 updater 替换成真正的 classComponentUpdater :

function adoptClassInstance (
  workInProgress: Fiber,
  instance: any
): void {
  instance.updater = classComponentUpdate
  ...
}

刚刚我们触发了这个对象中的 enqueueSetState 函数,那么可以看看实现:

const classComponentUpdate = {
  isMounted,
  /**
   * 触发组件状态的更新
   * @param inst ReactElement
   * @param payload any
   * @param callback 更新结束之后的回调
   */
  enqueueSetState(
    inst: ReactElement,
    payload: any,
    callback?: Function
  ) {
    // ReactElement -> fiber
    const fiber = getInstance(inst)
    // 当前时间
    const currentTime = requestCurrentTime()
    // 获取当前 suspense config
    const suspenseConfig = requestCurrentSuspenseConfig()
    // 计算当前 fiber 节点的任务过期时间
    const expirationTime = computeExpirationForFiber(
      currentTime,
      fiber,
      suspenseConfig
    )

    // 创建一个 update 实例
    const update = createUpdate(expirationTime, suspenseConfig)
    update.payload = payload
    // 将 update 装载到 fiber 的 queue 中
    enqueueUpdate(fiber, update)
    // 安排任务
    ScheduleWork(fiber, expirationTime)
  },
  ...
}

显然,这个函数的作用就是获得类组件对应的 fiber ,更新它在任务调度器中的过期时间(领导给了新工作,自然要定新的 Deadline ) ,然后就是创建一个新的 update 任务装载到 fiber 的任务队列中。最后通过 ScheduleWork (告诉任务调度器来任务了,赶紧干活) 要求从这个 fiber 开始调和,至于调和和更新的步骤我们在第一次渲染中已经有了大致的了解。

顺带提一提 Hooks 中的 useState 。网络上有挺多讲解 hook 实现的文章已经讲得很全面了,我们只需要搞清楚以下几点问题。

Q1. 函数组件不像类组件一样拥有实例,数据存储在哪里

A1. 任何以 ReactElement 为粒度的组件都需要围绕 fiber ,数据存储在 fiber.memorizedState 上

Q2. useState 的实现

A2. 如果你听过了 useState 那么你就应该听过 useReducer ,如果听过 reducer 就应该知道 redux。首先,useState 的本质就是 useReducer 的语法糖。我们都知道构建一个状态库需要一个 reducer ,useState 就是当 reducer 函数为 a => a 时的特殊情况。

function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  return typeof action === 'function' ? action(state) : action
}

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

Q3. 为什么 Hooks 的顺序和个数不允许改变

A3. 每次执行 Hooks 函数需要取出上一次渲染时数据的最终状态,因为结构是链表而不是一个 Map,所以这些最终状态也会是有序的,所以如果个数和次序改变会导致数据的错乱。

时间调度机制

虽然今年过期时间 expirationTime 机制已经被淘汰了,但是不管是航道模型还是过期时间,本质上都是任务优先级的不同体现形式。

在探究运行机制之前我们需要知道一个问题就是,为什么时间片的性能会优于同步计算的性能。此处借用司徒正美老师 文章 中的例子。

实验 1,通过 for 循环一次性向 document 中插入 1000 个节点

function randomHexColor(){
    return "#" + ("0000"+ (Math.random() * 0x1000000 << 0).toString(16)).substr(-6);
}
setTimeout(function() {
    var k = 0;
    var root = document.getElementById("root");
    for(var i = 0; i < 10000; i++){
        k += new Date - 0 ;
        var el = document.createElement("div");
        el.innerHTML = k;
        root.appendChild(el);
        el.style.cssText =  background:${randomHexColor()};height:40px ;
    }
}, 1000);

实验 2,进行 10 次 setTimeout 分批次操作,每次插入 100 个节点

function randomHexColor() {
    return "#" + ("0000" + (Math.random() * 0x1000000 << 0).toString(16)).substr(-6);
}
var root = document.getElementById("root");
setTimeout(function () {
    function loop(n) {
        var k = 0;
        console.log(n);
        for (var i = 0; i < 100; i++) {
            k += new Date - 0;
            var el = document.createElement("div");
            el.innerHTML = k;
            root.appendChild(el);
            el.style.cssText =  background:${randomHexColor()};height:40px ;
        }
        if (n) {
            setTimeout(function () {
                loop(n - 1);
            }, 40);
        }
    }
    loop(100);
}, 1000);

相同的结果,第一个实验花费了 1000 ms,而第二个实验仅仅花费了 31.5 ms。

这和 V8 引擎的底层原理有关,我们都知道浏览器是单线程,一次性需要做到 GUI 描绘,事件处理,JS 执行等多个操作时,V8 引擎会优先对代码进行执行,而不会对执行速度进行优化。如果我们稍微给浏览器一些时间,浏览器就能够进行 JIT ,也叫热代码优化。

简单来说, JS 是一种解释型语言,每次执行都需要被编译成字节码才能被运行。但是如果某个函数被多次执行,且参数类型和参数个数始终保持不变。那么这段代码会被识别为 热代码 ,遵循着“万物皆可空间换时间”的原则,这段代码的字节码会被缓存,下次再次运行的时候就会直接被运行而不需要进行耗时的解释操作。也就是 解释器 + 编译器 的模式。

做个比喻来说,我们工作不能一直蛮干,必须要给自己一些时间进行反思和总结,否则工作速度和效率始终是线性的,人也不会有进步。

还记得在 WorkLoop 函数中,每次处理完一个 fiber 都会跳出循环执行一次 shouldYield 函数进行判断,是否应该将执行权交还给浏览器处理用户时间或是渲染。看看这个 shouldYield 函数的代码:

// 当前是否应该阻塞 react 的工作
function shouldYield (): boolean {
  // 获取当前的时间点
  const currentTime = getCurrentTime()

  // 检查任务队列中是否有任务需要执行
  advanceTimers(currentTime)

  // 取出任务队列中任务优先级最高的任务
  const firstTask = peek(taskQueue)

  // 以下两种情况需要 yield
  // 1. 当前任务队列中存在任务,且第一个任务的开始时间还没到,且过期时间小于当前任务
  // 2. 处于固定的浏览器渲染时间区间
  return (
    (
      currentTask !== null &&
      firstTask !== null &&
      (firstTask as any).startTime <= currentTime &&
      (firstTask as any).expirationTime < currentTask.expirationTime
    )
    // 当前处于时间片的阻塞区间
    || shouldYieldToHost()
  )
}

决定一个任务当前是否应该被执行有两个因素。

  1. 这个任务是否非执行不可,正所谓一切的不论是不是先问为什么都是耍流氓。如果到期时间还没到,为什么不先把线程空出来留给可能的高优先级任务呢。
  2. 如果多个任务都非执行不可,那么任务的优先级是否是当前队列中最高的。

如果一个任务的过期时间已经到了必须执行,那么这个任务就应该处于 待执行队列 taskQueue 中。相反这个任务的过期时间还没到,就可以先放在 延迟列表 中。每一帧结束的时候都会执行 advanceTimer 函数,将一些延迟列表中到期的任务取出,插入待执行队列。

可能是出于最佳实践考虑,待执行队列是一个小根堆结构,而延迟队列是一个有序链表。

回想一下 React 的任务调度要求,当一个新的优先级更高的任务产生,需要能够打断之前的工作并插队。也就是说,React 需要维持一个始终有序的数组数据结构。因此,React 自实现了一个小根堆,但是这个小根堆无需像堆排序的结果一样整体有序,只需要保证每次进行 push 和 pop 操作之后,优先级最高的任务能够到达堆顶。

所以 shouldYield 返回 true 的一个关键条件就是,当前 taskQueue 堆中的堆顶任务的过期时间已经到了,那么就应该暂停工作交出线程使用权。

那么待执行的任务是如何被执行的呢。这里我们需要先了解 MessageChannel 的概念。Message

Channel 的实例会拥有两个端口,其中第一个端口为发送信息的端口,第二个端口为接收信息的端口。当接收到信息就可以执行指定的回调函数。

const channel = new MessageChannel()
// 发送端
const port = channel.port2
// 接收端
channel.port1.onmessage = performWorkUntilDeadline // 在一定时间内尽可能的处理任务

每当待执行任务队列中有任务的时候,就会通过 Channel 的发送端发送一个空的 message ,当接收端异步地接收到这个信号的时候,就会在一个时间片内尽可能地执行任务。

// 记录任一时间片的结束时刻
let deadline = 0

// 单位时间切片长度
let yieldInterval = 5

// 执行任务直到用尽当前时间片空闲时间
function performWorkUntilDeadline () {
  if (scheduledHostCallback !== null) {
    // 如果有计划任务,那么需要执行

    // 当前时间
    const currentTime = getCurrentTime()

    // 在每个时间片之后阻塞(5ms)
    // deadline 为这一次时间片的结束时间
    deadline = currentTime + yieldInterval

    // 既然能执行这个函数,就代表着还有时间剩余
    const hasTimeRemaining = true

    try {
      // 将当前阻塞的任务计划执行
      const hasMoreWork = scheduledHostCallback(
        hasTimeRemaining,
        currentTime
      )

      if (!hasMoreWork) {
        // 如果没有任务了,清空数据
        isMessageLoopRunning = false
        scheduledHostCallback = null
      } else {
        // 如果还有任务,在当前时间片的结尾发送一个 message event
                // 接收端接收到的时候就将进入下一个时间片
        port.postMessage(null)
      }
    } catch (error) {
      port.postMessage(null)
      throw(error)
    }
  } else {
    // 压根没有任务,不执行
    isMessageLoopRunning = false
  }
}

我们在之前说过,阻塞 WorkLoop 的条件有两个,第一个是任务队列的第一个任务还没到时间,第二个条件就是 shouldYieldToHost 返回 true,也就是处于时间片期间。

// 此时是否是【时间片阻塞】区间
export function shouldYieldToHost () {
  return getCurrentTime() >= deadline
}

总结一下,时间调度机制其实就是 fiber 遍历任务 WorkLoop 和调度器中的任务队列争夺线程使用权的过程。不过区别是前者完全是同步的过程,只会在每个 while 的间隙去询问 调度器 :我是否可以继续执行下去。而在调度器拿到线程使用权的每个时间片中,都会尽可能的处理任务队列中的任务。

传统武术讲究点到为止,以上内容,就是这次 React 原理的全部。在文章中我并没有放出大量的代码,只是放出了一些片段用来佐证我对于源码的一些看法和观点,文中的流程只是一个循序思考的过程,如果需要查看更多细节还是应该从源码入手。

当然文中的很多观点带有主观色彩,并不一定就正确,同时我也不认为网络上的其他文章的说法就和 React 被设计时的初衷完全一致,甚至 React 源码中的很多写法也未必完美。不管阅读什么代码,我们都不要神话它,而是应该辩证的去看待它。总的来说,功过 91 开。

前端世界并不需要第二个 React ,我们学习的意义并不是为了证明我们对这个框架有多么了解。而是通过窥探这些顶级工程师的实现思路,去完善我们自己的逻辑体系,从而成为一个更加严谨的人。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文