⚛️ Re: 从零开始的 React 再造之旅

Devrsi0n • 
更新于 

封面来自

通过不依赖任何第三方库的方式,抛弃边界处理、性能优化、安全性等弱相关代码手写一个基础版的 React,供大家学习和理解 React 的核心原理。

feynman

本文实现的是包含现代 React 最新特性 HooksConcurrent Mode 的版本,传统 class 组件的方式稍有不同,不影响理解核心原理。本文函数、变量等标识符命名都和官方尽量贴近,方便以后深入官方源码。

建议桌面端浏览本文,并且跟着文章手动敲一遍代码加深理解。每完成一个完整功能最后都会有对应的完整代码放在 CodeSandbox(在线开发环境),如果对代码有疑问,可以先查看完整代码,打断点调试下。

目录总览

  • 0: 从一次最简单的 React 渲染说起
  • I: 实现 createElement 函数
  • II: 实现 render 函数
  • III: 并发模式 / Concurrent Mode
  • IV: Fibers 数据结构
  • V: render 和 commit 阶段
  • VI: 更新和删除节点/Reconciliation
  • VII: 函数组件
  • VIII: 函数组件 Hooks

0: 从一次最简单的 React 渲染说起

jsx
1const element = <h1 title="hello">Hello World!</h1>;
2const container = document.getElementById("root");
3ReactDOM.render(element, container);

上面这三行代码是一个再简单不过的 React 应用:在 root 根结点上渲染一个 Hello World! h1 节点。

第一步的目标是用原生 DOM 方式替换 React 代码

JSX

熟悉 React 的读者都知道,我们直接在组件渲染的时候返回一段类似 html 模版的结构,这个就是所谓的 JSX。JSX 本质上还是 JS,是语法糖而不是 html 模版(相比 html 模版要学习千奇百怪的语法比如:{{#if value}},JSX 可以直接使用 JS 原生的 && || map reduce 等语法更易学表达能力也更强)。一般需要 babel 配合@babel/plugin-transform-react-jsx 插件(babel 转换过程不是本文重点,感兴趣可以阅读插件源码)转换成调用 React.createElement,函数入参如下:

js
1React.createElement(
2 type,
3 [props],
4 [...children]
5)

例如上面的例子中的 <h1 title="hello">Hello World!</h1>,换成 createElement 调用就是:

js
1const element = React.createElement(
2 'h1',
3 { title: 'hello' },
4 'Hello World!'
5);

React.createElement 返回一个包含元素(element)信息的对象,即:

js
1const element = {
2 type: "h1",
3 props: {
4 title: "hello",
5 // createElement 第三个及之后参数移到 props.children
6 children: "Hello World!",
7 },
8};

react 官方实现还包括了很多额外属性,简单起见本文未涉及,参看官方定义

这个对象描述了 React 创建一个节点(node)所需要的信息,type 就是 DOM 节点的名字,比如这里是 h1,也可以是函数组件,后面会讲到。props 包含所有元素的属性(比如 title)和特殊属性 children,children 可以包含其他元素,从根到叶也就能构成一颗完整的树,也就是描述了整个 UI 界面。

为了避免含义不清,“元素”特指 “React elements”,“节点”特指 “DOM elements”。

ReactDOM.render

下面替换掉 ReactDOM.render 调用,这里 React 会把元素更新到 DOM。

js
1const element = {
2 type: "h1",
3 props: {
4 title: "hello",
5 children: ["Hello World!"],
6 },
7};
8
9const container = document.getElementById("root");
10
11const node = document.createElement(element.type);
12node["title"] = element.props.title;
13
14const text = document.createTextNode("");
15text["nodeValue"] = element.props.children;
16
17node.appendChild(text);
18container.appendChild(node);

对比元素对象,首先用 element.type 创建节点,再把非 children 属性(这里是 title)赋值给节点。

然后创建 children 节点,由于 children 是字符串,故创建 textNode 节点,并把字符串赋值给 nodeValue,这里之所以用 createTextNode 而不是 innerText,是为了方便之后统一处理。

再把 children 节点 text 插到元素节点的子节点上,最后把元素节点插到根结点即完成了这次 React 的替换。

像上面代码 element 这样 JSX 转成的描述 UI 界面的对象就是所谓的 虚拟 DOM,相对的 node真实 DOMrender/渲染 过程就是把虚拟 DOM 转换成真实 DOM 的过程。


I: 实现 createElement 函数

第一步首先实现 createElement 函数,把 JSX 转换成 JS。以下面这个新的渲染为例,createElement 就是把 JSX 结构转成元素描述对象。

jsx
1const element = (
2 <div id="foo">
3 <a>bar</a>
4 <b />
5 </div>
6);
7// 等价转换 👇
8const element = React.createElement(
9 "div",
10 { id: "foo" },
11 React.createElement("a", null, "bar"),
12 React.createElement("b")
13);
14
15const container = document.getElementById("root");
16ReactDOM.render(element, container);

就像之前示例那样,createElement 返回一个包含 type 和 props 的元素对象,描述节点信息。

js
1// 这里用了最新 ECMAScript 剩余参数和展开语法(Rest parameter/Spread syntax),
2// 参考 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/Spread_syntax
3// 注意:这里 children 始终是数组
4function createElement(type, props, ...children) {
5 return {
6 type,
7 props: {
8 ...props,
9 children: children.map(child =>
10 typeof child === "object"
11 ? child
12 : createTextElement(child)
13 ),
14 },
15 }
16}
17
18function createTextElement(text) {
19 return {
20 type: "TEXT_ELEMENT",
21 props: {
22 nodeValue: text,
23 children: [],
24 },
25 }
26}

children 可能包含字符串或者数字这类基础类型值,给这里值包裹成 TEXT_ELEMENT 特殊类型,方便后面统一处理。

注意:React 并不会包裹字符串这类值,如果没有 children 也不会创建空数组,这里简单起见,统一这样处理可以简化我们的代码。

我们把本文的框架叫做 redact,以区别 react。示例 app 如下。

js
1const element = Redact.createElement(
2 "div",
3 { id: "foo" },
4 Redact.createElement("a", null, "bar"),
5 Redact.createElement("b")
6);
7const container = document.getElementById("root");
8// 后面实现 render 方法
9RedactDOM.render(element, container);

但是我们还是习惯用 JSX 来写组件,这里还能用吗?答案是能的,只需要加一行注释即可。

js
1/** @jsx Redact.createElement */
2const element = (
3 <div id="foo">
4 <a>bar</a>
5 <b />
6 </div>
7);
8const container = document.getElementById("root");
9RedactDOM.render(element, container);

注意第一行注释 @jsx 告诉 babel 用 Redact.createElement 替换默认的 React.createElement。或者直接修改 .babelrc 配置文件的 pragma 项,就不用每个 JSX 文件都添加注释了。

json
1{
2 "presets": [
3 [
4 "@babel/preset-react",
5 {
6 "pragma": "Redact.createElement"
7 }
8 ]
9 ]
10}

II: 实现 render 函数

实现我们的 render 函数,目前只需要添加节点到 DOM,删除和更新操作后面再加。

js
1function render(element, container) {
2 // 创建节点
3 const dom =
4 element.type === "TEXT_ELEMENT"
5 ? document.createTextNode("")
6 : document.createElement(element.type);
7
8 // 赋值属性(props)
9 const isProperty = key => key !== "children";
10 Object.keys(element.props)
11 .filter(isProperty)
12 .forEach(name => {
13 dom[name] = element.props[name]
14 });
15
16 // 递归遍历子节点
17 element.props.children.forEach(child =>
18 render(child, dom)
19 );
20
21 // 插入父节点
22 container.appendChild(dom);
23}

上面的代码放在了 CodeSandbox(在线开发环境),项目基于 Create React App 脚手架,试一试改下面的 JSX 代码验证下。


III: 并发模式 / Concurrent Mode

在我们深入其他 React 功能之前,先对代码重构,引入 React 最新的并发模式(截止本文发表该功能还未正式发布)。

可能读者会疑惑我们目前连最基本的组件状态更新都还没实现就先实现并发模式,其实目前代码逻辑还十分简单,现在重构,比之后实现所有功能再回头要容易很多,所谓积重难返就是这个道理。

有经验的开发者很容易发现上面的 render 代码有一个问题,渲染子节点时递归遍历了整棵树,当页面非常复杂时很容易阻塞主线程,我们都知道每个页面是单线程的(不考虑 worker 线程),主线程阻塞会导致页面不能及时响应高优先级操作,如用户点击事件或者渲染动画,页面给用户 “很卡,难用” 的负面印象,这肯定不是我们想要的。

因此,理想情况下,我们应该把 render 拆成更细分的单元,每完成一个单元的工作,允许浏览器打断渲染响应更高优先级的的工作,这个过程即 “并发模式”。

这里我们用 requestIdleCallback 这个浏览器 API 来实现。这个 API 有点类似 setTimeout,不过不是我们告诉浏览器什么时候执行回调函数,而是浏览器在线程空闲(idle)的时侯主动执行回调函数。

React 目前已经不用这个 API 了,而是自己实现调度算法 调度器/scheduler。但它们核心思路是类似的,简化起见用 requestIdleCallback 足矣。

js
1let nextUnitOfWork = null
2
3function workLoop(deadline) {
4 let shouldYield = false;
5 while (nextUnitOfWork && !shouldYield) {
6 nextUnitOfWork = performUnitOfWork(
7 nextUnitOfWork
8 );
9 // 回调函数入参 deadline 可以告诉我们在这个渲染周期还剩多少时间可用
10 // 剩余时间小于1毫秒就退出回调,等待浏览器再次空闲
11 shouldYield = deadline.timeRemaining() < 1;
12 }
13 requestIdleCallback(workLoop);
14}
15
16requestIdleCallback(workLoop);
17
18// 注意,这个函数执行完本次单元任务之后要返回下一个单元任务
19function performUnitOfWork(nextUnitOfWork) {
20 // TODO
21}

这就是我们简易并发模式的实现,渲染任务拆分为多个任务单元交给 performUnitOfWork 执行,具体怎么拆分任务?答案是 “Fibers”。

IV: Fibers 数据结构

为了方便描述渲染树和单元任务,React 设计了一种数据结构 “fiber 树”。每个元素都是一个 fiber,每个 fiber 就是一个单元任务。

假如我们渲染如下这样一棵树:

js
1Redact.render(
2 <div>
3 <h1>
4 <p />
5 <a />
6 </h1>
7 <h2 />
8 </div>,
9 container
10)

用 Fiber 树来描述就是:

fiber0

render 函数我们创建根 fiber,再把它设为 nextUnitOfWork。在 workLoop 函数把 nextUnitOfWorkperformUnitOfWork 执行,主要包含以下三步:

  1. 把元素添加到 DOM
  2. 为元素的后代创建 fiber 节点
  3. 选择下一个单元任务,并返回

为了完成这些目标需要设计的数据结构方便找到下一个任务单元。所以每个 fiber 直接链接它的第一个子节点(child),子节点链接它的兄弟节点(sibling),兄弟节点链接到父节点(parent)。示意图如下(注意不同节点之间的高亮箭头):

fiber1

当我们完成了一个 fiber 的单元任务,如果他有一个 子节点/child 则这个节点作为 nextUnitOfWork。如下图所示,当完成 div 单元任务之后,下一个单元任务就是 h1

fiber2

如果一个 fiber 没有 child,我们用 兄弟节点/sibling 作为下一个任务单元。如下图所示,p 节点没有 child 而有 sibling,所以下一个任务单元是 a 节点。

fiber3

如果一个 fiber 既没有 child 也没有 sibling,则找到父节点的兄弟节点,。如下图所示的 ah2

fiber4

如果父节点没有兄弟节点,则继续往上找,直到找到一个兄弟节点或者到达 fiber 根结点。到达根结点即意味本次 render 任务全部完成。学过算法的同学对这个过程肯定很熟悉,这是典型的深度优先搜索遍历(DFS/depth first search)。

把这个思路用代码表达如下:

提示:代码前后差异部分已高亮

js
1// 之前 render 的逻辑挪到这个函数
2function createDom(fiber) {
3 const dom =
4 fiber.type == "TEXT_ELEMENT"
5 ? document.createTextNode("")
6 : document.createElement(fiber.type);
7
8 const isProperty = key => key !== "children";
9 Object.keys(fiber.props)
10 .filter(isProperty)
11 .forEach(name => {
12 dom[name] = fiber.props[name];
13 });
14
15 return dom;
16}
17function render(element, container) {
18 // 创建根 fiber,设为下一次的单元任务
19 nextUnitOfWork = {
20 dom: container,
21 props: {
22 children: [element]
23 }
24 };
25}
26
27let nextUnitOfWork = null;
28function workLoop(deadline) {
29 let shouldYield = false;
30 while (nextUnitOfWork && !shouldYield) {
31 nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
32 shouldYield = deadline.timeRemaining() < 1;
33 }
34 requestIdleCallback(workLoop);
35}
36
37// 一旦浏览器空闲,就触发执行单元任务
38requestIdleCallback(workLoop);
39
40function performUnitOfWork(fiber) {
41 if (!fiber.dom) {
42 fiber.dom = createDom(fiber);
43 }
44
45 // 子节点 DOM 插到父节点之后
46 if (fiber.parent) {
47 fiber.parent.dom.appendChild(fiber.dom);
48 }
49
50 // 为每个子元素创建新的 fiber
51 const elements = fiber.props.children;
52 let index = 0;
53 let prevSibling = null;
54
55 while (index < elements.length) {
56 const element = elements[index];
57
58 const newFiber = {
59 type: element.type,
60 props: element.props,
61 parent: fiber,
62 dom: null
63 };
64 // 根据上面的图示,父节点只链接第一个子节点
65 if (index === 0) {
66 fiber.child = newFiber;
67 } else {
68 // 兄节点链接弟节点
69 prevSibling.sibling = newFiber;
70 }
71
72 prevSibling = newFiber;
73 index++;
74 }
75 // 返回下一个任务单元(fiber)
76 // 有子节点直接返回
77 if (fiber.child) {
78 return fiber.child;
79 }
80 // 没有子节点则找兄弟节点,兄弟节点也没有找父节点的兄弟节点,
81 // 循环遍历直至找到为止
82 let nextFiber = fiber;
83 while (nextFiber) {
84 if (nextFiber.sibling) {
85 return nextFiber.sibling;
86 }
87 nextFiber = nextFiber.parent;
88 }
89 return null;
90}

V: render 和 commit 阶段

我们的代码还有一个问题。

每完成一个任务单元都把节点添加到 DOM 上。请记住,浏览器是可以打断渲染流程的,如果还没渲染完整棵树就把节点添加到 DOM,用户会看到残缺不全的 UI 界面,给人一种很不专业的印象,这肯定不是我们想要的。因此需要重构节点添加到 DOM 这部分代码,整棵树(fiber)渲染完成之后再一次性添加到 DOM,即 React commit 阶段。

具体来说,去掉 performUnitOfWorkfiber.parent.dom.appendChild 代码,换成如下代码。

js
1function createDom(fiber) {
2 const dom =
3 fiber.type == "TEXT_ELEMENT"
4 ? document.createTextNode("")
5 : document.createElement(fiber.type);
6
7 const isProperty = key => key !== "children";
8 Object.keys(fiber.props)
9 .filter(isProperty)
10 .forEach(name => {
11 dom[name] = fiber.props[name];
12 });
13
14 return dom;
15}
16
17// 新增函数,提交根结点到 DOM
18function commitRoot() {
19 commitWork(wipRoot.child);
20 wipRoot = null;
21}
22
23// 新增子函数
24function commitWork(fiber) {
25 if (!fiber) {
26 return;
27 }
28 const domParent = fiber.parent.dom;
29 domParent.appendChild(fiber.dom);
30 // 递归子节点和兄弟节点
31 commitWork(fiber.child);
32 commitWork(fiber.sibling);
33}
34
35function render(element, container) {
36 // render 时记录 wipRoot
37 wipRoot = {
38 dom: container,
39 props: {
40 children: [element],
41 },
42 };
43 nextUnitOfWork = wipRoot;
44}
45
46let nextUnitOfWork = null;
47// 新增变量,跟踪渲染进行中的根 fiber
48let wipRoot = null;
49
50function workLoop(deadline) {
51 let shouldYield = false;
52 while (nextUnitOfWork && !shouldYield) {
53 nextUnitOfWork = performUnitOfWork(
54 nextUnitOfWork
55 );
56 shouldYield = deadline.timeRemaining() < 1;
57 }
58
59 // 当 nextUnitOfWork 为空则表示渲染 fiber 树完成了,
60 // 可以提交到 DOM 了
61 if (!nextUnitOfWork && wipRoot) {
62 commitRoot();
63 }
64 requestIdleCallback(workLoop);
65}
66
67// 一旦浏览器空闲,就触发执行单元任务
68requestIdleCallback(workLoop);
69
70function performUnitOfWork(fiber) {
71 if (!fiber.dom) {
72 fiber.dom = createDom(fiber);
73 }
74
75 const elements = fiber.props.children;
76 let index = 0;
77 let prevSibling = null;
78
79 while (index < elements.length) {
80 const element = elements[index];
81
82 const newFiber = {
83 type: element.type,
84 props: element.props,
85 parent: fiber,
86 dom: null,
87 };
88
89 if (index === 0) {
90 fiber.child = newFiber;
91 } else {
92 prevSibling.sibling = newFiber;
93 }
94
95 prevSibling = newFiber;
96 index++;
97 }
98
99 if (fiber.child) {
100 return fiber.child;
101 }
102
103 let nextFiber = fiber;
104 while (nextFiber) {
105 if (nextFiber.sibling) {
106 return nextFiber.sibling;
107 }
108 nextFiber = nextFiber.parent;
109 }
110}

VI: 更新和删除节点/Reconciliation

目前我们只添加节点到 DOM,还没考虑更新和删除节点的情况。要处理这2种情况,需要对比上次渲染的 fiber 和当前渲染的 fiber 的差异,根据差异决定是更新还是删除节点。React 把这个过程叫 Reconciliation

因此我们需要保存上一次渲染之后的 fiber 树,我们把这棵树叫 currentRoot。同时,给每个 fiber 节点添加 alternate 属性,指向上一次渲染的 fiber。

代码较多,建议按 render ⟶ workLoop ⟶ performUnitOfWork ⟶ reconcileChildren ⟶ workLoop ⟶ commitRoot ⟶ commitWork ⟶ updateDom 顺序阅读。

js
1function createDom(fiber) {
2 const dom =
3 fiber.type === "TEXT_ELEMENT"
4 ? document.createTextNode("")
5 : document.createElement(fiber.type);
6
7 updateDom(dom, {}, fiber.props);
8
9 return dom;
10}
11
12const isEvent = key => key.startsWith("on");
13const isProperty = key => key !== "children" && !isEvent(key);
14const isNew = (prev, next) => key => prev[key] !== next[key];
15const isGone = (prev, next) => key => !(key in next);
16
17// 新增函数,更新 DOM 节点属性
18function updateDom(dom, prevProps = {}, nextProps = {}) {
19 // 以 “on” 开头的属性作为事件要特别处理
20 // 移除旧的或者变化了的的事件处理函数
21 Object.keys(prevProps)
22 .filter(isEvent)
23 .filter(key => !(key in nextProps) || isNew(prevProps, nextProps)(key))
24 .forEach(name => {
25 const eventType = name.toLowerCase().substring(2);
26 dom.removeEventListener(eventType, prevProps[name]);
27 });
28
29 // 移除旧的属性
30 Object.keys(prevProps)
31 .filter(isProperty)
32 .filter(isGone(prevProps, nextProps))
33 .forEach(name => {
34 dom[name] = "";
35 });
36
37 // 添加或者更新属性
38 Object.keys(nextProps)
39 .filter(isProperty)
40 .filter(isNew(prevProps, nextProps))
41 .forEach(name => {
42 // React 规定 style 内联样式是驼峰命名的对象,
43 // 根据规范给 style 每个属性单独赋值
44 if (name === "style") {
45 Object.entries(nextProps[name]).forEach(([key, value]) => {
46 dom.style[key] = value;
47 });
48 } else {
49 dom[name] = nextProps[name];
50 }
51 });
52
53 // 添加新的事件处理函数
54 Object.keys(nextProps)
55 .filter(isEvent)
56 .filter(isNew(prevProps, nextProps))
57 .forEach(name => {
58 const eventType = name.toLowerCase().substring(2);
59 dom.addEventListener(eventType, nextProps[name]);
60 });
61}
62
63function commitRoot() {
64 deletions.forEach(commitWork);
65 commitWork(wipRoot.child);
66 currentRoot = wipRoot;
67 wipRoot = null;
68}
69
70function commitWork(fiber) {
71 if (!fiber) {
72 return;
73 }
74 const domParent = fiber.parent.dom;
75 if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
76 domParent.appendChild(fiber.dom);
77 } else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
78 updateDom(fiber.dom, fiber.alternate.props, fiber.props);
79 } else if (fiber.effectTag === "DELETION") {
80 domParent.removeChild(fiber.dom);
81 }
82 commitWork(fiber.child);
83 commitWork(fiber.sibling);
84}
85
86function render(element, container) {
87 wipRoot = {
88 dom: container,
89 props: {
90 children: [element]
91 },
92 alternate: currentRoot
93 };
94 deletions = [];
95 nextUnitOfWork = wipRoot;
96}
97
98let nextUnitOfWork = null;
99let currentRoot = null;
100let wipRoot = null;
101let deletions = null;
102
103function workLoop(deadline) {
104 let shouldYield = false;
105 while (nextUnitOfWork && !shouldYield) {
106 nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
107 shouldYield = deadline.timeRemaining() < 1;
108 }
109
110 if (!nextUnitOfWork && wipRoot) {
111 commitRoot();
112 }
113
114 requestIdleCallback(workLoop);
115}
116
117requestIdleCallback(workLoop);
118
119function performUnitOfWork(fiber) {
120 if (!fiber.dom) {
121 fiber.dom = createDom(fiber);
122 }
123
124 const elements = fiber.props.children;
125 // 原本添加 fiber 的逻辑挪到 reconcileChildren 函数
126 reconcileChildren(fiber, elements);
127
128 if (fiber.child) {
129 return fiber.child;
130 }
131 let nextFiber = fiber;
132 while (nextFiber) {
133 if (nextFiber.sibling) {
134 return nextFiber.sibling;
135 }
136 nextFiber = nextFiber.parent;
137 }
138}
139
140// 新增函数
141function reconcileChildren(wipFiber, elements) {
142 let index = 0;
143 // 上次渲染完成之后的 fiber 节点
144 let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
145 let prevSibling = null;
146
147 // 扁平化 props.children,处理函数组件的 children
148 elements = elements.flat();
149
150 while (index < elements.length || oldFiber != null) {
151 // 本次需要渲染的子元素
152 const element = elements[index];
153 let newFiber = null;
154
155 // 比较当前和上一次渲染的 type,即 DOM tag 'div',
156 // 暂不考虑自定义组件
157 const sameType = oldFiber && element && element.type === oldFiber.type;
158
159 // 同类型节点,只需更新节点 props 即可
160 if (sameType) {
161 newFiber = {
162 type: oldFiber.type,
163 props: element.props,
164 dom: oldFiber.dom, // 复用旧节点的 DOM
165 parent: wipFiber,
166 alternate: oldFiber,
167 effectTag: "UPDATE" // 新增属性,在提交/commit 阶段使用
168 };
169 }
170 // 不同类型节点且存在新的元素时,创建新的 DOM 节点
171 if (element && !sameType) {
172 newFiber = {
173 type: element.type,
174 props: element.props,
175 dom: null,
176 parent: wipFiber,
177 alternate: null,
178 effectTag: "PLACEMENT" // PLACEMENT 表示需要添加新的节点
179 };
180 }
181 // 不同类型节点,且存在旧的 fiber 节点时,
182 // 需要移除该节点
183 if (oldFiber && !sameType) {
184 oldFiber.effectTag = "DELETION";
185 // 当最后提交 fiber 树到 DOM 时,我们是从 wipRoot 开始的,
186 // 此时没有上一次的 fiber,所以这里用一个数组来跟踪需要
187 // 删除的节点
188 deletions.push(oldFiber);
189 }
190
191 if (oldFiber) {
192 // 同步更新下一个旧 fiber 节点
193 oldFiber = oldFiber.sibling;
194 }
195
196 if (index === 0) {
197 wipFiber.child = newFiber;
198 } else {
199 prevSibling.sibling = newFiber;
200 }
201
202 prevSibling = newFiber;
203 index++;
204 }
205}

注意:这个过程中 React 还用了 key 来检测数组元素变化了位置的情况,避免重复渲染以提高性能。简化起见,本文未实现。

下面 CodeSandbox 代码用了个小技巧,重复执行 render 实现更新界面的效果,输入几个字试试看效果。


VII: 函数组件

目前我们还只考虑了直接渲染 DOM 标签的情况,不支持组件,而组件是 React 是灵魂,下面我们来实现函数组件。

以一个非常简单的组件代码为例。

jsx
1/** @jsx Redact.createElement */
2function App(props) {
3 return <h1>Hi {props.name}</h1>;
4};
5
6// 等效 JS 代码 👇
7function App(props) {
8 return Redact.createElement(
9 "h1",
10 null,
11 "Hi ",
12 props.name
13 );
14}
15
16const element = <App name="foo" />;
17const container = document.getElementById("root");
18Redact.render(element, container);

函数组件有2个不同点:

  • 函数组件的 fiber 节点没有对应 DOM
  • 函数组件的 children 来自函数执行结果,而不是像标签元素一样直接从 props 获取,因为 children 不只是函数组件使用时包含的子孙节点,还需要组合组件本身的结构

注意以下代码省略了未改动部分。

js
1function commitWork(fiber) {
2 if (!fiber) {
3 return;
4 }
5
6 // 当 fiber 是函数组件时节点不存在 DOM,
7 // 故需要遍历父节点以找到最近的有 DOM 的节点
8 let domParentFiber = fiber.parent;
9 while (!domParentFiber.dom) {
10 domParentFiber = domParentFiber.parent;
11 }
12 const domParent = domParentFiber.dom;
13 if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
14 domParent.appendChild(fiber.dom);
15 } else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
16 updateDom(fiber.dom, fiber.alternate.props, fiber.props);
17 } else if (fiber.effectTag === "DELETION") {
18 // 直接移除 DOM 替换成 commitDeletion 函数
19 commitDeletion(fiber, domParent);
20 }
21
22 commitWork(fiber.child);
23 commitWork(fiber.sibling);
24}
25
26// 新增函数,移除 DOM 节点
27function commitDeletion(fiber, domParent) {
28 // 当 child 是函数组件时不存在 DOM,
29 // 故需要递归遍历子节点找到真正的 DOM
30 if (fiber.dom) {
31 domParent.removeChild(fiber.dom);
32 } else {
33 commitDeletion(fiber.child, domParent);
34 }
35}
36
37function performUnitOfWork(fiber) {
38 const isFunctionComponent = fiber.type instanceof Function;
39 // 原本逻辑挪到 updateHostComponent 函数
40 if (isFunctionComponent) {
41 updateFunctionComponent(fiber);
42 } else {
43 updateHostComponent(fiber);
44 }
45 if (fiber.child) {
46 return fiber.child;
47 }
48 let nextFiber = fiber;
49 while (nextFiber) {
50 if (nextFiber.sibling) {
51 return nextFiber.sibling;
52 }
53 nextFiber = nextFiber.parent;
54 }
55}
56
57// 新增函数,处理函数组件
58function updateFunctionComponent(fiber) {
59 // 执行函数组件得到 children
60 const children = [fiber.type(fiber.props)];
61 reconcileChildren(fiber, children);
62}
63
64// 新增函数,处理原生标签组件
65function updateHostComponent(fiber) {
66 if (!fiber.dom) {
67 fiber.dom = createDom(fiber);
68 }
69 reconcileChildren(fiber, fiber.props.children);
70}

VIII: 函数组件 Hooks

支持了函数组件,还需要支持组件状态 / state 才能实现刷新界面。

我们的示例也跟着更新,用 hooks 实现经典的 counter,点击计数器加1。

jsx
1/** @jsx Redact.createElement */
2function Counter() {
3 const [state, setState] = Redact.useState(1)
4 return (
5 <h1 onClick={() => setState(c => c + 1)}>
6 Count: {state}
7 </h1>
8 );
9}
10const element = <Counter />;
11const container = document.getElementById("root");
12Redact.render(element, container);

注意以下代码省略了未变化部分。

js
1// 新增变量,渲染进行中的 fiber 节点
2let wipFiber = null;
3// 新增变量,当前 hook 的索引,以支持同一个函数组件多次调用 useState
4let hookIndex = null;
5
6function updateFunctionComponent(fiber) {
7 // 更新进行中的 fiber 节点
8 wipFiber = fiber;
9 // 重置 hook 索引
10 hookIndex = 0;
11 // 新增 hooks 数组以支持同一个组件多次调用 useState
12 wipFiber.hooks = [];
13 const children = [fiber.type(fiber.props)];
14 reconcileChildren(fiber, children);
15}
16
17function useState(initial) {
18 // alternate 保存了上一次渲染的 fiber 节点
19 const oldHook =
20 wipFiber.alternate &&
21 wipFiber.alternate.hooks &&
22 wipFiber.alternate.hooks[hookIndex];
23 const hook = {
24 // 第一次渲染使用入参,第二次渲染复用前一次的状态
25 state: oldHook ? oldHook.state : initial,
26 // 保存每次 setState 入参的队列
27 queue: []
28 };
29
30 const actions = oldHook ? oldHook.queue : [];
31 actions.forEach(action => {
32 // 根据调用 setState 顺序从前往后生成最新的 state
33 hook.state = action instanceof Function ? action(hook.state) : action;
34 });
35
36 // setState 函数用于更新 state,入参 action
37 // 是新的 state 值或函数返回新的 state
38 const setState = action => {
39 hook.queue.push(action);
40 // 下面这部分代码和 render 函数很像,
41 // 设置新的 wipRoot 和 nextUnitOfWork
42 // 浏览器空闲时即开始重新渲染。
43 wipRoot = {
44 dom: currentRoot.dom,
45 props: currentRoot.props,
46 alternate: currentRoot
47 };
48 nextUnitOfWork = wipRoot;
49 deletions = [];
50 };
51
52 // 保存本次 hook
53 wipFiber.hooks.push(hook);
54 hookIndex++;
55 return [hook.state, setState];
56}

完整 CodeSandbox 代码如下,点击 Count 试试:


结语

除了帮助读者理解 React 核心工作原理外,本文很多变量都和 React 官方代码保持一致,比如,读者在 React 应用的任何函数组件里断点,再打开调试工作能看到下面这样的调用栈:

  • updateFunctionComponent
  • performUnitOfWork
  • workLoop

call stack

注意本文是教学性质的,还缺少很多 React 的功能和性能优化。比如:在这些事情上 React 的表现和 Redact 不同。

  • Redact 在渲染阶段遍历了整棵树,而 React 用了一些启发性算法,可以直接跳过某些没有变化的子树,以提高性能。(比如 React 数组元素推荐带 key,可以跳过无需更新的节点,参考官方文档
  • Redact 在 commit 阶段遍历整棵树, React 用了一个链表保存变化了的 fiber,减少了很多不必要遍历操作。
  • Redact 每次创建新的 fiber 树时都是直接创建 fiber 对象节点,而 React 会复用上一个 fiber 对象,以节省创建对象的性能消耗。
  • Redact 如果在渲染阶段收到新的更新会直接丢弃已渲染的树,再从头开始渲染。而 React 会用时间戳标记每次更新,以决定更新的优先级。
  • 源码还有很多优化等待读者去发现。。。

参考

征得原作者同意,本文参考了 build-your-own-react 部分内容,推荐英文水平不错读者直接在桌面端阅读原文以获得最佳阅读体验。

在 GitHub 上编辑此文

其他文章

使用 Electron 时 GitHub Actions 执行失败

GitHub Actions 是 GitHub 官方近期推出的免费自动化软件开发流程服务,可用来替代 circleci/travis 这类第三方服务,而且因为榜上微软这个金主,GitHub 的官方服务构建速度和易用度都挺不错的。比如下面著名 iOS 大神 onevcat…

2019-11-10
© 2019 – 2022 devrsi0n
Link to $https://bit.ly/2NcAZQZLink to $https://github.com/devrsi0nLink to $https://weibo.com/qianmofeiyu