前言
本篇文章只是对https://qcsite.gatsbyjs.io/build-your-own-react/网站内容,做的一个内容的压缩,结合上自己的理解与想法,告诉已经理解虚拟dom的人怎么去一步一步的写一个超级mini的React。
看完后,相信你对框架的理解也会更进一步。
有错误欢迎评论指出!以下“网站”一词指代上面的网站(绕口令哈哈)
起步工作
先自己手起一个初始的react项目,暂时不用管是什么版本的(我们起是18,网站里说的是16),不太重要。
npx create-react-app my-app
先看虚拟dom长啥样
在src下的index.js中打印:
const element = React.createElement(
"div",
{ id: "box" },
"盒子",
React.createElement("a", { id: "link" }, "点击")
);
console.log("虚拟dom", element);
展开子项:
其实虚拟dom长啥样想必大家都已经知道了,我们只需要模拟出那个几个关键的属性即可。
写个createElement
可以看到通过React.createElement
可以生成虚拟dom,那我们就模拟着写个简单的例子。
首先创建文件src/miniReact/createElement.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: [],
},
};
}
export default createElement;
可以拿上面React.createElement
输入案例来看,把入参都放到我们写的函数里,是不是也是一样的。
写个render
在src下的index.js中,我们可以这样渲染出真实的dom在页面上:
const element = React.createElement(
"div",
{ id: "box" },
"盒子",
React.createElement("a", { id: "link" }, "点击")
);
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(element);
可以看出是通过了render函数去做的真实dom转换,不过使用方式和网站上的16版本不太一样:
const container = document.getElementById("root");
ReactDOM.render(element, container);
咱们这里为了贴合网站的案例,也按照网站上的写一个render的简单实现。
创建文件src/miniReact/render.js
,然后写入:
function render(element, container) {
// 创建对应的真实dom对象
const dom =
element.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type);
// 判断是否为非children属性
const isProperty = (key) => key !== "children";
// 把虚拟dom上的children属性赋值在真实dom对象上
Object.keys(element.props)
.filter(isProperty)
.forEach((name) => {
dom[name] = element.props[name];
});
// 这里对子级进行递归处理
element.props.children.forEach((child) => render(child, dom));
// 每一层级的dom节点挂载
container.appendChild(dom);
}
export default render;
第一次试验
可以进行我们的第一个初步的试验了,看能不能替代原来的写法,首先创建src/miniReact/index.js
,然后写入:
// 合并导出
import createElement from "./createElement";
import render from "./render";
export default { createElement, render };
这个文件就相当于ReactDOM。
好啦,我们在src下的index.js把我们写的miniReact替换掉原来的ReactDOM:
import miniReact from "./miniReact";
const element = miniReact.createElement(
"div",
{ id: "box" },
"盒子",
miniReact.createElement("a", { id: "link" }, "点击")
);
const container = document.getElementById("root");
miniReact.render(element, container);
看看你的页面是不是正常渲染了!
了解Fiber
这个是为接下来的代码理解做的理论知识铺垫,一定要懂了!!!才好理解下面的代码!!!
这玩意网上讲的太复杂了,可以看我这篇大白话给你讲明白:【react框架】别把Fiber整得那么难理解,来参考下我是咋理解的,用大白话解释
写个Fiber化函数
知道fiber是个啥东东后,咱们可以写个fiber化的函数了,在src/miniReact/render.js
里面加入:
// 创建真实dom放在单独一个函数里执行
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;
}
// 节点fiber化:把每一个虚拟dom阶段fiber化,最后还要返回下一个节点,因为下一个节点在本次处理中是没有完全fiber化的
function performUnitOfWork(fiber) {
// 1 先给自己创建真实dom
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
// 2 接下来就是理清指向:父级、子级、兄弟
// 如果有父节点,那么要把这个fiber对象插入父节点下,要不怎么知道每个fiber之间的关系,而且这种形式可以向下慢慢发展成真实的dom树
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom);
}
const elements = fiber.props.children; // 拿到所有子级们
let index = 0;
let prevSibling = null; // 上一个兄弟节点
// 开始循环子级们
while (index < elements.length) {
const element = elements[index]; // 拿到每个子级
const newFiber = { // 创建子级的fiber对象
type: element.type,
props: element.props,
parent: fiber,
dom: null,
};
if (index === 0) { // 第一个子级的处理,正常写入到child属性中
fiber.child = newFiber;
} else { // 其他的子级就用每个fiber子级的sibling属性记录上一个兄弟节点的指向
prevSibling.sibling = newFiber;
}
prevSibling = newFiber; // 上一个兄弟节点不断移动至下一位
index++;
}
// 3 这个函数要返回一个fiber出来,作为下一个单元小任务
// 因为本节点已经完全fiber化了,所以可以看看有没有子节点,有的话继续fiber化
if (fiber.child) {
return fiber.child;
}
// 没有子节点了,就看看兄弟节点,不断的去找兄弟节点
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent; // 如果兄弟节点也没有了,返回到上一个节点
}
}
第一次看可能有点懵逼,沉住气,慢慢看,你会有收获的。
改造render
知道要有fiber化的过程后,就是要改造render函数了,在src/miniReact/render.js
里面改造。
首先我们要驱动fiber化函数,让它自动去遍历我们的虚拟dom,把每个阶段都fiber化:
let nextUnitOfWork = null; // 记录下一个单元任务,单元任务就是performUnitOfWork函数做的事情
// 单元任务轮番触发机制
function workLoop(deadline) { // 默认requestIdleCallback执行的时候会有参数传入
let shouldYield = false; // 主线程是否空闲
// 如果有下一个单元任务要执行并且主线程空闲
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork); // 执行当前单元任务并且拿到下一个单元任务
shouldYield = deadline.timeRemaining() < 1; // 这个方式可以知道主线程是否空闲
}
requestIdleCallback(workLoop); // 当主线程空闲时触发,这个api不懂的话去MDN查下,这里就不赘述了
}
requestIdleCallback(workLoop); // 第一次启动
这里方便实现就用requestIdleCallback
这个api了。
ok,这里就差不多了,我们接下来就只需要把第一个节点任务塞入到nextUnitOfWork
变量里即可(这样workLoop
就能正常工作了),这个事情就交给render:
function render(element, container) {
nextUnitOfWork = {
dom: container,
props: {
children: [element],
},
};
}
做完这一切,在页面上看是否一切正常,正常就表示改造成功!
按需渲染换成一次性渲染
按照我们之前的方式,最终呈现的结果是按需渲染,也就是先fiber化后的节点会先渲染出来。
问题来了,如果主线程非空闲时间过长,会有一个问题,页面会出现渲染一半的内容,等主线程空闲了才继续渲染完成。
要是大组件这样子处理还有按需加载那味,但是我们这个fiber是颗粒度很小的,显然这样是不行,我们还是要换成一次性完整的将视图渲染上去。
继续改造src/miniReact/render.js
。
首先在performUnitOfWork函数里,咱们不需要向下不断发展真实dom树了,所以把相关代码剔除:
function performUnitOfWork(fiber) {
// 1 先给自己创建真实dom
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
// 2 接下来就是理清指向:父级、子级、兄弟
// 如果有父节点,那么要把这个fiber对象插入父节点下,要不怎么知道每个fiber之间的关系,而且这种形式可以向下慢慢发展成真实的dom树
// if (fiber.parent) {
// fiber.parent.dom.appendChild(fiber.dom);
// }
// ...
没有父级指向了咋办,这里先不管
然后我们要能够知道什么时候虚拟树完全fiber化,并且能够拿到fiber节点树的根节点,就可以递归去把这颗树一次性渲染成真实的页面了。
怎么去拿到fiber节点树的根节点?可以创建一个变量,通过对象引用类型的特性去追踪当前fiber的节点对象:
let wipRoot = null; // 记录fiber化进度,也就是当前fiber节点
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
};
nextUnitOfWork = wipRoot;
}
然后我们要写个递归渲染真实dom 的函数,一般叫commit
:
// 从根节点开始渲染dom
function commitRoot() {
commitWork(wipRoot.child);
wipRoot = null;
}
// 递归渲染真实dom
function commitWork(fiber) {
if (!fiber) {
return;
}
const domParent = fiber.parent.dom;
domParent.appendChild(fiber.dom);
commitWork(fiber.child);
commitWork(fiber.sibling);
}
也就是说我们知道知道在完成fiber化后,调用commitRoot
即可从根节点开始渲染dom,所以可以在workLoop
里添加上:
// 单元任务轮番触发机制
function workLoop(deadline) {
// 默认requestIdleCallback执行的时候会有参数传入
let shouldYield = false; // 主线程是否空闲
// 如果有下一个单元任务要执行并且主线程空闲
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork); // 执行当前单元任务并且拿到下一个单元任务
shouldYield = deadline.timeRemaining() < 1; // 这个方式可以知道主线程是否空闲
}
// 如果没有单元任务了,开始一次性渲染真实dom ++++++++++++++=
if (!nextUnitOfWork && wipRoot) {
commitRoot();
}
requestIdleCallback(workLoop); // 当主线程空闲时触发,这个api不懂的话去MDN查下,这里就不赘述了
}
做完后再去跑跑代码,看看页面是否正常显示!
加入diff算法
做完了以上事情,我们不难发现,每次更新页面的时候,都要重新向下遍历一个新的真实dom树,不断的创建,添加dom,对性能很不友好。所以要加入一个简单的diff算法,只更改变换的地方。
不知道diff算法是啥的,建议先去了解下哈哈
未完待续…