creamidea / creamidea.github.com

冰糖火箭筒&&蜂蜜甜甜圈
https://creamidea.github.io/
4 stars 4 forks source link

React18 一些笔记 #40

Open creamidea opened 2 years ago

creamidea commented 2 years ago

简要的更新过程

以 FunctionComponent 和 一次 click 内 setState 为例

setState

省略 React 处理事件过程,直接进入 onClick 处理函数调用 setState,这里的 setState 就是 dispatchSetState 函数。

scheduleUpdateOnFiber

if 分支处理

ensureRootIsScheduled 以 Root 为参数,安排调度 performSyncWorkOnRoot

if 分支处理:没有获取到调度优先级

if 分支处理:本次更新和当前更新(如果有)比较

performSyncWorkOnRoot

开始遍历 root 前的一些准备工作

React 核心工作:Render + Commit

Render 阶段

Commit 阶段

此时使用 updateState,来到 updateReducer 处理函数

creamidea commented 2 years ago

useSyncExternalStore

React18 已经内置,packages/react-reconciler/src/ReactFiberHooks.new.js 3 个参数

mountSyncExternalStore 函数

如果没有 block 的优先级,那么就要放入源一致性检查


获取当前对应的 hook,hook.memoizedState = nextSnapshot

等价于使用了 useEffect(subscribeToStore, [subscribe])

fiber.flags = PassiveEffect | PassiveStaticEffect; 
effect.tag = HookHasEffect | HookPassive

hook.memoizedState = effect

// effect hook
create = subscribeToStore.bind(null, fiber, inst, subscribe)
deps = [subscribe]

后续调用,等价于 useEffect(updateStoreInstance) 其中没有设置 memoizedState 这步 updateStoreInstance 用于更新内部记录的外部源最新值。该函数被存储在 fiber.updateQueue*

pushEffect(
  HookHasEffect | HookPassive,
  updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
  undefined,
  null,
);

fiber.updateQueue 何时消费?

在 commitPassiveMountOnFiber 通过 commitHookEffectListMount(HookPassive | HookHasEffect, finishedWork) 函数内被读取消费

creamidea commented 2 years ago

升级React17,Toast组件不能用了

https://juejin.cn/post/6974025581557973005

import { useEffect, useState } from "react";
import { createPortal } from "react-dom";

function ToastButton() {
  const [show, setShow] = useState(false);

  useEffect(() => {
    const handler = () => {
      setShow(false);
    };

    document.addEventListener("click", handler);

    return () => {
      document.removeEventListener("click", handler);
    };
  }, [show]);

  console.log('## ToastButton', show)

  return (
    <div>
      <button onClick={() => {
        setShow(true)
      }}>toast button</button>
      {show && <div>toast</div>}
    </div>
  );
}

function PortalButton() {
  const [show, setShow] = useState(false);

  return (
    <div>
      <button onClick={() => setShow(true)}>portal button</button>
      {show && createPortal("portal", document.body)}
    </div>
  );
}

export function App() {
  return <>
    <ToastButton />
    <PortalButton />
  </>
}

toast 文案在点击 toast button 之后显示立即消失,视觉上就没有出现过

分析如下: onClick 在捕获阶段冒泡阶段被触发执行,设置为 true 进入更新逻辑 setState ensureRootIsScheduled

pendingPassiveEffectsLanes 设置也是在 commitRoot 阶段 根据 rootDoesHavePassiveEffects 为真值时,设置

creamidea commented 2 years ago

React内部的性能优化没有达到极致?

https://juejin.cn/post/7073692220313829407

image
import { useState } from "react";

export function App() {
  const [num, updateNum] = useState(0);
  console.log("App render", num);

  return (
    <div onClick={() => updateNum(1)}>
      <Child />
    </div>
  );
}

function Child() {
  console.log("child render");
  return <span>child</span>;
}

执行结果

初次渲染

App render 0
child render

点击一次

App render 1
child render

此时 fiber(App),fiber.lanes = 1 和 fiber.alternate.lanes = 1 设置值 (猜测原因是:dispatchSetState 时绑定的 fiber 是第一次渲染时绑定的 fiber,引入并发之后,可能被中断,就要保证中断之后 lanes 上的信息不消失。所以 2 个都要设置) 在 begin work 阶段和 renderWithHook 内,都会重置 workInProgress 的 lanes workInProgress.lanes = NoLanes。alternate 的 lanes 不会被重置

performUnitOfWork 内 begin work 阶段,判断 oldProps === newProps 为真(其父节点 HostRoot 是 cloneChildFibers 直接复制,所以 props 是相同的)。但是还是进入 updateFunctionComponent 处理,只是 didReceiveUpdate 为 false。didReceiveUpdate 将在 renderWithHooks 里,如同注释所说,Component 里调用 useState 等 Hooks 时,会根据 state 的变化情况,处理 didReceiveUpdate

// renderWithHooks
var children = Component(props, secondArg); // Check if there was a render phase update

// updateReducer(useState)
if (!objectIs(newState, hook.memoizedState)) {
  markWorkInProgressReceivedUpdate();
}

处理完 begin work 之后,会执行下面的代码,将「未来」的 props 变成「现在」的 props

unitOfWork.memoizedProps = unitOfWork.pendingProps;

completeWork childLanes 冒泡处理是在 completeWork 内处理

点击一次

App render 1

fiber(App) 没能进入 eagerState 处理逻辑,因为 alternate.lanes 不为 0

begin work 处理 fiber(App) 时oldProps === newProps 为真(其父节点 HostRoot 是 cloneChildFibers 直接复制,所以 props 是相同的)。但是还是进入 updateFunctionComponent 处理,只是 didReceiveUpdatefalse。

updateFunctionComponent 后半段代码

if (current !== null && !didReceiveUpdate) {
  // 处理 workInProgress 的 updateQueue 和 flags
  // 处理 current 的 lanes,去掉当前的更新优先级
  bailoutHooks(current, workInProgress, renderLanes);

  // !includesSomeLane(renderLanes, workInProgress.childLanes) 判断成功
  // 直接返回 null。于是,fiber(Child) 进入 bailout 处理。没有让 Child 渲染。
  return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}

于是,fiber(Child) 进入 bailout 处理。没有让 Child 渲染。

点击一次

(None)

进入 eagerState 的处理逻辑,没有 state, props, context 变化,于是什么都不会发生 :)

creamidea commented 2 years ago

useTransition 使用

import { useEffect, useState, useTransition } from "react";

export function App() {
  console.log("render App");

  const [count, setCount] = useState(0);
  const [isPending, startTransition] = useTransition();

  console.log('render', count, isPending)

  useEffect(() => {
    // debugger
    // strict mode + development mode 时,执行 2 次,模拟 OffScreen
    // https://reactjs.org/docs/strict-mode.html#ensuring-reusable-state
    console.log('run effect');
  }, []);

  return (
    <>
      <button
        onClick={() => {
          startTransition(() => {
            // 优先级 64
            setCount(c => c + 1)
          })
        }}
      >
        {isPending
          ? 'pending...'
          : `App Change ${count}`
        }
      </button>
    </>
  );
}
creamidea commented 2 years ago

didReceiveUpdate

didReceiveUpdate 判断是否有 props, state, context 变化 第一次赋值,发生在 beginWork,主要目的:有 propscontext 的变化 第二次赋值,发生在 useState 阶段,主要目的:有 state 的变化

React 几个比较,决定是否渲染

React 几个关键状态重置

更新从 dispatchSetState 开始

调度任务 performSyncWorkOnRootperformConcurrentWorkOnRoot 被执行

时间分片的原理

符合条件的更新,会将 performConcurrentWorkOnRoot 放入调度器。符合分片的任务会执行 renderRootConcurrent

function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
  if (workInProgressRoot !== root || workInProgressRootRenderLanes !== lanes) {
    // workInProgressRoot, workInProgressRootRenderLanes 发生变化,说明在这之前调用了 prepareFreshStack 或者 commit 被执行
    // 说明任务变了,被高优先级的任务抢占了,任务重新开始(第一次也会进入这里)
    prepareFreshStack(root, lanes);
  }

  do {
    try {
      workLoopConcurrent();
      break;
    } catch (thrownValue) {
      handleError(root, thrownValue);
    }
  } while (true);

  // ...
}

workLoopConcurrent 里面有时间分片的逻辑 shouldYield:现在是固定的时间分片时长 5ms。如果当前任务执行时间大于 5ms 就会退出 while 循环回到 performConcurrentWorkOnRoot,退出码为 RootInProgress

function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

退出码为 RootInProgress,再次进入 ensureRootIsScheduled,此时 existingCallbackPriority === newCallbackPriority 为真,就直接退出 ensureRootIsScheduled。 此时代码回到 performConcurrentWorkOnRoot

  if (root.callbackNode === originalCallbackNode) {
    // The task node scheduled for this root is the same one that's
    // currently executed. Need to return a continuation.
    return performConcurrentWorkOnRoot.bind(null, root);
  }

callbackNode 和 originalCallbackNode 一致,没有发生任务抢占。将当前任务返回,让调度器下次调用。从而实现时间分片的能力,从 devtool performance 输出来看,就是 5 ms 的 Call

image

在分片的间隙,给浏览器有时间去处理其他任务。此时 timer 定时器触发高优先级更新,高优先级任务一般是同步任务 performSyncWorkOnRoot(在一个宏任务内),最后会调用 ensureRootIsScheduled。如果还有任务,即 root.pendingLanes 不为空,那么就会创建新的调度任务放入调度器等待执行(也就是被中断的任务需要从头开始,有些组件会被执行 2 次)。

如果高优先级任务是异步任务,那么就会进入调度器。调度器进行排序入队,开始执行。

Q: 时间分片,在每个分片最后会注册任务,那么当高优先级抢占时,这个任务会被取消吗?

A:会被取消。首先有一个背景知识点:全局只会有一个 root

image

所以在后续任务进入 ensureRootIsScheduledconst existingCallbackNode = root.callbackNode; 就是获取上一个任务(最后一个时间分片的任务),接下来判断优先级,「肯定」是新任务的优先级更高,所以先取消上一个任务,再创建一个新的任务。在新的任务末尾,会重新调用 ensureRootIsScheduled,将中断的任务重新安排唤起。

creamidea commented 2 years ago
// 渲染几次?
// 渲染 3 次
// 第一次 mount
// 第二次 useEffect 注册的函数运行触发更新(状态值变了,is(newState, hook.memoizedState) 为 false。useState)
// 第三次 还是 useEffect 注册的函数运行触发更新(状态值没变,进入 bailout 阶段 bailoutHooks,此时去掉了 Passive Effect。updateFunctionComponent)
// 后续不打印,因为 useEffect 不运行
import { useEffect, useState } from "react";

export function App() {
  const [, updateNum] = useState(0);

  useEffect(() => {
    updateNum(1)
  })

  console.log('render App');

  return 'App';
}
creamidea commented 2 years ago
// add 函数内部的函数 batching 执行,所以每次增加 1
// 在 React18 实现里面,会在 ensureRootIsScheduled 内注册 micro task: performSyncWorkOnRoot
// 也就是在一个宏任务内执行。且由于是 click 触发更新,lanes 是 1。不会被进入到异步任务调度
// 在 React17 里,会在 dispatchDiscreteEvent 里面调用 flush sync queue(里面会先取消注册的 schedule 调度,然后开始同步执行)
// 所以也没有进入 异步任务调度 执行

// 如果是在 componentDidMount 通过 setTimeout 触发更新

// 传统模式,首次渲染 2 (setTimeout 里面是同步执行)
// 更新优先级 16。该更新级由 getCurrentEventPriority 返回 DefaultEventPriority,没有事件就返回默认优先级 16
// 进入 schedule 调度逻辑进行调度 performConcurrentWorkOnRoot 执行(但是不会时间分片,16 属于 Block 范围)

// 并发模式,首次渲染 1
// 更新优先级 1。该更新级由 getCurrentEventPriority 返回 getCurrentUpdatePriority,CurrentUpdatePriority 会在合成事件系统里 dispatchDiscreteEvent 设置 setCurrentUpdatePriority(DiscreteEventPriority)
// 进入 schedule 调度逻辑进行调度 performConcurrentWorkOnRoot 执行(但是不会时间分片,16 属于 Block 范围)

import { Component } from "react";

export class App extends Component {
  state = {
    count: 0,
  };

  add = () => {
    this.setState({
      count: this.state.count + 1,
    });
    this.setState({
      count: this.state.count + 1,
    });
  }

  componentDidMount() {
    setTimeout(this.add)
  }

  render() {
    return (
      <div>
        <div>{this.state.count}</div>
        <button onClick={this.add}>Add</button>
      </div>
    );
  }
}
creamidea commented 2 years ago
import { useEffect, useLayoutEffect, useState } from "react";

export function App() {
  const [state, setState] = useState(0)

  useLayoutEffect(() => {
    setState(1)
  }, [])

  useEffect(() => {
    setState(2)
    setState(3)
  }, [])

  console.log('render', state)

  return <div>{state}</div>
}

React 18 并发模式

render 0
render 1
render 3

原因:useEffect 注册的函数是在 flushPassiveEffects 中执行(由 useLayoutEffect 中 setState 注册的 performSyncWorkOnRoot 调用)

export function flushPassiveEffects(): boolean {
  if (rootWithPendingPassiveEffects !== null) {
    ...
    // 最高优先级为 DefaultEventPriority,也就是 16
    const priority = lowerEventPriority(DefaultEventPriority, renderPriority);
    ...
    try {
      ReactCurrentBatchConfig.transition = null;

      // 设置当前总更新的优先级为 16
      // 这里表明,从 useEffect 里触发最高为 16
      // 这里的优先级会影响 dispatchSetState 里 requestUpdateLane 的返回值,这个例子返回 16
      setCurrentUpdatePriority(priority);
      return flushPassiveEffectsImpl();
    } finally {
      ...
    }
  }
  return false;
}

从该代码可以得知,useEffect 里面触发的更新最高更新也只会是 16。这样就会和 useLayoutEffect 触发的更新「错开」(因为 useLayoutEffect 触发的更新为 1)。这就导致 React 会先执行完 useLayoutEffect 触发的更新,也就是第二次输出 render 1。然后在其 commit 阶段的最后,调用 ensureRootIsScheduled 函数,将还未执行的 16 优先级注册到调度器内,所以第三次输出就是 performConcurrentWorkOnRoot 函数引发的。

legacy 模式

render 0
render 3

传统模式和并发模式的区别在于 requestUpdateLane 始终返回的是 1。也就是 useEffect 和 useLayoutEffect 的优先级一致。那么在 useState 计算状态的时候,就不会跳过,这也就是为什么第二次渲染 3。也因为优先级相同,在 commit 阶段的最后,root.pendingLanes 为 0,这样 ensureRootIsScheduled 也就不会安排后续任务。所以总共输出 2 次。

function requestUpdateLane(fiber) {
  ...
  if ((mode & ConcurrentMode) === NoMode) {
    return SyncLane;
  } else if ( (executionContext & RenderContext) !== NoContext && workInProgressRootRenderLanes !== NoLanes) {
    ...
  }
  ...
}
creamidea commented 2 years ago
image image

首次渲染时,DOM 是一次性插入