Step 0: 简介
本文章从 createElement
函数讲起,然后一步一步完成了一个简单的 Fiber 、 Reconclie 、 commitRoot 等 react 的核心部分,最终实现一个 react。
如果你比较熟悉 react,dom,jsx 是如何工作的,可以跳过本节。
我们从最基本的vanilla JavaScript
开始。
这是一段 jsx 语法:
const element = <h1 title="foo">Hello</h1>;
复制代码
jsx 会被类似于 Babel 之类的工具转换成为 js,上面的 jsx 会被转化为:
const element = React.createElement("h1", { title: "foo" }, "Hello");
复制代码
而一个 virtual element 应该是一个带有 type 和 props 的对象:
const element = {
type: "h1",
props: {
title: "foo",
children: "Hello",
},
};
复制代码
其中的 type 即是document.createElement
传入的 tagName,还有一种情况是它也可能是一个function
,这个将在后面处理。
props 是另一个含有各种 key 和 value 的对象以及一个特殊的 property:children,children 可以是 string 也可以是一个 element 数组。
最后,还需要一个render
函数,把 element 挂载到真实的 dom 上,render 大概是做这些事情,创建 node,加入 props,然后挂载 children:
const container = document.getElementById("root")
const node = document.createElement(element.type)
node["title"] = element.props.title
const text = document.createTextNode("")
text["nodeValue"] = element.props.children
node.appendChild(text)
container.appendChild(node)
复制代码
Step 1: createElement
这里需要注意如果 children 是个 string 的话,应该是创建一个TEXT_ELEMENT
// createElement.ts
const createElement = (
type: string,
props: Partial<HTMLElement>,
...children: VElement[]
): VElement => {
return {
type,
props: {
...props,
children: children
// 这里使用flat的原因是如果jsx里面包含map返回的一个element数组,就需要把children给拍平
.flat()
.map((c) => (typeof c === "object" ? c : createTextElement(c))),
},
};
};
const createTextElement = (text: string): VElement => {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
};
};
复制代码
Step 2: render
目前只考虑一种最基本的情况,即添加元素,后面会加入对 element 的更新和删除
添加的过程很简单,就是把 property 依次添加上,然后递归地 render children。
const render = (element: VElement, container: HTMLElement) => {
const dom =
element.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type)
// 排除 特殊属性 "children"
const isProperty = key => key !== "children"
// 将元素属性 一一 写入 dom 节点上
Object.keys(element.props)
.filter(isProperty)
.forEach(name => {
dom[name] = element.props[name]
})
// 遍历递归 将 子元素 一个一个 都 附到 真实的 dom 节点上
element.props.children.forEach(child =>
render(child, dom)
)
// 最后挂载到 指定的 dom 节点容器上
container.appendChild(dom)
}
复制代码
Step 3: Concurrent mode
对于上一步实现的 render,实际上是有点问题的,就是在于上面的那个递归,只要开始了 render,那么直到它完成,整个 render 才会结束,如果 element tree 比较庞大,render 过程中可能会阻塞浏览器进程很长时间,就会影响浏览器更高优先级的事务处理(比如用户的输入和 ui 交互等等)
所以我们需要把这个大的任务切割为多个小的工作单元,这样的话,如果浏览器有更高优先级的事务处理,我们就可以中断 react 元素的渲染,这我们引入一个概念,称它为 并发模式 :
在 Concurrent 模式中,React 可以 同时 更新多个状态 —— 就像分支可以让不同的团队成员独立地工作一样:
对于 CPU-bound 的更新 (例如创建新的 DOM 节点和运行组件中的代码),并发意味着一个更急迫的更新可以“中断”已经开始的渲染。
对于 IO-bound 的更新 (例如从网络加载代码或数据),并发意味着 React 甚至可以在全部数据到达之前就在内存中开始渲染,然后跳过令人不愉快的空白加载状态。
我们可以用requestIdleCallback来实现此功能。React 目前不再使用requestIdleCallback
,而是scheduler package,不过从概念上来说这两个是一样的。
let nextUnitOfWork = null
function workLoop(deadline) {
// 是否要暂停
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
// 执行 一个工作单元 并返回下一个工作单元
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
// 判断空闲时间是否足够
shouldYield = deadline.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
function performUnitOfWork(nextUnitOfWork) {
// TODO
}
复制代码
Step 4: Fibers
假设 render 的 tree 是这样:
render(
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>,
container
);
复制代码
那么最开始,我们会将 root fiber 设置为上面的nextUnitOfWork
,剩下的事情就会在performUnitOfWork
中完成,主要是做三件事:
- 把 element 添加到 DOM 中
- 为 element 的每一个 child 创建 fiber
- 指定下一个 nextUnitOfWork
为了让每一个 fiber 能够方便的找到下一个 nextUnitOfWork
,它本身应该包含它的第一个 child,下一个相邻的元素以及它的父亲,数据结构为:
interface VElement {
type?: string | "TEXT_ELEMENT" | Function;
props: HTMLProps;
parent?: VElement | null;
child?: VElement | null;
sibling?: VElement | null;
}
interface HTMLProps extends Partial<Omit<HTMLElement | Text, "children">> {
nodeValue?: string | null;
children: VElement[];
}
复制代码
而遍历的时候采用的是深度优先遍历,即先从 root 递 到主 tree 的最后一个 first child,然后检查它是否有 sibling,当 siblings 遍历结束时再去遍历 parent 的 siblings…依次再 归 回到 root
const createDom = (fiber) => {
const dom = fiber.type === 'TEXT_ELEMENT' ?
document.createTextNode('') : document.createElement(fiber.type as string)
return dom
}
const performUnitOfWork = (fiber)=> {
// 创建一个 dom 元素,挂载到 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
// 保存上一个 sibling fiber 结构
let prevSibling = null
while (index < elements.length) {
const element = elements[index]
const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
dom: null,
}
// 第一个子元素 作为 child,其余的子元素都作为 sibling
if (index === 0) {
fiber.child = newFiber
} else {
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
// 如果有 child fiber ,则返回 child
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
// 如果有 sibling fiber,则返回 sibling
if (nextFiber.sibling) {
return nextFiber.sibling
}
// 否则返回它的 parent fiber
nextFiber = nextFiber.parent
}
}
复制代码
Step 5: Render and Commit
目前虽然是实现了可中断的 render 了,但是还有个问题就在它们是每次直接把 element 添加到 DOM 上,如果在这个过程中浏览器暂时中断了渲染的过程,那么就会呈现出不完整的 UI,这是非常不好的,在 react 官方文档中对 error boundary
的处理不完整的 UI 有这样的评论:
我们对这一决定有过一些争论,但根据我们的经验,把一个错误的 UI 留在那比完全移除它要更糟糕。例如,在类似 Messenger 的产品中,把一个异常的 UI 展示给用户可能会导致用户将信息错发给别人。同样,对于支付类应用而言,显示错误的金额也比不呈现任何内容更糟糕。
所以我们需要做出一些些改动,当所有工作单元执行完后,再一并将所有的 dom 的添加
export const workLoop = (deadline: TimeRemaining) => {
let shouldYeild = false;
while (!!nextUnitOfWork && !shouldYeild) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYeild = deadline.timeRemaining() < 1;
}
// 等到nextUnitOfWork做完之后再一并提交到 DOM
if (!nextUnitOfWork && wipRoot) {
commitRoot();
}
window.requestIdleCallback(workLoop);
};
const commitRoot = () => {
commitWork(wipRoot?.child!);
wipRoot = null;
};
const commitWork = (fiber?: Fiber) => {
if (!fiber) return;
let domParentFiber = fiber.parent;
while (!domParentFiber?.dom) {
domParentFiber = domParentFiber?.parent;
}
const domParent = domParentFiber?.dom;
domParent?.appendChild(fiber.dom);
commitWork(fiber.child);
commitWork(fiber.sibling);
};
复制代码
Step 6: Reconciliation
之前处理的都是向 DOM 之中添加 elements,其实还有两种操作:update
和delete
,所以在 commit 结束之后,需要保存当前的 fiber tree (用currentRoot
来保存),等到下次 render 时与 wipRoot(work in progress)的 fiber 树进行比较,同时在 wipRoot 中增加一个 alternate 属性来连接旧的 fiber 树。
同时在每次 commit 的时候执行加入 deletions 列表,然后在重新 render 时重置 deletions
const commitRoot = () => {
deletions?.forEach((d) => commitWork(d));
commitWork(wipRoot?.child!);
// 保存当前的tree
currentRoot = wipRoot;
wipRoot = null;
};
export const render = (element: VElement, container: HTMLElement) => {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
};
// 重置deletions
deletions = [];
nextUnitOfWork = wipRoot;
};
复制代码
对于performUnitOfWork
,我们把对 fiber 的遍历放到 reconcile 中
const performUnitOfWork = (fiber: VElement) => {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
const elements = fiber.props.children;
// 在这里操作fiber
reconcileChildren(fiber, elements);
if (fiber.child) return fiber.child;
let nextFiber = fiber;
// if sibling exists,return;else return parent
while (nextFiber) {
if (nextFiber.sibling) return nextFiber.sibling;
nextFiber = nextFiber.parent!;
}
return null;
};
const reconcileChildren = (fiber: Fiber, elements: VElement[]) => {
if (!fiber) return;
let index = 0;
// 获取到旧fiber
let oldFiber = fiber.alternate?.child;
let preSibling: VElement | null = null;
while (index < elements?.length || !!oldFiber) {
const element = elements[index];
const sameType = oldFiber && element && element.type === oldFiber.type;
let newFiber: VElement | null = null;
if (sameType) {
// update
newFiber = {
type: oldFiber!.type,
props: element.props,
// 使用之前的dom,只需要更新props
dom: oldFiber?.dom,
parent: fiber,
alternate: oldFiber,
// effectTag将在commit阶段使用
effectTag: "UPDATE",
};
} else if (!sameType && element) {
// add
newFiber = {
type: element.type,
props: element.props,
// 需要创建一个新的dom
dom: null,
parent: fiber,
alternate: null,
effectTag: "PLACEMENT",
};
} else {
// remove
oldFiber!.effectTag = "DELETION";
// 收集deletions,在commitRoot时统一移除
deletions!.push(oldFiber!);
}
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
if (index === 0) {
fiber.child = newFiber;
} else {
preSibling!.sibling = newFiber;
}
preSibling = newFiber;
index++;
}
};
复制代码
然后,在 commit 阶段来处理之前加上的effectTags
,其中的 UPDATE 即为更新当前 fiber 的 props,包括简单属性的更新(setProperty)以及以 on 开头的事件的更新(addEventListener)
const commitWork = (fiber?: Fiber) => {
if (!fiber) return;
let domParentFiber = fiber.parent;
while (!domParentFiber?.dom) {
domParentFiber = domParentFiber?.parent;
}
const domParent = domParentFiber?.dom;
if (fiber.effectTag === "PLACEMENT" && !!fiber.dom) {
// 如果是添加,就直接添加
domParent?.appendChild(fiber.dom);
} else if (fiber.effectTag === "UPDATE" && !!fiber.dom) {
// update fiber
updateDom(fiber.dom, fiber.alternate?.props!, fiber.props);
} else if (fiber.effectTag === "DELETION") {
let child = fiber;
while (!child?.dom) {
child = fiber.child!;
}
domParent?.removeChild(child.dom!);
}
// 继续commit child 和 sibling
commitWork(fiber.child);
commitWork(fiber.sibling);
};
const isNew = (prev: HTMLProps | {}, next: HTMLProps) => (key: string) =>
prev[key] !== next[key];
const isGone = (prev: HTMLProps | {}, next: HTMLProps) => (key: string) =>
!(key in next);
const isEvent = (key: string) => key.startsWith("on");
const isProperty = (key: string) => key !== "children" && !isEvent(key);
export const updateDom = (
dom: HTMLElement | Text,
prevProps: HTMLProps | {},
nextProps: HTMLProps
) => {
// Remove old properties
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach((name) => {
dom[name] = "";
});
// Set new or changed properties
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach((name) => {
dom[name] = nextProps[name];
});
//Remove old or changed event listeners
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]);
});
// Add event listeners
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach((name) => {
const eventType = name.toLowerCase().substring(2);
dom.addEventListener(eventType, nextProps[name]);
});
};
复制代码
Step 7: Function Component & useState
如果我们的例子是这样:
import { createElement } from "./react/createElement";
/** @jsx createElement */
function App(props) {
return <h1>Hi {props.name}</h1>;
}
const element = <App name="foo" />;
const container = document.getElementById("root");
render(element, container);
复制代码
再转译之后变成 js 将会是这样:
function App(props) {
return createElement("h1", null, "Hi ", props.name);
}
const element = createElement(App, {
name: "foo",
});
复制代码
所以在执行 reconcile 之前,应该检查一下 fiber 的 type 是否是 Function,如果的话 fiber 的 children 应该通过调用 fiber.type
来获得:
let wipFiber: VElement;
let hookIndex: number = -1;
const isFucComponent = fiber.type instanceof Function;
if (isFucComponent) {
wipFiber = fiber;
hookIndex = 0;
wipFiber.hooks = [];
const children = [(fiber.type as Function)(fiber.props)];
reconcileChildren(fiber, children);
} else {
const elements = fiber.props.children;
reconcileChildren(fiber, elements);
}
复制代码
关于 useState 的实现,我们用一个数组来存 hooks,同时用一个 hookIndex 来追踪状态,每一个 hook 中保存着前一个 state 和一个更新 state 的函数列表,然后调用 setState 时指定 nextUnitOfWork
触发 actions 更新 state。
export const useState = <T>(initial: T): [T, Function] => {
const oldHook = wipFiber?.alternate?.hooks![hookIndex];
const hook: { state: T; queue: Function[] } = {
state: oldHook ? oldHook.state : initial,
// 存放每次更新状态的队列
queue: [],
};
const actions = oldHook ? oldHook.queue : [];
actions.forEach(
(action) =>
(hook.state = action instanceof Function ? action(hook.state) : action)
);
const setState = (action: Function) => {
hook.queue.push(action);
wipRoot = {
dom: currentRoot?.dom,
props: currentRoot?.props!,
alternate: currentRoot,
};
nextUnitOfWork = wipRoot;
deletions = [];
};
wipFiber.hooks?.push(hook);
hookIndex++;
return [hook.state, setState];
};
复制代码
小结
为了便于理解 react 是如何工作的,这份代码用了和 react 源码里面一模一样的变量名和函数名,like:
- workLoop
- performUnitOfWork
- updateFunctionComponent
但是在有些地方和 react 本身的实现会有一些出入:
- 在 render 和 commit 阶段,我们都会遍历整个 fiber tree,而 react 会选择性地跳过一些并没有发生变化的子树
- 每一次我们构建一个新的
work in progress
tree 时,都会为每一个 fiber 创建一个新对象,而 react 会回收之前 tree 里面的一些 fiber - 在 render 阶段触发更新时,我们会丢弃掉整个
work in progress
tree,然后从 root 重新开始,而 react 会给每个更新打一个有过期时间的 tag,并且依赖它来确定哪一个更新具有更高的优先级 - …
总的代码量也就 200 多行,但是对于了解 react 大体上是如何工作的还是有很大帮助的。