跳转到主要内容
Chang Wei's Blog昌维的博客

Article

理解 React 工作原理并复刻一个 ez-react

2026年6月15日星期一 02:17Chang Wei (昌维) <changwei1006@gmail.com>zh-Hans-CN
永久链接:

React Logo:React 是一个用于构建用户界面的 JavaScript 库
React Logo:React 是一个用于构建用户界面的 JavaScript 库

我写 ez-react 的出发点很简单:如果只看官方 React 的文档和源码,React 往往会显得过于庞大。它有 Fiber、Lane、Scheduler、Concurrent Rendering、Hooks、Server Components、Suspense、Hydration 等等机制,每一个机制背后都有复杂的历史包袱和工程约束。可是如果一个人刚刚开始理解 React,他最需要的并不是马上进入这些工业级细节,而是先看清楚一条最基本的路径:一段 JSX 是如何变成页面上的 DOM 的?组件是怎样被创建和更新的?setState 为什么会触发重新渲染?Virtual DOM 到底是一个什么形状的数据?所谓 diff 具体 diff 的又是什么?

ez-react 就是围绕这条路径写出来的一个迷你实现。它不是为了替代 React,也不是为了追求与官方 React 完全一致的行为,而是把 React 最核心的几个概念压缩到可以阅读、可以调试、可以从头走一遍的规模里。项目分成三个包:@cw1997/ez-react 负责 JSX 运行时、组件基类、Virtual DOM 类型和 ref;@cw1997/ez-react-dom 负责把 Virtual DOM 渲染成浏览器 DOM,并在更新时执行简单 diff;ez-react-demo 则用计数器、时钟、表单输入、ref 画布等例子展示这些能力。

这篇文章会从 React 的基本原理开始讲起,然后进入 ez-react 的具体源码。文章中的代码来自项目中的实现,但为了让叙述更聚焦,有些片段会省略与当前主题无关的类型声明或测试细节。读完以后,你应该能够沿着下面这条主线在脑中跑完整个流程:

flowchart LR A[JSX] --> B[Babel 转换] B --> C[React.createElement] C --> D[Virtual DOM 对象] D --> E[ReactDOM.render] E --> F[_diffRender 分发] F --> G[创建或更新真实 DOM] G --> H[挂载到 container] I[事件或 setState] --> J[组件 render] J --> D

为什么要从 JSX 开始

很多人第一次接触 React,会把 JSX 当成一种“HTML 写在 JavaScript 里”的语法。这个说法虽然直观,但容易让人误解。JSX 本身并不是浏览器能直接执行的东西,也不是 HTML 字符串。它是 JavaScript 的一种语法扩展,需要经过 Babel、TypeScript 或其他编译工具转换成普通函数调用。

例如我们写:

const view = <div className="hello">hello ez-react</div>;

在经典 JSX transform 下,它会被转换成类似下面的代码:

const view = React.createElement(
  "div",
  { className: "hello" },
  "hello ez-react",
);

所以 JSX 的本质不是模板,而是一种更适合描述 UI 树的函数调用语法糖。它把标签名、属性、子节点都交给 createElement,由 createElement 生成一棵 JavaScript 对象树。这棵对象树就是我们通常说的 Virtual DOM。

ez-react 中的 createElement 非常短:

export default function createElement(
  tagName: VirtualDOM["tagName"],
  attributes?: VirtualDOM["attributes"],
  ...children: VirtualDOM["children"]
): VirtualDOM {
  return {
    tagName,
    attributes,
    children,
  };
}

短到几乎没有“算法”。但正是这个函数把 React 的第一层抽象暴露得很清楚:UI 首先被描述为数据,而不是立即被操作成 DOM。只要 UI 是数据,我们就可以比较两份数据、复用已有节点、延迟渲染、跨平台渲染,甚至可以把同一份描述渲染到浏览器、命令行、Canvas 或原生平台上。官方 React 后来的复杂能力,也都是从“UI 是一棵可计算的数据树”这个起点生长出来的。

ez-react 里,一个最简单的 JSX:

<span id="name">cw1997</span>

会形成类似下面的 Virtual DOM:

{
  tagName: "span",
  attributes: { id: "name" },
  children: ["cw1997"],
}

如果子节点里还有标签,就继续嵌套:

<div id="my-div">
  hello <span>world</span>!
</div>

对应的数据大致是:

{
  tagName: "div",
  attributes: { id: "my-div" },
  children: [
    "hello ",
    { tagName: "span", attributes: {}, children: ["world"] },
    "!",
  ],
}

这里最重要的不是对象字段叫什么,而是这棵树的形态:每一个节点要么是文本,要么是一个 HTML 标签节点,要么是一个组件节点。ez-react-dom 的渲染器后面就是围绕这三类节点分发处理。

flowchart TD V[VirtualNode] --> T[文本节点 string number boolean] V --> H[HTML 节点 tagName 为字符串] V --> C[组件节点 tagName 为函数或类] H --> HA[attributes] H --> HC[children] C --> CP[props = attributes + children]

Virtual DOM 不是 DOM 的拷贝

Virtual DOM 这个名字有一点迷惑性。它不是浏览器 DOM 的完整影子,也不是把 DOM API 原样复制成 JavaScript 对象。真实 DOM 上有大量浏览器状态、布局信息、事件系统、样式计算结果和平台能力,Virtual DOM 并不关心这些。Virtual DOM 关心的是“这一次渲染想要的 UI 长什么样”。

ez-react 中,核心类型可以简化理解为:

export type VirtualTextNode = number | string | boolean;
export type VirtualNode = VirtualDOM | VirtualTextNode;
 
export interface VirtualDOM {
  tagName: string | ReactComponent<any, any>;
  attributes: VirtualDOMAttributes;
  children?: VirtualNode[];
}

这说明 ez-react 对节点的分类非常直接:

第一类是文本节点。字符串、数字和布尔值都可以作为子节点出现。渲染器会把它们转成字符串,再创建或更新 Text 节点。

第二类是 HTML 节点。它的 tagName 是字符串,例如 "div""span""button"。渲染器会用 document.createElement(tagName) 创建真实 DOM。

第三类是组件节点。它的 tagName 是函数组件或类组件。渲染器不会直接创建 DOM,而是先创建组件实例,调用组件的 render,拿到组件返回的下一层 Virtual DOM,再继续渲染。

这三个分类已经足够讲清楚 React 渲染的最小闭环。官方 React 的 Fiber 节点当然远比这里复杂,它会记录 effect、优先级、alternate、lanes、flags、memoizedProps、pendingProps 等大量调度信息。但如果暂时不看并发和调度,只看“一棵 UI 描述如何落到 DOM 上”,ez-react 里的 VirtualNode 已经足够作为教学模型。

flowchart LR subgraph VirtualDOM[虚拟节点] A1[tagName] A2[attributes] A3[children] end subgraph DOM[真实 DOM] B1[nodeName] B2[attributes] B3[childNodes] B4[event handler] B5[style object] end A1 --> B1 A2 --> B2 A2 --> B4 A2 --> B5 A3 --> B3

上图也提示了一个关键点:Virtual DOM 和真实 DOM 并不是一一同构的。attributes 里面可能有 classNamestyleonClickrefvalue 等不同语义的属性,它们落到真实 DOM 上的方式并不一样。ez-react-dom 专门有 _setDomAttribute 处理这些差异,这也是 React DOM 渲染器存在的意义:React 核心只描述 UI,React DOM 才理解浏览器。

项目结构:把 React 和 ReactDOM 分开

ez-react 的 workspace 结构如下:

ez-react
├── packages
│   ├── ez-react
│   │   └── src
│   │       ├── createElement.ts
│   │       ├── Component.ts
│   │       └── index.ts
│   ├── ez-react-dom
│   │   └── src
│   │       ├── ReactDOM.ts
│   │       └── ReactDOM.test.tsx
│   └── ez-react-demo
│       └── src
│           ├── index.tsx
│           └── components
│               ├── Counter.tsx
│               ├── Clock.tsx
│               ├── DataBinding.tsx
│               └── TestRefByCanvas.tsx

这个分层和官方 React 的思想一致:react 包本身不依赖浏览器,它提供的是组件模型、元素创建、ref 等“描述层”能力;react-dom 才负责把描述层渲染到浏览器 DOM。这样分离以后,理论上你也可以写一个 ez-react-canvasez-react-terminalez-react-native-lite,只要它能理解同一份 Virtual DOM 数据。

flowchart TB R[@cw1997/ez-react] --> CE[createElement] R --> C[Component] R --> REF[createRef] RD[@cw1997/ez-react-dom] --> DR[_diffRender] RD --> DA[_diffAttribute] RD --> DC[_diffChildren] Demo[ez-react-demo] --> R Demo --> RD

从使用者角度看,demo 的入口和普通 React 很像:

import React from "@cw1997/ez-react";
import ReactDOM from "@cw1997/ez-react-dom";
 
function App(props: { data: string }) {
  return (
    <div className="App">
      <Counter name="cw1997" sex="male" />
      <Clock>Clock</Clock>
      <DataBinding />
      <TestRefByCanvas x={50} y={50} w={50} h={50} />
    </div>
  );
}
 
const dom = document.getElementById("root");
ReactDOM.render(<App data="cw1997" />, dom);

从这几行代码开始,整个渲染流程就启动了。<App data="cw1997" /> 首先会被转换成 React.createElement(App, { data: "cw1997" })。因为 App 是函数,所以这个 Virtual DOM 的 tagName 不是字符串,而是函数本身。ReactDOM.render 遇到这个组件节点时,会进入组件渲染分支,创建一个组件实例,然后调用它的 render。对函数组件来说,ez-react 会用 FunctionComponent 包装一层,让函数组件和类组件都以统一的实例形态进入后续流程。

首次渲染:从 container 到真实 DOM

ReactDOM.render 是浏览器渲染入口。ez-react-dom 的实现如下:

public static render(
  virtualDom: VirtualNode,
  container: HTMLElement,
  clearBeforeRender: boolean = true,
) {
  if (clearBeforeRender) {
    container.innerHTML = "";
  }
  const emptyNode = document.createElement("div");
  const oldTrueDom = container.appendChild(emptyNode);
  const realDOM = this._diffRender(oldTrueDom, virtualDom);
  if (realDOM) {
    container.innerHTML = null;
    container.appendChild(realDOM);
  }
}

这里有一个小技巧:它先在 container 里面塞入一个临时 div,再把这个临时节点作为 oldTrueDom 传给 _diffRender。这样首次渲染也能复用同一个 diff 入口,而不是单独写一套“初次创建”逻辑。等 _diffRender 返回真正的 DOM 后,再清空 container,把真实结果 append 进去。

这个流程可以画成下面这样:

sequenceDiagram participant User as 用户代码 participant ReactDOM as ReactDOM.render participant Diff as _diffRender participant Browser as Browser DOM User->>ReactDOM: render(<App />, container) ReactDOM->>Browser: container.innerHTML = "" ReactDOM->>Browser: appendChild(empty div) ReactDOM->>Diff: _diffRender(empty div, virtualDom) Diff-->>ReactDOM: realDOM ReactDOM->>Browser: 清空 container ReactDOM->>Browser: appendChild(realDOM)

这不是最高效的首次渲染方式,但对于一个教学实现来说非常清楚。首次渲染和后续更新都从 _diffRender(oldTrueDom, newVirtualDom) 开始,区别只在于 oldTrueDom 是否能被复用。

_diffRender:三个分支决定所有渲染路径

_diffRenderez-react-dom 的核心分发函数:

public static _diffRender(
  oldTrueDom: ReactNode,
  newVirtualDom: VirtualNode,
): ReactNode {
  switch (typeof newVirtualDom) {
    case "string":
    case "number":
    case "boolean": {
      return this._diffRenderText(oldTrueDom, newVirtualDom);
    }
    case "object":
    default: {
      const { tagName } = newVirtualDom;
      switch (typeof tagName) {
        case "string": {
          return this._diffRenderHTML(oldTrueDom, newVirtualDom);
        }
        case "function":
        case "object":
        default: {
          return this._diffRenderComponent(oldTrueDom, newVirtualDom);
        }
      }
    }
  }
}

这段代码就是整篇文章的“总开关”。它只问两个问题:

新节点本身是不是文本?如果是,就进入 _diffRenderText

新节点的 tagName 是不是字符串?如果是,就进入 _diffRenderHTML

否则,把它当成组件,进入 _diffRenderComponent

flowchart TD A[_diffRender oldTrueDom newVirtualDom] --> B{newVirtualDom 类型} B -->|string number boolean| C[_diffRenderText] B -->|object| D{tagName 类型} D -->|string| E[_diffRenderHTML] D -->|function object| F[_diffRenderComponent] C --> G[Text Node] E --> H[HTMLElement] F --> I[Component instance._node]

这个结构很适合阅读源码。你不需要一开始就记住所有细节,只要记住三条路:文本路、HTML 路、组件路。任何 JSX 最终都会在这三条路之间递归穿梭。例如 <App /> 是组件路,App 返回 <div> 后进入 HTML 路,<div> 的 children 中有 <Counter /> 又进入组件路,Counter.render() 返回 <button> 再回到 HTML 路,按钮里的数字 0 最后进入文本路。

文本渲染:最小但很重要

文本渲染看起来最简单:

private static _diffRenderText(
  oldTrueDom: ReactNode,
  newVirtualDom: VirtualTextNode,
): ReactNode {
  const newText = String(newVirtualDom);
  let newTrueDom = oldTrueDom;
  if (oldTrueDom?.nodeType === 3) {
    if (oldTrueDom.textContent !== newText) {
      oldTrueDom.textContent = newText;
    }
  } else {
    newTrueDom = document.createTextNode(newText);
  }
  return newTrueDom;
}

这段代码做了两件事。第一,把 numberboolean 都转成字符串。也就是说 {123} 会渲染成 "123"{true} 会渲染成 "true"{false} 会渲染成 "false"。官方 React 对布尔值子节点的处理并不完全相同,通常不会把 false 渲染成文本。ez-react 这里选择把它们直接字符串化,逻辑更简单,也便于观察 Virtual DOM 到 DOM 的转换。

第二,如果旧节点已经是文本节点,就直接修改 textContent;如果旧节点不是文本节点,才创建新的 Text 节点。这就是 diff 的最小形式:同类节点尽量复用,不同类节点重新创建。

flowchart TD A[新 VirtualTextNode] --> B[String 转换] B --> C{旧 DOM 是 Text 吗} C -->|是| D{textContent 是否变化} D -->|变化| E[更新 textContent] D -->|未变化| F[复用旧 Text] C -->|否| G[document.createTextNode]

这里还顺带带来了一个安全收益:文本是通过 document.createTextNodetextContent 写入的,不是拼接成 innerHTML。所以 demo 中这段字符串:

<Counter name="with dangerous string <script>alert(/1/)</script>>" sex="male" />

最终会作为文本显示,而不是执行脚本。ez-react-dom 的测试里也有类似用例,验证包含 <a> 的字符串会被浏览器转义成文本内容。这说明即使是一个迷你渲染器,只要坚持用 DOM API 创建文本节点,而不是把用户内容拼进 HTML 字符串,就能避免一类常见 XSS 问题。

HTML 渲染:创建元素、处理 children、设置属性

HTML 节点的渲染比文本节点复杂一些,因为它要处理标签、属性、子节点三件事:

private static _diffRenderHTML(
  oldTrueDom: ReactNode,
  newVirtualDom: VirtualHTMLDOM,
): ReactNode {
  const newHTMLTag = newVirtualDom.tagName;
  let newTrueDom;
 
  const isSameNodeType = this._isSameNodeType(oldTrueDom, newVirtualDom);
 
  const newChildren = [];
  newVirtualDom.children.forEach((child) => {
    if (Array.isArray(child)) {
      newChildren.push(...child);
    } else {
      newChildren.push(child);
    }
  });
  newVirtualDom.children = newChildren;
 
  if (isSameNodeType) {
    newTrueDom = this._diffChildren(oldTrueDom, newVirtualDom);
  } else {
    newTrueDom = document.createElement(newHTMLTag);
    newVirtualDom.children.forEach((newChild) => {
      const diffChild = this._diffRender(null, newChild);
      newTrueDom.appendChild(diffChild as Node);
    });
  }
 
  this._diffAttribute(newTrueDom as HTMLElement, newVirtualDom);
  return newTrueDom;
}

它的判断也很直接:如果旧 DOM 和新 Virtual DOM 是同一种标签,就复用旧 DOM,只 diff children;如果不是同一种标签,就创建新 DOM,并递归渲染所有子节点。

flowchart TD A[_diffRenderHTML] --> B[读取 tagName] B --> C[_isSameNodeType] C -->|相同标签| D[_diffChildren 复用旧元素] C -->|不同标签| E[document.createElement] E --> F[递归渲染 children] D --> G[_diffAttribute] F --> G G --> H[返回 HTMLElement]

这里有一个容易忽略的细节:newVirtualDom.children 可能出现嵌套数组,所以代码会先把子节点拍平一层。这是为了支持组件 children 中返回数组的场景。例如一个组件的 props.children 本身可能已经是数组,当它又被放进另一个数组位置时,就会形成 [first, childrenArray, last] 这样的结构。渲染器如果不处理,就会把数组当作普通对象节点,后续分发会出错。

HTML 渲染的最后一步是 _diffAttribute。这一步发生在 children 处理之后,意味着无论是复用旧节点还是创建新节点,最终都会根据新的 Virtual DOM 属性把 DOM 属性同步一遍。

属性处理:React DOM 的味道从这里开始

浏览器 DOM 的属性并不只是简单的 setAttribute。React 中的 classNamehtmlFortabIndexstyle、事件、表单 valueref 都有特殊处理。ez-react-dom 也实现了一个简化版:

public static _setDomAttribute(
  element: HTMLElement,
  key: string,
  value: any,
): void {
  if (key === "className") {
    element.setAttribute("class", value);
  } else if (key === "htmlFor") {
    element.setAttribute("for", value);
  } else if (key === "tabIndex") {
    element.setAttribute("tabindex", value);
  } else if (key === "style") {
    if (typeof value === "string") {
      element.style.cssText = value;
    } else if (typeof value === "object") {
      Object.keys(value).forEach((styleName) => {
        const rawStyleValue = value[styleName];
        element.style[styleName] =
          typeof rawStyleValue === "number" ? `${rawStyleValue}px` : rawStyleValue;
      });
    }
  } else if (/^on[A-Z][A-Za-z]+$/.test(key)) {
    const htmlEventName = key.toLowerCase();
    element[htmlEventName] = value;
  } else if (key === "key") {
    element["key"] = value;
  } else if (key === "value") {
    element["value"] = value;
  } else if (key === "ref") {
    // ref 逻辑后面单独讲
  } else {
    element.setAttribute(key, value);
  }
}

这段代码把 JSX 属性分成了几类:

className 被转换成 HTML 的 class。这是 React 里最常见的 DOM 属性差异,因为 class 是 JavaScript 保留字历史上带来的语义冲突,React 使用 className 来对齐 DOM property。

htmlFor 被转换成 for。这是 label 关联 input 时经常用到的属性,因为 JavaScript 里 for 也是关键字。

tabIndex 被转换成小写 tabindex。这体现了 JSX 属性命名和 HTML attribute 命名之间的映射。

style 支持字符串和对象两种形式。对象形式下,如果样式值是数字,就自动补上 px。所以 style={{ width: 200 }} 会落成 width: 200px

事件属性通过正则匹配 onClickonMouseEnter 这类名字,然后转成小写 DOM property,例如 onclick。这不是官方 React 的合成事件系统,而是直接把函数挂到 DOM 节点属性上。官方 React 会在根节点附近做事件委托和 SyntheticEvent 包装,ez-react 为了简单直接赋值。

key 不会成为标准 HTML attribute,而是挂到 DOM 对象上,供 _diffChildren 做 keyed diff。

value 也直接写 DOM property,而不是 setAttribute。这对 input 很重要,因为表单元素的当前值是 property 语义,单纯改 attribute 不一定能得到预期的交互行为。

flowchart LR A[VirtualDOM attributes] --> B{key} B -->|className| C[class] B -->|htmlFor| D[for] B -->|tabIndex| E[tabindex] B -->|style object| F[element.style] B -->|onXxx| G[element.onclick 等] B -->|value| H[element.value] B -->|ref| I[ref.current 或 callback] B -->|其他| J[setAttribute]

从这里可以看出,React DOM 的复杂度并不只在 diff。属性层本身就有大量平台差异。一个教学实现如果能把这些常见分支写出来,读者就能更容易理解为什么官方 React DOM 是一个独立包,而不是把 createElement 直接做成 document.createElement

组件渲染:函数组件和类组件统一成实例

组件是 React 的核心抽象。组件让我们不再只写标签树,而是可以把 UI 和逻辑组织成可复用的单元。ez-react 支持函数组件和类组件,但内部会尽量把它们统一成一种“组件实例”来处理。

组件创建函数如下:

export function create<P, S>(
  component: FC<P> | ObjectConstructor,
  properties: P,
): Component<P, S> {
  let instance: Component<P, S>;
  if (Component.isPrototypeOf(component)) {
    instance = new (component as ObjectConstructor)(properties) as Component<P, S>;
    autoBindThis(component as ObjectConstructor, instance);
  } else {
    instance = new FunctionComponent(component, properties);
  }
  return instance;
}

如果传进来的是继承自 Component 的类,就直接 new 一个实例。如果传进来的是普通函数,就创建一个 FunctionComponent 包装实例:

export class FunctionComponent<P> extends Component<P, never> {
  private readonly renderFunction: (props: P) => VirtualNode;
 
  constructor(renderFunction: (props: P) => VirtualNode, props: P) {
    super(props);
    this.renderFunction = renderFunction;
  }
 
  public render(): VirtualNode {
    return this.renderFunction(this.props);
  }
}

这是一种很适合教学的设计。函数组件本来没有实例,但渲染器如果同时处理“有实例的类组件”和“无实例的函数组件”,逻辑就会分叉。FunctionComponent 把函数组件包成类组件形态以后,后续就可以统一调用 setProps(instance, props)render(instance, nextProps, nextState),并且统一把实例挂在 DOM 节点的 _instance 字段上。

flowchart TD A[VirtualComponentDOM] --> B{tagName 是类组件吗} B -->|是| C[new ClassComponent props] B -->|否| D[new FunctionComponent fn props] C --> E[Component 实例] D --> E E --> F[setProps] F --> G[instance.render] G --> H[下一层 VirtualNode] H --> I[ReactDOM._diffRender]

函数组件示例:

function SimpleComponent(props: { children?: string | number | boolean }) {
  const { children } = props;
  return (
    <div className="SimpleComponent">
      <h1>SimpleComponent</h1>
      data is {children}, type is {typeof children}
    </div>
  );
}

类组件示例:

export default class Counter extends React.Component<IProps, IState> {
  constructor(props: IProps) {
    super(props);
    const count = 0;
    const jsx = this.getJsxByCountNumberParity(count);
    this.state = { count, jsx };
  }
 
  render() {
    const { name, sex } = this.props;
    const { count, jsx } = this.state;
    return (
      <div id="my-div-id" class="my-div-cls">
        <h1>{name} - {sex}</h1>
        <p>{count}</p>
        <button id="btn" onClick={this.addCount}>count: {count}</button>
        {jsx}
      </div>
    );
  }
}

这两个组件从使用方式看差别很大,但进入渲染器以后都是“实例有 props,实例能 render,render 返回 VirtualNode”。

_diffRenderComponent:组件节点如何变成 DOM

组件分支是连接 React 核心和 ReactDOM 的桥:

private static _diffRenderComponent(
  oldTrueDom: ReactElement | ReactNode,
  newVirtualDom: VirtualComponentDOM,
): ReactNode {
  const oldInstance = (oldTrueDom as ReactElement)?._instance;
  const newClass = newVirtualDom.tagName;
 
  const attributes = newVirtualDom.attributes ?? {};
  const children = newVirtualDom.children;
  const props = { children, ...attributes };
 
  const isSameClassComponentType =
    oldInstance?.__proto__.constructor === newClass;
  const isSameFunctionComponentType =
    FunctionComponent.prototype.constructor === oldInstance?.__proto__.constructor;
  const isSameComponentType =
    isSameClassComponentType || isSameFunctionComponentType;
 
  let instance;
  if (isSameComponentType) {
    instance = oldInstance;
  } else {
    if (oldInstance) {
      unmount(oldInstance);
      const node = oldInstance._node;
      node.parentNode.removeChild(node);
    }
    instance = create(newClass as FC<any> | ObjectConstructor, props);
  }
  setProps(instance, props);
  return instance._node;
}

它先从旧 DOM 节点上读取 _instance。这就是 ez-react 保存组件实例的方式:组件最终渲染出来的真实 DOM 节点,会反向挂一个 _instance 指向组件实例。下一次 diff 时,只要拿到旧 DOM,就能找回旧组件实例。

然后它把 attributeschildren 合并成 props。这也是 React 的重要语义:组件接收到的 props.children 本质上来自 JSX 标签体。下面这段:

<Clock>Clock</Clock>

会让 Clockprops.children 包含文本 "Clock",所以 Clock.render 可以写:

render() {
  const { children } = this.props;
  const { now } = this.state;
  return (
    <div>
      <h1>{children}</h1>
      <div>now: {now}</div>
    </div>
  );
}

之后,_diffRenderComponent 会判断新旧组件类型是否相同。如果相同,就复用旧实例;如果不同,就卸载旧组件,移除旧节点,再创建新实例。最后无论复用还是新建,都会调用 setProps(instance, props),让组件进入渲染流程。

sequenceDiagram participant Diff as _diffRenderComponent participant OldDOM as oldTrueDom participant Inst as Component instance participant Core as ez-react Component participant DOM as ReactDOM._diffRender Diff->>OldDOM: 读取 _instance Diff->>Diff: 合并 attributes 和 children 为 props alt 同类型组件 Diff->>Inst: 复用旧实例 else 不同类型组件 Diff->>Core: unmount(oldInstance) Diff->>Core: create(newClass, props) end Diff->>Core: setProps(instance, props) Core->>Inst: instance.render() Core->>DOM: _diffRender(oldNode, newVirtualNode) DOM-->>Diff: instance._node

这个流程对应官方 React 中“组件 identity”的最小版本。组件类型相同,就认为可以复用状态;组件类型不同,就卸载旧组件,创建新组件。官方 React 还会结合 key、位置、Fiber alternate 等机制做更精细的判断,但直觉上仍然是这个模型。

setPropsrender:生命周期与更新入口

组件实例创建出来以后,并不会直接由 ReactDOM 调用它的 renderez-react 把这个动作放在 setProps 和内部 render 函数里:

export function setProps<P, S>(instance: Component<P, S>, properties: P) {
  if (instance._node) {
    instance.UNSAFE_componentWillReceiveProps?.(properties);
  } else {
    instance.componentWillMount?.();
  }
 
  render(instance, properties, instance.state);
}

instance._node 是否存在,用来区分组件是否已经挂载过。没有 _node,说明这是首次渲染,调用 componentWillMount;已有 _node,说明这是更新 props,调用 UNSAFE_componentWillReceiveProps。然后统一进入 render(instance, properties, instance.state)

内部 render 函数更关键:

export function render<P, S>(
  instance: Component<P, S>,
  nextProps: P,
  nextState: S,
) {
  const prevProps: P = instance.props;
  const prevState: S = instance.state;
  const oldNode = instance._node;
 
  if (oldNode) {
    if (Component.getDerivedStateFromProps) {
      Component.getDerivedStateFromProps(nextProps, prevState);
    } else {
      instance.UNSAFE_componentWillUpdate?.(nextProps, nextState);
    }
  }
 
  if (!instance.shouldComponentUpdate(nextProps, nextState)) {
    return;
  }
 
  instance.props = nextProps;
  instance.state = nextState;
  const newVirtualNode = instance.render();
  const newNode = ReactDOM._diffRender(oldNode, newVirtualNode);
  newNode._instance = instance;
  instance._node = newNode;
 
  if (oldNode) {
    if (instance.getSnapshotBeforeUpdate) {
      const snapshot = instance.getSnapshotBeforeUpdate(prevProps, prevState);
      instance.componentDidUpdate?.(prevProps, prevState, snapshot);
    } else if (instance.componentDidUpdate) {
      instance.componentDidUpdate(prevProps, prevState);
    }
  }
 
  instance.componentDidMount?.();
}

这段代码做了组件更新最核心的几件事:

记录旧的 props、state 和旧 DOM 节点。

如果旧 DOM 存在,说明是更新阶段,先调用更新前生命周期。

调用 shouldComponentUpdate,如果返回 false,直接跳过。

把实例上的 propsstate 更新成下一份数据。

调用组件自己的 render(),得到新的 Virtual DOM。

把旧 DOM 和新 Virtual DOM 交给 ReactDOM._diffRender

把组件实例挂回新 DOM 的 _instance,并把实例的 _node 指向新 DOM。

更新后调用 getSnapshotBeforeUpdatecomponentDidUpdate

最后调用 componentDidMount

这里也能看出 ez-react 和官方 React 的差异:这份实现里 componentDidMount 在每次 render 末尾都会调用,即使是更新阶段也会调用;官方 React 中 componentDidMount 只会在首次挂载后调用,更新后调用的是 componentDidUpdate。另外 getDerivedStateFromProps 的调用也写成了 Component.getDerivedStateFromProps,而不是读取具体组件类上的静态方法。这些都是迷你实现中可以继续改进的地方。它们不影响我们理解主流程,反而提醒我们:教学实现的价值在于把路径跑通,而不是完全复制生产级 React 的所有边界。

flowchart TD A[setProps 或 setState] --> B[render instance nextProps nextState] B --> C[保存 prevProps prevState oldNode] C --> D{oldNode 存在吗} D -->|存在| E[更新前生命周期] D -->|不存在| F[挂载阶段] E --> G[shouldComponentUpdate] F --> G G -->|false| H[跳过更新] G -->|true| I[写入 props state] I --> J[instance.render] J --> K[新的 VirtualNode] K --> L[ReactDOM._diffRender] L --> M[保存 _instance 和 _node] M --> N[更新后或挂载后生命周期]

setState:为什么状态变化会重新渲染

在 React 中,UI 是 state 的函数。state 变化,组件重新执行 render,得到新的 UI 描述,再由渲染器把差异同步到页面。ez-reactsetState 直接体现了这个模型:

public setState(updateState: Partial<S>) {
  const nextState: S = { ...this.state, ...updateState };
  this.state = nextState;
  render(this, this.props, nextState);
  return nextState;
}

它先把当前 state 和局部更新合并,得到 nextState。然后把实例 state 指向下一份状态,并调用内部 render。注意这里没有批处理,没有异步队列,也没有优先级调度。调用 setState 的那一刻,更新会同步发生。

demo 中的 Counter 很适合解释这个过程:

addCount() {
  const { count } = this.state;
  const nextCount = count + 1;
  const jsx = this.getJsxByCountNumberParity(nextCount);
  this.setState({ count: nextCount, jsx });
}

按钮绑定的是:

<button id="btn" onClick={this.addCount}>count: {count}</button>

当用户点击按钮时,浏览器触发 onclick,也就是 _setDomAttribute 之前挂上去的函数。addCount 计算下一次 count 和对应 JSX,然后调用 setStatesetState 进入组件内部 renderCounter.render() 重新返回一棵新的 Virtual DOM。新的树里,按钮文本从 count: 0 变成 count: 1<p>{count}</p> 里的文本也变成 1jsxEvenNumber 分支变成 OddNumber 分支。然后 _diffRender 会拿旧 DOM 和新 Virtual DOM 做比较,尽可能复用旧 DOM,只更新变化的部分。

sequenceDiagram participant User as 用户点击按钮 participant DOM as button.onclick participant C as Counter.addCount participant S as setState participant R as Component render participant D as ReactDOM diff User->>DOM: click DOM->>C: 调用 addCount C->>S: setState({ count, jsx }) S->>R: render(this, props, nextState) R->>C: Counter.render() C-->>R: new VirtualDOM R->>D: _diffRender(oldNode, newVirtualDOM) D-->>R: newNode 或复用 oldNode

这就是 React 响应式的最小闭环。React 并不会神奇地“监听变量变化”。变量变化本身没有意义,只有当你通过 setState 进入框架提供的更新入口,框架才知道应该重新计算 UI 并同步 DOM。

自动绑定 this:类组件事件处理的便利

JavaScript 类方法默认不会自动绑定 this。在普通 React 类组件里,如果写:

<button onClick={this.addCount}>count</button>

addCount 是普通方法,那么事件触发时 this 可能不是组件实例。因此官方 React 早期文档常见写法是手动在 constructor 里 this.addCount = this.addCount.bind(this),或者使用 class field 箭头函数。

ez-react 在创建类组件实例时做了自动绑定:

function autoBindThis<P, S>(
  component: ObjectConstructor,
  instance: Component<P, S>,
) {
  const componentKeys = Reflect.ownKeys(component.prototype);
  componentKeys.forEach((key) => {
    if (key !== "constructor" && typeof instance[key] === "function") {
      instance[key] = instance[key].bind(instance);
    }
  });
}

所以 Counter 可以直接写普通方法:

addCount() {
  const { count } = this.state;
  const nextCount = count + 1;
  const jsx = this.getJsxByCountNumberParity(nextCount);
  this.setState({ count: nextCount, jsx });
}

然后在 JSX 中直接传:

<button id="btn" onClick={this.addCount}>count: {count}</button>

这个设计让 demo 更简洁,也能帮助读者观察“事件触发到 setState”的主流程。不过从工程角度看,自动绑定所有 prototype 方法也有代价:它会改变实例方法引用,可能绑定一些并不需要作为事件处理器的方法,并且和现代 React 函数组件主流写法不同。作为教学实现,它是一个很直观的便利功能;作为生产框架,就需要更谨慎地评估行为一致性和性能。

children:标签体如何进入 props

React 组件的 children 是一个非常优雅的抽象。它让组件可以像普通 HTML 标签一样包裹内容:

<Clock>Clock</Clock>

在 JSX transform 后,"Clock" 会作为 createElement 的第三个参数进入 children。到了 _diffRenderComponentez-react-dom 会执行:

const attributes = newVirtualDom.attributes ?? {};
const children = newVirtualDom.children;
const props = { children, ...attributes };

于是组件内部就能通过 this.props.children 读取标签体。Clock 组件中的这段:

render() {
  const { children } = this.props;
  const { now } = this.state;
  return (
    <div>
      <h1>{children}</h1>
      <div>now: {now}</div>
    </div>
  );
}

本质上就是把外层传入的 children 再放回自己的 Virtual DOM 里。这个过程会形成一条递归链:父组件的 children 作为子组件 props,子组件 render 后又把 children 嵌入下一层 Virtual DOM,最终 HTML 分支把它渲染成真实 DOM。

flowchart LR A["<Clock>Clock</Clock>"] --> B[createElement Clock null Clock] B --> C[VirtualComponentDOM.children] C --> D[props.children] D --> E[Clock.render] E --> F["<h1>{children}</h1>"] F --> G[Text node Clock]

Counter 里的 EvenNumberOddNumber 也展示了 children 的另一种用法:

getJsxByCountNumberParity(count) {
  const { EvenNumber, OddNumber } = this;
  return count % 2 === 0
    ? <EvenNumber>click count is {count} , it is EvenNumber</EvenNumber>
    : <OddNumber>click count is {count} , it is OddNumber</OddNumber>;
}

这里 EvenNumberOddNumber 本质上也是组件函数,只是它们定义在类实例上,并通过自动绑定保持 this 语义。每次 count 改变时,jsx state 中保存的组件节点也会改变,后续 diff 会按组件类型判断是否复用或替换。

diff children:没有 key 和有 key 的两条路

当 HTML 节点类型相同,ez-react-dom 会进入 _diffChildren

private static _diffChildren(
  oldTrueDom: any,
  newVirtualDom: VirtualHTMLDOM,
): ReactNode {
  let oldChildKeyed = {};
  let oldChildren = [];
 
  oldTrueDom.childNodes.forEach((child) => {
    if (child.key) {
      oldChildKeyed[child.key] = child;
    } else {
      oldChildren.push(child);
    }
  });
 
  newVirtualDom.children.forEach((newChild: any) => {
    const newChildKey = newChild.attributes?.key;
 
    let oldChild;
    if (newChildKey && oldChildKeyed[newChildKey]) {
      oldChild = oldChildKeyed[newChildKey];
      delete oldChildKeyed[newChildKey];
    } else {
      oldChild = oldChildren.shift();
    }
 
    const diffChild = this._diffRender(oldChild, newChild);
    if (diffChild !== oldChild) {
      if (oldChild) {
        oldChild.parentNode?.replaceChild(diffChild as Node, oldChild);
      } else {
        oldTrueDom.appendChild(diffChild as Node);
      }
    }
  });
 
  oldChildren.forEach((child) => {
    oldTrueDom.removeChild(child);
  });
  for (const childKey in oldChildKeyed) {
    oldTrueDom.removeChild(oldChildKeyed[childKey]);
  }
 
  return oldTrueDom;
}

这段代码先把旧 children 分成两组:有 key 的放进 oldChildKeyed,没有 key 的按顺序放进 oldChildren。然后遍历新的 children。新 child 如果带 key 且旧 key 表里找得到,就复用对应旧节点;否则从无 key 队列头部取一个旧节点。之后对这一对新旧节点继续递归 _diffRender

flowchart TD A[oldTrueDom.childNodes] --> B{child.key 存在吗} B -->|存在| C[oldChildKeyed key to child] B -->|不存在| D[oldChildren 顺序队列] E[newVirtualDom.children] --> F{newChild 有 key 且命中吗} F -->|是| G[按 key 取旧节点] F -->|否| H[oldChildren.shift] G --> I[_diffRender oldChild newChild] H --> I I --> J{返回节点是否等于旧节点} J -->|不同| K[replaceChild 或 appendChild] J -->|相同| L[复用] C --> M[删除未命中的 keyed 旧节点] D --> N[删除剩余无 key 旧节点]

这个实现体现了 key 的基本价值:当列表顺序变化时,key 能帮助渲染器用“身份”匹配节点,而不是只按位置匹配。没有 key 时,旧节点只会按顺序消费。对于简单列表这没问题,但当你插入、删除、重排项目时,按位置复用可能导致状态和 DOM 对不上。

官方 React 的 child reconciliation 更复杂,会处理移动、插入、删除的 effect,并尽量减少 DOM 操作。ez-react 的实现没有显式移动节点,也没有 Fiber effect list,但它已经把 key 的核心思想展示出来了:key 不是给后端用的 id,也不是为了消除 warning 的装饰;key 是给渲染器在“新旧两棵子树之间建立身份对应关系”的线索。

表单输入:value、onInput 和重渲染

DataBinding 组件展示了受控输入的基本模型:

export default class DataBinding extends React.Component<any, any> {
  constructor(props) {
    super(props);
    this.state = {
      data: "",
    };
  }
 
  onChange(event: InputEvent) {
    const data = event.target.value;
    this.setState({ data });
  }
 
  render() {
    const { data } = this.state;
    return (
      <div id="data-binding">
        <label htmlFor="onInput">
          onInput
          <input type="text" id="onInput" onInput={this.onChange} value={data} />
        </label>
        <div id={data}>see the attribute: id, {data}</div>
      </div>
    );
  }
}

输入框的当前值来自 state.data,输入事件触发后又把新的 DOM value 写回 state。然后 setState 触发重新渲染,新的 value={data} 又被 _setDomAttribute 写到 input 的 element.value。这就形成了受控组件的闭环。

sequenceDiagram participant User as 用户输入 participant Input as input DOM participant Handler as onInput participant State as this.state.data participant Render as render User->>Input: 键入字符 Input->>Handler: event.target.value Handler->>State: setState({ data }) State->>Render: 重新计算 Virtual DOM Render->>Input: value = data

demo 的注释里提到使用 onInput 而不是 onChange,因为 onChange 在这里不能实时更新 state。这也反映出 ez-react 的事件系统是直接 DOM 事件,而不是官方 React 的合成事件。官方 React 对 onChange 做了跨浏览器归一化,在文本输入中表现得接近实时输入事件;ez-react 没有这层抽象,所以直接使用浏览器原生语义更容易预测。

这也是迷你实现很有价值的地方:它会迫使我们看清“React 提供了哪些额外抽象”。如果直接操作 DOM,inputchangecompositionbeforeinput 等事件各有浏览器语义;官方 React 把其中一部分语义重新包装成开发者熟悉的行为。ez-react 没有包装,所以它更接近平台本身。

生命周期:挂载、更新和卸载

ez-reactComponent 类定义了一组生命周期方法:

export abstract class Component<P, S> {
  public _node?: Node;
  public props: P;
  public state: S;
 
  public componentWillMount() {}
  public UNSAFE_componentWillReceiveProps?(nextProps: P);
  public static getDerivedStateFromProps?<P, S>(nextProps: P, prevState: S);
  public shouldComponentUpdate(nextProps: P, nextState: S) { return true; }
  public UNSAFE_componentWillUpdate?(nextProps: P, nextState: S);
  public getSnapshotBeforeUpdate?(prevProps: P, prevState: S);
  public componentDidUpdate?(prevProps: P, prevState: S, snapshot?);
  public componentDidMount?();
  public componentWillUnmount?();
  public componentDidCatch?(error, info);
}

demo 中 ClockDataBinding 都用到了挂载前和卸载时的生命周期来管理定时器:

componentWillMount() {
  this.intervalId = setInterval(() => {
    const now = String(new Date());
    this.setState({ now });
  }, 1000);
}
 
componentWillUnmount() {
  clearInterval(this.intervalId);
}

这说明生命周期本质上是组件实例和外部世界之间的连接点。render 应该尽量只描述 UI,但定时器、网络请求、订阅、DOM 测量、Canvas 绘制都属于副作用,需要在某个明确时机开始和清理。类组件时代,这些时机主要由生命周期方法表达;Hooks 时代则主要由 useEffectuseLayoutEffect 表达。

ez-react 的流程里,卸载发生在组件类型变化时:

if (oldInstance) {
  unmount(oldInstance);
  const node = oldInstance._node;
  node.parentNode.removeChild(node);
}

unmount 又会调用:

export function unmount<P, S>(instance: Component<P, S>) {
  instance.componentWillUnmount?.();
}
flowchart TD A[组件首次出现] --> B[componentWillMount] B --> C[render] C --> D[DOM 创建] D --> E[componentDidMount] F[props 或 state 更新] --> G[UNSAFE_componentWillUpdate] G --> H[shouldComponentUpdate] H --> I[render] I --> J[diff DOM] J --> K[componentDidUpdate] L[组件类型变化或被移除] --> M[componentWillUnmount] M --> N[removeChild]

这张图表达的是 ez-react 的理想生命周期顺序。实际实现中,如前面提到的,componentDidMount 当前会在每次内部 render 后调用,这和官方 React 不完全一致。如果要继续完善项目,可以把挂载阶段和更新阶段拆得更明确:只有 oldNode 不存在时调用 componentDidMount,只有 oldNode 存在时调用 componentDidUpdate

ref:从声明式 UI 回到命令式 DOM

React 鼓励声明式 UI,但并不意味着永远不能碰 DOM。有些场景必须拿到底层 DOM 实例,例如聚焦输入框、测量元素尺寸、调用 Canvas API、播放视频等。ref 就是这个逃生通道。

ez-react 中的 ref 类型很简单:

export class Ref {
  current: any;
}
 
function createRef(): Ref {
  return new Ref();
}

_setDomAttribute 遇到 ref 时,会根据 ref 类型处理:

} else if (key === "ref") {
  switch (typeof value) {
    case "object":
      value.current = element;
      break;
    case "function":
      value(element);
      break;
    case "string":
      console.warn("String Refs is deprecated...");
      break;
    default:
      console.warn("UnSupport ref type...");
  }
}

demo 中的 TestRefByCanvas 展示了对象 ref 的用法:

export default class TestRefByCanvas extends React.Component<IProps, IState> {
  private readonly canvasRef: Ref;
 
  constructor(props: IProps) {
    super(props);
    this.canvasRef = React.createRef();
  }
 
  componentDidMount() {
    const { x, y, w, h } = this.props;
    const canvasContext =
      (this.canvasRef.current as HTMLCanvasElement).getContext("2d");
    canvasContext.fillRect(x, y, w, h);
    canvasContext.stroke();
  }
 
  render() {
    return (
      <canvas
        id="my-canvas"
        width="100"
        height="100"
        style={{ border: "1px solid black" }}
        ref={this.canvasRef}
      />
    );
  }
}

这个流程是:

sequenceDiagram participant C as TestRefByCanvas participant V as VirtualDOM participant D as _setDomAttribute participant R as canvasRef participant Canvas as canvas DOM C->>V: render 返回 <canvas ref={canvasRef} /> V->>D: 处理 ref 属性 D->>R: canvasRef.current = element C->>C: componentDidMount C->>Canvas: getContext("2d") C->>Canvas: fillRect / stroke

这说明 ref 的核心不是“让 React 不声明式”,而是在声明式渲染完成以后,给组件一个访问真实宿主对象的机会。React 的边界很清楚:创建和更新 DOM 的主流程仍然由框架管理;只有那些必须命令式调用的平台 API,才通过 ref 暴露出来。

从一次点击看完整更新链路

现在我们把前面的知识串起来,用 Counter 的按钮点击走一遍完整流程。

初始渲染时,demo 写了:

<Counter name="cw1997" sex="male" />

JSX 转换后,createElement 生成组件 Virtual DOM:

{
  tagName: Counter,
  attributes: { name: "cw1997", sex: "male" },
  children: [],
}

ReactDOM._diffRender 发现 tagName 是类组件,进入 _diffRenderComponent。因为没有旧实例,所以调用 create(Counter, props)create 创建 Counter 实例并自动绑定方法。随后 setProps 调用 componentWillMount 和内部 renderCounter.render() 返回 <div><h1><p><button> 等 HTML Virtual DOM。渲染器递归创建真实 DOM,并把 Counter 实例挂到根 DOM 节点 _instance 上。

用户点击按钮后,浏览器调用按钮 DOM 上的 onclick,也就是 this.addCount。由于创建实例时做了自动绑定,addCount 中的 this 指向 Counter 实例。addCount 调用 setStatesetState 合并 state 并进入内部 render。这时 oldNode 是上一次渲染出的根 DOM,newVirtualNodeCounter.render() 新返回的 Virtual DOM。ReactDOM._diffRender(oldNode, newVirtualNode) 开始比较。

根节点还是 div,所以复用。它 diff children:h1 的文本可能不变,复用;p 中的文本从 0 变成 1,更新 textContentbutton 中的文本从 count: 0 变成 count: 1,更新文本;底部的 jsx 子组件从 EvenNumber 变成 OddNumber,组件类型变化,卸载旧组件并创建新组件。最后更新完成,调用更新生命周期。

flowchart TD A[点击 button] --> B[onclick = this.addCount] B --> C[计算 nextCount 和 jsx] C --> D[setState] D --> E[Component.render 内部函数] E --> F[Counter.render 返回新 VirtualDOM] F --> G[_diffRender old div new div] G --> H[_diffChildren] H --> I[更新 p 文本] H --> J[更新 button 文本] H --> K[EvenNumber 替换为 OddNumber] K --> L[unmount old instance] L --> M[create new instance] M --> N[更新 DOM 完成]

这条链路就是 React 心智模型的核心:事件不会直接修改 DOM,而是修改 state;state 变化不会神秘地自动改页面,而是让组件重新 render;render 不是直接生成 DOM,而是生成新的 Virtual DOM;渲染器拿旧 DOM 和新 Virtual DOM 比较,再把差异同步到真实 DOM。

为什么 React 不直接改 DOM

看到这里可能会有一个疑问:如果按钮点击只是把 count 从 0 改成 1,那为什么不直接 document.querySelector("#btn").textContent = ...?为什么要绕一圈 Virtual DOM?

答案是:对一个按钮来说,直接改 DOM 当然更短;但对一个应用来说,直接改 DOM 很快会失控。

直接 DOM 操作的问题不在于 API 难,而在于状态来源会变得分散。一个列表的筛选条件在一个变量里,选中项在 DOM class 里,表单值在 input DOM property 里,弹窗状态在某个全局对象里,异步请求回来以后又手动插入几个节点。随着功能增长,你很难回答“当前界面应该长什么样”,只能回答“过去发生过哪些 DOM 操作”。这会让调试和维护变得困难。

React 的思路是反过来:你只描述在当前 state 和 props 下 UI 应该是什么样,框架负责把它变成 DOM。Virtual DOM 是这个模型的中间表示。它让 UI 变成普通数据,可以被重新计算、比较和测试。

flowchart LR A[命令式 DOM] --> B[发生事件] B --> C[手动找节点] C --> D[手动改属性和 children] D --> E[状态散落在 DOM 和变量里] F[声明式 React] --> G[发生事件] G --> H[更新 state] H --> I[render 得到 UI 描述] I --> J[框架同步 DOM] J --> K[状态集中在数据模型里]

ez-react 不是为了证明 Virtual DOM 一定比直接 DOM 快。事实上,直接 DOM 在很多微小场景更快。ez-react 想展示的是另一件事:当 UI 可以由数据描述,组件、状态、diff、生命周期、ref 这些概念就能以一条清晰的链路组织起来。性能优化是后面的工程问题,而可维护的 UI 模型是更基础的问题。

与官方 React 的差异

ez-react 是教学实现,所以它有很多刻意简化的地方。理解这些差异,可以帮助我们反过来理解官方 React 为什么复杂。

首先,ez-react 没有 Fiber。它的渲染是递归同步执行的,一次 setState 会马上走完 render 和 DOM diff。官方 React 使用 Fiber 把渲染工作拆成可中断、可恢复的单元,从而支持并发渲染、优先级调度和更细粒度的 effect 处理。

其次,ez-react 没有 Hooks。函数组件被包装成 FunctionComponent,但没有 useStateuseEffectuseRef 这些能力。官方 React 的函数组件状态依赖 Fiber 上的 hook 链表和严格的调用顺序约束。

第三,ez-react 的事件系统是直接 DOM property 赋值。官方 React 有合成事件系统,会做事件委托、跨浏览器兼容、事件对象归一化和批处理边界。

第四,ez-react 的 diff children 比较简单。它有 key 的概念,但没有完整的移动优化和 effect list。官方 React 的 reconciliation 会结合 key、type、位置和 Fiber alternate 判断复用、插入、删除、移动。

第五,生命周期实现和官方 React 不完全一致。例如当前 componentDidMount 的调用时机需要进一步修正。教学实现最重要的是把概念路径打开,后续可以在这条路径上逐步补齐行为。

flowchart TB A[ez-react] --> A1[同步递归渲染] A --> A2[简单 VirtualDOM] A --> A3[直接 DOM 事件] A --> A4[类组件和函数组件包装] A --> A5[简单 children diff] B[官方 React] --> B1[Fiber 架构] B --> B2[并发和优先级] B --> B3[合成事件] B --> B4[Hooks] B --> B5[完整 reconciliation]

这些差异不是缺点清单,而是学习路线图。你可以先理解 ez-react 的同步递归模型,再去看 Fiber 为什么要把递归调用改造成链表结构;先理解 setState 直接触发更新,再去看 React 为什么要批处理;先理解对象 ref,再去看 useRef 为什么能跨 render 保持同一个对象。

如果继续完善 ez-react

如果要把 ez-react 从教学实现继续往前推进,我会优先考虑几个方向。

第一,修正生命周期阶段。当前内部 render 可以根据 oldNode 是否存在明确区分 mount 和 update:首次渲染后只调用 componentDidMount,更新后只调用 componentDidUpdate。同时,getDerivedStateFromProps 应该从具体组件类读取,而不是从 Component 基类读取。

第二,补充函数组件状态模型。可以先实现一个极简 useState,用全局 current instance 和 hook index 存储状态,帮助理解 Hooks 的顺序约束。再进一步可以加入 useEffect,把副作用推迟到 DOM 更新后执行。

第三,完善 children diff。现在 keyed 节点可以被匹配,但节点移动和插入策略还比较粗糙。可以引入更明确的操作类型,例如 INSERTUPDATEREMOVEMOVE,先生成 patch,再统一提交到 DOM。

第四,改进属性删除逻辑。当前 _diffAttribute 对删除属性的处理是把缺失属性设置成 undefined,更严谨的做法应该区分 DOM property 和 attribute,对普通 attribute 使用 removeAttribute,对事件清理旧 handler,对 ref 做解绑。

第五,增加错误边界。Component 类型里已经有 componentDidCatch,但渲染流程尚未真正捕获子树错误。可以在组件 render 和 diff 子树时加入 try/catch,再向上寻找实现了错误处理的组件。

第六,加入测试覆盖。项目已经有 ReactDOM.test.tsx,可以继续补充 keyed children、ref callback、生命周期调用次数、属性删除、事件更新、组件替换卸载等用例。迷你框架很适合用测试表达行为边界,因为每个测试都能对应一个明确的 React 概念。

如何把源码当成调试路线来读

阅读框架源码最怕的是从文件列表开始平均用力。一个包里有类型、有构建配置、有测试、有 demo,如果没有问题意识,很容易在不同文件之间跳来跳去,最后只记住一些零散函数名。读 ez-react 更好的方法,是先挑一个最小 JSX,然后用“这一步产生了什么数据”的方式一路追踪。

我建议从这段代码开始:

ReactDOM.render(<Counter name="cw1997" sex="male" />, dom);

第一步,不要急着看 ReactDOM.render,先在脑中把 JSX 展开。它不是直接传了一个组件实例,而是传了一个 createElement 的返回值:

React.createElement(Counter, {
  name: "cw1997",
  sex: "male",
});

这个对象的关键不在于它长得像不像 DOM,而在于它把“组件类型”和“组件输入”分开保存了。Counter 只是 tagNamenamesexattributes,还没有任何真实 DOM 被创建。此时如果你在 createElement 里打断点,只会看到一棵普通对象树。这是理解 React 的第一个关口:render 前半段都在计算数据,不在操作页面。

第二步,进入 ReactDOM.render。这里你会看到 container 被清空,并且被塞入一个临时空 div。这个临时节点看起来有点奇怪,但它让首次渲染和更新都统一成同一种函数调用:旧 DOM 加新 Virtual DOM。调试时可以观察 _diffRender(oldTrueDom, virtualDom) 的两个参数,一个来自浏览器,一个来自 JSX 数据。React 渲染器做的事情,就是不断把这两种世界对齐。

第三步,进入 _diffRender。这时不要把它当成一个普通工具函数,而要把它当成交通枢纽。每次递归回来,程序都会重新问:新节点是文本、HTML 还是组件?如果是组件,就先得到组件 render 的结果;如果是 HTML,就处理子节点;如果是文本,就落到最底层的 Text 节点。你可以在这三个分支上分别打断点,很快就会发现一次渲染会多次穿过这个入口。

flowchart TD A[断点 1 createElement] --> B[观察 Virtual DOM 对象] B --> C[断点 2 ReactDOM.render] C --> D[观察 oldTrueDom 和 virtualDom] D --> E[断点 3 _diffRender] E --> F[记录进入文本 HTML 组件哪条分支] F --> G[断点 4 Component.render] G --> H[观察组件返回的新 Virtual DOM] H --> I[断点 5 _diffChildren] I --> J[观察旧 child 如何被复用或替换]

第四步,进入 _diffRenderComponent。这一步要重点看 props 是怎样形成的。很多 React 初学者会把 attributes 和 children 看成两种完全不同的东西,但对组件来说,它们最终都会变成 props。<Clock>Clock</Clock> 的标签体不是特殊模板语法,而是 children 数组。<Counter name="cw1997" />name 也不是 DOM attribute,而是传给组件的普通输入。组件拿到 props 后,才决定返回怎样的下一层 UI。

第五步,进入组件自己的 render。这一步是最接近日常开发体验的地方:你写 const { count } = this.state,写 <button>{count}</button>,看起来像在写页面。但从框架视角看,组件 render 仍然只是在返回下一棵 Virtual DOM。它不应该直接改 DOM,也不应该在 render 中启动定时器。这个约束非常重要,因为只有 render 保持纯粹,框架才能安全地重复调用它、比较它的结果、在未来甚至中断或丢弃某次结果。

第六步,进入 _diffRenderHTML_diffChildren。这时你终于会看到真实 DOM 被创建、复用、替换或删除。对于初学者来说,最有价值的观察是:并不是每次 state 变化都会整棵树重建。只要标签类型相同,根 DOM 会被复用,变化会继续向 children 深处传播。文本变化就更新文本,属性变化就同步属性,组件类型变化才会卸载旧实例并创建新实例。

第七步,点击按钮,再走一遍同样的断点。第一次渲染和点击更新的区别,会在 oldNode 上体现得很明显。第一次组件还没有 _node,所以是挂载;点击后组件已经有 _node,所以是更新。你会看到 setState 并没有直接找按钮 DOM,而是重新进入组件 render。这个现象比任何概念解释都更能说明声明式 UI 的本质:事件只是改变状态,状态驱动新的 UI 描述,渲染器负责把描述同步到页面。

这种调试方法也适合阅读官方 React。官方源码更复杂,但你仍然可以问同样的问题:这一步的输入是什么?输出是什么?它是在计算 UI 描述,还是在提交 DOM 变化?它是在创建组件实例,还是在复用已有实例?它是在 render 阶段,还是 commit 阶段?当你习惯用这些问题读 ez-react,再看官方 React 的 Fiber 树、beginWork、completeWork、commitMutationEffects,就不会完全迷路。

几个容易误解的点

第一个误解是“Virtual DOM 是为了比真实 DOM 快”。更准确地说,Virtual DOM 是为了让 UI 更新可以被声明式描述和集中管理。它有时能减少 DOM 操作,但它本身也有计算成本。真正重要的是,开发者不需要手动维护一连串 DOM 修改步骤,而是只需要描述当前状态下的 UI。性能优化建立在这个模型之上,而不是它唯一的理由。

第二个误解是“组件就是自定义标签”。组件表面上像标签,但它和 HTML 标签不是同一层东西。<div>tagName 是字符串,渲染器可以直接 document.createElement("div")<Counter>tagName 是函数或类,渲染器必须先创建或复用组件实例,调用 render,再处理 render 返回的结果。组件不会直接出现在 DOM 树里,出现在 DOM 树里的是组件最终返回的宿主节点。

第三个误解是“props 变化会自动改 DOM”。props 只是组件 render 的输入。props 变化后,如果组件被复用,框架会把新 props 写入实例,然后重新调用 render。真正改 DOM 的仍然是 render 结果和旧 DOM 的 diff。也就是说,props 本身不操作页面,state 本身也不操作页面,操作页面的是渲染器对新旧 UI 描述的同步过程。

第四个误解是“key 会传给组件使用”。在 React 心智模型中,key 主要是给渲染器识别兄弟节点身份的,不应该被当成业务 props 使用。ez-react 把 key 挂到 DOM 节点上供 _diffChildren 查找,这个实现很直观地暴露了 key 的用途:它服务于新旧 child 匹配。业务逻辑如果需要 id,应该显式传 id 或其他 prop,而不是依赖 key。

第五个误解是“ref 是 React 推荐的主要数据流”。ref 很有用,但它不是替代 props 和 state 的数据通道。ref 适合访问 DOM 实例或保存不参与渲染的数据。如果你用 ref 保存会影响 UI 的状态,框架不会因为 ref.current 变化而重新 render。TestRefByCanvas 是 ref 的合适例子:Canvas 绘图是命令式浏览器 API,必须拿到 DOM 后调用 getContext,这不是普通声明式属性能完整表达的行为。

第六个误解是“生命周期就是 render 的前后日志”。生命周期真正解决的是副作用时机。定时器、订阅、DOM 测量、Canvas 绘制、网络请求都不应该随便放在 render 里。Clock 在挂载前启动定时器,在卸载时清理定时器,这个例子虽然简单,却抓住了生命周期的关键:组件出现时连接外部世界,组件消失时断开连接。

flowchart LR A[常见误解] --> B[Virtual DOM 只是更快 DOM] A --> C[组件会直接变成 DOM 标签] A --> D[props/state 自动修改页面] A --> E[key 是业务参数] A --> F[ref 是主要数据流] A --> G[生命周期只是日志钩子] B --> B2[实际是声明式 UI 的中间表示] C --> C2[组件 render 后才得到宿主节点] D --> D2[render + diff 才同步 DOM] E --> E2[key 服务于兄弟节点身份匹配] F --> F2[ref 是命令式访问出口] G --> G2[生命周期管理副作用边界]

用 ez-react 反推官方 React 的复杂度

当我们理解了 ez-react 的主流程,就可以更自然地理解官方 React 的很多设计。官方 React 不是一开始就为了复杂而复杂,而是在同一条主流程上不断处理更难的工程问题。

例如,ez-react_diffRender 是递归调用。如果组件树很大,一次更新就可能长时间占用主线程。官方 React 的 Fiber 把递归栈变成可手动控制的链表结构,让渲染工作可以被拆分、暂停和恢复。这样浏览器在高优先级输入到来时,就有机会先处理用户交互,而不是被一次大渲染卡住。

再比如,ez-reactsetState 是同步的。同步模型非常容易理解,但多个连续更新可能导致重复 render。官方 React 会在合适边界内批处理更新,把多次 state 变化合并到一次渲染中。到了并发模式,更新还会带有优先级,不同更新可以走不同 lane。lane 听起来抽象,但它解决的问题可以从 ez-react 的同步更新反推出来:不是所有更新都同样紧急,输入响应和后台列表刷新不应该抢同一条路。

又比如,ez-react 的事件处理是 element.onclick = handler。这很直接,但当页面上有成千上万个节点时,直接在每个节点上管理事件会带来更多绑定和清理成本。官方 React 的事件系统通过委托和合成事件统一管理,既能做跨浏览器兼容,也能把事件和更新批处理连接起来。

Hooks 也可以这样理解。ez-react 用类实例保存 state,所以状态天然有地方放。函数组件如果只是普通函数,每次调用都会重新创建局部变量,状态放在哪里?官方 React 的答案是把 hooks 状态挂在 Fiber 上,并通过调用顺序把 useStateuseEffect 等调用和内部槽位对应起来。只要你理解了 FunctionComponent 包装函数组件的目的,就更容易理解 Hooks 为什么需要“不能写在条件分支里”这种规则。

Server Components 和 Hydration 也不是凭空出现的概念。ez-react 只处理浏览器内从 Virtual DOM 到真实 DOM 的过程;而现代 React 还要处理服务端生成的 HTML、客户端接管已有 DOM、只把部分组件发到浏览器、在网络边界上传输组件树描述等问题。主线仍然是 UI 描述如何变成可交互界面,只是输入不再只有浏览器里的 JSX,输出也不再只是一次简单 appendChild。

flowchart TB A[ez-react 的简单问题] --> B[递归同步渲染] B --> C[官方 React: Fiber 可中断工作单元] A --> D[setState 立即更新] D --> E[官方 React: 批处理 优先级 Lane] A --> F[DOM 事件直接赋值] F --> G[官方 React: 合成事件和事件委托] A --> H[类实例保存 state] H --> I[官方 React: Fiber 上保存 Hooks 链] A --> J[只做客户端创建 DOM] J --> K[官方 React: SSR Hydration Server Components]

所以学习迷你实现并不是绕远路。相反,它能帮我们建立一条清晰的“复杂度来源地图”。当你知道简单版本怎么工作,再看复杂版本时,就能分辨哪些复杂度来自功能扩展,哪些来自兼容历史,哪些来自性能优化,哪些来自开发体验。源码阅读也会从“到处都是陌生名词”变成“这个结构在解决我已经见过的哪个问题”。

总结

React 看起来复杂,是因为它在真实工程中解决了太多问题。但如果沿着最小路径拆开,它的核心并不神秘:

JSX 会被编译成 createElement 调用。

createElement 生成 Virtual DOM,也就是一棵描述 UI 的 JavaScript 对象树。

ReactDOM.render 把 Virtual DOM 交给渲染器。

渲染器根据节点类型分成文本、HTML、组件三条路径。

HTML 节点创建或复用真实 DOM,递归处理 children,并同步属性。

组件节点创建或复用组件实例,调用 render 得到下一层 Virtual DOM。

事件触发 setStatesetState 让组件重新 render,然后 diff 更新 DOM。

ref 在声明式渲染完成后,把真实 DOM 暴露给必须命令式操作的平台 API。

ez-react 把这条链路压缩到了一个适合阅读的规模。它保留了 React 最关键的概念:JSX、Virtual DOM、函数组件、类组件、props、state、children、diff、生命周期、事件和 ref;同时省略了 Fiber、Hooks、并发、调度等高级机制。这样的实现不适合生产使用,但非常适合学习。因为它让我们能亲手看到:React 并不是魔法,而是一套围绕“UI 是状态的函数”建立起来的、层层递进的工程模型。

当你能从 <Counter /> 一路追踪到 createElement_diffRenderComponentCounter.render()_diffRenderHTML_diffChildren_diffRenderText,再从一次点击追踪到 setState 和 DOM 更新时,React 的很多概念就会从抽象名词变成具体路径。理解这条路径以后,再回头看官方 React 的 Fiber、Hooks 和 Concurrent Rendering,就不会只是面对一堆陌生术语,而是在问一个更有方向的问题:为了让这条路径在大型应用里更快、更稳、更可中断、更可组合,React 又引入了哪些额外结构?

这也是我写 ez-react 的意义。它不是 React 的简陋替代品,而是一张可以拿在手里反复对照的简化地图。真正的 React 是一座复杂城市,但只要先看懂主干道路,再进入立交桥和地下管线,就会容易得多。