前言
众所周知,React是一个数据驱动视图的框架,在视图渲染更新时,就会涉及到 虚拟DOM、Fiber、Diff算法 之类的内容,这些内容的原理作为前端面试八股文已经屡见不鲜了,但我还从未想过如何从代码层面实现。
最近遇到了一个很有趣的网站:Build your own React,这个网站用简洁的代码讲述、实现了React是如何让视图进行渲染更新的。
这篇文章,我将以我的理解大致翻译该网站的内容,并进行代码层面的实现。
〇、基础回顾
本章节讲述了如何使用原生的JS代码替代React的语法及API,如果你对React的虚拟DOM对象及原生的DOM节点十分了解,那么你可以跳过这一章节。
举个例子,下方的代码可以实现一个最简单的React项目,我们试着用原生的JS代码替代React特殊的语法及API。
js
const element = <h1 className='label'>Hello React</h1>;
const container = document.getElementById('root');
ReactDOM.render(element, container);
首先,第一行代码等价于:
js
const element = React.createElement("h1", { title: "foo" }, "Hello");
这还是依赖于React的API,但我们可以依此将其构造成一个对象:
js
const element = {
type: "h1",
props: { title: "foo", children: "Hello" }
};
对象有两个属性,type是元素的类型,props是元素的属性,这其中包括子元素。这里的子元素是字符串,但在更多时候子元素会是其它的元素------想象一下DOM树的结构。
有了这个对象,我们就可以通过原生的JS来替代上述代码:
js
const element = {
type: "h1",
props: { title: "foo", children: "Hello" }
};
const node = document.createElement(element.type);
node["title"] = element.props.title;
const childNode = document.createTextNode("");
childNode["nodeValue"] = element.props.children;
const container = document.getElementById('root');
node.appendChild(childNode);
container.appendChild(node);
在子元素的实现上我们选择用createTextNode
来创建而不是用innerText
属性,是为了统一创建元素的方式,后续能够更方便地将其封装成一个方法。
至此,我们实现了用原生的JS代码完全替代React。
一、实现createElement函数
从这一章节开始,我们将慢慢实现自己的React。第一步,从实现createElement
函数开始。
首先,我们重新创建一个React项目,这次我们来制造一些简单的父子级关系:
js
const container = document.getElementById("root");
const element = (
<div id="box">
<a>Hello React</a>
<b />
</div>
);
ReactDOM.render(element, container);
同样的,我们先用React的createElement来重写这一部分代码,看看它做了些什么事情:
js
const container = document.getElementById("root");
const element = React.createElement(
"div",
{ id: "box" },
React.createElement("a", null, "Hello React"),
React.createElement("b")
)
ReactDOM.render(element, container);
我们可以看到,创建一个元素所需要的只是type, props和children,而children也只需要嵌套调用创建的函数即可。
在先前的步骤中,我们已经将元素抽象为了一个含有type和props属性的对象。那么,我们的createElement函数只需要创建这样的一个对象就行了。
我们可以据此写出一个雏形:
js
function createElement(type, props, ...children) {
return {
type,
props: { ...props, children }
}
}
在函数的形参部分,我们用剩余参数语法 来传递children,这样就能确保返回的props中的children为一个数组。
children数组也有可能包含着像string, number这样的基本类型数据 ,因此我们要对不同类型的child进行不同的处理:
js
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map(child =>
typeof child === "object" ? child : createTextElement(child)
)
}
}
}
function createTextElement(value) {
return {
type: "TEXT_ELEMENT",
props: { nodeValue: value, children: [] }
}
}
我们将基本类型数据 处理为一个type为TEXT_ELEMENT
的对象,它的值在props中的nodeValue属性中,并且children属性为一个空数组。
注意:真正的React并不是这样处理的,我们这样处理是为了简化我们的代码。对于我们要实现的东西来说,简洁的代码比优异的性能更为重要。
至此,我们实现了一个自己的createElement函数。这个函数所创建的并不是真实的DOM节点,而是一个庞大的、树形结构的对象 ,可以把它想象成一个虚拟的DOM。
我们的目的是替代React,我们可以给我们的库取个名字,比如说:Teact
,并将createElement方法放在Teact对象中,让它看起来更像一个库。
还有一个问题需要解决:如何让babel
在编译时使用Teact.createElement
而不是React.createElement
?
我们可以加一行注解 来解决这个问题:/** @jsx Teact.createElement */
如果你的React版本在17以上,那么你可能会遇到跟我一样的另一个问题:
意为不能在runtime
为自动模式时使用这个注解。
我们只需要将runtime
设置为传统模式 即可解决:/** @jsxRuntime classic */
具体可以查阅这篇文章:介绍全新的 JSX 转换
这样一来,babel在编译代码时,就会使用我们定义的createElement
来创建元素了。
意料之外的问题
除此之外,我还遇到了另一个问题:
我创建出来的元素并不被ReactDOM.render
认可,无法将它渲染出来。我检查了代码并对比了React.createElement创建出来的元素的结构,并没有发现什么问题。
我尝试将React的版本降到16.8.6 (与沙盒 中的版本保持一致),但这个问题仍然存在。由于沙盒中的代码并没有这个问题,并且后续我们自行实现render方法后也就不会遇到这个问题了,所以我暂时不打算深究它。
二、实现render函数
这一章节,我们将来实现自己的render
函数,以取代ReactDOM.render
函数。
首先,我们来实现添加节点的功能,更新和删除的功能之后再进行完善。
参考ReactDOM.render
函数,接收两个参数,一个是之前通过createElement创建的虚拟DOM对象 ,一个是承载DOM结构的容器。
有了思路那么很容易就能写出一个雏形:
js
function render(element, container) {
const dom = element.type === "TEXT_ELEMENT" ?
document.createTextNode(element.props.nodeValue) :
document.createElement(element.type);
element.props.children.map(child => render(child, dom));
container.appendChild(dom);
}
其实就是一个循环递归 的过程,根据type创建真实的DOM节点,再添加到父级容器中。同时,我们还要对TEXT_ELEMENT
这个特殊的类型进行处理。
调用Teact.render
取代React.render
,我们成功地将内容显示在了页面上。
我们要做的最后一件事情,就是将元素的props属性添加到节点上去:
js
function render(element, container) {
const dom = element.type === "TEXT_ELEMENT" ?
document.createTextNode(element.props.nodeValue) :
document.createElement(element.type);
const isProperty = key => key !== "children";
Object.keys(element.props).filter(isProperty).map(name => {
dom[name] = element.props[name];
});
element.props.children.map(child => render(child, dom));
container.appendChild(dom);
}
至此,我们就实现了一个能够将JSX转换成DOM的库了。
三、并行模式
在上一章节,我们实现了render方法,但存在一个问题:一旦我们开始渲染DOM树,就只有到渲染完成时才会停止。如果DOM树很复杂,那么渲染的过程有可能会阻塞主线程 (JS是一个单线程的语言)。这也会影响浏览器执行一些优先级更高的操作,例如用户的输入、保持帧动画的流畅。
所以接下来,我们要将渲染工作分割成更小的片段 ,每当一个片段完成时,我们将给到浏览器去中断渲染并执行优先级更高的操作的机会。
为了实现上述调度工作 的特性,我们选择使用requestIdleCallback
这个API,它类似于setTimeout
,不同的是它执行的时机是在浏览器某一帧存在空闲时,也即是我们可以在主线程空闲时去执行渲染任务。
js
function workLoop(deadLine) {
......
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
大致上就是这样一个循环调用workLoop
的结构。另外,requestIdleCallback
的回调函数提供一个参数deadLine
,我们可以通过它得知当前帧还有多少空闲时间,当空闲时间结束时,我们需要中断渲染操作,将控制权交给浏览器去执行优先级更高的操作。
js
function workLoop(deadLine) {
let shouldYield = false;
while (!shouldYield) {
......
shouldYield = deadLine.timeRemaining() < 1;
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
接下来我们处理workLoop
中执行渲染工作的逻辑:
js
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) {
......
}
我们编写一个performUnitOfWork
函数,这个函数的作用是执行当前的渲染工作片段,并且返回下一个渲染工作片段。顺带完善一下其它逻辑,用一个变量来保存下一个渲染工作的片段,并在每次执行渲染工作时更新它。
本章节实现了一个大致的代码框架(主要是思路),具体performUnitOfWork
函数中执行渲染工作的代码将在后续章节中介绍。
四、Fibers
这一章节,我们将介绍一个耳熟能详的数据结构:Fiber Tree,用这个数据结构来管理各个片段的渲染工作。
Fiber Tree
的本质是一个链表 ,下文将提到的Fiber
可以理解为是链表中的一个节点。
我们先来看一个例子,假设我们要渲染这样一个DOM结构:
js
Teact.render(
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>,
container
);
那么在渲染过程中,我们首先需要创建根Fiber节点 ,并将其设置为初始的nextUnitOfWork
。剩余的渲染工作将放在performUnitOfWork
函数中。
也就是说,我们在performUnitOfWork
函数中需要做3件事情:
1、将元素添加到真实DOM结构中;
2、创建当前元素的子级Fiber节点们;
3、返回下一个渲染工作片段。
了解 Fiber Tree 的结构
在编写代码之前,我们先来熟悉一下Fiber Tree
的结构,并看看它是如何进行下一个渲染工作片段的选取的。
从上图可以看出,Fiber Tree
的结构与DOM
结构有明显的不同:每一个节点只会有一个子节点(child) ,其余节点则被划分为了兄弟节点(sibling) ,并且子节点和兄弟节点都有指向父级节点(parent) 的箭头。
当我们完成一个Fiber节点的渲染工作,那么就去寻找它的子节点 作为下一个渲染工作片段:例如渲染完div节点 后,下一个渲染工作片段应该是p节点。
如果没有子节点那么就去寻找它的兄弟节点 作为下一个渲染工作片段:例如渲染完p节点 后,下一个渲染工作片段应该是a节点。
如果也没有兄弟节点,那么就去寻找它的 "uncle节点"(父级节点的兄弟节点) :例如渲染完a节点 后,下一个渲染工作片段应该是h2节点。
如果也没有,那么就去寻找再上一级的父级节点,选取它的兄弟节点。
以此类推......
当我们再一次回到根节点时,意味着这一次渲染的所有工作均已完成。
接下来,我们开始编写代码,实现上述逻辑。
重构 render 函数
我们先重构 一下之前实现的render
函数,现在,我们只需要保留它创建DOM节点的部分 ,舍弃 递归执行render
以及添加到真实DOM的部分:
js
function createDom(fiber) {
const dom = fiber.type === "TEXT_ELEMENT" ?
document.createTextNode("") :
document.createElement(fiber.type);
const isProperty = key => key !== "children";
Object.keys(fiber.props).filter(isProperty).map(name => {
dom[name] = fiber.props[name];
});
return dom;
}
另外,我们将原先的element
替换成了fiber
,所以其实fiber
节点的结构就是在element
的基础上增加了指向其它节点的指针,基本的节点属性仍保持一致。
在render函数中,我们去设置初始的nextUnitOfWork
:
js
function render(element, container) {
nextUnitOfWork = {
dom: container,
props: {
children: [element]
}
}
}
初始的nextUnitOfWork
是Fiber Tree
的根节点,其实就是承载DOM结构的容器 ,因此它不需要有type
属性,并且props属性中也只有children属性(另外还有指向子节点的指针将在performUnitOfWork
中添加)。根节点我们可以直接创建,除此以外其它节点的创建将在performUnitOfWork
中进行。
实现 performUnitOfWork 函数
接下来,我们分3步实现performUnitOfWork
函数的具体逻辑。
1、将元素添加到真实DOM结构中:
js
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom);
}
在每一个Fiber
节点中,增加了一个dom
属性,用于构建真实DOM结构,添加子节点等。
2、创建当前元素的子级Fiber节点们:
js
const elements = fiber.props.children;
let index = 0;
let prevSibling = null;
while (index < elements.length) {
const element = elements[index];
const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
dom: null
}
// 设置指向子节点和兄弟节点的指针
if (index === 0) {
fiber.child = newFiber;
} else {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
遍历当前Fiber
节点的所有子级节点,创建对应的Fiber
节点,并将其划分为子节点和兄弟节点 ,并且每个Fiber
节点会有指向父/子/兄弟节点的指针 ,以此完成Fiber Tree
的构建。
每个Fiber
节点也和之前一样保留了type
和props
属性用于将Fiber节点创建为真实DOM节点。
3、返回下一个渲染工作片段:
js
if (fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
按照之前描述的寻找下一个渲染工作片段 的顺序返回对应的Fiber
节点。
至此,我们实现了一个完整的performUnitOfWork
函数:
js
function performUnitOfWork(fiber) {
// 将元素添加到真实DOM结构中
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom);
}
// 创建当前元素的子级Fiber节点们,构建Fiber Tree
const elements = fiber.props.children;
let index = 0;
let prevSibling = null;
while (index < elements.length) {
const element = elements[index];
const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
dom: null
}
// 设置指向子节点和兄弟节点的指针
if (index === 0) {
fiber.child = newFiber;
} else {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
// 返回下一个渲染工作片段
if (fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
用它替换掉原先的render
函数,依旧可以正常运行!
五、渲染与提交
现在,我们遇到了一个新的问题:由于渲染过程是会被浏览器打断的,而我们目前的渲染逻辑会直接将完成渲染的部分添加到真实DOM上,这样可能会导致页面上展示的内容为半成品。这一章节,我们就要解决这个问题。
首先,我们将添加到真实DOM结构上的代码注释掉:
js
// if (fiber.parent) {
// fiber.parent.dom.appendChild(fiber.dom);
// }
然后,我们用一个变量(wipRoot - work in progress root )来保存整个Fiber Tree
,当其全部完成渲染时再一次性添加到真实DOM中:
js
let wipRoot = null;
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element]
}
};
nextUnitOfWork = wipRoot;
}
js
function workLoop(deadLine) {
let shouldYield = false;
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadLine.timeRemaining() < 1;
}
if (!nextUnitOfWork && wipRoot) {
commitRoot();
}
requestIdleCallback(workLoop);
}
当nextUnitOfWork
为空时就认为完成了全部的渲染工作。
在commitRoot
函数中,我们用递归 的方法将所有节点添加到真实DOM上,就像我们一开始在render
函数中做的那样:
js
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);
}
这样一来,我们就解决了章节开头提到的那个问题。
六、调和(Reconciliation)
目前,我们只实现了向真实DOM树中添加节点,接下来我们要实现更新 和删除的功能。
要实现这两个功能,我们需要将上一次渲染的结果保存下来,与当前渲染的元素进行比较。
我们用currentRoot
来保存上一次渲染的结果,另外,我们为每个Fiber节点 添加一个新的属性:alternate
,指向上一次渲染中对应的Fiber节点,便于两者进行比较。
那么,render
函数中的根节点就变成了这样:
js
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element]
},
alternate: currentRoot
};
nextUnitOfWork = wipRoot;
}
在commit函数中将提交渲染的结果保存下来:
js
function commitRoot() {
commitWork(wipRoot.child);
currentRoot = wipRoot;
wipRoot = null;
}
alternate
在第一次渲染时为null
,但在后续的渲染中便是上一次渲染的结果。
重构 performUnitOfWork 函数
接下来,我们修改performUnitOfWork
中创建Fiber节点构建Fiber Tree的部分,对每一个Fiber节点进行比较。
我们将具体逻辑抽离到reconcileChildren
函数中:
js
function reconcileChildren(wipFiber, elements) {
let index = 0;
let prevSibling = null;
while (index < elements.length) {
const element = elements[index];
const newFiber = {
type: element.type,
props: element.props,
parent: wipFiber,
dom: null
}
// 设置指向子节点和兄弟节点的指针
if (index === 0) {
wipFiber.child = newFiber;
} else {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
}
接下来开始增加新的逻辑。
首先我们需要获取上一次渲染的节点,与当前要渲染的元素进行比较,可以通过alternate
属性:
js
let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
注意:这里的oldFiber
是上一次渲染中对应的子级节点 ,因为我们要比较的元素也是子级元素,需要对应起来。
另外,我们增加一个循环条件:oldFiber !== null
,处理上一次渲染中存在而当前渲染不存在的节点。
接下来我们需要比较oldFiber
和element
的type
,根据比较的结果进行不同的处理:
js
function reconcileChildren(wipFiber, elements) {
let index = 0;
let prevSibling = null;
let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
while (index < elements.length || oldFiber !== null) {
const element = elements[index];
let newFiber = null;
// 当前要渲染的element与上一次commit的Fiber Tree节点进行比较
const sameType = oldFiber && element && element.type === oldFiber.type;
if (sameType) { // update
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: "UPDATE"
}
}
if (element && !sameType) { // add
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT"
}
}
if (oldFiber && !sameType) { // delete
oldFiber.effectTag = "DELETION";
deletions.push(oldFiber);
}
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
// 设置指向子节点和兄弟节点的指针
if (index === 0) {
wipFiber.child = newFiber;
} else {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
}
我们在每个Fiber节点 上增加了一个新的属性:effectTag
,后续在渲染到真实DOM时需要根据effectTag
的值进行不同的处理。
新旧节点的比较有3种情况:
1、type
属性相同,判定为UPDATE
。保留oldFiber的type和dom,仅更新props属性。
2、type
属性不同,存在element
,判定为PLACEMENT
。新增 的Fiber节点,和之前一样,dom属性暂时为null。
3、type
属性不同,存在oldFiber
,判定为DELETION
。删除 的Fiber节点,不需要创建新的Fiber节点,只需要在oldFiber上增加一个值为DELETION的effectTag属性即可。另外,因为提交时是根据当前的Fiber Tree进行渲染的,在那上面并不存在上一次渲染结果中需要删除的节点,所以我们需要用一个数组 将被删除的Fiber节点保存下来。
注意:如果一个节点的type发生了变化,会被认为是旧节点的删除 以及新节点的新增;。
以上就是重构后的构建Fiber Tree的内容了,接下来我们进行commitWork
的重构,让它能够处理effectTag
的逻辑。
重构 commitWork 函数
js
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 === "DELETION") {
domParent.removeChild(fiber.dom);
} else if (fiber.effectTag === "UDPATE" && fiber.dom !== null) {
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
如果effectTag
是PLACEMENT
,那么和原先一样将其添加到DOM结构中;如果是DELETION
,那么正好相反,将其从DOM结构中移除。
比较复杂的是UPDATE
的情况,我们将更新 的逻辑抽离出来,放在updateDom
函数中。
我们保持dom节点不变,更新dom节点上的props属性,将先前的props移除,添加新增以及变动的props:
js
const isProperty = key => key !== "children";
const isNew = (prev, next) => key => prev[key] !== next[key];
const isGone = next => key => !(key in next);
function updateDom(dom, prevProps, nextProps) {
// 移除变更的props
Object.keys(prevProps).filter(isProperty).
filter(isGone(nextProps)).map(name => dom[name] = "");
// 添加新增或变化的props
Object.keys(nextProps).filter(isProperty).
filter(isNew(prevProps, nextProps)).map(name => dom[name] = nextProps[name]);
}
还有一种特殊情况是props属性中有监听事件,对于这一类props我们需要特殊处理:
js
const isEvent = key => key.startsWith("on");
const isProperty = key => key !== "children" && !isEvent(key);
const isNew = (prev, next) => key => prev[key] !== next[key];
const isGone = next => key => !(key in next);
function updateDom(dom, prevProps, nextProps) {
// 移除变更的监听事件
Object.keys(prevProps).filter(isEvent).filter(key => isGone(nextProps)(key) || isNew(prevProps, nextProps)(key)).map(name => {
const eventType = name.toLowerCase().substring(2);
dom.removeEventListener(eventType, prevProps[name]);
});
// 添加新增或变更的监听事件
Object.keys(nextProps).filter(isEvent).filter(isNew(prevProps, nextProps)).map(name => {
const eventType = name.toLowerCase().substring(2);
dom.addEventListener(eventType, nextProps[name]);
});
// 移除变更的props
Object.keys(prevProps).filter(isProperty).filter(isGone(nextProps)).map(name => dom[name] = "");
// 添加新增或变化的props
Object.keys(nextProps).filter(isProperty).filter(isNew(prevProps, nextProps)).map(name => dom[name] = nextProps[name]);
}
至此,我们就成功地实现了更新DOM树的功能!
理解与讨论
js
deletions.map(item => commitWork(item));
......
else if (fiber.effectTag === "DELETION") {
domParent.removeChild(fiber.dom);
}
......
在删除 的操作部分我一开始有这样的疑惑:要删除的元素并不在当前要渲染的DOM结构中,而是在上一次渲染的DOM结构中,为什么要去处理它呢?
实际上的逻辑是这样的:当前要渲染的DOM结构是基于 上一次渲染的DOM结构的。也就是说,我对div节点进行了修改,并不会重新创建一个新的div节点,而是在上一次渲染的基础上进行更新 ,上一次渲染的div节点是包含子节点的,所以我们要通过上一次渲染的DOM结构去将其删除。
小结
在这篇文章中,我们了解了React渲染的底层逻辑以及Fiber的结构与原理,实现了一个简易版的React ,它可以实现页面的渲染和更新,不过还只是基于类式组件 的。对于函数式组件的兼容适配,我将在下一篇文章进行介绍,有兴趣的朋友们可以关注一下。
Git项目地址:Build-Your-Own-React
原文链接:Build your own React