陌上人如玉
公子世无双

11.11 手写 mini React(300 行)

11.11 手写 mini React(300 行)

一句话:写一个 mini React 只需要四块——createElement 造元素对象 → Fiber 树用「parent/child/sibling」三个指针串起来 → workLoop 用「全局指针 + 时间片」实现可中断的深度优先遍历 → commit 阶段把 Fiber 树同步落到 DOM,再加一套「挂在 fiber 上的 Hook 链表」就有了 useState 与重渲染。

一、整体架构(先看清它长什么样)

mini-react 的四个模块(与真实 React 一一对应):

① createElement      →  造「元素对象」{ type, props: { children, ... } }
② Fiber + workLoop   →  「渲染阶段」:可中断的遍历,产出 fiber 树(含 effectTag)
③ commit             →  「提交阶段」:不可中断地改 DOM(PLACEMENT/UPDATE/DELETION)
④ useState           →  Hook 挂在 fiber 上(链表/数组),setState 触发「新一次渲染」
⭐⭐ 三个「必须先想清楚」的设计决定(想错了后面全崩):

① 【DOM 什么时候创建?】
   创建在「render 阶段」(fiber 生成时建好 dom 并 append 到父的 dom 上),
   但【插入文档】延后到 commit(否则每渲染一个节点就触发一次真实布局)。
   ⚠️ 这意味着:render 过程中 DOM 是「在游离容器里」的——所以**可中断**。

② 【「更新」怎么表达?】
   用一个全局 `wipRoot`(work in progress root):
   ①  `render(element, container)`  → 首次创建 wipRoot
   ②  每次 `setState` → 用「当前树(currentRoot)」克隆一个 wipRoot(带 alternate 指针)
   ③  workLoop 遍历 wipRoot,在「新旧 fiber 对比」时打上 effectTag
   ④  commit 完成后 `currentRoot = wipRoot`(⭐ 双缓存切换)

③ 【「可中断」怎么实现?】
   把「递归」换成「while + 全局指针 nextUnitOfWork」:
   每一次 `performUnitOfWork` 处理【一个 fiber】,然后检查 `shouldYield()`。
   ⭐ 只要遍历状态存在全局变量/对象里,就能随时「保存并返回」。

二、第一块:createElement(把 JSX 变成对象)

// mini-react.js —— 第 1 块:createElement

/** 文本子节点用特殊 type 标记(方便后面区分「文本节点」与「元素节点」) */
export const TEXT_ELEMENT = 'TEXT_ELEMENT';

/**
 * ⭐ 把「children 变长参数」拍平成 props.children 数组
 *   并把「字符串/数字」包装成「文本元素」(这样后面只需处理一种 child 形态)
 */
export function createElement(type, config, ...children) {
  const props = { ...(config ?? {}) };

  const normalizedChildren = children
    .flat()                       // ⭐ 处理 {arr.map(...)} 产生的数组
    .filter((c) => c !== null && c !== undefined && c !== false)
    .map((child) =>
      typeof child === 'object' ? child : createTextElement(String(child))
    );

  delete props.children;
  if (normalizedChildren.length > 0) props.children = normalizedChildren;

  return { type, props };
}

function createTextElement(text) {
  return { type: TEXT_ELEMENT, props: { nodeValue: text } };
}

/** JSX 运行时(配 `/** @jsx createElement *\/` 或 babel 的 classic runtime) */
export const jsx = (type, props, key) => createElement(type, { ...props, key }, props?.children);
/** ============ 第 2 块:Fiber 结构与「创建 DOM」 ============ */

/** effectTag:本次渲染相对上一次的「变更类型」 */
const PLACEMENT = 'PLACEMENT';
const UPDATE = 'UPDATE';
const DELETION = 'DELETION';

/** 创建真实 DOM 节点(文本 / 元素 / 函数组件由调用方处理) */
function createDom(fiber) {
  if (fiber.type === TEXT_ELEMENT) {
    return document.createTextNode('');
  }
  const dom = document.createElement(fiber.type);
  updateDom(dom, {}, fiber.props);          // ⭐ 首次创建也要应用 props(含事件)
  return dom;
}

/**
 * ⭐⭐ 全篇最关键的函数之一:把「新旧 props」的差异应用到真实 DOM 上
 *   这也是「为什么 React 能只改差异」的落地点
 */
function updateDom(dom, prevProps, nextProps) {
  const isEvent = (key) => key.startsWith('on');
  const isProperty = (key) => key !== 'children' && !isEvent(key);
  const isGone = (prev, next) => (key) => !(key in next);

  // ① 移除「旧事件」里被改掉的(移除 + 后面重新加)
  Object.keys(prevProps)
    .filter(isEvent)
    .filter((key) => !(key in nextProps) || prevProps[key] !== nextProps[key])
    .forEach((key) => {
      const eventType = key.toLowerCase().substring(2);      // onClick → click
      dom.removeEventListener(eventType, prevProps[key]);
    });

  // ② 移除「旧的普通属性」
  Object.keys(prevProps)
    .filter(isProperty)
    .filter(isGone(prevProps, nextProps))
    .forEach((key) => {
      dom[key] = '';
    });

  // ③ 设置「新增/变化的属性」(⭐ 这里对 style 做了特殊处理)
  Object.keys(nextProps)
    .filter(isProperty)
    .filter((key) => prevProps[key] !== nextProps[key])
    .forEach((key) => {
      if (key === 'style' && typeof nextProps[key] === 'object') {
        Object.assign(dom.style, nextProps[key] ?? {});
        // ⚠️ 省略了「删除旧 style 键」的处理(真实 React 会做 diff)
      } else {
        dom[key] = nextProps[key];
      }
    });

  // ④ 添加「新事件」
  Object.keys(nextProps)
    .filter(isEvent)
    .filter((key) => prevProps[key] !== nextProps[key])
    .forEach((key) => {
      const eventType = key.toLowerCase().substring(2);
      dom.addEventListener(eventType, nextProps[key]);
    });
}

三、第三块:可中断的 workLoop

/** ============ 第 3 块:渲染阶段(可中断的深度优先遍历) ============ */

/** ⭐ 全局遍历状态(能存在这里,才谈得上「可中断」) */
let nextUnitOfWork = null;         // 下一个要处理的 fiber
let wipRoot = null;                // 正在构建的根(work in progress)
let currentRoot = null;            // 上一次已提交的根(用于 diff)
let deletions = [];                // ⭐ 本次渲染要删除的 fiber(commit 时统一处理)

/** 内建的最小调度:时间片 + 让出(思想与真实 React 的 Scheduler 一致,见 4.17) */
const FRAME_YIELD_MS = 5;
function shouldYield() {
  return performance.now() - frameStart > FRAME_YIELD_MS;
}
let frameStart = performance.now();

/** 入口:把元素渲染到容器里 */
export function render(element, container) {
  wipRoot = {
    dom: container,
    props: { children: [element] },
    alternate: currentRoot,                 // ⭐ 双缓存:指向上一棵树
  };
  deletions = [];
  nextUnitOfWork = wipRoot;
  frameStart = performance.now();
  scheduleWork();
}

/** 用 rAF 驱动(真实 React 用 MessageChannel,见 4.17) */
function scheduleWork() {
  requestAnimationFrame(() => {
    frameStart = performance.now();
    workLoop();
  });
}

function workLoop() {
  // ⭐⭐ 可中断的关键:每处理一个 fiber 检查一次「是否该让出」
  while (nextUnitOfWork && !shouldYield()) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  if (nextUnitOfWork) {
    scheduleWork();                          // 本帧没跑完 → 下一帧继续(断点续跑)
  } else if (wipRoot) {
    commitRoot();                            // 全部处理完 → 提交
  }
}

/**
 * ⭐⭐ 处理【一个】fiber:三件事
 *   ① 给它建好 dom 并挂到父 dom 上
 *   ② 对比新旧 children,生成子 fiber(并打 effectTag)
 *   ③ 返回「下一个要处理的 fiber」(child → sibling → 上溯找 uncle)
 */
function performUnitOfWork(fiber) {
  // ① 函数组件:先执行它拿到 children(不创建 dom)
  const isFunctionComponent = typeof fiber.type === 'function';
  if (isFunctionComponent) {
    updateFunctionComponent(fiber);
  } else {
    updateHostComponent(fiber);
  }

  // ③ 返回下一个工作单元(深度优先)
  if (fiber.child) return fiber.child;

  let nextFiber = fiber;
  while (nextFiber) {
    if (nextFiber.sibling) return nextFiber.sibling;     // 有兄弟 → 处理兄弟
    nextFiber = nextFiber.parent;                        // 没有 → 回到父,继续找父的兄弟
  }
  return null;                                           // 回到根 → 结束
}
/** ============ 第 3.5 块:两类组件的处理 ============ */

function updateHostComponent(fiber) {
  if (!fiber.dom) {
    fiber.dom = createDom(fiber);
  }
  // ⭐ 注意:这里【不】把 dom append 到父 dom(延迟到 commit),
  //    这样 render 阶段可中断、可丢弃。这里我们采用「render 阶段建 DOM、
  //    commit 阶段插入」的折中(Didact 的原始做法是在这里 append,
  //    但那样会「在 render 期间改动真实 DOM」,违背可中断的前提)。
  const elements = fiber.props.children ?? [];
  reconcileChildren(fiber, elements);
}

function updateFunctionComponent(fiber) {
  wipFiber = fiber;                    // ⭐ 当前正在渲染的 fiber(Hook 需要它)
  hookIndex = 0;                       // ⭐ 重置 Hook 游标
  wipFiber.hooks = [];

  const children = [fiber.type(fiber.props)];             // 执行组件函数
  reconcileChildren(fiber, children.flat().filter(Boolean));
}

/**
 * ⭐⭐ 协调(reconcile):把「新元素数组」与「旧 fiber 子链」对比,
 *     复用能复用的 fiber,标记新增/删除
 */
function reconcileChildren(wipFiber, elements) {
  let index = 0;
  let oldFiber = wipFiber.alternate?.child ?? null;        // 旧子链的头
  let prevSibling = null;

  while (index < elements.length || oldFiber) {
    const element = elements[index];
    let newFiber = null;

    // ⭐ 判据:type 相同 → 复用;type 不同 → 新建 + 删旧的
    const sameType = element && oldFiber && element.type === oldFiber.type;

    if (sameType) {
      newFiber = {
        type: oldFiber.type,
        props: element.props,
        dom: oldFiber.dom,                                   // ⭐ 复用真实 DOM
        parent: wipFiber,
        alternate: oldFiber,                                 // ⭐ 双缓存指针
        effectTag: UPDATE,
      };
    }

    if (element && !sameType) {
      newFiber = {
        type: element.type,
        props: element.props,
        dom: null,
        parent: wipFiber,
        alternate: null,
        effectTag: PLACEMENT,
      };
    }

    if (oldFiber && !sameType) {
      oldFiber.effectTag = DELETION;                          // ⭐ 标记删除
      deletions.push(oldFiber);
    }

    if (oldFiber) oldFiber = oldFiber.sibling;

    // 串成「child / sibling」链
    if (index === 0) {
      wipFiber.child = newFiber;
    } else if (element && prevSibling) {
      prevSibling.sibling = newFiber;
    }

    prevSibling = newFiber;
    index++;
  }
}

四、第四块:useState(Hook 与重渲染)

/** ============ 第 4 块:Hook 系统(useState / useReducer) ============ */

let wipFiber = null;      // 当前正在渲染的函数组件 fiber
let hookIndex = null;     // ⭐ Hook 游标(顺序稳定的保证)

/**
 * ⭐⭐ Hook 的最小实现:
 *   · 每个 fiber 上挂一个 hooks 数组
 *   · 每次渲染从头开始,用 hookIndex 逐个「按顺序」取
 *   → 这就是「Hook 不能写在条件里」的根本原因:顺序必须每次一致
 */
export function useState(initial) {
  return useReducer(null, initial);
}

function useReducer(reducer, initial) {
  const oldHook =
    wipFiber.alternate?.hooks?.[hookIndex] ??                // ⭐ 从上一棵树取旧 Hook
    { state: initial, queue: [] };

  const hook = { state: oldHook.state, queue: [] };

  // ⭐ 消费「上次 setState 推入的更新队列」
  oldHook.queue.forEach((action) => {
    hook.state = reducer ? reducer(hook.state, action) : typeof action === 'function' ? action(hook.state) : action;
  });

  const setState = (action) => {
    hook.queue.push(action);
    // ⭐⭐ 触发重渲染:以「当前树」为基准,构造一棵新的 wipRoot
    wipRoot = {
      dom: currentRoot.dom,
      props: currentRoot.props,
      alternate: currentRoot,                  // 让 diff 能对比到旧树
    };
    nextUnitOfWork = wipRoot;
    deletions = [];
    scheduleWork();
  };

  wipFiber.hooks.push(hook);
  hookIndex++;
  return [hook.state, setState];
}
/** ============ 第 5 块:commit(把 diff 结果同步落到 DOM) ============ */

function commitRoot() {
  // ① 先处理删除(⭐ 必须在新增/更新之前,否则可能出现「先插入再删除同位置」的闪烁)
  deletions.forEach(commitWork);

  // ② 递归提交
  commitWork(wipRoot.child);

  // ⭐ ③ 双缓存切换:新树成为「当前树」
  currentRoot = wipRoot;
  wipRoot = null;
}

function commitWork(fiber) {
  if (!fiber) return;

  // ⭐ 找到「最近的有 dom 的祖先」(函数组件的 fiber 没有 dom)
  let domParentFiber = fiber.parent;
  while (domParentFiber && !domParentFiber.dom) {
    domParentFiber = domParentFiber.parent;
  }
  const domParent = domParentFiber?.dom;

  switch (fiber.effectTag) {
    case PLACEMENT:
      if (fiber.dom && domParent) {
        domParent.appendChild(fiber.dom);
      } else if (!fiber.dom && domParent) {
        // 函数组件没有 dom → 把它的子节点挂上去
        // (由子节点自己的 PLACEMENT 处理,这里不做事)
      }
      break;

    case UPDATE:
      if (fiber.dom) {
        updateDom(fiber.dom, fiber.alternate.props, fiber.props);
      }
      break;

    case DELETION:
      commitDeletion(fiber, domParent);
      return;                                   // ⭐ 删除后不再遍历子树(已整棵移除)
  }

  commitWork(fiber.child);
  commitWork(fiber.sibling);
}

function commitDeletion(fiber, domParent) {
  if (fiber.dom) {
    domParent.removeChild(fiber.dom);
  } else {
    // ⭐ 函数组件没有 dom → 递归找它的子节点删
    commitDeletion(fiber.child, domParent);
  }
}
/** ============ 使用示例 ============ */
import { render, createElement as h, useState } from './mini-react';

function Counter() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);

  return h(
    'div',
    { style: { padding: '16px', fontFamily: 'system-ui' } },
    h('h2', null, `计数:${count}`),
    h('button', { onClick: () => setCount((c) => c + step) }, `+${step}`),
    h('button', { onClick: () => setCount((c) => c - step) }, `-${step}`),
    h('button', { onClick: () => setStep((s) => s + 1) }, `步长 ${step} → ${step + 1}`),
    h('ul', null, ...Array.from({ length: Math.min(count, 5) }, (_, i) => h('li', { key: i }, `第 ${i + 1} 项`))),
  );
}

render(h(Counter, null), document.getElementById('root'));
⭐⭐ 这个实现里「与真实 React 对应」的六个点(面试价值最高的部分):

① 【`performUnitOfWork` 的「深度优先」】
   返回「child → sibling → 上溯 uncle」——这正是 Fiber 把「递归」变成「循环」的手法
   (见 4.2)。真实 React 里对应 `beginWork` / `completeUnitOfWork`。

② 【`effectTag` 三值】
   `PLACEMENT`(新增)、`UPDATE`(更新)、`DELETION`(删除)
   → 真实 React 里是 `flags`(二进制位运算,可组合,如 Placement | Update)。

③ 【`deletions` 数组「延迟删除」】
   删除必须在「所有新增/更新之前」执行,否则会「先插入后删除」产生闪烁。
   真实 React 同样把 `childDeletions` 收集起来,在 mutation 阶段优先处理。

④ 【`alternate` 双缓存】
   每次更新都「基于 currentRoot 建新树」,并把 `alternate` 指向旧树,
   从而能在 reconcile 时「拿到旧的 props/state 做对比」。
   → 这就是「双缓存(double buffering)」的最小形态(见 4.2)。

⑤ 【`hookIndex` 顺序】
   Hook 存在「fiber.hooks 数组」里、靠「下标」定位 →
   ⭐ 这就是「Hook 必须无条件按序调用」的机制原因。

⑥ 【「render 可中断 + commit 不可中断」】
   `workLoop` 里每处理一个 fiber 检查 `shouldYield()`;
   而 `commitRoot` 是「一口气递归到底」——**没有让出点**。
   → 这就是「渲染阶段可以被打断、提交阶段不能」的最小复现(见 4.17)。
⭐⭐⭐ 这个实现「故意没做」的五件事(理解边界才算真懂):

① 【没有优先级/车道】
   真实 React 有 Lane 模型(可以「被打断后重来」「高优先级插队」);
   这里只有一个「时间片」,被打断后【从断点继续】而不是「丢弃重来」。
   ⚠️ 差别很重要:真实的并发渲染会「丢弃 wip 树、用更高优先级重渲染」,
      而简单实现只是「分帧」。这也是「为什么 dev StrictMode 会双调用」的根源。

② 【没有 diff 的性能优化】
   真实 React 有「同层比较、type 变了整棵重建、key 匹配」;
   这里做了「type 相同就复用」的简化版——⚠️ 我甚至没实现 key 的复用
   (上面 `Array.from` 的例子用 index 当 key,会「就地更新」而不是「移动」)。

③ 【没有合成事件委托】
   这里是「一个元素一个 addEventListener」,真实 React 是「全部委托到根节点」,
   并用「事件冒泡 + 合成事件对象」统一(见 4.9)。

④ 【没有批量更新】
   每次 `setState` 都会立刻 `scheduleWork()`(真实 React 会「合并同一个事件里的多次 setState」)。
   ⭐ 改进:把「触发调度」放进微任务(`queueMicrotask`),自然就有批处理了。

⑤ 【没有 Context / Effect / memo / Suspense】
   `useEffect` 本可以只加 20 行(在 commit 后执行 fiber.effects 并保存清理函数),
   Suspense 见 11.12。
// ✅ 「④ 批量更新」的最小改法(让一个事件里的多次 setState 只调度一次)
let isScheduled = false;

function requestUpdate() {
  if (isScheduled) return;
  isScheduled = true;
  queueMicrotask(() => {                    // ⭐ 等当前同步代码跑完
    isScheduled = false;
    scheduleWork();
  });
}
// 把 setState 里的 scheduleWork() 换成 requestUpdate() 即可

五、本篇特有的坑

// ① 在 render 阶段直接往 document 里 append(破坏「可中断」的前提)
domParent.appendChild(fiber.dom);            // ❌ 在 performUnitOfWork 里做
// ⚠️ 一旦让出,用户就看到「半截 DOM」→ 必须「render 建 DOM、commit 插入」

// ② 忘了「函数组件没有 dom」这件事
domParent.removeChild(fiber.dom);            // ❌ fiber.dom 可能是 null(函数组件)
// ✅ 先「向上找最近的有 dom 的祖先」/「向下找第一个有 dom 的后代」

// ③ 删除操作没「提前执行」
// ⚠️ 如果先处理 PLACEMENT 再处理 DELETION,
//    可能出现「同一位置先插入新节点、再删除旧节点」→ 视觉闪烁 + 焦点丢失
// ✅ deletions 数组 + 在 commit 最开始统一处理

// ④ Hook 用「对象 + key」存储(顺序错乱时无声出错)
const hooks = {}; if (hooks[someKey]) ...     // ❌ 无法检测「Hook 顺序变了」
// ✅ 用数组 + 下标(配合「长度不一致」的检测能提前报警)

// ⑤ 在「循环/条件」里调用 useState
if (cond) useState(0);                        // ❌ hookIndex 会错位
// ⭐ 机制原因:Hook 按「调用顺序的下标」定位,少调一次 → 后面全错位

// ⑥ 忘了「每次渲染都要重置 hookIndex」
// ⚠️ 不重置的话,第二次渲染就会从「数组末尾」接着写 → 状态错乱
// ✅ 在 updateFunctionComponent 开头 `hookIndex = 0`

// ⑦ setState 里直接用「闭包捕获的 state」做计算
setCount(count + 1);                          // ⚠️ 连续调用会丢失更新
// ✅ setCount((c) => c + 1)(用函数式更新,读「最新的 state」)

// ⑧ `updateDom` 里「删除属性」用了 `dom[key] = ''`
// ⚠️ 对某些属性(如 `value`、`checked`)会留下奇怪的值
// ✅ 对「布尔属性」用 `removeAttribute`;或者干脆「重建节点」(真实 React 有时就这么做)

// ⑨ 事件监听「每次都重新绑定」但没有解绑
// ⚠️ 我的实现里通过「比较 prev/next 的函数引用」来解绑 + 重绑;
//    如果每次传的 arrow function 都是新的(内联函数),就会「每帧都解绑+重绑」
// ✅ 真实 React 用「事件委托到根节点」规避了这个问题

// ⑩ 用「数组下标当 key」(本实现的 reconcile 没实现 key 匹配,问题更严重)
// ⚠️ 列表重排序时会「就地更新内容」→ 组件状态错位、动画错乱
// ✅ 真实 React 用 key 做「移动」而不是「重建」

// ⑪ 忘了「在 commit 后清理 wipRoot」
// ⚠️ 不清的话,下一次渲染会「基于已经提交的旧 wipRoot」→ 状态错乱
// ✅ `currentRoot = wipRoot; wipRoot = null`

// ⑫ 用「同步递归」写 workLoop
function workLoopSync(fiber) { ... workLoopSync(fiber.child); }   // ❌ 无法中断
// ✅ 用「全局指针 + while」把递归状态「外化」

// ⑬ 认为「mini React 能当生产框架用」
// ⚠️ 它没有「错误边界、Suspense、Context、并发优先级、SSR、合成事件、
//     受控组件、ref、Portal、Fragment、key 复用、批量更新、内存回收…」
// ✅ 它的价值是「理解机制」,不是「替代 React」

// ⑭ 在「让出之后」直接改 fiber 上的字段(导致「半更新」状态可见)
// ⚠️ 中断点之后如果另一半数据没准备好,别把「部分结果」暴露给外部
// ✅ 所有对外可见的改动都留在 commit 阶段

// ⑮ 忘记「fiber.dom 必须复用」(否则每次更新都重建 DOM)
newFiber.dom = createDom(newFiber);           // ❌ 丢失输入框内容/焦点/滚动位置
// ✅ 复用 `oldFiber.dom`,只更新变化的属性

// ⑯ 「文本节点」没有走 updateDom
// ⚠️ 文本节点的内容变化要单独处理(`dom.nodeValue = props.nodeValue`)
// ✅ 在 updateDom 开头分支处理 TEXT_ELEMENT

// ⑰ `reconcileChildren` 里 `oldFiber` 的推进写错
// ⚠️ 如果「新增元素多于旧 fiber」,循环必须还能继续(靠 `|| oldFiber` 的条件)
// ✅ `while (index < elements.length || oldFiber)`

// ⑱ 忘了「alternate 可能在半途中被丢弃」
// ⚠️ 真实 React 里「被打断的 wip 树」会被丢弃(所以 render 必须纯);
//    简单实现里「从断点继续」,因此**恰好掩盖了不纯的 render**
//    → 这解释了「为什么简单实现跑得好好的代码,在真实 React 里会出问题」
// ⑯ 的完整处理(文本节点的更新)
function updateDom(dom, prevProps, nextProps) {
  if (dom instanceof Text) {
    if (prevProps.nodeValue !== nextProps.nodeValue) {
      dom.nodeValue = nextProps.nodeValue ?? '';
    }
    return;                                   // ⭐ 文本节点不处理其它属性
  }
  // ...其余逻辑
}

六、面试延伸

  1. 「手写一个 mini React,你会怎么设计?」
    四块:① createElement——把 JSX 变成 { type, props: { children } } 的纯对象,并把字符串子节点包装成「文本元素」(统一 child 形态);② Fiber 遍历——用 parent/child/sibling 三个指针把树串成链表,performUnitOfWork 每次处理一个 fiber 并返回「下一个」(child → sibling → 上溯 uncle),这使遍历状态可以外化到全局指针,从而可中断;③ commit——把 effectTag(PLACEMENT/UPDATE/DELETION)同步落到 DOM,其中删除必须先执行(避免闪烁),完成后 currentRoot = wipRoot 做双缓存切换;④ Hook——在 fiber 上挂 hooks 数组,用 hookIndex 按顺序取用,setState 推入更新队列并基于 currentRoot 构造新 wipRoot 触发重渲染。

  2. 「为什么 Fiber 用链表而不是递归?」
    因为递归的调用栈无法保存。Fiber 把「树」变成「带 parent/child/sibling 指针的链表」,遍历状态就落在「一个全局指针 nextUnitOfWork」上——于是可以「随时保存并返回」,等下一帧再接着跑。这就是「可中断(时间切片)」的前提。⭐ 反过来看:这也是「render 阶段可能被丢弃并重来」的根源(真实 React 在有更高优先级更新时,会丢弃半成品树用新优先级重渲染)→ 所以 render 必须纯。

  3. 「effectTag 是什么?为什么要单独收集 deletions?」
    effectTag 记录「这个 fiber 相比上一次是什么变更」:PLACEMENT(新增)、UPDATE(更新)、DELETION(删除)——真实 React 用位运算的 flags(可组合,如「既要插入又要更新 ref」)。单独收集 deletions 的原因是「顺序」:删除必须在「新增/更新之前」执行,否则同一位置会出现「先插入新节点、再删除旧节点」的中间态(视觉闪烁、焦点丢失、动画错乱)。所以实现上把要删的 fiber 收集进数组,在 commit 最开始统一处理。

  4. 「Hook 为什么不能写在条件里?」
    因为 Hook 是按「调用顺序的下标」定位的:实现上每个 fiber 挂一个 hooks 数组,每次渲染从头开始、用 hookIndex 逐个取(fiber.hooks[hookIndex])。如果某次渲染少调用了一个 Hook,后面的 Hook 就会全部错位(useState 拿到的是别的 Hook 的 state)→ 状态彻底错乱。⭐ 这也解释了「为什么它必须在函数组件顶层无条件调用」——它依赖的是「每次渲染的调用序列完全一致」。

  5. 「为什么 render 阶段可以中断,commit 阶段不能?」
    render 阶段只做「计算与创建 fiber」,产物在 wip 树里(还没接到真实文档上,或者只是「游离的 DOM」)——所以可以随时暂停/丢弃。而 commit 阶段在修改真实 DOM,如果做到一半被打断,用户会看到不一致的界面(列表删了一半、样式只应用了一部分)。⭐ 所以 React 的取舍是「渲染可以慢慢算,提交必须一口气做完」。这也是为什么「commit 阶段的耗时」直接决定「这一帧会不会掉帧」。

  6. 「你的实现里 setState 每次都会立刻重新调度渲染,真实 React 也是这样吗?」
    不是。真实 React 会批处理:在同一个事件处理里调 3 次 setState 只渲染一次——机制是「SyncLane 的渲染被放进微任务(scheduleMicrotask(flushSyncCallbacks)),等当前同步代码跑完再统一渲染」(见 4.17)。在 mini 实现里只需把「触发调度」换成微任务(queueMicrotask)就能得到等价的批处理效果。⭐ 这个对比很能说明「批处理不是特例,而是调度的自然结果」。

  7. 「mini React 与真实 React 最大的差距在哪?」
    五处:① 优先级/车道——真实 React 有 Lane 模型,可以「被打断后丢弃重来」与「高优先级插队」;简单实现只有「时间片分帧、断点续跑」。② diff 的完整性——真实 React 有 key 匹配 + 移动复用(getPlacement 等逻辑),简单实现只有「type 相同就复用」。③ 合成事件——真实 React 把事件委托到根节点,简单实现是「一个元素一个监听器」(内联函数会导致反复解绑/重绑)。④ 功能覆盖——Context/Effect/ref/Portal/Fragment/Suspense/错误边界/受控组件/SSR 都没有。⑤ 内存与边界处理——真实 React 有大量细节(属性白名单、style diff、dangerouslySetInnerHTML 等)。

  8. 「如果给你的 mini React 加 useEffect,你会怎么做?」
    三步:① 在 Hook 里登记——useEffect(create, deps) 时把 { create, deps, cleanup } 推到 wipFiber.hooks(与 useState 共用同一套「按下标取」的机制,所以要共用一个 hookIndex);② 判断是否需要重跑——比较 deps 与上一次的 deps(逐项 Object.is;deps 为 undefined 时每次都跑,[] 时只在挂载时跑);③ 在 commit 之后执行——把「需要跑的 effect」收集起来,在 commitRoot 结束后统一执行(⭐ 真实 React 的 passive effects 是「异步」执行的,用微任务/调度器延迟,以免阻塞提交后的绘制;而 useLayoutEffect 是在 commit 内同步执行的,见 2.17)。

一句话速记

mini React 的四块是「createElement(JSX → 纯对象 + 文本节点包装)→ Fiber 链表(parent/child/sibling)+ workLoop(全局指针 nextUnitOfWork + shouldYield() → 可中断的深度优先遍历,返回顺序是 child → sibling → 上溯 uncle)→ commit(effectTag = PLACEMENT/UPDATE/DELETION,删除最先执行,结束做 currentRoot = wipRoot 双缓存切换)→ Hook(fiber.hooks 数组 + hookIndex 按顺序取,setState 推队列并基于 currentRoot 造新 wipRoot)」;三个「为什么」:Fiber 用链表是因为递归的栈无法保存(因此 render 可能被丢弃重来 → 必须纯)、Hook 不能写在条件里是因为它按「调用顺序的下标」定位、删除要单独收集是因为它必须先于插入执行;与真实 React 的差距是「Lane 优先级(会丢弃重来)、key 复用、合成事件委托、批处理(微任务)、Context/Effect/Suspense」——加 useEffect 只需「登记 → 比 deps → commit 后执行」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 11.11 手写 mini React(300 行)

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫