【react18原理探究实践】更新阶段 Render 与 Diff 算法详解

切记、切记、切记,diff对比的是ReactElement和old fiber,生成新的fiber节点,不是两个fiber对比

🧑‍💻 前言

之前的文章中,我们从 首次挂载流程 出发,理解了 React 如何一步步"生造"出一棵 Fiber 树,并创建 DOM 节点。

更新阶段则不同:React 不再从零创建 Fiber ,而是基于现有 Fiber 树,通过 Diff ReactElement 与旧 Fiber 来生成新的 Fiber 节点,并决定哪些 DOM 需要更新、移动或删除。

本篇文章,我们将结合源码,逐步拆解 更新阶段的 Diff 流程,理解 React 如何高效复用 Fiber 节点,同时生成新 Fiber 链表。

一、更新阶段与首次挂载的区别

1.1 核心差异对比

维度 首次挂载 更新阶段
current 树 为 null(除 HostRootFiber) 存在完整的树
alternate 指针 null 互相指向
beginWork 逻辑 直接创建子 Fiber 对比 props,可能复用或更新
reconcileChildren mountChildFibers(不标记) reconcileChildFibers(执行 diff)
副作用标记 只标记根节点 Placement 标记 Update、Placement、Deletion
DOM 操作 创建所有 DOM 复用、更新或删除 DOM

1.2 双缓冲树在更新时的状态

javascript 复制代码
// 更新前:

FiberRoot
  ↓ current
current 树(屏幕显示)
  HostRootFiber
    ↓
  AppFiber {
    memoizedProps: {count: 0},
    memoizedState: {count: 0},
    child: divFiber
  }
    ↓
  divFiber {
    type: 'div',
    children: [...]
  }


// 更新开始:触发 setState({count: 1})

FiberRoot
  ↓ current
current 树               workInProgress 树
  HostRootFiber ←→ HostRootFiber'
    ↓                    ↓
  AppFiber ←→ AppFiber'
  {count: 0}   {count: 1} (新state)
    ↓
  divFiber

// 通过 alternate 互相指向
// workInProgress 树复用 current 树的节点


// 更新完成:

FiberRoot
  ↓ current (切换!)
workInProgress 树变成新的 current 树

1.3 更新的三种情况

javascript 复制代码
// 1. props 没变,state 没变
// 结果:bailout 优化,跳过整个子树

// 2. props 变了,或 state 变了
// 结果:重新 render,执行 diff

// 3. context 变了
// 结果:强制更新

二、更新的触发方式

2.1 类组件的 setState

javascript 复制代码
class App extends React.Component {
  state = { count: 0 };

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

  render() {
    return <div onClick={this.handleClick}>{this.state.count}</div>;
  }
}

// setState 的完整流程:

1. 调用 this.setState({count: 1})
   ↓
2. 创建 update 对象
   update = {
     payload: {count: 1},
     next: null,
   }
   ↓
3. 加入 updateQueue
   fiber.updateQueue.shared.pending = update
   ↓
4. 调度更新
   scheduleUpdateOnFiber(fiber, lane)
   ↓
5. 进入 render 阶段
   workLoopSync() 或 workLoopConcurrent()
   ↓
6. beginWork 处理 AppFiber
   ↓
7. 计算新 state
   processUpdateQueue() → newState = {count: 1}
   ↓
8. 调用 render()
   nextChildren = instance.render()
   ↓
9. 执行 diff
   reconcileChildFibers(current.child, nextChildren)

2.2 函数组件的 useState

javascript 复制代码
function App() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  };

  return <div onClick={handleClick}>{count}</div>;
}

// useState 更新流程:

1. 调用 setCount(1)
   ↓
2. dispatchSetState(fiber, queue, 1)
   ↓
3. 创建 update 对象
   update = {
     action: 1,
     next: null,
   }
   ↓
4. 加入 hook.queue.pending
   queue.pending = update
   ↓
5. 调度更新
   scheduleUpdateOnFiber(fiber, lane)
   ↓
6. 进入 render 阶段
   ↓
7. renderWithHooks
   - 设置 HooksDispatcherOnUpdate
   - 调用 App()
   ↓
8. 执行 useState(0)
   实际调用 updateState()
   ↓
9. 计算新 state
   遍历 queue.pending,计算最终值 = 1
   ↓
10. 返回 [1, setCount]
   ↓
11. render() 返回新的 children
   ↓
12. 执行 diff

2.3 forceUpdate 强制更新

javascript 复制代码
class App extends React.Component {
  handleClick = () => {
    this.forceUpdate(); // 跳过 shouldComponentUpdate
  };

  render() {
    return <div onClick={this.handleClick}>Force Update</div>;
  }
}

// forceUpdate 的特点:
// 1. 不检查 props 和 state
// 2. 强制执行 render
// 3. 子组件仍然会进行 diff

三、beginWork 更新路径

3.1 beginWork 的更新判断

javascript 复制代码
// 📍 位置:ReactFiberBeginWork.new.js

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes
): Fiber | null {
  if (current !== null) {
    // ========== 更新路径 ==========

    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;

    // 步骤1:检查 props 是否变化
    if (
      oldProps !== newProps ||
      hasLegacyContextChanged() ||
      (__DEV__ ? workInProgress.type !== current.type : false)
    ) {
      // Props 变了,标记需要更新
      didReceiveUpdate = true;
    } else {
      // Props 没变,检查其他更新来源

      const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
        current,
        renderLanes
      );

      if (
        !hasScheduledUpdateOrContext &&
        (workInProgress.flags & DidCapture) === NoFlags
      ) {
        // 🎯 完全不需要更新,执行 bailout
        didReceiveUpdate = false;
        return attemptEarlyBailoutIfNoScheduledUpdate(
          current,
          workInProgress,
          renderLanes
        );
      }

      if ((current.flags & ForceUpdateForLegacySuspense) !== NoFlags) {
        didReceiveUpdate = true;
      } else {
        didReceiveUpdate = false;
      }
    }
  } else {
    // 首次挂载路径
    didReceiveUpdate = false;
  }

  // 清空 lanes
  workInProgress.lanes = NoLanes;

  // 根据 tag 分发
  switch (workInProgress.tag) {
    case ClassComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);
      return updateClassComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes
      );
    }

    case FunctionComponent: {
      const Component = workInProgress.type;
      const unresolvedProps = workInProgress.pendingProps;
      const resolvedProps =
        workInProgress.elementType === Component
          ? unresolvedProps
          : resolveDefaultProps(Component, unresolvedProps);
      return updateFunctionComponent(
        current,
        workInProgress,
        Component,
        resolvedProps,
        renderLanes
      );
    }

    // 其他类型...
  }
}

3.2 bailout 优化机制

javascript 复制代码
function attemptEarlyBailoutIfNoScheduledUpdate(
  current: Fiber,
  workInProgress: Fiber,
  renderLanes: Lanes
): Fiber | null {
  /* 💡 bailout 的条件:
   * 1. props 没变(oldProps === newProps)
   * 2. 没有 state 更新(没有 setState)
   * 3. context 没变
   * 4. 没有 forceUpdate
   */

  workInProgress.lanes = NoLanes;

  switch (workInProgress.tag) {
    case HostRoot:
      // 根节点特殊处理
      pushHostRootContext(workInProgress);
      resetHydrationState();
      break;

    case ClassComponent: {
      const Component = workInProgress.type;
      if (isLegacyContextProvider(Component)) {
        pushLegacyContextProvider(workInProgress);
      }
      break;
    }

    // 其他类型...
  }

  // 🔥 关键:直接克隆子节点,不执行 render
  return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}

function bailoutOnAlreadyFinishedWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes
): Fiber | null {
  if (current !== null) {
    // 复用 current 的依赖
    workInProgress.dependencies = current.dependencies;
  }

  // 检查子节点是否有更新
  if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
    // 🎯 子树也没有更新,跳过整个子树!
    return null;
  }

  // 子树有更新,克隆子节点
  cloneChildFibers(current, workInProgress);
  return workInProgress.child;
}

bailout 示例:

jsx 复制代码
class Parent extends React.Component {
  state = { count: 0 };

  render() {
    return (
      <div>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          {this.state.count}
        </button>
        <Child name="React" /> {/* props 没变 */}
      </div>
    );
  }
}

class Child extends React.Component {
  render() {
    console.log("Child render"); // 不会执行!
    return <div>{this.props.name}</div>;
  }
}

// 点击 button 触发更新:
// 1. Parent 重新 render
// 2. Child 的 props 没变(name="React")
// 3. Child bailout,不执行 render
// 4. 直接复用 Child 的子树

四、Diff 算法核心原理

4.1 传统 Diff 的问题

scss 复制代码
传统的树 diff 算法:
- 时间复杂度:O(n³)
- 对于 1000 个节点,需要 10 亿次比较
- 完全不可用

为什么是 O(n³)?
1. 遍历树1的每个节点:O(n)
2. 遍历树2的每个节点:O(n)
3. 计算最小编辑距离:O(n)
总共:O(n³)

4.2 React Diff 的三个策略

React 通过三个假设,将复杂度降到 O(n):

javascript 复制代码
// 策略1:Tree Diff - 只比较同层级
/* 💡 假设:
 * DOM 节点跨层级移动的情况非常少见
 *
 * 结果:
 * - 只比较同一层级的节点
 * - 不同层级的节点不比较
 * - 如果节点跨层级移动,会删除重建
 */

旧树:          新树:
  A               A
 ↙ ↘            ↙ ↘
B   C          D   C
   ↙             ↙
  D             B

// React 的处理:
// 1. A 层:A 保留
// 2. B、C 层:
//    - B 删除
//    - C 保留
//    - D 创建(在 C 下)
// 3. D 层:
//    - D 下的 B 创建(新的 B)

// 不会识别 B 移动到了 D 下!


// 策略2:Component Diff - 同类型组件对比
/* 💡 假设:
 * - 同类型组件生成相似的树结构
 * - 不同类型组件生成不同的树结构
 *
 * 结果:
 * - 同类型组件:继续对比子节点(Virtual DOM Diff)
 * - 不同类型组件:直接删除重建
 */

// 例子1:同类型组件
<div className="old">     →    <div className="new">
  <p>text</p>                    <p>text</p>
</div>                           </div>

// 处理:
// 1. div 类型相同,复用 Fiber
// 2. 更新 className: old → new
// 3. 继续对比子节点 p


// 例子2:不同类型组件
<div>                     →    <span>
  <p>text</p>                    <p>text</p>
</div>                           </span>

// 处理:
// 1. div → span,类型不同
// 2. 删除整个 div 子树(包括 p)
// 3. 创建整个 span 子树(包括 p)
// 4. 不会复用 p!


// 策略3:Element Diff - 同层级节点对比
/* 💡 假设:
 * - 通过 key 可以准确识别节点
 * - 有 key 的节点可以跨位置复用
 *
 * 结果:
 * - 有 key:通过 key 匹配,可以移动
 * - 无 key:通过 index 匹配,按顺序对比
 */

// 例子:列表更新
旧:[A, B, C, D]
新:[D, A, B, C]

// 有 key 的情况:
// 1. 通过 key 识别 D 移动到了最前面
// 2. A、B、C 位置变化,但复用 Fiber
// 3. 只需要移动 DOM 节点

// 无 key 的情况:
// 1. 按 index 对比:
//    - 0: A → D (更新)
//    - 1: B → A (更新)
//    - 2: C → B (更新)
//    - 3: D → C (更新)
// 2. 所有节点都需要更新!

4.3 reconcileChildren 的分发逻辑

javascript 复制代码
// 📍 位置:ReactChildFiber.new.js

function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes
) {
  if (current === null) {
    // 首次挂载
    workInProgress.child = mountChildFibers(
      workInProgress,
      null,
      nextChildren,
      renderLanes
    );
  } else {
    // 🔥 更新:执行 diff
    workInProgress.child = reconcileChildFibers(
      workInProgress,
      current.child,
      nextChildren,
      renderLanes
    );
  }
}

// reconcileChildFibers 的内部实现
function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
  lanes: Lanes
): Fiber | null {
  // 判断 newChild 的类型,分发到不同的处理函数

  // 1. 单个元素
  if (typeof newChild === "object" && newChild !== null) {
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        // 🔥 单节点 diff
        return placeSingleChild(
          reconcileSingleElement(
            returnFiber,
            currentFirstChild,
            newChild,
            lanes
          )
        );

      case REACT_PORTAL_TYPE:
        return placeSingleChild(
          reconcileSinglePortal(returnFiber, currentFirstChild, newChild, lanes)
        );
    }

    // 2. 数组(多个子节点)
    if (isArray(newChild)) {
      // 🔥 多节点 diff
      return reconcileChildrenArray(
        returnFiber,
        currentFirstChild,
        newChild,
        lanes
      );
    }
  }

  // 3. 文本节点
  if (typeof newChild === "string" || typeof newChild === "number") {
    return placeSingleChild(
      reconcileSingleTextNode(
        returnFiber,
        currentFirstChild,
        "" + newChild,
        lanes
      )
    );
  }

  // 4. Fragment
  if (typeof newChild === "object" && newChild !== null) {
    if (newChild.$$typeof === REACT_LAZY_TYPE) {
      // lazy 组件
    }
  }

  // 5. 删除剩余节点
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}

五、单节点 Diff

5.1 单节点 Diff 的流程

javascript 复制代码
// 📍 位置:ReactChildFiber.new.js

function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement,
  lanes: Lanes
): Fiber {

  const key = element.key;
  let child = currentFirstChild;

  // ========== 第一步:遍历所有旧子节点,尝试复用 ==========

  while (child !== null) {
    // 1.1 比较 key
    if (child.key === key) {
      // key 相同,继续比较 type

      const elementType = element.type;

      // 1.2 比较 type
      if (child.elementType === elementType ||
          /* 其他类型判断 */) {

        // 🎯 key 和 type 都相同,可以复用!

        // 删除剩余的兄弟节点
        deleteRemainingChildren(returnFiber, child.sibling);

        // 复用 current Fiber,创建 workInProgress Fiber
        const existing = useFiber(child, element.props);
        existing.ref = coerceRef(returnFiber, child, element);
        existing.return = returnFiber;

        return existing;

      } else {
        // key 相同但 type 不同,删除所有旧节点
        deleteRemainingChildren(returnFiber, child);
        break;
      }

    } else {
      // key 不同,删除这个节点,继续找
      deleteChild(returnFiber, child);
    }

    child = child.sibling;
  }

  // ========== 第二步:没有找到可复用的,创建新节点 ==========

  const created = createFiberFromElement(element, returnFiber.mode, lanes);
  created.ref = coerceRef(returnFiber, currentFirstChild, element);
  created.return = returnFiber;

  return created;
}

5.2 单节点 Diff 的四种情况

javascript 复制代码
// 情况1:key 和 type 都相同 - 复用
// 旧:<div key="a">old</div>
// 新:<div key="a">new</div>

reconcileSingleElement() {
  // child.key === "a",element.key === "a" ✓
  // child.type === "div",element.type === "div" ✓
  // 结果:复用 Fiber,更新 props
}

Fiber 变化:
div Fiber {
  key: "a",
  type: "div",
  memoizedProps: {children: "old"},  // 旧 props
  pendingProps: {children: "new"},   // 新 props
  stateNode: <div>old</div>,          // 复用 DOM
  flags: Update,                      // 标记更新
}


// 情况2:key 相同但 type 不同 - 删除旧的,创建新的
// 旧:<div key="a">text</div>
// 新:<span key="a">text</span>

reconcileSingleElement() {
  // child.key === "a",element.key === "a" ✓
  // child.type === "div",element.type === "span" ✗
  // 结果:删除 div Fiber,创建 span Fiber
}

操作:
1. divFiber.flags |= Deletion  // 标记删除
2. 创建 spanFiber
3. spanFiber.flags |= Placement  // 标记插入


// 情况3:key 不同 - 继续找或创建新的
// 旧:<div key="a">text</div><p key="b">text</p>
// 新:<div key="b">text</div>

reconcileSingleElement() {
  // 第一次循环:
  // child.key === "a",element.key === "b" ✗
  // deleteChild(divFiber)

  // 第二次循环:
  // child.key === "b",element.key === "b" ✓
  // child.type === "p",element.type === "div" ✗
  // deleteRemainingChildren(pFiber)

  // 创建新的 divFiber
}

操作:
1. divFiber(key="a").flags |= Deletion
2. pFiber(key="b").flags |= Deletion
3. 创建新的 divFiber(key="b")
4. 新 divFiber.flags |= Placement


// 情况4:旧节点为 null - 直接创建
// 旧:null
// 新:<div key="a">text</div>

reconcileSingleElement() {
  // child === null
  // 跳过 while 循环
  // 创建新 Fiber
}

5.3 useFiber - 复用 Fiber 的关键

javascript 复制代码
function useFiber(fiber: Fiber, pendingProps: mixed): Fiber {
  // 复用 current Fiber,创建 workInProgress Fiber

  const clone = createWorkInProgress(fiber, pendingProps);
  clone.index = 0;
  clone.sibling = null;
  return clone;
}

function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
  let workInProgress = current.alternate;

  if (workInProgress === null) {
    // 首次更新,创建 alternate
    workInProgress = createFiber(
      current.tag,
      pendingProps,
      current.key,
      current.mode
    );
    workInProgress.elementType = current.elementType;
    workInProgress.type = current.type;
    workInProgress.stateNode = current.stateNode; // 🔥 复用 DOM!

    workInProgress.alternate = current;
    current.alternate = workInProgress;
  } else {
    // 再次更新,复用 alternate
    workInProgress.pendingProps = pendingProps;
    workInProgress.type = current.type;

    // 重置副作用
    workInProgress.flags = NoFlags;
    workInProgress.subtreeFlags = NoFlags;
    workInProgress.deletions = null;
  }

  // 复用其他属性
  workInProgress.childLanes = current.childLanes;
  workInProgress.lanes = current.lanes;

  workInProgress.child = current.child;
  workInProgress.memoizedProps = current.memoizedProps;
  workInProgress.memoizedState = current.memoizedState;
  workInProgress.updateQueue = current.updateQueue;

  // ... 复用更多属性

  return workInProgress;
}

六、多节点 Diff

6.1 多节点 Diff 的挑战

javascript 复制代码
// 多节点 diff 需要处理的情况:

// 1. 节点更新
旧:[<div key="a">old</div>, <div key="b">old</div>]
新:[<div key="a">new</div>, <div key="b">new</div>]
// props 变了,需要更新


// 2. 节点增删
旧:[<div key="a" />, <div key="b" />]
新:[<div key="a" />, <div key="b" />, <div key="c" />]
// 新增了 c


// 3. 节点移动
旧:[<div key="a" />, <div key="b" />, <div key="c" />]
新:[<div key="c" />, <div key="a" />, <div key="b" />]
// c 移动到了最前面


// 4. 混合情况
旧:[<div key="a" />, <div key="b" />, <div key="c" />]
新:[<div key="d" />, <div key="a" />, <div key="e" />]
// b 删除,c 删除,d 新增,e 新增,a 移动

6.2 多节点 Diff 的完整算法

javascript 复制代码
// 📍 位置:ReactChildFiber.new.js

function reconcileChildrenArray(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChildren: Array<any>,
  lanes: Lanes
): Fiber | null {
  /* 💡 Diff 算法的两轮遍历:
   *
   * 第一轮遍历:
   * - 处理节点更新的情况
   * - 按位置一一对比
   * - 遇到不能复用的节点就跳出
   *
   * 第二轮遍历:
   * - 处理节点新增、删除、移动
   * - 使用 Map 优化查找
   * - 判断是否需要移动
   */

  let resultingFirstChild: Fiber | null = null;
  let previousNewFiber: Fiber | null = null;

  let oldFiber = currentFirstChild;
  let lastPlacedIndex = 0; // 最后一个可复用节点的位置
  let newIdx = 0;
  let nextOldFiber = null;

  // ========== 第一轮遍历:处理更新 ==========

  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    // 1. 检查位置是否匹配
    if (oldFiber.index > newIdx) {
      nextOldFiber = oldFiber;
      oldFiber = null;
    } else {
      nextOldFiber = oldFiber.sibling;
    }

    // 2. 尝试更新节点
    const newFiber = updateSlot(
      returnFiber,
      oldFiber,
      newChildren[newIdx],
      lanes
    );
    /* 💡 updateSlot 的逻辑:
     * - 比较 key
     * - key 相同:尝试复用(比较 type)
     * - key 不同:返回 null(跳出第一轮)
     */

    if (newFiber === null) {
      // key 不同,无法复用,跳出第一轮
      if (oldFiber === null) {
        oldFiber = nextOldFiber;
      }
      break;
    }

    // 3. 处理不能复用的情况
    if (shouldTrackSideEffects) {
      if (oldFiber && newFiber.alternate === null) {
        // 新 Fiber 没有复用 old Fiber,需要删除 old
        deleteChild(returnFiber, oldFiber);
      }
    }

    // 4. 记录位置
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);

    // 5. 构建新的 Fiber 链表
    if (previousNewFiber === null) {
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;
    oldFiber = nextOldFiber;
  }

  // ========== 第一轮遍历的三种结束情况 ==========

  // 情况1:新节点遍历完,删除剩余旧节点
  if (newIdx === newChildren.length) {
    deleteRemainingChildren(returnFiber, oldFiber);
    return resultingFirstChild;
  }

  // 情况2:旧节点遍历完,新增剩余新节点
  if (oldFiber === null) {
    for (; newIdx < newChildren.length; newIdx++) {
      const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
      if (newFiber === null) {
        continue;
      }
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
    return resultingFirstChild;
  }

  // 情况3:新旧节点都没遍历完,进入第二轮

  // ========== 第二轮遍历:处理移动 ==========

  // 1. 将剩余旧节点放入 Map(key → Fiber)
  const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
  /* 💡 Map 结构:
   * Map {
   *   "a" => aFiber,
   *   "b" => bFiber,
   *   "c" => cFiber,
   * }
   */

  // 2. 遍历剩余新节点
  for (; newIdx < newChildren.length; newIdx++) {
    // 2.1 尝试从 Map 中找到可复用的节点
    const newFiber = updateFromMap(
      existingChildren,
      returnFiber,
      newIdx,
      newChildren[newIdx],
      lanes
    );
    /* 💡 updateFromMap 的逻辑:
     * - 通过 key 或 index 在 Map 中查找
     * - 找到:尝试复用
     * - 找不到:创建新节点
     */

    if (newFiber !== null) {
      if (shouldTrackSideEffects) {
        if (newFiber.alternate !== null) {
          // 从 Map 中删除已复用的节点
          existingChildren.delete(
            newFiber.key === null ? newIdx : newFiber.key
          );
        }
      }

      // 2.2 判断是否需要移动
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);

      // 2.3 构建链表
      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
  }

  // 3. 删除 Map 中剩余的节点(没被复用的)
  if (shouldTrackSideEffects) {
    existingChildren.forEach((child) => deleteChild(returnFiber, child));
  }

  return resultingFirstChild;
}

6.3 placeChild - 判断是否需要移动

javascript 复制代码
function placeChild(
  newFiber: Fiber,
  lastPlacedIndex: number,
  newIndex: number
): number {
  newFiber.index = newIndex;

  if (!shouldTrackSideEffects) {
    // 首次渲染,不需要标记
    return lastPlacedIndex;
  }

  const current = newFiber.alternate;
  if (current !== null) {
    // 🔥 节点复用的情况

    const oldIndex = current.index;

    if (oldIndex < lastPlacedIndex) {
      // 🔥 需要移动!
      /* 💡 为什么?
       * lastPlacedIndex 记录的是"最后一个不需要移动的节点"的位置
       *
       * 如果 oldIndex < lastPlacedIndex,说明:
       * - 这个节点在旧列表中的位置,比"最后一个不需要移动的节点"还靠前
       * - 但现在它在新列表中的位置,比"最后一个不需要移动的节点"还靠后
       * - 所以需要向右移动
       */

      newFiber.flags |= Placement;
      return lastPlacedIndex;
    } else {
      // 不需要移动
      return oldIndex;
    }
  } else {
    // 新节点
    newFiber.flags |= Placement;
    return lastPlacedIndex;
  }
}

6.4 多节点 Diff 完整示例

javascript 复制代码
// 示例:理解 lastPlacedIndex

旧列表:A(0) - B(1) - C(2) - D(3)
新列表:D - A - B - C

// 第一轮遍历:
newIdx=0, 新节点D, 旧节点A
- D.key !== A.key
- 跳出第一轮

// 第二轮遍历:
// 构建 Map:{A: AFiber, B: BFiber, C: CFiber, D: DFiber}
// lastPlacedIndex = 0

newIdx=0, 新节点D:
- 从Map找到DFiber, oldIndex=3
- oldIndex(3) >= lastPlacedIndex(0) ✓
- 不需要移动
- lastPlacedIndex = 3
- 标记:DFiber.flags = 0

newIdx=1, 新节点A:
- 从Map找到AFiber, oldIndex=0
- oldIndex(0) < lastPlacedIndex(3) ✗
- 🔥 需要移动!
- lastPlacedIndex = 3 (不变)
- 标记:AFiber.flags |= Placement

newIdx=2, 新节点B:
- 从Map找到BFiber, oldIndex=1
- oldIndex(1) < lastPlacedIndex(3) ✗
- 🔥 需要移动!
- lastPlacedIndex = 3 (不变)
- 标记:BFiber.flags |= Placement

newIdx=3, 新节点C:
- 从Map找到CFiber, oldIndex=2
- oldIndex(2) < lastPlacedIndex(3) ✗
- 🔥 需要移动!
- lastPlacedIndex = 3 (不变)
- 标记:CFiber.flags |= Placement

结果:
- D 不动(作为参考点)
- A、B、C 都标记 Placement(移动到 D 后面)

实际 DOM 操作:
parent.insertBefore(A, D.nextSibling)
parent.insertBefore(B, A.nextSibling)
parent.insertBefore(C, B.nextSibling)

优化的例子:

javascript 复制代码
// 反例:最差情况

旧列表:A(0) - B(1) - C(2) - D(3)
新列表:D - C - B - A

// Diff 结果:
// lastPlacedIndex 会被 D(3) 设置为 3
// A、B、C 的 oldIndex 都 < 3
// 所以 A、B、C 都需要移动

// 更好的方案:只移动 D
// 但 React 的算法不会识别这种情况
// 因为算法是从左到右遍历的


// 最佳实践:把不变的节点放在前面

旧列表:A(0) - B(1) - C(2) - D(3)
新列表:A - B - C - D - E

// Diff 结果:
// A、B、C、D 都不需要移动
// 只有 E 需要插入

七、Key 的作用机制

7.1 为什么需要 key?

jsx 复制代码
// 场景:列表头部插入

// 没有 key:
旧:[<li>1</li>, <li>2</li>, <li>3</li>]
新:[<li>0</li>, <li>1</li>, <li>2</li>, <li>3</li>]

// Diff 过程(按 index 对比):
index=0: <li>1</li> → <li>0</li>  // 更新文本
index=1: <li>2</li> → <li>1</li>  // 更新文本
index=2: <li>3</li> → <li>2</li>  // 更新文本
index=3: null → <li>3</li>        // 插入

// 结果:4次操作(3次更新 + 1次插入)


// 有 key:
旧:[<li key="1">1</li>, <li key="2">2</li>, <li key="3">3</li>]
新:[<li key="0">0</li>, <li key="1">1</li>, <li key="2">2</li>, <li key="3">3</li>]

// Diff 过程(通过 key 对比):
key="0": 新节点,插入
key="1": 找到,不需要移动
key="2": 找到,不需要移动
key="3": 找到,不需要移动

// 结果:1次操作(1次插入)

7.2 key 的匹配逻辑

javascript 复制代码
// 在 updateSlot 中使用 key

function updateSlot(
  returnFiber: Fiber,
  oldFiber: Fiber | null,
  newChild: any,
  lanes: Lanes
): Fiber | null {
  const key = oldFiber !== null ? oldFiber.key : null;

  if (typeof newChild === "object" && newChild !== null) {
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE: {
        // 🔥 比较 key
        if (newChild.key === key) {
          // key 相同,尝试复用
          return updateElement(returnFiber, oldFiber, newChild, lanes);
        } else {
          // key 不同,返回 null
          return null;
        }
      }
    }
  }

  return null;
}

// 在 updateFromMap 中使用 key

function updateFromMap(
  existingChildren: Map<string | number, Fiber>,
  returnFiber: Fiber,
  newIdx: number,
  newChild: any,
  lanes: Lanes
): Fiber | null {
  if (typeof newChild === "object" && newChild !== null) {
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE: {
        // 🔥 通过 key 或 index 查找
        const matchedFiber =
          existingChildren.get(newChild.key === null ? newIdx : newChild.key) ||
          null;

        return updateElement(returnFiber, matchedFiber, newChild, lanes);
      }
    }
  }

  return null;
}

7.3 key 的最佳实践

jsx 复制代码
// ❌ 错误1:使用 index 作为 key

{
  list.map((item, index) => <Item key={index} data={item} />);
}

// 问题:
// 当列表顺序变化时,index 不会变
// 但对应的 item 变了
// 导致错误的复用

// ❌ 错误2:使用随机数作为 key

{
  list.map((item) => <Item key={Math.random()} data={item} />);
}

// 问题:
// 每次 render,key 都会变
// 永远无法复用
// 等于没有 key

// ❌ 错误3:key 不稳定

{
  list.map((item) => <Item key={item.id + Math.random()} data={item} />);
}

// 问题:
// key 在不同 render 之间会变化
// 无法复用

// ✅ 正确:使用稳定的唯一标识

{
  list.map((item) => <Item key={item.id} data={item} />);
}

// 要求:
// 1. 唯一:在兄弟节点中唯一
// 2. 稳定:不会在 render 之间变化
// 3. 可预测:同一个 item 总是有同一个 key

// ✅ 如果没有 id,可以使用其他唯一标识

{
  list.map((item) => <Item key={item.name + item.type} data={item} />);
}

// ✅ 只有在列表是静态的(不会重排)时,才能用 index

const STATIC_LIST = ["Home", "About", "Contact"];

{
  STATIC_LIST.map((item, index) => (
    <Link key={index} to={item}>
      {item}
    </Link>
  ));
}

7.4 key 的底层存储

javascript 复制代码
// key 在 Fiber 节点中的存储

FiberNode {
  key: string | null,  // 🔥 key 值

  // 通过 key 可以快速匹配节点
}

// 在 Map 中使用 key

const map = new Map();

// 有 key 的节点
map.set(fiber.key, fiber);  // "a" → aFiber

// 没有 key 的节点
map.set(fiber.index, fiber);  // 0 → fiber


// 查找节点

const key = element.key;
const matchedFiber = map.get(key !== null ? key : index);

八、完整更新流程实例

8.1 示例代码

jsx 复制代码
class App extends React.Component {
  state = {
    list: [
      { id: "a", text: "A" },
      { id: "b", text: "B" },
      { id: "c", text: "C" },
    ],
  };

  handleClick = () => {
    // 更新:移动 + 删除 + 新增
    this.setState({
      list: [
        { id: "c", text: "C-updated" }, // 移动到最前面,且文本变化
        { id: "a", text: "A" }, // 移动
        { id: "d", text: "D" }, // 新增
      ],
    });
  };

  render() {
    return (
      <div>
        <button onClick={this.handleClick}>Update</button>
        <ul>
          {this.state.list.map((item) => (
            <li key={item.id}>{item.text}</li>
          ))}
        </ul>
      </div>
    );
  }
}

8.2 完整的 Diff 执行过程

ini 复制代码
=== 更新前的 Fiber 树 ===

AppFiber {
  memoizedState: {
    list: [
      {id: 'a', text: 'A'},
      {id: 'b', text: 'B'},
      {id: 'c', text: 'C'},
    ]
  }
}
  ↓
divFiber
  ↓
ulFiber
  ↓ child
liFiber(key="a") → liFiber(key="b") → liFiber(key="c")
  ↓                  ↓ sibling          ↓ sibling
textFiber("A")    textFiber("B")    textFiber("C")


=== 点击按钮,触发 setState ===

1. 创建 update 对象
2. 放入 updateQueue
3. 调度更新
4. 进入 Render 阶段


=== beginWork(AppFiber) ===

1. current !== null,进入更新路径
2. oldProps === newProps,但有 state 更新
3. didReceiveUpdate = true
4. processUpdateQueue()
   - 计算新 state:
     {
       list: [
         {id: 'c', text: 'C-updated'},
         {id: 'a', text: 'A'},
         {id: 'd', text: 'D'},
       ]
     }
5. instance.render()
   - 返回新的 children


=== reconcileChildren(ulFiber, newChildren) ===

旧子节点:
currentFirstChild = liFiber(key="a")
  ↓ sibling
liFiber(key="b")
  ↓ sibling
liFiber(key="c")

新子节点(数组):
newChildren = [
  <li key="c">C-updated</li>,
  <li key="a">A</li>,
  <li key="d">D</li>,
]

进入 reconcileChildrenArray()


--- 第一轮遍历 ---

newIdx=0:
- 新节点:<li key="c">
- 旧节点:liFiber(key="a")
- updateSlot():
  - newChild.key="c", oldFiber.key="a"
  - key 不同,返回 null
- 跳出第一轮遍历


--- 第二轮遍历 ---

// 构建 Map
existingChildren = Map {
  "a" => liFiber(key="a", index=0),
  "b" => liFiber(key="b", index=1),
  "c" => liFiber(key="c", index=2),
}

lastPlacedIndex = 0


newIdx=0, 新节点<li key="c">C-updated</li>:
- updateFromMap():
  - 从 Map 中找到 liFiber(key="c")
  - oldIndex = 2
  - 比较 type:"li" === "li" ✓
  - 复用 Fiber,更新 props

- placeChild():
  - oldIndex(2) >= lastPlacedIndex(0) ✓
  - 不需要移动
  - lastPlacedIndex = 2

- 从 Map 删除:
  existingChildren.delete("c")

- 结果:
  newCFiber = useFiber(oldCFiber, {children: "C-updated"})
  newCFiber.flags = Update  // props 变化


newIdx=1, 新节点<li key="a">A</li>:
- updateFromMap():
  - 从 Map 中找到 liFiber(key="a")
  - oldIndex = 0
  - 复用 Fiber

- placeChild():
  - oldIndex(0) < lastPlacedIndex(2) ✗
  - 🔥 需要移动!
  - lastPlacedIndex = 2 (不变)

- 从 Map 删除:
  existingChildren.delete("a")

- 结果:
  newAFiber = useFiber(oldAFiber, {children: "A"})
  newAFiber.flags = Placement  // 移动


newIdx=2, 新节点<li key="d">D</li>:
- updateFromMap():
  - 从 Map 中找不到 key="d"
  - 创建新 Fiber

- placeChild():
  - current === null
  - 新节点
  - lastPlacedIndex = 2 (不变)

- 结果:
  newDFiber = createFiber({children: "D"})
  newDFiber.flags = Placement  // 插入


遍历结束,Map 中剩余:
existingChildren = Map {
  "b" => liFiber(key="b"),
}

删除剩余节点:
- liFiber(key="b").flags |= Deletion


--- 第二轮遍历结果 ---

新的 Fiber 链表:
ulFiber.child = newCFiber
  ↓ sibling
newAFiber
  ↓ sibling
newDFiber

副作用标记:
- newCFiber.flags = Update
- newAFiber.flags = Placement
- newDFiber.flags = Placement
- oldBFiber.flags = Deletion

deletions 数组:
ulFiber.deletions = [oldBFiber]


=== completeWork ===

// 从下往上完成
// DOM 在 Commit 阶段处理


=== Commit 阶段(简要)===

1. 删除阶段:
   - ul.removeChild(oldBFiber.stateNode)  // 删除 B

2. 更新阶段:
   - newCFiber.stateNode.textContent = "C-updated"  // 更新 C 的文本

3. 插入阶段:
   - ul.insertBefore(newAFiber.stateNode, referenceNode)  // 移动 A
   - ul.appendChild(newDFiber.stateNode)  // 插入 D


最终 DOM:
<ul>
  <li>C-updated</li>  <!-- 复用,更新 -->
  <li>A</li>          <!-- 复用,移动 -->
  <li>D</li>          <!-- 新建,插入 -->
</ul>

8.3 副作用的收集和执行

javascript 复制代码
// Render 阶段收集的副作用:

ulFiber {
  flags: NoFlags,
  subtreeFlags: Update | Placement | Deletion,
  deletions: [oldBFiber],
  child: newCFiber,
}

newCFiber {
  flags: Update,  // 文本从 "C" → "C-updated"
  sibling: newAFiber,
}

newAFiber {
  flags: Placement,  // 需要移动
  sibling: newDFiber,
}

newDFiber {
  flags: Placement,  // 需要插入
  sibling: null,
}

oldBFiber {
  flags: Deletion,  // 需要删除
}


// Commit 阶段的执行顺序:

// 1. Mutation 阶段(DOM 操作)
commitMutationEffects() {
  // 1.1 删除
  commitDeletionEffects(ulFiber.deletions)
    - ul.removeChild(oldBFiber.stateNode)

  // 1.2 更新
  commitWork(newCFiber)
    - updateDOMProperties(newCFiber.stateNode, oldProps, newProps)
    - li.textContent = "C-updated"

  // 1.3 插入
  commitPlacement(newAFiber)
    - const before = getHostSibling(newAFiber)  // 找参考节点
    - ul.insertBefore(newAFiber.stateNode, before)

  commitPlacement(newDFiber)
    - ul.appendChild(newDFiber.stateNode)
}

// 2. Layout 阶段(生命周期)
commitLayoutEffects() {
  // 调用 componentDidUpdate、useLayoutEffect 等
}

九、常见问题深度解答

Q1: 为什么 React 不采用双端 Diff?

深度解答:

Vue 使用双端 Diff(从两端向中间遍历),而 React 使用单端 Diff(从左向右遍历)。

javascript 复制代码
// Vue 的双端 Diff(伪代码)

let oldStartIdx = 0;
let oldEndIdx = oldChildren.length - 1;
let newStartIdx = 0;
let newEndIdx = newChildren.length - 1;

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  if (oldStartIdx.key === newStartIdx.key) {
    // 头头比较
  } else if (oldEndIdx.key === newEndIdx.key) {
    // 尾尾比较
  } else if (oldStartIdx.key === newEndIdx.key) {
    // 头尾比较
  } else if (oldEndIdx.key === newStartIdx.key) {
    // 尾头比较
  } else {
    // 使用 key 查找
  }
}

// React 为什么不用?
// 1. React 的 Fiber 架构不适合双端遍历
// 2. Fiber 链表只有 child 和 sibling,没有反向指针
// 3. 添加反向指针会增加内存开销
// 4. React 的时间切片机制需要可随时中断,双端遍历不易实现
// 5. 实际场景中,从左到右遍历已经足够高效

Q2: 为什么多节点 Diff 需要两轮遍历?

深度解答:

javascript 复制代码
// 原因1:处理最常见的情况 - 节点更新

// 大部分更新场景:只是 props 变化,顺序不变
旧:[A, B, C, D]
新:[A', B', C', D']  // 只是内容变化

// 第一轮遍历可以快速处理:
for (let i = 0; i < newChildren.length; i++) {
  if (canReuse(oldChildren[i], newChildren[i])) {
    update(oldChildren[i], newChildren[i]);
  } else {
    break;  // 遇到不能复用的,跳出
  }
}

// 如果第一轮就完成了,不需要第二轮
// 避免创建 Map 的开销


// 原因2:Map 的创建有成本

// 只有在需要查找时才创建 Map
// 第一轮遍历发现需要查找,才进入第二轮
const map = new Map();  // 创建 Map 有成本
for (let fiber of oldChildren) {
  map.set(fiber.key, fiber);
}

Q3: lastPlacedIndex 的原理是什么?

深度解答:

javascript 复制代码
// lastPlacedIndex 的含义:
// "最后一个不需要移动的节点"在旧列表中的位置

// 例子1:理解"不需要移动"

旧:A(0) - B(1) - C(2) - D(3)
新:A - B - D - C

// Diff 过程:
A: oldIndex=0, lastPlacedIndex=0, 0>=0 ✓, 不移动, lastPlacedIndex=0
B: oldIndex=1, lastPlacedIndex=0, 1>=0 ✓, 不移动, lastPlacedIndex=1
D: oldIndex=3, lastPlacedIndex=1, 3>=1 ✓, 不移动, lastPlacedIndex=3
C: oldIndex=2, lastPlacedIndex=3, 2<3 ✗,  移动!

// 为什么 C 需要移动?
// - C 在旧列表中的位置(2),比 D 在旧列表中的位置(3) 靠前
// - 但在新列表中,C 在 D 后面
// - 说明 C 相对于 D 向右移动了


// 例子2:理解算法的局限

旧:A(0) - B(1) - C(2)
新:C - A - B

// Diff 过程:
C: oldIndex=2, lastPlacedIndex=0, 2>=0 ✓, 不移动, lastPlacedIndex=2
A: oldIndex=0, lastPlacedIndex=2, 0<2 ✗,  移动!
B: oldIndex=1, lastPlacedIndex=2, 1<2 ✗,  移动!

// 实际上,只移动 C 到最前面更高效
// 但算法会移动 A 和 B
// 因为算法是从左到右的,C 先被处理,成为"参考点"


// 优化建议:
// 把不需要移动的节点放在前面
旧:A - B - C - D
新:A - B - D - E  // C删除,E新增
// 这样只需要删除 C,插入 E

Q4: 什么时候会触发整个子树重建?

深度解答:

jsx 复制代码
// 情况1:组件类型变化

// 旧:
<div>
  <ComponentA />
</div>

// 新:
<div>
  <ComponentB />
</div>

// 处理:
// 1. ComponentA 的整个子树被删除
// 2. ComponentB 的整个子树被重建
// 3. 不会尝试复用任何子节点


// 情况2:父节点类型变化

// 旧:
<div>
  <span>A</span>
  <span>B</span>
</div>

// 新:
<section>
  <span>A</span>
  <span>B</span>
</section>

// 处理:
// 1. div 及其所有子节点被删除(包括两个 span)
// 2. section 及其所有子节点被重建(包括两个 span)
// 3. 即使 span 的 type 和 key 都相同,也不会复用!


// 情况3:key 变化

// 旧:
<div>
  <Item key="a" />
  <Item key="b" />
</div>

// 新:
<div>
  <Item key="c" />
  <Item key="d" />
</div>

// 处理:
// 1. Item(key="a") 被删除
// 2. Item(key="b") 被删除
// 3. Item(key="c") 被创建
// 4. Item(key="d") 被创建


// 避免重建的方法:
// 1. 保持组件类型稳定
// 2. 保持 key 稳定
// 3. 避免条件渲染改变组件类型:

// ❌ 会导致重建
{condition ? <ComponentA /> : <ComponentB />}

// ✅ 在组件内部处理
<Component type={condition ? 'A' : 'B'} />

Q5: Diff 算法的时间复杂度真的是 O(n) 吗?

深度解答:

javascript 复制代码
// 理论上的时间复杂度:

// 第一轮遍历:O(n)
// - 遍历新子节点数组:n 次
// - 每次对比:O(1)
// 总计:O(n)

// 第二轮遍历:
// - 创建 Map:O(m),m 是剩余旧节点数
// - 遍历剩余新节点:O(k),k 是剩余新节点数
// - 每次从 Map 查找:O(1)
// 总计:O(m + k)

// 总的时间复杂度:O(n + m + k)
// 由于 m + k <= n,所以是 O(n)


// 实际场景的分析:

// 最好情况:只有更新,没有移动
旧:[A, B, C, D]
新:[A', B', C', D']
// 第一轮遍历就完成了,O(n)

// 最坏情况:所有节点都移动
旧:[A, B, C, D]
新:[D, C, B, A]
// 需要两轮遍历,但总体还是 O(n)

// 极端最坏情况:列表非常长,且完全反序
旧:[1, 2, 3, ..., 100000]
新:[100000, ..., 3, 2, 1]
// 仍然是 O(n),但常数因子较大
// 因为每个节点都需要标记 Placement


// 优化:避免最坏情况

// ❌ 完全反序:性能差
list.reverse()

// ✅ 排序:使用稳定排序,尽量保持原有顺序
list.sort((a, b) => a.priority - b.priority)

十、总结

10.1 Diff 算法的核心要点

三个策略:

  1. Tree Diff:只比较同层级,跨层级移动会重建
  2. Component Diff:同类型继续比较,不同类型直接重建
  3. Element Diff:通过 key 识别节点,支持移动和复用

两轮遍历:

  1. 第一轮:处理更新,按位置对比,遇到不能复用就跳出
  2. 第二轮:处理移动、新增、删除,使用 Map 优化查找

关键机制:

  1. key:唯一标识节点,实现精确复用
  2. lastPlacedIndex:判断节点是否需要移动
  3. flags:标记副作用(Placement、Update、Deletion)

10.2 更新阶段的完整流程

sql 复制代码
触发更新(setState/useState/forceUpdate)
    ↓
创建 update 对象
    ↓
放入 updateQueue/hook.queue
    ↓
调度更新 scheduleUpdateOnFiber
    ↓
进入 Render 阶段
    ↓
beginWork(更新路径)
    ├─ current !== null
    ├─ 比较 props
    ├─ 检查 updateQueue
    └─ 决定 render 或 bailout
    ↓
render 获取新 children
    ↓
reconcileChildren(执行 Diff)
    ├─ 单节点:reconcileSingleElement
    └─ 多节点:reconcileChildrenArray
        ├─ 第一轮:处理更新
        └─ 第二轮:处理移动/增删
    ↓
标记副作用 flags
    ↓
继续遍历子树
    ↓
completeWork
    ↓
收集副作用到父节点
    ↓
Render 阶段完成
    ↓
进入 Commit 阶段(执行 DOM 操作)

相关推荐
lbh2 小时前
Chrome DevTools 详解(二):Console 面板
前端·javascript·浏览器
wxr06162 小时前
部署Spring Boot项目+mysql并允许前端本地访问的步骤
前端·javascript·vue.js·阿里云·vue3·springboot
万邦科技Lafite3 小时前
如何对接API接口?需要用到哪些软件工具?
java·前端·python·api·开放api·电商开放平台
知识分享小能手3 小时前
微信小程序入门学习教程,从入门到精通,WXSS样式处理语法基础(9)
前端·javascript·vscode·学习·微信小程序·小程序·vue
看晴天了3 小时前
🌈 Tailwind CSS 常用类名总结
前端
看晴天了3 小时前
Tailwind的安装,配置,使用步骤
前端
看晴天了3 小时前
nestjs学习, PM2进程守护,https证书配置
前端
blues_C3 小时前
Playwright MCP vs Chrome DevTools MCP vs Chrome MCP 深度对比
前端·人工智能·chrome·ai·chrome devtools·mcp·ai web自动化测试
木心操作3 小时前
nodejs动态创建sql server表
前端·javascript·sql