mini-react

3 阅读7分钟
// =============================================================================
// 一、createElement 体系:JSX → 虚拟 DOM
// =============================================================================
/**
 * 文本节点的虚拟 DOM 工厂函数
 *
 * 为什么要单独处理文本节点?
 *   JSX 中的字符串 "hello" 不是一个标签,不能用 document.createElement('hello')
 *   浏览器创建文本内容需要用 document.createTextNode('')
 *   所以要给它一个特殊标记 'TEXT_ELEMENT',在创建真实 DOM 时区分处理
 *
 * @param text  文本内容,如 "hello"、数字 123
 */
function createTextNode(text) {
  return {
    type: "TEXT_ELEMENT",          // 标记:这是一个文本节点
    props: {
      nodeValue: text,             // 文本值,对应真实 DOM 的 nodeValue
      children: [],                // 文本节点没有子节点
    },
  };
}
/**
 * createElement — JSX 编译器转换的目标函数
 *
 * JSX 写法:<div id="title">hello</div>
 * 编译后:createElement('div', { id: 'title' }, 'hello')
 *
 * 这个函数把散落的参数聚合成一棵虚拟 DOM 树:
 *   - type:        标签名
 *   - props:       属性 + children
 *   - children:    自动收集剩余参数,统一成虚拟 DOM 对象数组
 *
 * @param type      标签名,如 'div'、'span'、'button';或组件函数(后续阶段)
 * @param props     JSX 传入的属性对象(不含 children),如 { id: 'title', onClick: fn }
 * @param children  所有子节点,自动收集在剩余参数中
 */
function createElement(type, props, ...children) {
  return {
    type,
    props: {
      ...props,
      // 关键:把字符串/数字子节点也转成虚拟 DOM 对象
      // 原因:后续代码统一处理 fiber,不需要区分"原始值"和"对象"
      // 'hello' -> createTextNode('hello')
      // {type:'span',...} -> 直接使用
      children: children.map((child) => {
        return typeof child === "string" || typeof child === "number"
          ? createTextNode(child)
          : child;
      }),
    },
  };
}

// =============================================================================
// 二、render — 入口,把虚拟 DOM "种子" 交给调度器
// =============================================================================

/**
 * render — React 和 ReactDOM 的边界函数
 *
 * 调用方:ReactDOM.createRoot(el).render(<App />)
 *
 * 这里只做一件事:初始化根 fiber,然后启动调度器
 * 渲染工作在 workLoop 中分帧执行,不会阻塞浏览器
 *
 * 为什么根 fiber 的 dom 直接是 container?
 *   因为 container(#root)已经是真实 DOM 了,不需要再创建
 *   它只是整棵 fiber 树的"根容器",不是要渲染的节点
 *
 * @param el        App 组件的虚拟 DOM(经过 createElement 处理后的对象树)
 * @param container 真实 DOM 中的挂载点,document.querySelector('#root')
 */
function render(el, container) {
  nextWorkOfUnit = {
    dom: container,                // 根 fiber 的 dom 直接指向真实容器
    props: {
      children: [el],              // App 虚拟 DOM 作为 children 的第一个元素
    },
    // child / parent / sibling 暂时为 undefined
    // 由 initChildren 在遍历时动态构建
  };
}

// =============================================================================
// 三、调度器(Scheduler):分帧执行,浏览器不卡顿
// =============================================================================

/**
 * nextWorkOfUnit — 整个调度系统的"断点记录器"
 *
 * 作用:
 *   - 每处理完一个 fiber,performWorkOfUnit 返回"下一个 fiber"
 *   - 这个值被保存到 nextWorkOfUnit 中
 *   - 当一帧的时间用完(shouldYield = true),while 退出
 *   - 下一帧 workLoop 被再次调用,从 nextWorkOfUnit 恢复,继续处理
 *
 * 为什么叫 "Work Of Unit"?
 *   "Unit" = 一个 fiber = 一个最小可调度单元
 *   每次循环只处理一个 fiber,处理完就可能让出
 *
 * 为什么放在函数外部(全局)?
 *   因为 workLoop 和 performWorkOfUnit 是两个分离的函数
 *   只有通过全局变量才能在不同调用之间传递"断点"
 */
let nextWorkOfUnit = null;

/**
 * workLoop — 帧循环,调度器的核心
 *
 * 核心思想:
 *   每帧末尾(raf 之后,paint 之前)检查浏览器是否空闲
 *   如果有空闲时间,就处理 fiber 任务
 *   如果时间不够,立刻停下,把"断点"留给下一帧
 *
 * 为什么需要 while 循环?
 *   因为一次空闲期可能够处理多个 fiber(如果节点很简单)
 *   每次处理完后重新检查时间,不浪费任何空闲
 *
 * 为什么最后要再次 requestIdleCallback(workLoop)?
 *   两种情况:
 *     1. nextWorkOfUnit != null(还没渲染完)-> 需要继续调度下一帧
 *     2. nextWorkOfUnit == null(渲染完了)-> 什么也不做,while 直接退出
 *
 * @param deadline  浏览器传入的对象
 *                   deadline.timeRemaining() = 剩余可用毫秒数(通常 0~50ms)
 *                   deadline.didTimeout        = 是否因强制超时被调用
 */
function workLoop(deadline) {
  let shouldYield = false;

  // 循环不变式:
  //   每次循环开始时,nextWorkOfUnit 指向"下一个要处理的 fiber"
  //   每次循环结束时,nextWorkOfUnit 更新为"再下一个"
  while (!shouldYield && nextWorkOfUnit) {
    nextWorkOfUnit = performWorkOfUnit(nextWorkOfUnit);
    // 执行一个 fiber 后,立刻检查时间够不够处理下一个
    shouldYield = deadline.timeRemaining() < 1;
  }

  // 注册下一帧的调度(无论本次是否做完)
  // 如果渲染已完成,nextWorkOfUnit 为 null,while 条件直接为 false,什么都不做
  requestIdleCallback(workLoop);
}

// =============================================================================
// 四、DOM 操作层:创建真实节点 & 设置属性
// =============================================================================

/**
 * createDom — 根据 fiber.type 创建对应的真实 DOM
 *
 * 为什么要区分 TEXT_ELEMENT?
 *   document.createElement('div')        -> 创建元素节点
 *   document.createTextNode('')          -> 创建文本节点
 *   这是两种完全不同的 API,不能混用
 *
 * @param type  'TEXT_ELEMENT' -> 文本节点 | 其他字符串 -> 普通元素节点
 */
function createDom(type) {
  return type === "TEXT_ELEMENT"
    ? document.createTextNode("")
    : document.createElement(type);
}

/**
 * updateProps — 把 props 中的属性同步到真实 DOM 上
 *
 * 处理范围:
 *   - 普通属性:dom.id = 'title'、dom.src = 'img.png'
 *   - 事件后续会处理:onClick -> click,onChange -> change
 *
 * 排除项:
 *   - children:这是子节点,不是属性,由 initChildren + append 处理
 *
 * @param dom    真实 DOM 节点
 * @param props  fiber.props 对象
 */
function updateProps(dom, props) {
  Object.keys(props).forEach((key) => {
    if (key !== "children") {
      dom[key] = props[key];
    }
  });
}

// =============================================================================
// 五、Fiber 链表构建 — 把树状虚拟 DOM 转成可遍历的链表结构
// =============================================================================

/**
 * initChildren — 把虚拟 DOM 的 children 数组展平成 Fiber 链表
 *
 * 链表结构图解:
 *
 *   虚拟 DOM(数组):
 *     children: [p_node, p_node, span_node]
 *
 *   Fiber 链表(initChildren 后):
 *     fiber.child ──► p_node(0) ──sibling─► p_node(1) ──sibling─► span_node(2)
 *                       │
 *                       └─child─┐
 *                               ▼
 *                         (由下一次 initChildren 展开)
 *
 * 为什么 sibling 是在"上一个 fiber"上设置的,而不是在当前 fiber 上?
 *   因为我们是从左到右遍历 children 的
 *   每遇到一个新 child,就把它挂在"上一个 child 的 sibling"上
 *   这样就串成了一条单向链表
 *
 * 为什么只需要 child + sibling 两个指针就够了?
 *   遍历时只有三种走法:往下(child)、往右(sibling)、往上(parent.sibling)
 *   单向链表完全覆盖这三种走法,不需要 parent 数组
 *
 * @param fiber  当前正在处理的 fiber,initChildren 在它上面构建 child / sibling 指针
 */
function initChildren(fiber) {
  const children = fiber.props.children;  // 虚拟 DOM 层的 children(数组)
  let prevChild = null;                   // 记住上一个 fiber,用于设置 sibling

  children.forEach((child, index) => {
    const newFiber = {
      type: child.type,
      props: child.props,
      child: null,       // 子链表由递归调用 initChildren 构建(下一个 workLoop 周期)
      parent: fiber,     // 回指父 fiber(用于"子树处理完,向上找叔叔")
      sibling: null,     // 暂时 null,等下一个 child 来填
      dom: null,         // 真实 DOM 在 performWorkOfUnit 中创建
    };

    if (index === 0) {
      fiber.child = newFiber;       // 第一个子节点挂在 parent.child
    } else {
      prevChild.sibling = newFiber; // 后续子节点挂在 prevChild.sibling(串链表)
    }
    prevChild = newFiber;
  });
}

/**
 * performWorkOfUnit — 处理一个 fiber 的完整工作,并返回下一个 fiber
 *
 * 处理流程(三步,每步都不可省略):
 *
 *   阶段 1 — 创建并挂载真实 DOM
 *     fiber.dom = createDom(fiber.type)     创建 DOM
 *     fiber.parent.dom.append(fiber.dom)    挂到父节点(父节点 DOM 已存在)
 *     updateProps(dom, fiber.props)         设置属性
 *
 *   阶段 2 — 构建子链表
 *     initChildren(fiber)                   在 fiber 上生成 child / sibling 指针
 *     为下一轮 workLoop 的遍历做好准备
 *
 *   阶段 3 — 确定下一个处理目标
 *     返回值决定遍历顺序,是深度优先遍历的具体实现
 *
 * 遍历顺序示例:
 *   DOM 结构:
 *     <A>
 *       <B><D/></B>
 *       <C><E/><F/></C>
 *     </A>
 *
 *   遍历顺序:
 *     A -> B -> D -> C -> E -> F
 *     (A 的 child = B,B 的 child = D,D 没有子节点也没有兄弟,回到 B;
 *      B 没有兄弟,回到 A;A 的 sibling = C,C 的 child = E,E -> F)
 *
 * @param  fiber 当前要处理的 fiber
 * @return       下一个要处理的 fiber
 *               - 有 child      → 返回 child(继续往深处走)
 *               - 无 child 有 sibling → 返回 sibling(子树完了,处理兄弟)
 *               - 都没有       → 返回 parent?.sibling(子树完了,找叔叔)
 *               - 根节点都没有 → 返回 undefined(整棵树处理完毕)
 */
function performWorkOfUnit(fiber) {
  // ---- 阶段 1:创建并挂载真实 DOM ----
  if (!fiber.dom) {
    fiber.dom = createDom(fiber.type);      // 创建:div / span / 文本节点
    fiber.parent.dom.append(fiber.dom);     // 挂载:父节点 DOM 已存在
    updateProps(fiber.dom, fiber.props);    // 设置:id / class / onClick 等
  }

  // ---- 阶段 2:构建子 fiber 链表 ----
  // 重要:这个调用执行后,fiber 上才有 child / sibling 指针
  // 否则下一轮 workLoop 就不知道往哪里走了
  initChildren(fiber);

  // ---- 阶段 3:返回下一个要处理的 fiber ----
  // 调度策略 = 深度优先(先处理完一个完整的子树,再处理下一个)
  // 实现:优先 child → 其次 sibling → 最后父节点的 sibling
  if (fiber.child) {
    return fiber.child;           // 有子节点 → 继续往深处走
  }

  if (fiber.sibling) {
    return fiber.sibling;        // 没子节点,有兄弟 → 处理兄弟
  }

  return fiber.parent?.sibling;   // 子节点和兄弟都没有 → 回到父节点,找叔叔(父的兄弟)
}

// =============================================================================
// 六、启动调度器
// =============================================================================

// requestIdleCallback(workLoop) 在页面加载时就注册了第一个调度周期
// 之后由 workLoop 内部递归注册自己,形成"自驱的帧循环"
// 只要 nextWorkOfUnit 还有值,这个循环就不会停止
requestIdleCallback(workLoop);

// =============================================================================
// 七、导出公共 API
// =============================================================================

const React = {
  render,
  createElement,
};

export default React;