构建简易Reac
build-your-own-react是一篇操作说明书,指导用户一步步实现一个简易的React,从中了解到React的大体工作流程。这篇文章是我的观后整理和记录,或许对大家会有所帮助。
构建简易React,分为九个阶段:
- 介绍
createElement
与render
- 实现
createElement
- 实现
render
- 介绍并发模式
- 实现 Fibers
- render 和 commit 阶段
- 实现协调
- 支持函数组件
- 实现 Hooks
介绍 createElement
与 render
JSX描述结构,由Babel转译为对createElement
的调用;
createElement
接收 tagName、props、children,返回 ReactElement 对象;
render
接收 ReactElement 对象和挂载节点,产生渲染效果。
实现createElement
createElement
做以下几件事:
props
中包括key
和ref
,需要做一次分离children
子项可能是String/Number
这类原始类型数据。原始类型数据与文本节点对应,因此将其统一处理为TEXT_ELEMENT
类型的对象- 将
children
附加到props
对象上 - 返回 ReactElement 对象
function createElement (type, config, ...children) {
let key = null;
let ref = null;
let props = {};
// 从 props 中分离 key 和 ref
if (config) {
for (const name in config)
if (Object.prototype.hasOwnProperty.call(config, name)) {
if (name === "key") {
key = config[name];
} else if (name === "ref") {
ref = config[name];
} else {
props[name] = config[name];
}
}
}
}
// 处理 children 项,并将 children 附加到 props 上
props.children = children.map((child) =>
typeof child === "object"
? child
: {
type: "TEXT_ELEMENT",
props: {
nodeValue: child,
children: [],
},
}
);
return {
type,
key,
ref,
props,
};
}
实现render
render
接收到的 ReactElement 对象,其实可以说是虚拟DOM结构的根,通过props.children
连接子 ReactElement 对象
render
的目的是产生渲染效果。最直观的方法是从根 ReactElement 开始进行深度优先遍历,生成整棵 DOM 树后挂载到根节点上。
function render(element, container) {
const { type, props } = element;
// 前序创建节点
const dom =
type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(type);
Object.keys(props).forEach((name) => {
if (isProperty(name)) {
dom[name] = props[name];
}
});
props.children.filter(Boolean).forEach((child) => this.render(child, dom));
// 后序挂载节点
container.appendChild(dom);
}
这其实类似于React v16之前的 stack reconciler。其特点在于利用调用栈实现遍历。
介绍并发模式
按照目前的方式进行更新时,需要将整颗虚拟DOM树一次性处理完毕。当树层级结构变得复杂,JS计算将长时间占用主线程,会导致卡顿、无法响应的糟糕体验。
能否实现增量渲染。具体来说,能否将虚拟DOM树处理划分为一个个小任务,并在主线程上并发执行呢?
依赖于调用栈,难以将整个过程中断,也就无法实现任务拆分。不如在内存中自行维护一个支持 DFS 的数据结构,代替调用栈的功能。React控制主动权,自主做任务拆分和维护。这个数据结构就是 Fiber 树了。
那么如何在主线程上并发执行,或者说怎么确定任务的执行时机。浏览器的主线程需要处理HTML解析、样式计算、布局、系统级任务、JavaScript执行、垃圾回收等一众任务,由任务队列调度。当主线程处于空闲状态时安排上 Fiber 处理那是最好不过。恰好,浏览器端提供了一个API——requestIdleCallback(callback)
,当浏览器空闲时会主动执行 callback
函数。但是,可惜的是这个方法目前在各浏览器的支持度和稳定性还无法得到保证。因此 React 团队自行实现了 Scheduler 库来代替requestIdleCallback
实现任务调度。
上面说的两个过程就是任务分片和任务调度了,他们一个由 Fiber 实现,一个由 Scheduler 实现。
Fibers
Fiber和ReactElement的关系
ReactElement 对象已经是虚拟DOM的一种表示方法了,一个 ReactElement 对象对应一个 FiberNode,只需给 FiberNode 加上核心信息 type
和props
。
FiberNode {
type: element.type,
props: element.props,
child: Fiber,
sibling: Fiber,
parent: Fiber
}
Fiber如何支持DFS
Fiber 结构的最大特点是child/sibling/parent
三个指针,分别指向第一个子节点、紧跟着的兄弟节点、父节点。这三个指针使深度优先遍历成为可能。
root - div - h1 - p - a - h2
- 沿着 child 指针向下遍历,直到叶子节点。
- 叶子节点依赖 sibling 指针向右遍历该层兄弟节点。
- 兄弟节点遍历完毕再沿 parent 指针回到上一层
- 直到回到根节点停止
Fiber和任务分片
前文说过 Fiber 的作用在任务分片。在虚拟DOM树的处理过程中,最小的处理粒度是一个节点。我们把处理单个FiberNode的任务称为“unitOfWork”,方便起见,下文称之为单位任务。
总结
- 一个 ReactElement 对象对应 一个 Fiber 节点,一个 Fiber 节点对应一个单位任务。
- Fiber 节点通过
parent/child/sibing
三个指针构成 Fiber 树,Fiber 树支撑深度优先遍历。
任务调度
在主线程上,每个空闲的时间片长度不一。我们希望在一个时间片有限的时间内尽量多的执行任务。
因此在处理完一个单位任务之后查询是否还有空闲,再决定是否执行下一个单位任务。这部分代码由workLoop
函数实现。
// 依赖requestIdleCallback实现调度
let nextOfUnitWork = null;
function workLoop(deadline) {
let shouldYield = false;
while (nextOfUnitWork && !shouldYield) {
nextOfUnitWork = performUnitOfWork(nextOfUnitWork);
shouldYield = deadline.timeRemaining() < 1;
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
处理单位任务
处理单位任务的函数是performUnitOfWork
,在这个函数里做了三件事:
- 创建DOM;
- 为当前 Fiber 的所有子元素创建 Fiber,并且构建连接;
- 按照深度优先遍历的顺序(child > sibling > parent),确定下一个待处理任务。
是的,“构建Fiber树” 和 “Fiber节点处理” 是自上而下同步进行的。
const isProperty = (prop) => prop !== "children";
const SimactDOM = {
render(element, container) {
nextOfUnitWork = {
dom: container,
props: {
children: [element],
},
};
},
};
// workLoop依赖requestIdleCallback实现调度
let nextOfUnitWork = null;
function workLoop(deadline) {
let shouldYield = false;
while (nextOfUnitWork && !shouldYield) {
nextOfUnitWork = performUnitOfWork(nextOfUnitWork);
shouldYield = deadline.timeRemaining() < 1;
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
// 处理 unitOfWork
function performUnitOfWork(fiber) {
// 创建DOM
if (!fiber.dom) {
fiber.dom = createDOM(fiber);
}
// 挂载DOM
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom);
}
const elements = fiber.props.children;
let index = 0;
let prevSibling = null;
// 创建 children fibers
while (index < elements.length) {
const element = elements[index];
const newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: fiber,
};
if (index === 0) {
fiber.child = newFiber;
}
if (prevSibling) {
prevSibling.sibling = newFiber;
}
index++;
prevSibling = newFiber;
}
// 返回 next unitOfWork
if (fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
export { SimactDOM as default };
仔细阅读上面的代码,会发现render
调用和任务调度执行,在代码上并没有顺序联系。这和我们常见的代码结构有些许不同。
render 和 commit 阶段
在一个任务中直接进行DOM挂载,同时任务分散在多个时间片内并发执行。这会导致部分视图已更新,部分视图未更新 的现象。
那么如何防止DOM发生突变(mutate),尽量将其模拟成一个不可变对象呢?方法是将 Fiber树处理过程和挂载DOM树过程分离开。就是说分为两个阶段:render 和 commit。
render 阶段增量处理Fiber节点,commit阶段将结果一次性提交到DOM树上。
render 阶段负责:
- 生成 Fiber 树
- 为 Fiber 创建对应的 DOM 节点。确保进入 commit 前,每一个 Fiber 上都有节点。但 DOM 节点的更新、插入、删除由 commit 负责。
commit 阶段再次遍历 Fiber 树,将 DOM 节点挂载到文档上。
在内存中维护一颗 Fiber 树(workInProgress)充当处理的目标对象。整棵 Fiber 树处理完毕后,一次性渲染到视图上。
function render(element, container) {
// workInProgress Tree 充当目标
wipRoot = {
dom: container,
props: {
children: [element],
},
}
nextUnitOfWork = wipRoot
}
let nextUnitOfWork = null
let wipRoot = null
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
shouldYield = deadline.timeRemaining() < 1
}
// 进入commit阶段的判断条件:有一棵树在渲染流程中,并且render阶段已执行完毕
if (!nextUnitOfWork && wipRoot) {
commitRoot()
}
requestIdleCallback(workLoop)
}
function commitRoot() {
// commit阶段递归遍历fiber树,挂载DOM节点
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)
}
实现协调
我们开始考虑状态更新的情况,上述代码重复执行render
将会导致 DOM 节点追加,而非更新。虚拟DOM进行协调简单来说是实现一颗新树,比较和记录新树和老树之间的差异。
workInProgress树负责生成新树。我们需要一颗老树,和新树做对比。这颗老树也是与视图对应的Fiber树,称为current树 。workInProgress树和current树的关系,类似于缓冲区和显示区。缓冲区处理完毕,复制给显示区。
计算两棵树的最小修改策略的 Diffing 算法,由
的时间复杂度降维到
,关键因素在于三点:
- 节点很少出现跨层级移动,因此只比较同一层级节点
- 两个不同类型的节点往往会产生不同的树。因此当节点类型不同时,不再比较其子树,直接销毁并创建新子树
- 同一层级节点可以通过
key
标识对应关系
我们来实现 Diffing 算法。
- 依赖
alternate
确定节点的对应关系 - render阶段:根据节点类型变化确定更新策略
effectTag
- commit阶段:根据
effectTag
应用具体DOM操作
如何确定两棵树中节点的对应关系?
Fiber节点上alternate
属性记录同一层级对应位置的老Fiber节点。而alternate
属性的赋值是在创建子Fiber节点时进行的。
- 根节点
workInProgressRoot.alternate = currentRoot
- 创建子Fiber节点时,依赖child指针和sibling指针找到current树中的对应老Fiber节点
- 通过
alternate
建立新老子层节点的对应关系,到下一层递归
这一部分代码应该更能直观说明:
let workInProgressRoot = null;
let currentRoot = null;
// 根节点建立联系
const SimactDOM = {
render(element, container) {
deletions = [];
workInProgressRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
};
nextOfUnitWork = workInProgressRoot;
},
};
function performUnitOfWork () {
...
let oldFiber = fiber.alternate && fiber.alternate.child;
...
// 处理一个Fiber节点时,创建其子节点。
// 依赖对应老节点的child指针和子节点的sibling指针,确定子节点对应关系
// 通过alternate建立新老子层节点的对应关系,到下一层递归
let index = 0;
while (index < elements.length) {
const newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: fiber,
alternate: oldFiber,
};
....
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
index++;
}
...
}
// 渲染完毕后,更新current树,重置workInProgress树
function commitRoot() {
commitWork(workInProgressRoot.child);
currentRoot = workInProgressRoot;
workInProgressRoot = null;
}
render阶段 :根据节点类型确定更新策略
在 render 阶段记录节点对应的操作标识,由Fiber的effectTag
记录;
- 同类型节点复用DOM元素,只需进行属性更改(
"UPDATE"
) - 不同类型的节点销毁原有DOM元素(
"DELETION"
),创建新的DOM元素("PLACEMENT"
)
const deletions = [];
function reconcileChildren(fiber, elements) {
// create children fibers
let oldFiber = fiber.alternate && fiber.alternate.child;
let index = 0;
let prevSibling = null;
while (index < elements.length || oldFiber) {
let newFiber = null;
const element = elements[index];
// 判断类型是否相同
const isSameType = element && oldFiber && element.type === oldFiber.type;
// 同类型,复用dom,并建立alternate联系
if (isSameType) {
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: fiber,
alternate: oldFiber,
effectTag: "UPDATE",
};
}
// 不同类型,创建新dom,并切断子树比较
if (element && !isSameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: fiber,
alternate: null,
effectTag: "PLACEMENT",
};
}
// 不同类型,销毁旧dom
if (oldFiber && !isSameType) {
deletions.push(oldFiber);
oldFiber.effectTag = "DELETION";
}
if (index === 0) {
fiber.child = newFiber;
}
if (prevSibling) {
prevSibling.sibling = newFiber;
}
index++;
prevSibling = newFiber;
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
}
}
function performUnitOfWork(fiber) {
// create dom
if (!fiber.dom) {
fiber.dom = createDOM(fiber);
}
// create chilren fibers
reconcileChildren(fiber, fiber.props.children);
// return next unitOfWork
if (fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
commit阶段 :应用DOM操作
在 commit 阶段根据effectTag
应用不同的DOM操作 。
"DELETION"
:移除要删除的DOM节点"PLACEMENT"
:挂载新创建的DOM节点"UPDATE"
:更新DOM节点属性
function commitRoot() {
deletions.forEach(commitWork);
commitWork(workInProgressRoot.child);
currentRoot = workInProgressRoot;
workInProgressRoot = null;
}
function commitWork(fiber) {
if (!fiber) {
return;
}
const domParent = fiber.parent.dom;
if (fiber.effectTag === "DELETION") {
domParent.removeChild(fiber.dom)
} else if (fiber.effectTag === "PLACEMENT" && fiber.dom !== null) {
domParent.appendChild(fiber.dom);
} else if (fiber.effectTag === "UPDATE" && fiber.dom !== null) {
updateDOM(fiber.dom, fiber.alternate.props, fiber.props);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
const isProperty = (prop) => prop !== "children";
const isEvent = (prop) => prop.startsWith("on");
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).forEach((name) => {
if (isEvent(name) && (!(name in prevProps) || isNew(name))) {
dom.removeEventListener(name.toLowerCase().substring(2), prevProps[name]);
}
if (isProperty(name) && isGone(name)) {
dom[name] = "";
}
});
Object.keys(nextProps).forEach((name) => {
if (isEvent(name) && isNew(name)) {
dom.addEventListener(name.toLowerCase().substring(2), nextProps[name]);
}
if (isProperty(name) && isNew(name)) {
dom[name] = nextProps[name];
}
});
}
支持函数组件
函数组件和原生元素的区别在于:
- ReactElement 对象的
type
值是组件的定义函数,执行定义函数返回子 ReactElement 对象。因此在performUnitOfWork
中无需创建 DOM 节点,并且需要调用定义函数获得子代。 - 函数组件对应一个 Fiber 节点,但其没有对应的 DOM 节点。因此在 commit 阶段进行DOM操作需要找到真正的父子节点。
function performUnitOfWork(fiber) {
if (fiber.type instanceof Function) {
updateFunctionComponent(fiber);
} else {
updateHostComponent(fiber);
}
}
// 更新函数组件
function updateFunctionComponent(fiber) {
// 调用组件定义函数,获取子ReactElement对象
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
// 更新原生元素
function updateHostComponent(fiber) {
// create dom
if (!fiber.dom) {
fiber.dom = createDOM(fiber);
}
reconcileChildren(fiber, fiber.props.children);
}
function commitWork(fiber) {
if (!fiber) {
return;
}
let parentFiber = fiber.parent;
// 插入和更新操作需要找到真正的父dom节点
while (parentFiber.dom === null) {
parentFiber = parentFiber.parent;
}
const domParent = parentFiber.dom;
if (fiber.effectTag === "DELETION") {
commitDeletion(domParent, fiber);
} else if (fiber.effectTag === "PLACEMENT" && fiber.dom !== null) {
domParent.appendChild(fiber.dom);
} else if (fiber.effectTag === "UPDATE" && fiber.dom !== null) {
updateDOM(fiber.dom, fiber.alternate.props, fiber.props);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
function commitDeletion(domParent, fiber) {
// 删除操作需要找到真正的子dom节点
if (fiber.dom) {
domParent.removeChild(fiber.dom);
} else {
commitDeletion(domParent, fiber.child);
}
}
支持Hooks
全局变量workInProgressFiber
存储当前正在处理的 Fiber 节点,以供useState
访问。
为了支持在一个组件中多次使用useState
,hooks 作为队列在 Fiber 节点中维护。全局变量hookIndex
维持useState
执行顺序和hook的关系。
Fiber {
hooks: [ // hook按调用顺序存放
{
state,
queue: [action]
// 任务分片执行,在未处理到当前节点前。更改状态将重新执行渲染流程,需要保留未生效的修改
}
]
}
let workInProgressFiber = null;
let hookIndex = null;
function updateFunctionComponent(fiber) {
workInProgressFiber = fiber;
hookIndex = 0;
workInProgressFiber.hooks = [];
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
function useState(initial) {
const oldHook =
workInProgressFiber.alternate &&
workInProgressFiber.alternate.hooks &&
workInProgressFiber.alternate.hooks[hookIndex];
// 根据老节点的hook确定初始状态
const hook = {
state: oldHook ? oldHook.state : initial,
queue: [],
};
// 应用状态更新
if (oldHook) {
oldHook.queue.forEach((action) => {
hook.state = action(hook.state);
});
}
const setState = (action) => {
// 加入更新队列,在下一次渲染流程中应用。
// 开启渲染流程
hook.queue.push(action);
deletions = [];
workInProgressRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
nextOfUnitWork = workInProgressRoot;
};
workInProgressFiber.hooks.push(hook);
hookIndex++;
return [hook.state, setState];
}
React的功能和优化并没有完全在上述过程中实现,包括:
- 在render阶段,我们遍历了整棵Fiber树。而在React中使用启发式算法跳过未修改的子树
- 在commit阶段,我们同样遍历了整棵Fiber树。而在React中则是依赖Effect List存储有修改的Fiber,避免对 Fiber树的再次遍历
- 在处理单位任务时,我们会为workInProgress树创建新的Fiber节点 。而在React中会重复使用current树中的老节点
- 我们在render阶段接收到新的状态会重新开始渲染流程。而在React中会为每个更新标记一个expiration timestamp,比较更新的优先级。
同时,你也可以自行添加一些功能,比如:
- 支持
style prop
的对象定义 - 支持列表元素
- 实现
useEffect
- 在协调过程中支持
key
标识