背景
本文是对Build your own React这篇文章的进一步解析说明,如果有能力可以直接阅读原文。
起因
文章在很早以前就大致看了一遍,但没有精读。直到有一天我看到几道题目,发现竟然毫无头绪。
- JSX为什么能在JS中写HTML代码,如何处理和编译
- React hooks内部实现原理
- 为什么不能在条件语句中写 hook
可能对于有些人来说这两题很简单,因为时常也能在热门文章中看到许多大佬辩论一些我完全看不懂的问题,一看大佬竟然还是在校学生。挺让人自卑的,但这两题确实是将我难住了。不过知耻而后勇嘛,于是我也去搜索了许多相关文章,总觉得晦涩难懂,一堆源码级的解析属实是让我望而却步,硬着头皮也看不下去的那种,所幸最后回想起上面这篇文章,精读一番后发现其实里面的内容完全能解答上面的问题,属实是非常简单优秀的文章,因此也在这分享给各位可能和我一样比较菜的兄弟。
须知
Build your own React这篇文章虽然写在一两年前,React也是16版本,但文章内容完全足够用来入门React原理,万变不离其宗,最新版本也只是改了一些实现方式,思路是想通的,希望各位能静下心来看。
如果文章有错误或者不好的地方,也请在评论区留言指正。
目录
创建一个React项目
npx create-react-app my-react
打开index.js,会发现最新的React已经通过ReactDom.createRoot创建根目录,再使用创建的root的render方法来渲染App组件。与16版本的使用ReactDom.render方法稍有差异。不过问题不大,反正最终运行我们都是要使用自己的方法,我们就以16版本的写法来改写。
从JSX到Render
首先我们要知道从JSX到Render的过程中发生了什么?
// 这是原React的代码
const element = <h1 title="foo">Hello</h1>
const container = document.getElementById("root")
ReactDOM.render(element, container)
实际在编译过后,jsx会通过bable解析为js,每个<>标签解析成了React.createElement()的格式,createElement函数再将入参转为object格式返回,这里我们直接将返回值先赋值给element。
// 1、jsx通过bable解析为js,每个<>标签解析成了React.createElement()的格式。
const element = React.createElement("h1", { title: "foo" }, "Hello");
// 2、createElement函数再将入参转成object形式返回,如果是非object则转成text节点。
const element = {
type: "h1", // 当element是函数式组件时,type会是function,在后续会讲
props: {
title: "foo",
children: "Hello", // 这里是string,但是一般会是数组,代表所有子元素
},
};
接下来render函数接收createElement解析的结果后,生成dom并一一挂载。
// 3、render函数
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);
执行结果,理所当然的渲染了Hello,至此只是通过js先带着大家走一遍执行过程,下一步,我们就需要具体地实现这两个函数并让bable编译时使用它们。
const element = {
type: "h1",
props: {
title: "foo",
children: "Hello",
},
};
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);
createElement函数
首先在bable解析后会将标签名、标签属性、标签值这三部分作为入参传入函数。然后出参我们希望是一个可以供render函数解析的对象。执行过程就是将入参赋值给这个对象的过程。
// 1、使用...来接收children可以使其永远是一个数组
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map((child) =>
// 2、这里要判断一下如果是object说明是一个标签,否则则是内容。
typeof child === "object" ? child : createTextElement(child)
),
},
};
}
// 3、创建文本内容时要返回的对象
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
};
}
render函数
我们将解析的结果作为render函数的第一个参数,把要挂载的父元素作为第二个入参,不需要返回,执行的内容就是解析并挂载的过程。
function render(element, container) {
// 1、根据返回对象中的type创建dom元素,如果是TEXT_ELEMENT说明为标签内容,创建文本节点
const dom =
element.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type);
// 2、这一步是遍历对象中的props,为当前dom添加上所有属性
// children是我们用来关联子元素的属性,不需要被添加到dom上
Object.keys(element.props)
.filter((key) => key !== "children")
.forEach((name) => {
dom[name] = element.props[name];
});
// 3、当存在子元素时,我们递归调用render函数,使所有节点都可以被渲染
// 当前的节点作为需要挂载的父节点传入
element.props.children.forEach((child) => {
render(child, dom);
});
// 4、将当前dom挂载到父节点上
container.appendChild(dom);
// TODO updating and deleting
}
让JSX使用我们的函数
我们先将写好的两个函数通过对象保存起来,这样就可以像React.XXX一样使用了。
const Didact = {
createElement,
render,
};
然后在定义element前加上下方的注释,这样在bable编译时就会使用我们的方法了
/** @jsx Didact.createElement */
const element = (
<div style="background: salmon">
<h1>Hello World</h1>
<h2 style="text-align:right">from Didact</h2>
</div>
);
const root = document.getElementById("root");
Didact.render(element, root);
运行一下代码,不出意外地出现意外了😓,给我们报了一个pragma and pragmaFrag cannot be set when runtime is automatic的错误。这是React新旧版本采用不同模式的导入运行时导致的,我们需要切换成旧版传统模式手动导入运行时。
/** @jsxRuntime classic */
/** @jsx Didact.createElement */
const element = ....
再次执行,发现运行成功,这可能就是文章的实效性吧😂
Fiber
但是我们会发现有一个问题,现在只有几个元素还好,但如果我们的element有成百上千个元素呢?我们前面写的函数并没有中断的概念,那么在完全render所有元素之前,整个执行过程就不会停止,此时的浏览器会无法响应用户的io操作或者流畅地运行一些动效。
我不希望大家理所当然的就认为,我们需要用Fiber这种结构来存储数据从而实现分段执行。在讲Fiber前,我希望大家思考一下:
- 前面写的代码哪些部分是我们需要重构的
- 我们需要怎么拆分这些代码
当然我无法阻止你不思考。。。
先回答第一个问题,我们前面的代码其实就两部分,一个是createElement函数,一个是render函数,前者我们动不了,因为它的执行时机是由bable决定的,所以我们只能拆分后者。
第二个问题,我们需要怎么拆分我们的render函数?这里先再看一眼函数
function render(element, container) {
const dom =
element.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type);
Object.keys(element.props)
.filter((key) => key !== "children")
.forEach((name) => {
dom[name] = element.props[name];
});
element.props.children.forEach((child) => {
render(child, dom);
});
container.appendChild(dom);
}
造成无法中断的原因显然是,我们遍历children后递归调用render造成的,那么解决的方案就变成了,我们需要找到一个浏览器可以供我们使用的空余时间,来执行我们递归render的操作,我们不关心这个时间的长短,只要能让我们执行一次递归,我们再将下次需要执行的内容保存起来,等待下次空余时间即可。
为了解决这个问题,我们要做两方面的打算:
- 寻找不影响浏览器工作的时间点来执行函数
- 将代码的运行拆分为一个个更小的单元,使得我们可以随时打断它,比如每次只执行一次render
requestIdleCallback
我们使用requestIdleCallback函数来作为我们执行工作的时间点,将需要执行的callback作为第一个参数传入其中。
这个函数的作用是,在浏览器空闲时期调用我们传入的callback。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。
callback会接收到一个名为 IdleDeadline 的参数,这个参数可以获取当前空闲时间以及回调是否在超时时间前已经执行的状态。
// 1、nextUnitOfWork为需要执行的工作单元
let nextUnitOfWork = null;
// 2、被requestIdleCallback执行的callback
function workLoop(deadline) {
let shouldYield = false;
// 3、只有当还有需要被执行的工作,并且有给我们执行工作的时间时,我们才调用工作函数
// 当传给我们的deadline剩余时间不足时,不执行工作函数
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadline.timeRemaining() < 1;
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
// 具体执行工作的函数,明确函数需要做的两件事
// 1、执行传入的工作单元 2、返回下一个需要执行的工作单元
function performUnitOfWork(nextUnitOfWork) {
// TODO
}
可以看到requestIdleCallback(RIC)的用法和requestAnimationFrame(RAF)差不多,其实React内部并不是使用RIC实现的,RIC被尝试过但最终被弃用,原因是它在许多浏览器内并不被兼容。在16.10.0之前,React还使用过RAF+setTimeoutde作为RIC的polyfill来替代抹平各种浏览器间的差异。再到后来,React使用MessageChannel的方式来实现,具体可以看这篇文章。
performUnitOfWork
我们已经解决了第一个问题,对于第二个问题,我们的目标非常明确:
- 执行一次工作(例如解析element到挂载到dom上这一流程)
- 返回下一次需要执行的工作,以便RIC下次调用
function performUnitOfWork(nextUnitOfWork) {
// 其实这里就是render做的工作
const container = nextUnitOfWork.dom
const element = nextUnitOfWork.props.children
const dom = createDom(element, container)
container.appendChild(dom)
// 现在最最困难的是,我们怎么选择下一步要执行的对象
nextUnitOfWork = ?
}
function createDom(element, container) {
const dom =
element.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type);
Object.keys(element.props)
.filter((key) => key !== "children")
.forEach((name) => {
dom[name] = element.props[name];
});
return dom;
}
为什么是Fiber
无论我们怎么去规划performUnitOfWork中的内容,都离不开对下一步执行对象的选择,我们思考一下可能的方案,假设我们要渲染的节点如下图,每一个格子代表一个元素,箭头连接父子元素,同一行则为兄弟元素。
我们要怎么去决定执行的顺序呢?如果按照广度遍历来,即:
这代表我们在执行3之前,需要记住2,然后在执行4之后再去遍历2的子元素,从而执行5。在执行完6后需要找到3,执行3的子节点7,这代表着我们需要找到6和7之间的联系,即6的父节点的兄弟节点的子节点。或者去找7和8的联系,显然非常的麻烦。
如果按照深度遍历来,即:
我们不论是找5、6间的关系或者是7、8间的关系都变得简短很多,因为跟在之后渲染的这个节点,必定会是当前节点的直系长辈节点的兄弟节点。
那我们还有优化的点吗?自然是有的。
比如从4到5,我们按照前面的想法查找的顺序会是4的父元素3、3的父元素2、2的子元素5,我们完全可以在遍历2的子元素时就将5作为3的兄弟属性指向5,依此类推,2的兄弟属性指向6,6的兄弟属性指向8,完全就少了回到父元素这一步了。
此外我们还可以为所有兄弟元素添加上父属性,比如5的父属性就是2,这样在找6时,就不需要先去3再去2再去6,而是直接去2再去6.
而有了兄弟属性,且兄弟元素拥有指向父元素的父属性,那么我们就可以去掉父元素和所有子元素的联系了,他只需要和第一个子元素建立联系即可,比如1和6的箭头就可以去掉了。
我们重新构建这张图:
然后我们再来看一下介绍Fiber最常用的图
对比我们可以发现,两张图基本一致。难道。。。难道他真的是个天才?
思考到了最后,我们殊途同归,我们没有让别人告诉我们就是要用Fiber这种结构实现下一个工作单元的选择,而是自己思考出来,恭喜我们自己🎉。
再回到performUnitOfWork
感觉过了好久😂,我们又回到了执行工作函数,旧地重游,之前困扰我们的无法选择下一个执行对象的问题却已经被解决。
我们自定义了Fiber的结构,它是一个对象,它继承element的属性,但是为了方便遍历,我们又为他添加了子属性(child)、父属性(parent)、兄弟属性(sibling)。此外因为每个Fiber都会被解析成dom,然后挂载到其父元素上,我们还需要为其添加一个dom属性,使得解析完成后的结果可以保留在Fiber上。所以一个完整的Fiber结构应该如下:
const newFiber = {
type: element.type,// 代表标签的类型
props: element.props,// 标签属性和children(所有后代的element)
parent: fiber,//每个Fiber都有parent
child: childFiber,// 只有第一个子元素才会被作为父元素的child属性
sibling: siblingFiber,// 遍历到的下一个兄弟元素
dom: null,// 只有生成dom后才会赋值给这个属性
};
我们的performUnitOfWork贯彻这样一个思路,每次生成一个dom,挂载一个dom。挂载后还需要生成下一次被函数执行的fiber,为了关联兄弟元素,我们还需要在渲染当前元素时遍历完它的所有子元素,以便于后续能找到执行目标。我们将这个思路变成三个TODO,然后看下面代码:
function performUnitOfWork(fiber) {
// TODO 生成一个dom,挂载一个dom
// 1、每次先为当前fiber生成dom
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
// 2、如果有父元素,就挂载到上面去
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom);
}
// TODO 第一个子元素作为父元素的child属性
// 关联兄弟元素,后一个子元素为上一个子元素的sibling属性
// 3、element为当前元素的所有后代(createElement生成)
const elements = fiber.props.children;
let index = 0;
// 4、保存上一个子元素的值
let prevSibling = null;
// 5、使用index遍历,index为0时,生成的newFiber成为了当前fiber的child
// 并且newFiber作为上一个子元素被保存
// 下一次循环,新的newFiber走else,成为了上一个的sibling
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++;
}
// TODO 选择下一次要渲染的fiber
// 按照前面深度遍历的思路,我们有子属性选择子属性
if (fiber.child) {
return fiber.child;
}
// 没有子属性,则选择当前元素的兄弟属性
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
// 连兄弟属性都没有就返回到上一层
nextFiber = nextFiber.parent;
}
}
看到这里,我们已经攻克了最难的难关,恭喜你。
重写render
回忆一下,我们在Fiber这一节已经找到了浏览器执行的时机和具体执行的函数,但我们具体执行的函数拆分自原先的render函数--performUnitOfWork中的createDom函数和appendChild的过程。
如今的render仿佛已经没什么用了,当我们再想一下,render除了渲染节点外,它还有一个功能。那就是只有当执行到render时,才代表我们要最开始的渲染了。也就是说,如今的render只需要给我们提供一个执行的时机。
不知道你还能否记起workLoop函数,它在requestIdleCallback这一节,我们在那一节里就启动了requestIdleCallback函数,它其实一直在浏览器空余时间执行着我们的wookLoop函数,而wookLoop函数则一直等着一个时机,一个具体的nextUnitOfWork对象。
function render(element, container) {
// 我们之所以返回这样一个格式,是为了适配performUnitOfWork函数
// 第一次执行会根据children生成第一层fiber
// 执行第一个子元素时,就会将container作为父元素挂载
nextUnitOfWork = {
dom: container,
props: {
children: [element],
},
}
}
让我们再执行一遍,看看能不能成功渲染。
import React from "react";
// 1、nextUnitOfWork为需要执行的工作单元
let nextUnitOfWork = null;
// 1、使用...来接收children可以使其永远是一个数组
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map((child) =>
// 2、这里要判断一下如果是object说明是一个标签,否则则是内容。
typeof child === "object" ? child : createTextElement(child)
),
},
};
}
// 3、创建文本内容时要返回的对象
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
};
}
function createDom(element, container) {
const dom =
element.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type);
// 2、这一步是遍历对象中的props,为当前dom添加上所有属性
// children是我们用来关联子元素的属性,不需要被添加到dom上
Object.keys(element.props)
.filter((key) => key !== "children")
.forEach((name) => {
dom[name] = element.props[name];
});
return dom;
}
function performUnitOfWork(fiber) {
// TODO 生成一个dom,挂载一个dom
// 1、每次先为当前fiber生成dom
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
// 2、如果有父元素,就挂载到上面去
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom);
}
// TODO 第一个子元素作为父元素的child属性
// 关联兄弟元素,后一个子元素为上一个子元素的sibling属性
// 3、element为当前元素的所有后代(createElement生成)
const elements = fiber.props.children;
let index = 0;
// 4、保存上一个子元素的值
let prevSibling = null;
// 5、使用index遍历,index为0时,生成的newFiber成为了当前fiber的child
// 并且newFiber作为上一个子元素被保存
// 下一次循环,新的newFiber走else,成为了上一个的sibling
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++;
}
// TODO 选择下一次要渲染的fiber
// 按照前面深度遍历的思路,我们有子属性选择子属性
if (fiber.child) {
return fiber.child;
}
// 没有子属性,则选择当前元素的兄弟属性
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
// 连兄弟属性都没有就返回到上一层
nextFiber = nextFiber.parent;
}
}
function render(element, container) {
// 我们之所以返回这样一个格式,是为了适配performUnitOfWork函数
// 第一次执行会根据children生成第一层fiber
// 执行第一个子元素时,就会将container作为父元素挂载
nextUnitOfWork = {
dom: container,
props: {
children: [element],
},
};
}
const Didact = {
createElement,
render,
};
/** @jsxRuntime classic */
/** @jsx Didact.createElement */
const element = (
<div style="background: salmon">
<h1>Hello World</h1>
<h2 style="text-align:right">from Didact</h2>
</div>
);
const root = document.getElementById("root");
Didact.render(element, root);
function workLoop(deadline) {
let shouldYield = false;
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadline.timeRemaining() < 1;
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
整体渲染
按照前面performUnitOfWork的运行顺序,我们会发现它的渲染是每次执行都挂载一部分到dom上去,这就会导致如果被浏览器打断,就会是一个不完整的ui
为了解决这个问题,我们需要做如下几步:
- 将performUnitOfWork函数中appendChild的过程抽出来
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
const elements = fiber.props.children;
...
}
- 创建commitRoot函数用作执行渲染的具体工作
function commitRoot() {
// TODO add nodes to dom
}
- 寻找一个可以执行commitRoot函数的时机 我们希望能将完整的dom一次性挂载到页面上,所以只有当所有fiber都被执行完成,没有后续工作时我们才能获取到完整的dom。此外我们需要一个用来保存整条完整fiber链的对象,我们叫它wipRoot。
let nextUnitOfWork = null;
// 写在代码最上方,我们创建wipRoot用作后续保存fiber链
let wipRoot = null
。。。
function workLoop(deadline) {
let shouldYield = false;
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadline.timeRemaining() < 1;
}
// 每次循环工作后判断,当没有后续工作,且有wipRoot时,进行整体渲染
if (!nextUnitOfWork && wipRoot) {
commitRoot()
}
requestIdleCallback(workLoop);
}
此时我们发现wipRoot还并没有被赋值,我们为其赋值
function render(element, container) {
// 重写render函数,将根节点作为wipRoot
// 后续所有子元素会以child属性挂载到这个对象上
wipRoot = {
dom: container,
props: {
children: [element],
},
}
// 我们可以试着打印wipRoot
console.log(wipRoot, wipRoot.child);
nextUnitOfWork = wipRoot
}
可以看到,在打印时,wipRoot是没有child属性的,但是因为对象指针没有改变,后续生成的fiber作为它的后代都放到了child属性里。
然后我们补全commitRoot函数
function commitRoot() {
commitWork(wipRoot.child)
// 渲染完成后,清空wipRoot
wipRoot = null
}
function commitWork(fiber) {
// 递归调用commitWork函数,直到所有fiber的dom都被挂载
if (!fiber) {
return
}
const domParent = fiber.parent.dom
domParent.appendChild(fiber.dom)
commitWork(fiber.child)
commitWork(fiber.sibling)
}
保存执行,页面还是和原来一样被渲染了出来
Diff
我们已经完成了所有挂载元素的内容,但是我们还没有更新和删除节点的方法。
在写之前,我相信大家都知道,React通过diff,比较新旧虚拟dom,其实就是我们的fiber,通过比较后判断节点是新增、更新亦或是删除,不同情况为节点打上不同标签,最终在渲染时根据标签进行不同处理。
那么按照这个思路,我们需要做的就是先保存好旧fiber对象。
let nextUnitOfWork = null
let wipRoot = null
// 定义好currentRoot,保存我们当前执行的fiber
let currentRoot = null
。。。
function commitRoot() {
commitWork(wipRoot.child)
// 在清空wipRoot之前,将它保存到currentRoot上
currentRoot = wipRoot
wipRoot = null
}
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
// 如果是第一次渲染,alternate最终为null
// 如果出现更新,alternate会保存本次渲染的fiber作为旧fiber
alternate: currentRoot,
};
nextUnitOfWork = wipRoot;
}
具体比较方法
有了旧的fiber,我们需要找到另一个比较的对象,即刚刚通过createElement生成的新element对象。
因为我们的目的是判断后打标签,最后通过标签进行不同渲染,那么这个标签就只能打在我们的fiber对象上。
所以这一步,我们要做的是比较旧fiber和新element,然后打上不同标签
我们只在performUnitOfWork中生成过fiber,修改这个函数
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
const elements = fiber.props.children
// 我们将之前生成fiber的部分抽离到reconcileChildren函数中
reconcileChildren(fiber, elements)
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
// 1、将内容放到新函数后,使用wipFiber代表当前在修改的fiber
function reconcileChildren(wipFiber, elements) {
let index = 0
// 2、已知我们在重新渲染后,会将上一次的fiber保存到alternate属性上。
// 因此我们之需要取alternate上的child即可,取出的为fiber
// 此外,elements为props.children传入,为解析出的对象
let oldFiber = wipFiber.alternate && wipFiber.alternate.child
let prevSibling = null
// 3、除了遍历所有新的elements对象外,还需要遍历旧fiber
// 因为如果只遍历前者,会出现不需要的旧节点无法被遍历到删除的情况
while (index < elements.length || oldFiber != null) {
const element = elements[index]
//const newFiber = {
// type: element.type,
// props: element.props,
// parent: wipFiber,
// dom: null,
//}
// 4、与原先不同我们的newFiber此时需要根据不同情形生成
let newFiber = null
// 5、我们通过fiber和elements对象都有的type属性来判断接下来要做的处理
const sameType = oldFiber && element && element.type == oldFiber.type
if (sameType) {
// TODO 如果两者类型相同,代表dom节点可以被复用,只需要更新新的props值
}
if (element && !sameType) {
// TODO 如果两者不同,且只存在element中,代表这是一个新节点,那么我们需要在后续创建它
}
if (oldFiber && !sameType) {
// TODO 如果只存在于oldFiber中,代表节点后续需要被删除
}
// 6、如果当前我们在遍历oldFiber,则将下一次执行对象指向当前fiber的兄弟元素
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
if (index === 0) {
wipFiber.child = newFiber
} else {
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
}
生成newFiber
然后我们再一一生成对应的newFiber
if (sameType) {
newFiber = {
type: oldFiber.type,// 新旧type都可以
props: element.props,// 最新的props,确保节点为最新状态
dom: oldFiber.dom,// 使用旧的dom,减少渲染时间
parent: wipFiber,// 本就是为当前fiber的所有子元素生成fiber,parent自然为当前fiber
// 保存oldFiber用于后续比较,需要注意这里是为了后续比较,而非下一次更新比较
// 比如后续需要比较这个newFiber的子元素时
alternate: oldFiber,
effectTag: "UPDATE",// UPDATE标签代表更新此节点
}
}
那我们再思考一下生成新节点的newFiber格式,你可以试着先自己写一下
if (element && !sameType) {
newFiber = {
type: element.type, // 新节点自然是新type
props: element.props,
dom: null,// 没有可以复用的dom,设置为null以便后续生成
parent: wipFiber,
// 如果为新增节点,后续的节点自然都是新的,那就没有比较的必要了,所以alternate为null即可
alternate: null,
effectTag: "PLACEMENT",
}
}
删除旧节点
if (oldFiber && !sameType) {
// 1、为旧节点打上删除的标签,不过我们整体渲染时只遍历新的wipRoot
// 所以我们需要将所有要删除的节点都push到需要删除的节点列表中
// 这样在后续整体渲染时,我们先手动遍历这个列表去删除即可
oldFiber.effectTag = "DELETION";
deletions.push(oldFiber);
}
// 2
let nextUnitOfWork = null
。。。
let deletions = null
// 3、每次在render时还需要清空上一次的deletions列表
function render(element, container) {
wipRoot = {
。。。
};
deletions = [];
nextUnitOfWork = wipRoot;
}
// 4、手动遍历一遍,列表的值会依次作为commitWork的入参传入执行
function commitRoot() {
deletions.forEach(commitWork);
commitWork(wipRoot.child);
currentRoot = wipRoot;
wipRoot = null;
}
创建、更新、删除节点
至此我们拥有了一条打上了各种标签的fiber链,我们需要根据每个fiber上的effectTag属性来分别处理dom
你应该还记得,我们是在commitWork中执行具体dom操作的,让我们再看一下这个函数
function commitWork(fiber) {
if (!fiber) {
return;
}
const domParent = fiber.parent.dom;
domParent.appendChild(fiber.dom);
commitWork(fiber.child);
commitWork(fiber.sibling);
}
可以看到,我们之前只有appendChild,即添加节点,所以现在我们还要加上更新和删除等情况
function commitWork(fiber) {
if (!fiber) {
return;
}
const domParent = fiber.parent.dom;
// 1、如果是新增,我们继续以前的策略,将dom挂载到父元素上
if (fiber.effectTag === "PLACEMENT" && fiber.dom != null
) {
domParent.appendChild(fiber.dom)
} else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
// 2、如果是更新,我们传入dom、旧的props和新的props进一步比较
updateDom(fiber.dom, fiber.alternate.props, fiber.props)
} else if (fiber.effectTag === "DELETION") {
// 3、如果是删除,则父元素使用removeChild方法,移除此元素
domParent.removeChild(fiber.dom)
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
updateDom
之所以将更新节点再单独拿出来讲,是因为在这个过程中,我们为了更新props,还需要进行一系列比较
首先我们需要知道,我们可以直接修改dom上的属性以更新props,比如:
这样我们再进行具体操作,先看这三个比较函数
// 1、第一个函数不知道你还有没有印象,我们在createDom中就使用过,children属性不会作为元素上的属性进行渲染
const isProperty = key => key !== 'children'
// 2、属性值不同,代表该属性需要更新
const isNew = (prev, next) => (key) => prev[key] !== next[key];
// 3、属性不存在新props中,代表需要被删除
const isGone = (prev, next) => (key) => !(key in next);
然后是updateDom函数
function updateDom(dom, prevProps, nextProps) {
// 删除已经不存在了的props
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach((name) => {
dom[name] = "";
});
// 更新所有新出现或者值不相等的props
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach((name) => {
dom[name] = nextProps[name];
});
}
你以为就这样结束了?作为单独一节自然要有它的排面,我们还有一个特殊的属性需要修改,那就是事件属性。
不同于普通属性,我们和React的规定一样,只有on开头的,才是我们的事件,所以为了区分事件属性,我们进行如下操作:
const isEvent = (key) => key.startsWith("on");
// 因为事件需要单独处理,在遍历其他属性时,需要忽略事件属性
const isProperty = (key) => key !== "children" && !isEvent(key);
然后在updateDom函数的头尾,我们分别加上
function updateDom(dom, prevProps, nextProps) {
// 删除已经不存在了的监听事件
// 你会发现我们不光过滤了isGone,其实也过滤了isNew
// 这是因为一个事件可以绑定多个执行函数,如果不先删除,就会存在新旧执行函数,都被调用的情况
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]);
});
// 删除已经不存在了的props
。。。
// 更新所有新出现或者值不相等的props
。。。
// Add event listeners
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach((name) => {
const eventType = name.toLowerCase().substring(2);
// 取on后的字符串作为真正的事件,react中的事件机制其实还有很多合成绑定的概念
// 这里不多做介绍,如果想了解可以看下面链接的文章
dom.addEventListener(eventType, nextProps[name]);
});
}
那么至此,我们其实已经完成了80%,元素的创建、更新、删除都可以使用,我们可以先执行一遍试一下,为了体现出更新的效果,我们可以先这样写
/** @jsxRuntime classic */
/** @jsx Didact.createElement */
const root = document.getElementById("root");
const updateValue = (e) => {
rerender(e.target.value);
};
const rerender = (value) => {
const element = (
<div>
<input onInput={updateValue} value={value}></input>
<h2>Hello {value}</h2>
</div>
);
Didact.render(element, root);
};
rerender("World");
我们希望h2标签里的内容,能根据输入框中的内容,实时更新,这里放上完整的代码我们试一下
import React from "react";
let nextUnitOfWork = null;
let wipRoot = null;
let currentRoot = null;
let deletions = null;
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: [],
},
};
}
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)
.forEach((name) => {
dom[name] = fiber.props[name];
});
return dom;
}
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
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;
}
}
function reconcileChildren(wipFiber, elements) {
let index = 0;
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;
if (sameType) {
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: "UPDATE",
};
}
if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT",
};
}
if (oldFiber && !sameType) {
oldFiber.effectTag = "DELETION";
deletions.push(oldFiber);
}
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
if (index === 0) {
wipFiber.child = newFiber;
} else {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
}
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
};
deletions = [];
nextUnitOfWork = wipRoot;
}
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 commitRoot() {
deletions.forEach(commitWork);
commitWork(wipRoot.child);
console.log(wipRoot.child)
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) {
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
} else if (fiber.effectTag === "DELETION") {
domParent.removeChild(fiber.dom);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
function workLoop(deadline) {
let shouldYield = false;
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadline.timeRemaining() < 1;
}
if (!nextUnitOfWork && wipRoot) {
commitRoot();
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
const Didact = {
createElement,
render,
};
/** @jsxRuntime classic */
/** @jsx Didact.createElement */
const root = document.getElementById("root");
const updateValue = (e) => {
rerender(e.target.value);
};
const rerender = (value) => {
const element = (
<div>
<input onInput={updateValue} value={value}></input>
<h2>Hello {value}</h2>
</div>
);
Didact.render(element, root);
};
rerender("World");
然后你就会发现,并没有更新。。。你可以先试着猜一下,是哪里出了问题。
其实是因为旧的createDom函数并没有处理对事件的监听,如今我们有了updateDom函数,正好可以重写一下createDom
function createDom(fiber) {
const dom =
fiber.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type);
updateDom(dom, {}, fiber.props);
return dom;
}
再次执行看看吧
函数式组件
我们知道React还支持函数式组件,如果在我们现在的代码中尝试使用函数式组件会怎么样呢?
/** @jsxRuntime classic */
/** @jsx Didact.createElement */
const root = document.getElementById("root");
function App(props) {
return <h1>Hi {props.name}</h1>
}
const element = <App name="foo" />;
Didact.render(element, root);
答案是会报错
我们在‘从JSX到Render’那一节介绍element对象时就说过,对象的type属性一般为字符串,即标签名,但是在解析函数式组件时,它的值就会为function,而html并没有function这种标签,自然在创建时就报错了。
上方代码实际会被解析为
function App(props) {
return Didact.createElement(
"h1",
null,
"Hi ",
props.name
)
}
const element = Didact.createElement(App, {
name: "foo",
})
可以看到,对于例子中的函数式组件,App被作为了type属性的值,那么为了拿到实际return的element对象其实很简单,我们只需要执行这个函数,并且将props作为参数传入即可
performUnitOfWork增加对函数组件的处理
可以看到对于函数式组件,我们获取到element对象的方式会有所不同,为了能对其进行处理,我们需要再次重写performUnitOfWork函数
function performUnitOfWork(fiber) {
// 1、先判断是否为函数式组件
const isFunctionComponent = fiber.type instanceof Function;
if (isFunctionComponent) {
updateFunctionComponent(fiber);
} else {
updateHostComponent(fiber);
}
if (fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
// 2、函数式组件只需要执行它的type即具体函数,然后将props作为参数
function updateFunctionComponent(fiber) {
const children = [fiber.type(fiber.props)]
// 获取到最新的element对象后传入比较函数
reconcileChildren(fiber, children)
}
function updateHostComponent(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
reconcileChildren(fiber, fiber.props.children);
}
这里有一点需要我们注意,不知道你有没有发现,在updateHostComponent函数中,我们为当前fiber生成了dom,但在updateFunctionComponent函数中,我们却并没有这样做。这是因为作为函数式组件,函数名并不是一个标签,虽然函数最终返回了一个具体的element对象,使得我们可以通过这个对象生成dom,但它应当作为一个fiber元素,放到函数组件fiber的child属性下。
commitWork中增加对函数组件的处理
函数式组件挂载实际dom的操作亦有些不同。首先因为函数组件fiber时上没有dom,所以在判断到fiber.dom != null时就无法继续了。然后在挂载其return的fiber时,因为它的父元素是函数组件,没有parent.dom供我们挂载,所以需要向上查询,直到找到实体dom。
function commitWork(fiber) {
if (!fiber) {
return;
}
// const domParent = fiber.parent.dom;
let domParentFiber = fiber.parent
// 1、使用循环是因为函数式组件存在嵌套的情况,我们不知道哪层才有dom
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.parent
}
const domParent = domParentFiber.dom
if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
。。。
} else if (fiber.effectTag === "DELETION") {
// 2、删除dom也需要兼容函数式组件
commitDeletion(fiber, domParent)
}
}
和挂载dom一样,删除函数式组件内部的dom也需要先找到实体的dom
// 递归调用,直到fiber.dom存在
function commitDeletion(fiber, domParent) {
if (fiber.dom) {
domParent.removeChild(fiber.dom)
} else {
commitDeletion(fiber.child, domParent)
}
}
Hooks
说完函数式组件就不得不提hooks,不过我们先来看一个例子,我们想要在函数式组件中,实现之前通过input输入框控制标签内容
function App(props) {
let value = 'World'
const updateValue = (e) => {
value = e.target.value
};
return (
<div>
<input onInput={updateValue} value={value}></input>
<h2>
{props.name} {value}
</h2>
</div>
);
}
const root = document.getElementById("root");
const element = <App name="Hello" />;
Didact.render(element, root);
执行后,无论你怎么输入,h2标签的内容都不会有任何反应,因为我们根本没有为nextUnitOfWork赋值去触发performUnitOfWork,而且即使触发,也会在解析App时将value重置为'World'。
也就是说,我们不能在函数内部保存数据,而是需要在某个不会被重置的地方保存,这个地方就是我们的fiber。而且在值修改后,我们需要触发重新渲染。
useState
我们先像React一样创建一个useState函数
const Didact = {
createElement,
render,
useState,
};
// 1、函数接收一个初始值
function useState(initial) {
// 2、它应当返回一个列表,第一项为存储的state,第二项为一个函数,用于接收对state的操作
const setState = (action) => {
}
return [state, setState]
}
然后在函数组件中使用
function App(props) {
const [value, setValue] = Didact.useState("World");
const updateValue = (e) => {
setValue(e.target.value)
};
return (
<div>
<input onInput={updateValue} value={value}></input>
<h2>
{props.name} {value}
</h2>
</div>
);
}
我们用fiber保存我们的hook,这样在下一次更新时,我们就可以通过fiber上的alternate来获取旧的hook了
。。。
// 1、先定义一个当前修改中的fiber,因为我们在别的函数中也用过这个名字
// 如果让你感到困惑,你也可以换个名字
let wipFiber = null
。。。
// 2、修改更新函数组件函数,我们需要在这里为wipFiber赋值,以便后续更新操作中能获取旧hook
function updateFunctionComponent(fiber) {
wipFiber = fiber
// 3、再为wipFiber定义一个hooks属性,用于保存新的hooks,用于下一次更新
// 也就是这次更新完成后,wipFiber作为alternate属性保存,下次就可以通过alternate获取这次的hooks
wipFiber.hooks = []
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
可以看到,我们每次updateFunctionComponent都是对函数fiber.type(fiber.props)的一次重新调用,但此时函数因为无法保存状态,所有数据都会被重置。
但我们还可以看到,每次执行App函数,useState其实也会被重新调用,所以我们只需要在此时的state中返回hook中保存的值,而非默认值即可完成更新操作
function useState(initial) {
// 1、先获取到旧的hooks
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks
// 2、通过hook中的state来保存值,如果有oldHook,说明是更新操作,我们state设置为上一次的值,否则取默认值
const hook = { state: oldHook ? oldHook.state : initial }
const setState = (action) => {
}
// 3、保存最新hook,并返回state
wipFiber.hooks.push(hook)
return [hook.state, setState]
}
可以看到我们的代码里有hooks和hook,这是因为一个函数式组件中不仅仅只有一个hook,比如我们将例子改为
function App(props) {
const [value, setValue] = Didact.useState("World");
const [value2, setValue2] = Didact.useState(1);
const updateValue = (e) => {
setValue(e.target.value);
setValue2(2);
};
return (
<div>
<input onInput={updateValue} value={value}></input>
<h2>
{props.name} {value} {value2}
</h2>
</div>
);
}
在这种情况下,我们就需要遍历所有hook,但这个遍历的过程并不需要我们自己来做,因为比如像例子中的两个useState函数在组件执行时会依次调用,我们只需要按顺序去取旧hooks数组中的值即可
let wipFiber = null
// 1、定义一个hooks下标,用于找到对应的hook
let hookIndex = null
。。。
function updateFunctionComponent(fiber) {
wipFiber = fiber
// 2、每次更新组件,下标都需要重置,即我们要从第一个useState开始更新
hookIndex = 0
wipFiber.hooks = []
const children = [fiber.type(fiber.props)];
reconcileChildren(fiber, children);
}
function useState(initial) {
// 3、此时需要去获取的就不是hooks而是具体的某个hook了,根据下标获取
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex]
const hook = { state: oldHook ? oldHook.state : initial }
const setState = (action) => {
}
// 3、保存最新hook,并返回state
wipFiber.hooks.push(hook)
// 4、执行完当前hook后下标++,使得下一个useState被调用时,hook也是对应的下一个
hookIndex++
return [hook.state, setState]
}
看到这里相信大家也就明白了为什么不能在条件语句中执行hook了,因为一旦调用的顺序改变,比如我们有三个useState,第二个没有被执行,第三个被执行了,那么它执行时的下标会是对应第二个hook,结果返回的也是第二个state,数据就发生了错乱。
处理完了state我们接下来处理setState,我们在定义setState时就说过,这个函数主要是用来处理传入的action,然后通过action来获取最新的state,比如例子中的setValue(e.target.value)当然传入的也有可能是一个函数。此外我们修改完state后,也不能忘了要去触发重新渲染
function useState(initial) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex];
const hook = {
state: oldHook ? oldHook.state : initial,
// 1、我们将所有action保存到queue中,这样在一个函数组件中
// 如果我们对同一个setState进行多次调用,就可以在渲染时一次性更新上去
// 这也是我们在setState后,页面数据总是取最后一次的原因
queue: [],
}
// 2、我们去获取旧hook中的queue然后执行
const actions = oldHook ? oldHook.queue : []
actions.forEach(action => {
// 3、如果接收到的action则将state作为参数执行,否则直接返回action
hook.state = action instanceof Function ? action(hook.state) : action;
})
const setState = (action) => {
hook.queue.push(action);
// 我们模仿render中的操作,来触发工作流
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
nextUnitOfWork = wipRoot;
deletions = [];
};
wipFiber.hooks.push(hook);
hookIndex++;
return [hook.state, setState];
}
我们可以看到,按照这个流程,函数式组件在第一次执行useState时,就能获取到初始state和一个setState函数,通过结构赋值这两者都可以被随意取名。
每次调用setState,就会为hook中的queue存储操作,然后触发工作流,触发后函数式组件重新被执行,useState也再次被执行,updateFunctionComponent将当前fiber存储到了wipFiber中,然后这次我们在wipFiber中找到了旧的hook,函数式组件的state从而有了载体,我们就可以获取到上一次的state,然后执行queue中的action更新
最终执行一次,value会是输入框的值,value2每次输入加一
function App(props) {
const [value, setValue] = Didact.useState("World");
const [value2, setValue2] = Didact.useState(1);
const updateValue = (e) => {
setValue(e.target.value);
setValue2((value2) => value2 + 1);
};
return (
<div>
<input onInput={updateValue} value={value}></input>
<h2>
{props.name} {value} {value2}
</h2>
</div>
);
}
写在最后
感谢各位能看到这,如果有什么建议请一定要提出来发在评论区。
最后,求求各位好哥哥们点点赞吧,你们的赞就是我跑路的资本。