Article
理解 React 工作原理并复刻一个 ez-react
我写 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 的具体源码。文章中的代码来自项目中的实现,但为了让叙述更聚焦,有些片段会省略与当前主题无关的类型声明或测试细节。读完以后,你应该能够沿着下面这条主线在脑中跑完整个流程:
为什么要从 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 的渲染器后面就是围绕这三类节点分发处理。
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 已经足够作为教学模型。
上图也提示了一个关键点:Virtual DOM 和真实 DOM 并不是一一同构的。attributes 里面可能有 className、style、onClick、ref、value 等不同语义的属性,它们落到真实 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-canvas、ez-react-terminal 或 ez-react-native-lite,只要它能理解同一份 Virtual DOM 数据。
从使用者角度看,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 进去。
这个流程可以画成下面这样:
这不是最高效的首次渲染方式,但对于一个教学实现来说非常清楚。首次渲染和后续更新都从 _diffRender(oldTrueDom, newVirtualDom) 开始,区别只在于 oldTrueDom 是否能被复用。
_diffRender:三个分支决定所有渲染路径
_diffRender 是 ez-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。
这个结构很适合阅读源码。你不需要一开始就记住所有细节,只要记住三条路:文本路、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;
}这段代码做了两件事。第一,把 number 和 boolean 都转成字符串。也就是说 {123} 会渲染成 "123",{true} 会渲染成 "true",{false} 会渲染成 "false"。官方 React 对布尔值子节点的处理并不完全相同,通常不会把 false 渲染成文本。ez-react 这里选择把它们直接字符串化,逻辑更简单,也便于观察 Virtual DOM 到 DOM 的转换。
第二,如果旧节点已经是文本节点,就直接修改 textContent;如果旧节点不是文本节点,才创建新的 Text 节点。这就是 diff 的最小形式:同类节点尽量复用,不同类节点重新创建。
这里还顺带带来了一个安全收益:文本是通过 document.createTextNode 或 textContent 写入的,不是拼接成 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,并递归渲染所有子节点。
这里有一个容易忽略的细节:newVirtualDom.children 可能出现嵌套数组,所以代码会先把子节点拍平一层。这是为了支持组件 children 中返回数组的场景。例如一个组件的 props.children 本身可能已经是数组,当它又被放进另一个数组位置时,就会形成 [first, childrenArray, last] 这样的结构。渲染器如果不处理,就会把数组当作普通对象节点,后续分发会出错。
HTML 渲染的最后一步是 _diffAttribute。这一步发生在 children 处理之后,意味着无论是复用旧节点还是创建新节点,最终都会根据新的 Virtual DOM 属性把 DOM 属性同步一遍。
属性处理:React DOM 的味道从这里开始
浏览器 DOM 的属性并不只是简单的 setAttribute。React 中的 className、htmlFor、tabIndex、style、事件、表单 value、ref 都有特殊处理。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。
事件属性通过正则匹配 onClick、onMouseEnter 这类名字,然后转成小写 DOM property,例如 onclick。这不是官方 React 的合成事件系统,而是直接把函数挂到 DOM 节点属性上。官方 React 会在根节点附近做事件委托和 SyntheticEvent 包装,ez-react 为了简单直接赋值。
key 不会成为标准 HTML attribute,而是挂到 DOM 对象上,供 _diffChildren 做 keyed diff。
value 也直接写 DOM property,而不是 setAttribute。这对 input 很重要,因为表单元素的当前值是 property 语义,单纯改 attribute 不一定能得到预期的交互行为。
从这里可以看出,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 字段上。
函数组件示例:
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,就能找回旧组件实例。
然后它把 attributes 和 children 合并成 props。这也是 React 的重要语义:组件接收到的 props.children 本质上来自 JSX 标签体。下面这段:
<Clock>Clock</Clock>会让 Clock 的 props.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),让组件进入渲染流程。
这个流程对应官方 React 中“组件 identity”的最小版本。组件类型相同,就认为可以复用状态;组件类型不同,就卸载旧组件,创建新组件。官方 React 还会结合 key、位置、Fiber alternate 等机制做更精细的判断,但直觉上仍然是这个模型。
setProps 和 render:生命周期与更新入口
组件实例创建出来以后,并不会直接由 ReactDOM 调用它的 render。ez-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,直接跳过。
把实例上的 props 和 state 更新成下一份数据。
调用组件自己的 render(),得到新的 Virtual DOM。
把旧 DOM 和新 Virtual DOM 交给 ReactDOM._diffRender。
把组件实例挂回新 DOM 的 _instance,并把实例的 _node 指向新 DOM。
更新后调用 getSnapshotBeforeUpdate 和 componentDidUpdate。
最后调用 componentDidMount。
这里也能看出 ez-react 和官方 React 的差异:这份实现里 componentDidMount 在每次 render 末尾都会调用,即使是更新阶段也会调用;官方 React 中 componentDidMount 只会在首次挂载后调用,更新后调用的是 componentDidUpdate。另外 getDerivedStateFromProps 的调用也写成了 Component.getDerivedStateFromProps,而不是读取具体组件类上的静态方法。这些都是迷你实现中可以继续改进的地方。它们不影响我们理解主流程,反而提醒我们:教学实现的价值在于把路径跑通,而不是完全复制生产级 React 的所有边界。
setState:为什么状态变化会重新渲染
在 React 中,UI 是 state 的函数。state 变化,组件重新执行 render,得到新的 UI 描述,再由渲染器把差异同步到页面。ez-react 的 setState 直接体现了这个模型:
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,然后调用 setState。setState 进入组件内部 render,Counter.render() 重新返回一棵新的 Virtual DOM。新的树里,按钮文本从 count: 0 变成 count: 1,<p>{count}</p> 里的文本也变成 1,jsx 从 EvenNumber 分支变成 OddNumber 分支。然后 _diffRender 会拿旧 DOM 和新 Virtual DOM 做比较,尽可能复用旧 DOM,只更新变化的部分。
这就是 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。到了 _diffRenderComponent,ez-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。
Counter 里的 EvenNumber 和 OddNumber 也展示了 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>;
}这里 EvenNumber 和 OddNumber 本质上也是组件函数,只是它们定义在类实例上,并通过自动绑定保持 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。
这个实现体现了 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。这就形成了受控组件的闭环。
demo 的注释里提到使用 onInput 而不是 onChange,因为 onChange 在这里不能实时更新 state。这也反映出 ez-react 的事件系统是直接 DOM 事件,而不是官方 React 的合成事件。官方 React 对 onChange 做了跨浏览器归一化,在文本输入中表现得接近实时输入事件;ez-react 没有这层抽象,所以直接使用浏览器原生语义更容易预测。
这也是迷你实现很有价值的地方:它会迫使我们看清“React 提供了哪些额外抽象”。如果直接操作 DOM,input、change、composition、beforeinput 等事件各有浏览器语义;官方 React 把其中一部分语义重新包装成开发者熟悉的行为。ez-react 没有包装,所以它更接近平台本身。
生命周期:挂载、更新和卸载
ez-react 的 Component 类定义了一组生命周期方法:
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 中 Clock 和 DataBinding 都用到了挂载前和卸载时的生命周期来管理定时器:
componentWillMount() {
this.intervalId = setInterval(() => {
const now = String(new Date());
this.setState({ now });
}, 1000);
}
componentWillUnmount() {
clearInterval(this.intervalId);
}这说明生命周期本质上是组件实例和外部世界之间的连接点。render 应该尽量只描述 UI,但定时器、网络请求、订阅、DOM 测量、Canvas 绘制都属于副作用,需要在某个明确时机开始和清理。类组件时代,这些时机主要由生命周期方法表达;Hooks 时代则主要由 useEffect 和 useLayoutEffect 表达。
在 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?.();
}这张图表达的是 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}
/>
);
}
}这个流程是:
这说明 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 和内部 render。Counter.render() 返回 <div>、<h1>、<p>、<button> 等 HTML Virtual DOM。渲染器递归创建真实 DOM,并把 Counter 实例挂到根 DOM 节点 _instance 上。
用户点击按钮后,浏览器调用按钮 DOM 上的 onclick,也就是 this.addCount。由于创建实例时做了自动绑定,addCount 中的 this 指向 Counter 实例。addCount 调用 setState,setState 合并 state 并进入内部 render。这时 oldNode 是上一次渲染出的根 DOM,newVirtualNode 是 Counter.render() 新返回的 Virtual DOM。ReactDOM._diffRender(oldNode, newVirtualNode) 开始比较。
根节点还是 div,所以复用。它 diff children:h1 的文本可能不变,复用;p 中的文本从 0 变成 1,更新 textContent;button 中的文本从 count: 0 变成 count: 1,更新文本;底部的 jsx 子组件从 EvenNumber 变成 OddNumber,组件类型变化,卸载旧组件并创建新组件。最后更新完成,调用更新生命周期。
这条链路就是 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 变成普通数据,可以被重新计算、比较和测试。
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,但没有 useState、useEffect、useRef 这些能力。官方 React 的函数组件状态依赖 Fiber 上的 hook 链表和严格的调用顺序约束。
第三,ez-react 的事件系统是直接 DOM property 赋值。官方 React 有合成事件系统,会做事件委托、跨浏览器兼容、事件对象归一化和批处理边界。
第四,ez-react 的 diff children 比较简单。它有 key 的概念,但没有完整的移动优化和 effect list。官方 React 的 reconciliation 会结合 key、type、位置和 Fiber alternate 判断复用、插入、删除、移动。
第五,生命周期实现和官方 React 不完全一致。例如当前 componentDidMount 的调用时机需要进一步修正。教学实现最重要的是把概念路径打开,后续可以在这条路径上逐步补齐行为。
这些差异不是缺点清单,而是学习路线图。你可以先理解 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 节点可以被匹配,但节点移动和插入策略还比较粗糙。可以引入更明确的操作类型,例如 INSERT、UPDATE、REMOVE、MOVE,先生成 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 只是 tagName,name 和 sex 是 attributes,还没有任何真实 DOM 被创建。此时如果你在 createElement 里打断点,只会看到一棵普通对象树。这是理解 React 的第一个关口:render 前半段都在计算数据,不在操作页面。
第二步,进入 ReactDOM.render。这里你会看到 container 被清空,并且被塞入一个临时空 div。这个临时节点看起来有点奇怪,但它让首次渲染和更新都统一成同一种函数调用:旧 DOM 加新 Virtual DOM。调试时可以观察 _diffRender(oldTrueDom, virtualDom) 的两个参数,一个来自浏览器,一个来自 JSX 数据。React 渲染器做的事情,就是不断把这两种世界对齐。
第三步,进入 _diffRender。这时不要把它当成一个普通工具函数,而要把它当成交通枢纽。每次递归回来,程序都会重新问:新节点是文本、HTML 还是组件?如果是组件,就先得到组件 render 的结果;如果是 HTML,就处理子节点;如果是文本,就落到最底层的 Text 节点。你可以在这三个分支上分别打断点,很快就会发现一次渲染会多次穿过这个入口。
第四步,进入 _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 在挂载前启动定时器,在卸载时清理定时器,这个例子虽然简单,却抓住了生命周期的关键:组件出现时连接外部世界,组件消失时断开连接。
用 ez-react 反推官方 React 的复杂度
当我们理解了 ez-react 的主流程,就可以更自然地理解官方 React 的很多设计。官方 React 不是一开始就为了复杂而复杂,而是在同一条主流程上不断处理更难的工程问题。
例如,ez-react 的 _diffRender 是递归调用。如果组件树很大,一次更新就可能长时间占用主线程。官方 React 的 Fiber 把递归栈变成可手动控制的链表结构,让渲染工作可以被拆分、暂停和恢复。这样浏览器在高优先级输入到来时,就有机会先处理用户交互,而不是被一次大渲染卡住。
再比如,ez-react 的 setState 是同步的。同步模型非常容易理解,但多个连续更新可能导致重复 render。官方 React 会在合适边界内批处理更新,把多次 state 变化合并到一次渲染中。到了并发模式,更新还会带有优先级,不同更新可以走不同 lane。lane 听起来抽象,但它解决的问题可以从 ez-react 的同步更新反推出来:不是所有更新都同样紧急,输入响应和后台列表刷新不应该抢同一条路。
又比如,ez-react 的事件处理是 element.onclick = handler。这很直接,但当页面上有成千上万个节点时,直接在每个节点上管理事件会带来更多绑定和清理成本。官方 React 的事件系统通过委托和合成事件统一管理,既能做跨浏览器兼容,也能把事件和更新批处理连接起来。
Hooks 也可以这样理解。ez-react 用类实例保存 state,所以状态天然有地方放。函数组件如果只是普通函数,每次调用都会重新创建局部变量,状态放在哪里?官方 React 的答案是把 hooks 状态挂在 Fiber 上,并通过调用顺序把 useState、useEffect 等调用和内部槽位对应起来。只要你理解了 FunctionComponent 包装函数组件的目的,就更容易理解 Hooks 为什么需要“不能写在条件分支里”这种规则。
Server Components 和 Hydration 也不是凭空出现的概念。ez-react 只处理浏览器内从 Virtual DOM 到真实 DOM 的过程;而现代 React 还要处理服务端生成的 HTML、客户端接管已有 DOM、只把部分组件发到浏览器、在网络边界上传输组件树描述等问题。主线仍然是 UI 描述如何变成可交互界面,只是输入不再只有浏览器里的 JSX,输出也不再只是一次简单 appendChild。
所以学习迷你实现并不是绕远路。相反,它能帮我们建立一条清晰的“复杂度来源地图”。当你知道简单版本怎么工作,再看复杂版本时,就能分辨哪些复杂度来自功能扩展,哪些来自兼容历史,哪些来自性能优化,哪些来自开发体验。源码阅读也会从“到处都是陌生名词”变成“这个结构在解决我已经见过的哪个问题”。
总结
React 看起来复杂,是因为它在真实工程中解决了太多问题。但如果沿着最小路径拆开,它的核心并不神秘:
JSX 会被编译成 createElement 调用。
createElement 生成 Virtual DOM,也就是一棵描述 UI 的 JavaScript 对象树。
ReactDOM.render 把 Virtual DOM 交给渲染器。
渲染器根据节点类型分成文本、HTML、组件三条路径。
HTML 节点创建或复用真实 DOM,递归处理 children,并同步属性。
组件节点创建或复用组件实例,调用 render 得到下一层 Virtual DOM。
事件触发 setState,setState 让组件重新 render,然后 diff 更新 DOM。
ref 在声明式渲染完成后,把真实 DOM 暴露给必须命令式操作的平台 API。
ez-react 把这条链路压缩到了一个适合阅读的规模。它保留了 React 最关键的概念:JSX、Virtual DOM、函数组件、类组件、props、state、children、diff、生命周期、事件和 ref;同时省略了 Fiber、Hooks、并发、调度等高级机制。这样的实现不适合生产使用,但非常适合学习。因为它让我们能亲手看到:React 并不是魔法,而是一套围绕“UI 是状态的函数”建立起来的、层层递进的工程模型。
当你能从 <Counter /> 一路追踪到 createElement、_diffRenderComponent、Counter.render()、_diffRenderHTML、_diffChildren、_diffRenderText,再从一次点击追踪到 setState 和 DOM 更新时,React 的很多概念就会从抽象名词变成具体路径。理解这条路径以后,再回头看官方 React 的 Fiber、Hooks 和 Concurrent Rendering,就不会只是面对一堆陌生术语,而是在问一个更有方向的问题:为了让这条路径在大型应用里更快、更稳、更可中断、更可组合,React 又引入了哪些额外结构?
这也是我写 ez-react 的意义。它不是 React 的简陋替代品,而是一张可以拿在手里反复对照的简化地图。真正的 React 是一座复杂城市,但只要先看懂主干道路,再进入立交桥和地下管线,就会容易得多。