- 作者:老汪软件技巧
- 发表时间:2024-09-11 07:02
- 浏览量:
全文参考翻译自 Build your own React, 基于 React 16.8。
本文将带你深入探索 React 的核心机制,从最基础的 createElement 和 render 方法开始,逐步深入到 React Fiber 架构,并探讨 React Hooks 的实现,逐步构建一个 React 的简化版本。
为了保证大家都能看跟上文章的思路,我在代码中加了完整的注释,有任问题都可以在评论区一起探讨。
拯救一个很菜(但很帅)的少年就靠您啦!
前期准备
我们创建一个文件夹,我起名为 cookie-react,开始写自己的 React。因为我们要指定版本,所以没有使用 create-react-app,而是手动安装相关依赖。先初始化 package.json,然后安装 react react-dom 和 react-scripts。
mkdir cookie-react && cd cookie-react
npm init -y
npm install react@16.8.6 react-dom@16.8.6 react-scripts@3.0.1
安装好之后,我们更改 package.json 增加脚本命令。
{
"name": "cookie-react",
"version": "1.0.0",
"description": "简化版React",
"main": "index.js",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
},
"keywords": [],
"author": "idonteatcookie",
"license": "ISC",
"dependencies": {
"react": "16.8.6",
"react-dom": "16.8.6",
"react-scripts": "3.0.1"
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
]
}
接下来,我们在目录下创建两个文件 src/index.js 入口文件 和 public/index.html 页面模板。
public/index.html 比较简单,只需要加一个 id=root 的 div,让我们在 React 生成的 DOM 挂载到其下。
html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我不吃饼干的 React Apptitle>
head>
<body>
<div id="root">div>
body>
html>
src/index.js 也并不复杂,我们创建了一个 React 元素,并把它挂载到了指定的 DOM 节点下。
import React from 'react'
import ReactDOM from 'react-dom'
const element = <h1 title="foo">idonteatcookieh1>
const container = document.getElementById("root")
ReactDOM.render(element, container)
现在我们运行 npm start 可以在 :3000/ 看到我们的页面了。
基础复习
本小节我们来分析下 src/index.js 的三行代码中,React 做了什么。
其中 idonteatcookie 是一个 JSX 语法,并不是一个合法的 JavaScript 语法,需要通过 Babel 等工具转义后才能执行,我们可以 npm run build 打包后看下:
可以看到标签被替换成了调用 createElement,并将标签名、属性和子元素作为参数传给 createElement。
其实 createElement 调用的结果就是返回一个对象,也就是我们常说的虚拟 DOM。格式如下:
const element = {
type: "h1",
props: {
title: "foo",
children: "idonteatcookie",
},
}
虚拟 DOM 就是用一个 JS 对象来表示一个 DOM,它只包含 type 和 props 两个属性(实际上还有很多其他复杂的属性,不过我们暂时不关心)。type 表示了DOM 节点的类型,比如h1,div等,也就是我们传给 createElement 的标签名。props 是 JSX 中的属性,props 中有一个特殊的属性 children,表示节点的子节点,上面的例子中 children 是一个字符串,不过大多数情况下,它是一个子节点对象数组。
我们可以尝试把代码中的element 打印输出:
const element = <h1 title="foo">idonteatcookieh1>
console.log(element)
可以看到 React 生成的虚拟 DOM:
然后我们看下 render 部分:
const container = document.getElementById("root")
ReactDOM.render(element, container)
首先获取 id=root 的 DOM 元素,然后通过 render 函数把虚拟 DOM 挂载到了真实的 DOM 元素下。
而 render 函数就是做两件事:
我们可以尝试自己实现 render 函数,它创建 JSX 对应的 DOM 元素并挂载,不过并没有通用性,只是方便我们理解它具体做了什么。
import React from 'react'
const element = <h1 title='foo'>idonteatcookie-01h1>
const container = document.getElementById('root')
// element = {
// type: 'h1',
// props: {title: 'foo', children: 'idonteatcookie'}
// }
// 根据节点类型创建节点
const node = document.createElement(element.type)
// 设置节点属性
node['title'] = element.props.title
// 创建子节点
const text = document.createTextNode('')
text['nodeValue'] = element.props.children
// 把子节点添加到当前节点
node.appendChild(text)
// 插入container元素
container.appendChild(node)
可以看的页面仍然可以正常显示。
实现 createElement 函数
我们已经知道了 const element = idonteatcookie 这一句中,element 最终获得是虚拟 DOM。在这个过程中 Babel 会将 JSX 转为 JS,然后通过 React 提供的 createElement 函数来获取虚拟 DOM。现在我们来尝试实现 createElement 函数。
// jsx
const element = (
<div id="foo">
<a>bara>
<b />
div>
)
// 转为js
const element = React.createElement(
"div",
{ id: "foo" },
React.createElement("a", null, "bar"),
React.createElement("b")
)
要实现一个 createElement 函数,它接受三个参数:type, props,children,并返回虚拟 DOM 对象。
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children,
},
}
}
其中,children 可能为字符串,因为字符串是没有 Tag 的,我们需要加一下判断。通过 createTextElement 来创建一个字符串节点。这样可以方便不同类型的节点统一处理,简化我们的代码。
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map((child) =>
typeof child === 'object'
? child
: createTextElement(child)
),
},
}
}
function createTextElement(text) {
return {
type: 'TEXT_ELEMENT',
props: {
nodeValue: text,
children: [],
},
}
}
现在我们可以创建我们的 React,我给它取名 CookieReact 并导出。
// src/cookieReact.js
function createElement() {
// ...
}
export const CookieReact = {
createElement,
}
接下来我们通过/** @jsx CookieReact.createElement */注释就可以手动指定 JSX 使用我们自己的createElement函数了!
在控制台打印,我们可以看到element对象已经是使用我们的CookieReact.createElement函数转换后的结果。
// index.js
import { CookieReact } from './cookieReact'
/** @jsx CookieReact.createElement */
const element = <h1 title='foo'>idonteatcookieh1>
console.log(element)
/*
{
"type": "h1",
"props": {
"title": "foo",
"children": [
{
"type": "TEXT_ELEMENT",
"props": { "nodeValue": "idonteatcookie", "children": [] }
}
]
}
}
*/
实现 render 函数
有了虚拟 DOM,我们需要一个 render 函数把虚拟 DOM 挂载到页面上。
在上面我们已经理解了 render 函数所做的事情,现在我们需要实现一个通用版的 render 函数。我们的 render 函数先专注于将节点添加到 DOM 中,节点的更新和删除功能放到后面去考虑。
首先,我们将根据元素的类型创建相应的 DOM 节点。接着,我们会为这个节点添加必要的属性。然后递归地处理所有子节点。最后我们将完整构建的 DOM 节点挂载到指定的容器中:
// cookieReact.js
// ... 省略上面 createElement 相关代码
function render(element, container) {
// 我们先用元素类型创建 DOM 节点,然后添加到容器内。
const dom =
element.type === 'TEXT_ELEMENT'
? document.createTextNode('')
: document.createElement(element.type);
// 判断非 children 的属性
const isProperty = (key) => key !== 'children';
// 把非 children 的属性赋值到 DOM
Object.keys(element.props)
.filter(isProperty)
.forEach((name) => {
dom[name] = element.props[name];
});
// 递归创建所有子节点,并添加到当前节点
element.props.children.forEach((child) => render(child, dom));
// 添加到容器
container.appendChild(dom);
}
export const CookieReact = {
createElement,
render,
}
我们在 index.js 使用我们自己的 render 函数进行渲染。
// index.js
import { CookieReact } from './cookieReact'
/** @jsx CookieReact.createElement */
const element = <h1 title='foo'>idonteatcookieh1>
const container = document.getElementById('root')
CookieReact.render(element, container)
现在我们的代码已经可以完全剥离了 React 正常运行了。
并发模式
上面的代码有一个问题,就是当 DOM 元素结构层级过深时,渲染可能会耗费大量时间。众所周知,浏览器的事件循环会在渲染过程中阻塞主线程,这时其他更重要的操作(如用户输入事件)将无法及时响应。
为了优化渲染,我们可以将渲染操作拆分成多个小单元。每完成一个单元后,如果有更重要的任务则可以中断渲染,优先处理这些任务。
这类似于面试中常见的问题:“如何渲染 10000 条数据?” 这时我们就可以回答,通过使用setTimeout分多次进行渲染。伪代码说明下:
function renderList(list) {
for (let i = 0; i < list.length; i += 100) {
// 每次只渲染 100 条数据
setTimeout(() => {
// 省略具体的渲染函数
}, 0)
}
}
React 也使用了类似的思路,通过 Fiber 架构将渲染过程分割成许多小的渲染单元,并使用requestIdleCallback来分别渲染。requestIdleCallback的作用类似于setTimeout,不过我们日常工作中较少使用。所以下面简要介绍一下requestIdleCallback。
requestIdleCallback
requestIdleCallback 的用法和 setTimeout 有点类似,不过它并不是指定延时后回调,而是在浏览器进行一轮事件循环后,如果有空闲时间,就会执行回调。
requestIdleCallback 是一个浏览器 API,它允许开发者在主线程空闲时执行低优先级的任务。这个功能的目的是使开发者能够利用浏览器的空闲时间来处理不需要立即完成的任务,从而提高页面的性能和响应能力。
调用 requestIdleCallback 时,可以传递一个回调函数,浏览器会在主线程变为空闲时调用这个函数。还可以提供一个选项对象,其中可以设置一个 timeout 属性,用于指定浏览器在多少毫秒内必须运行这个回调,即使主线程还不是空闲状态。
下面是 requestIdleCallback 的基本用法:
window.requestIdleCallback(myIdleCallback, { timeout: 2000 });
function myIdleCallback(deadline) {
while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && tasks.length > 0) {
doWorkIfNeeded(); // 执行一些低优先级的任务
}
}
在这个例子中,myIdleCallback 是在主线程空闲时被调用的函数。deadline 对象提供了两个有用的属性和方法:
我们可以使用这些信息来决定是否有足够的时间来执行更多的任务或者应该推迟一些工作到下一个空闲周期。
requestIdleCallback 特别适合执行那些不会影响到用户立即体验的任务,例如数据追踪、预加载资源、或者在后台更新 UI 的非关键部分。
不过注意,本文是基于 React 16.8,
我们给出一个通过 requestIdleCallback 来优化执行大量任务的伪代码:
// 标记下一个任务单元
let nextUnitOfWork = null
/**
* 循环执行任务
*/
function workLoop(deadline) {
let shouldYield = false
// 如果有下一个单元任务且不需要让权
while (nextUnitOfWork && !shouldYield) {
// 执行当前单元任务并返回下一个单元任务
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
// 剩余空闲时间是否小于1
// 如果空闲时间小于1,证明需要让出执行权,等待下一次空闲时再执行
shouldYield = deadline.timeRemaining() < 1
}
// 等待下一次requestIdleCallback回调再执行
requestIdleCallback(workLoop)
}
// 开始执行
requestIdleCallback(workLoop)
/**
* 执行传入的单元任务,并返回下一个单元任务
*/
function performUnitOfWork(nextUnitOfWork) {
// TODO
}
优化的思路就是把一个大任务分成很多小的单元任务来执行(就比如在上面举的 10000 条数据的例子中,我们的单元任务就是渲染 100 条数据),要使用 workLoop 执行任务,需要设置第一个单元任务。
workLoop 会不停的运行,当发现 nextUnitOfWork 存在时,就会通过 performUnitOfWork 来执行 nextUnitOfWork,performUnitOfWork 函数执行当前的工作单元,并返回下一个工作单元,这样 workLoop 会执行完所有工作单元直到结束。
Fibers
根据我们的上面的分析,我们首先需要把渲染过程拆分成很多小单元,那么我们拆分的方法就是:每个节点渲染过程作为一个渲染单元。
除此之外,我们还需要根据当前工作单元找到下一个工作单元,也就是我们需要根据一个节点找到下一个要渲染节点。因此我们为每一个节点创建一个对象,我们把它称为 fiber 节点。在这个对象中,我们去保存渲染当前节点所必须的数据,以及它的下一个节点。这样所有节点对应的 fiber 节点即组成了 fiber 树。而每一个 fiber 节点,也就是我们需要的渲染单元。
那我们怎么在 fiber 中去记录它的下一个节点呢,我们可以遍历整颗节点树,然后让每个 fiber 节点都去记录它的父节点、子节点、相邻的兄弟节点,有了这些信息,我们便可以根据任意一个节点找到它的下一个节点了。
下面是一棵元素树和它对应的 fiber 树示例:
元素树fiber树
接下来我们尝试实现代码,通过 fiber 去优化渲染。
整体的结构如我们上一节总结,通过 performUnitOfWork 和 workLoop 来优化代码。通过 requestIdleCallback 循环执行工作单元,这样可以在浏览器的空闲时段进行计算,以避免主线程的长时间阻塞,从而提高应用的性能和响应性。
开始这个 workLoop 循环,需要指定初始的工作单元,所以我们的 render 函数中只需要指定第一个工作单元(根 fiber)即可,而具体的渲染逻辑,我们放到 performUnitOfWork 函数中去实现。
/**
* 设置根节点的工作单元(根fiber)
* @param {Object} element 要渲染的元素
* @param {HTMLElement} container 容器 DOM 节点
*/
function render(element, container) {
nextUnitOfWork = {
dom: container,
props: {
children: [element],
},
}
}
performUnitOfWork 函数作为构建 fiber 树的核心,需要对每一个 fiber 做三件事:
把节点添加到 DOM为节点的所有子节点创建 fiber指定下一个工作单元
我们使用 fiber 结构是为了方便地找到下一个工作单元,每个 fiber 节点都连接着它的第一个子节点,它的兄弟节点和父节点,这样当我们进行完当前 fiber 的工作时,我们就找它的第一个子节点,如果没有子节点就找相邻的兄弟节点,最后返回到它的父节点。这样我们就可以依次遍历整棵节点树。
还是拿上面那颗 DOM 树举例,我们会以下面的顺序去遍历:
下面是 performUnitOfWork 函数的实现:
// cookieReact.js
/**
* 创建 DOM 节点
* @param {Object} fiber 表示当前工作单元的对象
* @returns {HTMLElement} 创建的 DOM 节点
*/
function createDom(fiber) {
// 根据 fiber 类型创建对应的 DOM 节点,文本类型特殊处理
const dom =
fiber.type === 'TEXT_ELEMENT'
? document.createTextNode('')
: document.createElement(fiber.type)
// 判断属性是否为 children
const isProperty = (key) => key !== 'children'
// 将非 children 属性赋值到 DOM 节点上
Object.keys(fiber.props)
.filter(isProperty)
.forEach((name) => {
dom[name] = fiber.props[name]
})
return dom
}
/**
* 处理当前 fiber 并返回下一个工作单元
* @param {Object} fiber 当前处理的 fiber 对象
* @returns {Object|null} 下一个工作单元的 fiber 对象
*/
function performUnitOfWork(fiber) {
console.log('performUnitOfWork', fiber)
// 如果 fiber 上没有 DOM 节点,创建并赋值到 fiber.dom
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
// 如果存在父节点,将当前 DOM 节点挂在到父节点上
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
// 处理子元素,为每个子元素创建新的 fiber
const elements = fiber.props.children
let index = 0
let prevSibling = null
while (index < elements.length) {
const element = elements[index]
// 创建新的 fiber 对象
const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
dom: null,
}
// 将新 fiber 对象连接到 fiber 树中
if (index === 0) {
// 如果是第一个子元素,作为 child 属性连接到当前节点
fiber.child = newFiber
} else {
// 如果不是第一个,作为前一个兄弟元素的 sibling 属性连接
prevSibling.sibling = newFiber
}
// 更新引用,继续下一个子元素
prevSibling = newFiber
index++
}
// 寻找并返回下一个工作单元,存在子节点则下一个 fiber 为第一个子节点
if (fiber.child) {
return fiber.child
}
// 否则找该节点的相邻节点,没有相邻节点就向上找父节点的相邻节点,直到找到根节点
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
performUnitOfWork 每次处理一个 fiber 节点:
会先给当前 fiber 节点创建对应 DOM 元素(如果 DOM 元素不存在的话),然后把 DOM 元素绑定到 fiber 节点;接下来处理该节点的子元素,为每一个子节点创建对应的 fiber,然后把相邻节点通过 sibling 连接;最后指定下一个工作单元,优先指定下一个 fiber 为当前节点的子节点,如果没有子节点则指定为该节点的相邻节点,没有相邻节点就向上找父节点的相邻节点,直到找到根节点。
此时完整的 cookieReact.js 文件如下:
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map((child) =>
typeof child === 'object' ? child : createTextElement(child)
),
},
};
}
function createTextElement(text) {
return {
type: 'TEXT_ELEMENT',
props: {
nodeValue: text,
children: [],
},
};
}
// 标记下一个任务单元
let nextUnitOfWork = null;
/**
* 循环执行任务
*/
function workLoop(deadline) {
let shouldYield = false;
// 如果有下一个单元任务且不需要让权
while (nextUnitOfWork && !shouldYield) {
// 执行当前单元任务并返回下一个单元任务
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
// 剩余空闲时间是否小于1
// 如果空闲时间小于1,证明需要让出执行权,等待下一次空闲时再执行
shouldYield = deadline.timeRemaining() < 1;
}
// 等待下一次requestIdleCallback回调再执行
requestIdleCallback(workLoop);
}
// 开始执行
requestIdleCallback(workLoop);
/**
* 设置根节点的工作单元(根fiber)
* @param {Object} element 要渲染的元素
* @param {HTMLElement} container 容器 DOM 节点
*/
function render(element, container) {
nextUnitOfWork = {
dom: container,
props: {
children: [element],
},
};
}
/**
* 创建 DOM 节点
* @param {Object} fiber 表示当前工作单元的对象
* @returns {HTMLElement} 创建的 DOM 节点
*/
function createDom(fiber) {
// 根据 fiber 类型创建对应的 DOM 节点,文本类型特殊处理
const dom =
fiber.type === 'TEXT_ELEMENT'
? document.createTextNode('')
: document.createElement(fiber.type);
// 判断属性是否为 children
const isProperty = (key) => key !== 'children';
// 将非 children 属性赋值到 DOM 节点上
Object.keys(fiber.props)
.filter(isProperty)
.forEach((name) => {
dom[name] = fiber.props[name];
});
return dom;
}
/**
* 处理当前 fiber 并返回下一个工作单元
* @param {Object} fiber 当前处理的 fiber 对象
* @returns {Object|null} 下一个工作单元的 fiber 对象
*/
function performUnitOfWork(fiber) {
console.log('performUnitOfWork', fiber);
// 如果 fiber 上没有 DOM 节点,创建并赋值到 fiber.dom
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
// 如果存在父节点,将当前 DOM 节点挂在到父节点上
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom);
}
// 处理子元素,为每个子元素创建新的 fiber
const elements = fiber.props.children;
let index = 0;
let prevSibling = null;
while (index < elements.length) {
const element = elements[index];
// 创建新的 fiber 对象
const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
dom: null,
};
// 将新 fiber 对象连接到 fiber 树中
if (index === 0) {
// 如果是第一个子元素,作为 child 属性连接到当前节点
fiber.child = newFiber;
} else {
// 如果不是第一个,作为前一个兄弟元素的 sibling 属性连接
prevSibling.sibling = newFiber;
}
// 更新引用,继续下一个子元素
prevSibling = newFiber;
index++;
}
// 寻找并返回下一个工作单元,存在子节点则下一个 fiber 为第一个子节点
if (fiber.child) {
return fiber.child;
}
// 否则找该节点的相邻节点,没有相邻节点就向上找父节点的相邻节点,直到找到根节点
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
export const CookieReact = {
createElement,
render,
};
现在尝试一下在 index.js 中渲染一个复杂的结构试一下:
import { CookieReact } from './cookieReact'
/** @jsx CookieReact.createElement */
const element = (
<div>
<h1>
<p>我不吃饼干p>
<a />
h1>
<h2 />
div>
)
const container = document.getElementById('root')
CookieReact.render(element, container)
页面正常渲染,在控制台可以看到节点渲染顺序,可以看到节点是从上到下,从左到右依次进行遍历的。
渲染和提交阶段
这里开始会有点上难度了,大家看不懂可以多看几遍
我们之前的代码存在一个潜在的问题:我们是由上到下,由左到右依次渲染所有节点,每次处理一个节点,就创建 DOM 元素并挂载到页面上。而在渲染整个 DOM 树的过程中,浏览器可能会中断渲染。这会导致用户可能会遇到只渲染了一半的 UI 界面的情况。
为了避免这种情况,我们可以等到整棵 DOM 树完全处理完毕后,再一次性挂载到页面上,而不是一边处理一边渲染。很明显,这就要求我们对正在处理中的 fiber 树进行缓存。我们通过这个正在进行中的 fiber 树的根来记录它,我们把它称为 wipRoot(work in progress root),直到整个树处理完毕后,我们再进行统一渲染,我们称之为提交(commit)。
我们声明一个变量 wipRoot,在每一次 render 时,记录本次渲染的 fiber 根节点,然后删除 performUnitOfWork 中的挂载到页面的逻辑。最后在 workLoop 判断整个 fiber 渲染完成后,去统一挂载整棵树。
首先对之前的代码进行如下调整:
// 记录下一个要处理的工作单元
let nextUnitOfWork = null
+ // 处理中 fiber 树的根
+ let wipRoot = null
function render(element, container) {
- nextUnitOfWork = {
+ wipRoot = {
dom: container,
props: {
children: [element],
},
}
+ nextUnitOfWork = wipRoot
}
function performUnitOfWork(fiber) {
// ...
- // 如果存在父节点,将当前 DOM 节点挂在到父节点上
- if (fiber.parent) {
- fiber.parent.dom.appendChild(fiber.dom)
- }
// ... 其余部分不变
}
function workLoop(deadline) {
// ...
while(nextUnitOfWork && !shouldYield) {
// ...
}
+ // 没有下一个工作单元 证明已经全部处理结束 此时再进行统一渲染
+ if (!nextUnitOfWork && wipRoot) {
+ commitRoot()
+ }
// 等待下一次requestIdleCallback回调再执行
requestIdleCallback(workLoop)
}
可以看到我们在 workLoop,通过 !nextUnitOfWork && wipRoot 来判断当前已经没有下一个工作单元,同时存在一棵处理中未渲染的 fiber 树,此时我们去通过 commitRoot 挂载这颗 fiber 树。
接下来我们补充 commitRoot 函数:
/**
* 根据 fiber 根节点进行挂载,然后清空 wipRoot
*/
function commitRoot() {
commitWork(wipRoot.child)
wipRoot = null
}
/**
* 每次先创建当前节点,并把当前子节点挂载到父节点上
* 然后递归处理子节点和兄弟节点
*/
function commitWork(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
domParent.appendChild(fiber.dom)
commitWork(fiber.child)
commitWork(fiber.sibling)
}
这样我们就实现了处理 fiber 树过程中缓存 fiber 树,并在整棵树处理完成后,统一提交挂载。
Reconciliation
Reconciliation 在这里可以翻译成协调,调和。
我们在上面已经完成了向 DOM 中新增内容,但是节点后续的更新和删除还没有实现。
在 React 中,每次在节点更新后,我们都会创建一个新的 fiber 树,第一次创建的 fiber 树,我们生成对应的 DOM 并直接挂载到容器上即可,但对于后续的更新,如果每次都删除全部 DOM 再重新生成,则消耗太大。所以 React 会根据前后两次生成的 fiber 树进行比较(我们把这个比较过程称为是 Reconciliation),然后基于这个比较的结果,React 来确定哪些部分需要在真实 DOM 中被更新。
为了去对前后两棵 fiber 树进行比较,我们需要在每次 commit 的时候把 fiber 树缓存下来,我们把它记为 currentRoot。这样当下一个 fiber 树处理完成后,就可以把 wipRoot 和 currentRoot 去进行比较。同时我们在 fiber 上新增一个属性 alternate 用来记录上一次的旧 fiber。
function commitRoot() {
commitWork(wipRoot.child)
+ // 每次 commit 时都把当前的 fiber root 缓存
+ currentRoot = wipRoot
wipRoot = null
}
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
+ alternate: currentRoot,
}
nextUnitOfWork = wipRoot
}
// 记录下一个要处理的工作单元
let nextUnitOfWork = null
// 处理中 fiber 树的根
let wipRoot = null
+ // 缓存上一次 commit 的 Fiber root
+ let currentRoot = null
接下来我们将重构 performUnitOfWork 函数,将子节点的协调(reconciliation)逻辑抽离到 reconcileChildren 函数中。在此过程中,我们将不涉及遍历子节点的细节,而是专注于比较新旧 fiber 节点。
在比较新旧 fiber 时,我们考虑以下三种情况:
在实际的 React 协调过程中,key 属性会被用来优化性能,但本文为了简洁性,将不考虑 key。
/**
* 处理当前 fiber 并返回下一个工作单元
* @param {Object} fiber 当前处理的 fiber 对象
* @returns {Object|null} 下一个工作单元的 fiber 对象
*/
function performUnitOfWork(fiber) {
// 如果 fiber 上没有 DOM 节点,创建并赋值到 fiber.dom
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
// 逻辑放到 reconcileChildren
const elements = fiber.props.children
reconcileChildren(fiber, elements)
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
// 处理子元素,为每个子元素创建新的 fiber
function reconcileChildren(wipFiber, elements) {
let index = 0
// 在 wipFiber.alternate 上获取对应旧 fiber 的子元素集合
let oldFiber = wipFiber.alternate && wipFiber.alternate.child
let prevSibling = null
while (index < elements.length || oldFiber != null) {
const element = elements[index]
let newFiber = null
const sameType = oldFiber && element && element.type === oldFiber.type
// 如果新旧fiber类型相同,我们标记为更新
if (sameType) {
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: 'UPDATE',
}
}
// 如果类型不同,我们要创建新DOM
// 在后面 performUnitOfWork 处理到该节点时,会创建 DOM
if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: 'PLACEMENT',
}
}
// 同时如果类型不同且旧fiber存在,我们需要删除旧fiber
if (oldFiber && !sameType) {
oldFiber.effectTag = 'DELETION'
deletions.push(oldFiber)
}
if (oldFiber) {
oldFiber = oldFiber.sibling
}
// 将新 fiber 对象连接到 fiber 树中
if (index === 0) {
// 如果是第一个子元素,作为 child 属性连接
wipFiber.child = newFiber
} else if (element) {
// 如果不是第一个,作为前一个兄弟元素的 sibling 属性连接
prevSibling.sibling = newFiber
}
// 更新引用,继续下一个子元素
prevSibling = newFiber
index++
}
}
我们把处理子节点的逻辑都封装到了 reconcileChildren 函数里面,我们遍历子元素数组,根据下标来找到它对应在旧 fiber 树上的子元素,然后去对比新旧两个节点,最后为每个子元素生成新 fiber。
可以看到我们在新创建的 fiber 中增加了属性 alternate 和 effectTag, alternate 记录了旧 fiber,而 effectTag 对于更新的节点我们赋值 UPDATE,对于新创建的节点我们赋值 PLACEMENT,这个属性后面再 commit 阶段会被用到。
对于删除旧节点的情况,因为没有对应的新 fiber,所以没办法通过 effectTag 记录,我们通过 deletions 来把所有需要删除的 fiber 节点保存起来。我们在全局声明一下 deletions 并在 render 函数中进行初始化。
// 需要删除的 fiber 节点
let deletions = null
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
}
// 初始化待删除节点
deletions = []
nextUnitOfWork = wipRoot
}
最后在 commit 阶段的代码进行更新:
/**
* 根据 fiber 根节点进行挂载
*/
function commitRoot() {
// 对所有需要删除的fiber先处理
deletions.forEach(commitWork)
commitWork(wipRoot.child)
// 每次 commit 时都把当前的 fiber root 缓存
currentRoot = wipRoot
wipRoot = null
}
/**
* 每次先创建当前节点,并把当前子节点挂载到父节点上
* 然后递归处理子节点和兄弟节点
*/
function commitWork(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
if (fiber.effectTag === 'PLACEMENT' && fiber.dom != null) {
// 新创建的节点,添加到父节点
domParent.appendChild(fiber.dom)
} else if (fiber.effectTag === 'UPDATE' && fiber.dom != null) {
// 更新的节点,需要更新对应DOM的相关属性
updateDom(fiber.dom, fiber.alternate.props, fiber.props)
} else if (fiber.effectTag === 'DELETION') {
// 删除的节点,在父节点上移除
domParent.removeChild(fiber.dom)
}
if (fiber.effectTag !== 'DELETION') {
commitWork(fiber.child)
commitWork(fiber.sibling)
}
}
/**
* 根据新旧属性来更新节点
*/
function updateDom(dom, prevProps, nextProps) {
// TODO
}
对于删除的节点,我们无法在新 fiber 树上遍历到,所以在开始之前手动调用 deletions.forEach(commitWork) 去进行处理。
然后在 commitWork 中,根据 effectTag 对节点进行不同的处理。其中对于 UPDATE 的情况,我们不需要删除或新增节点,只需要对现有节点的属性进行调整。
最后补全一下 updateDom 函数的逻辑:
const isEvent = (key) => key.startsWith('on')
const isProperty = (key) => key !== 'children' && !isEvent(key)
const isNew = (prev, next) => (key) => prev[key] !== next[key]
const isGone = (prev, next) => (key) => !(key in next)
/**
* 根据新旧属性来更新节点
*/
function updateDom(dom, prevProps, nextProps) {
// 删除旧的事件处理
Object.keys(prevProps)
.filter(isEvent)
.filter((key) => !(key in nextProps) || isNew(prevProps, nextProps)(key))
.forEach((name) => {
const eventType = name.toLowerCase().substring(2)
dom.removeEventListener(eventType, prevProps[name])
})
// 删除旧属性
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach((name) => {
dom[name] = ''
})
// 设置新属性
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach((name) => {
dom[name] = nextProps[name]
})
// 添加新的事件监听器
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach((name) => {
const eventType = name.toLowerCase().substring(2)
dom.addEventListener(eventType, nextProps[name])
})
}
function createDom(fiber) {
const dom =
fiber.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type)
// 新增 updateDom 逻辑
updateDom(dom, {}, fiber.props)
return dom
}
代码逻辑很简单,根据是否以 on 开头来判断是否为事件监听器, 然后就是删除旧属性并移除旧的监听函数,然后添加新属性,绑定新的监听函数。
最后把 createDom 函数也复用 updateDom 的更新逻辑。
最后尝试下我们在代码中进行更新:
import { CookieReact } from './cookieReact'
/** @jsx CookieReact.createElement */
const container = document.getElementById('root')
const updateValue = (e) => {
rerender(e.target.value)
}
const rerender = (value) => {
if (!value) {
const element = <div>nothingdiv>
return CookieReact.render(element, container)
}
const element = (
<div>
<input oninput={updateValue} value={value} />
{value && <h2>Hello {value}h2>}
div>
)
CookieReact.render(element, container)
}
rerender('World')
更新效果展示:
函数组件
接下来我们要做的是支持函数组件,支持下面的 App 组件:
import { CookieReact } from './cookieReact'
/** @jsx CookieReact.createElement */
function App(props) {
return <h1>Hi {props.name}h1>
}
const element = <App name='foo' />
const container = document.getElementById('root')
CookieReact.render(element, container)
经过 Babel 处理,函数 App 会转换成下面的样子:
function App(props) {
return CookieReact.createElement(
"h1",
null,
"Hi ",
props.name
)
}
const element = CookieReact.createElement(App, {
name: "foo",
})
在此之前我们的 createElement 仅支持传入 HTML 标签名,要支持传入 App,createElement 不仅需要可以处理 HTML 标签名,还需要处理函数的能力。
这两者的区别也很简单,对于标签名来说,我们需要通过 document.createElement(fiber.type) 来创建 DOM(或者对于纯文本使用 document.createTextNode(child)),而对于函数组件,它没有对应的 DOM,我们需要执行函数,把节点属性作为函数参数,然后把返回的结果当做子元素。
函数组件在我们的 fiber 树中同样会对应一个 fiber 节点,但最大的区别是它没有对应的 DOM 节点。
我们把 performUnitOfWork 中创建 DOM 的逻辑调整如下:
/**
* 处理当前 fiber 并返回下一个工作单元
* @param {Object} fiber 当前处理的 fiber 对象
* @returns {Object|null} 下一个工作单元的 fiber 对象
*/
function performUnitOfWork(fiber) {
const isFunctionComponent = fiber.type instanceof Function
if (isFunctionComponent) {
updateFunctionComponent(fiber)
} else {
updateHostComponent(fiber)
}
// 下面不变
if (fiber.child) {
return fiber.child
}
// ...
}
// 更新函数组件
function updateFunctionComponent(fiber) {
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
// 更新原生组件
function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
reconcileChildren(fiber, fiber.props.children)
}
在 updateFunctionComponent 中,fiber.type 为函数(比如在上面的例子中,为 App 函数),它的返回值为 h1 元素。然后我们对其和普通节点一样进行 reconcileChildren。
除此之外,在 commitWork 的时候,由于函数组件对应的 fiber 节点没有对应的 DOM,所以我们需要把 domParent 的逻辑从父节点的 DOM 改为最近一个拥有 DOM 的祖先节点对应的 DOM 元素。
function commitWork(fiber) {
if (!fiber) {
return
}
- const domParent = fiber.parent.dom
+ let domParentFiber = fiber.parent
+ while (!domParentFiber.dom) {
+ domParentFiber = domParentFiber.parent
+ }
+ const domParent = domParentFiber.dom
if (fiber.effectTag === 'PLACEMENT' && fiber.dom != null) {
// 新创建的节点,添加到父节点
domParent.appendChild(fiber.dom)
} else if (fiber.effectTag === 'UPDATE' && fiber.dom != null) {
// 更新的节点,需要更新对应DOM的相关属性
updateDom(fiber.dom, fiber.alternate.props, fiber.props)
} else if (fiber.effectTag === 'DELETION') {
// 删除的节点,在父节点上移除
- domParent.removeChild(fiber.dom)
+ commitDeletion(fiber, domParent)
}
if (fiber.effectTag !== 'DELETION') {
commitWork(fiber.child)
commitWork(fiber.sibling)
}
}
同理,删除的时候,我们也需要过滤那些没有对应 DOM 的 fiber 节点:
/**
* 删除节点提交,如果当前节点没有对应 DOM 就一直向下找到存在 DOM 的层级
* @param fiber 当前fiber
* @param domParent 当前fiber挂载的DOM元素
*/
function commitDeletion(fiber, domParent) {
if (fiber.dom) {
domParent.removeChild(fiber.dom)
} else {
commitDeletion(fiber.child, domParent)
}
}
这样简单的改造后,我们的 React 就可以支持函数组件了!
Hooks
我们终于来到了本课程的终章,同时也是 React 的一个神奇领域:Hooks。通过本节的学习,你将深入了解 Hooks 的工作原理,也能理解为何它们必须直接位于函数组件的顶层,而不能被放置在条件语句之中。
用 React Hooks 写一个经典的 Counter 组件:
import { CookieReact } from './cookieReact'
/** @jsx CookieReact.createElement */
function Counter() {
const [state, setState] = CookieReact.useState(1)
return <h1 onClick={() => setState((c) => c + 1)}>Count: {state}h1>
}
const element = <Counter />
const container = document.getElementById('root')
CookieReact.render(element, container)
现在需要给我们的 React 加一个 useState 函数,来让上面的代码正常运行。useState 返回状态 state 和设置状态的函数 setState。
因为每个函数组件都对应一个 fiber,所以我们可以把 hooks 存储在函数组件对应的 fiber 上。
我们在更新组件后,state 会继承之前的状态,所以我们需要在更新时,继承旧 fiber 的 hooks 数据。
在 setState 时,会触发组件的更新,所以这里的操作和 render 类似,去设置 nextUnitOfWork。
完整代码如下:
// 记录当前正在处理 fiber
let wipFiber = null
// 追踪当前组件的 `hooks` 在数组中的位置
let hookIndex = null
// 更新函数组件
function updateFunctionComponent(fiber) {
// 初始化当前函数组件的 hooks
wipFiber = fiber
hookIndex = 0
wipFiber.hooks = []
// 此处的fiber.type为函数,我们把fiber.props作为参数传入
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
function useState(initial) {
// 通过 wipFiber.alternate 来获取旧 fiber
// 然后通过 hookIndex 来获取当前 hooks 对应旧 fiber 中 hooks
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex]
// 如果存在旧 hooks,我们要继承上一次的状态,否则使用初始状态
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [],
}
// 我们每次 setState 都会在 hook.queue 中添加一个 action
// 为了获取在多次 setState 后的数据,需要依次执行 action
const actions = oldHook ? oldHook.queue : []
actions.forEach((action) => {
hook.state = action(hook.state)
})
// 每次 setState 就在 hook.queue 中添加一个 action 记录当前操作
// 然后设置 nextUnitOfWork 触发渲染
const setState = (action) => {
hook.queue.push(action)
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
}
nextUnitOfWork = wipRoot
deletions = []
}
// 把当前 hook 放到 fiber 的 hooks 中,并增加 hookIndex
wipFiber.hooks.push(hook)
hookIndex++
return [hook.state, setState]
}
export const CookieReact = {
createElement,
render,
// 最后把 useState 导出
useState,
};
首先我们设置 wipFiber,用于记录当前正在工作的 fiber,然后用 hookIndex 来追踪当前组件的 hooks 在数组中的位置。
在我们调用 useState 时,我们会经历下面几个步骤:
尝试从上一次渲染的 fiber(wipFiber.alternate)的 hooks 数组中获取当前 hook 的旧状态。如果存在旧的 hook,则使用旧的状态;如果不存在,则使用传入的 initial 参数作为状态的初始值。创建一个新的 hook 对象,其中包含当前状态和一个更新状态的队列 queue。遍历旧 hook 的 queue,应用每个动作来更新 hook 的状态。定义 setState 函数,它接受一个动作,并将其添加到 hook.queue 中。然后,它安排一次新的渲染,通过创建一个新的工作中的根 fiber(wipRoot)并设置 nextUnitOfWork。将新的 hook 对象添加到 wipFiber.hooks 数组中,并递增 hookIndex。返回一个包含当前状态和 setState 函数的数组。
试一下运行效果:
可以看到我们的 hooks 已经生效了。
总结
到此为止,一个简化版本的 React 就已经完成了,它支持 Fibers,函数组件和 Hooks。但是有许多 React 的特性和优化本文并没有实现,更多的细节还是需要去 React 源码中学习。致谢原文Build your own React作者,深入浅出的讲解了 React 的这些知识。