还记得我上次给大家安利的build ur own react
那篇文章吗, 其实有些同学和我说那个是上万字还全是英文的,有些看不太明白, 于是我...总结了一个中文的版本(根据自己的对react的理解联合上部分觉得他文中说的比较好的一些地方), 希望能够对大家有所帮助
顺便再安利一下这篇文章, 针不戳, 有能力的同学可以看了我这篇中文文档再去看看这篇英文文档: pomb.us/build-your-…
那么, let's go
这篇文章的一个核心目的是带着大家基于react的一个源码架构, 我们来从0实现一个自己的mini版本的react, 为啥是mini呢, 因为抓大放小, 抓的大就是我们把react的核心功能都实现一遍, 放的小就是我们把一些react在源码中做的性能优化和一些平时我们用得少甚至不会去用的功能给忽略掉
本篇博文要实现的功能是基于React16.8存在的
我们要实现的一个功能大致如下:
- createElement 函数
- render 函数
- Concurrent Mode & Fiber
- render阶段和commit阶段
- Reconciliation(react中的diff算法)
基本回顾
这一块主要是和大家一起回顾一下React, JSX, Dom的一个基本工作模式, 如果你觉得自己对这块已经比较熟了, 那你完全可以跳过这一节直接进入下一节的阅读
来看一个例子
const element = (<div >hello, div</div>);
const container = document.getElementById("root");
ReactDOM.render(element, container);
上面的代码完全是基于React架构的一个实现, 他实现了将一个div
元素渲染进id
为root
的真实dom容器中的功能
我们现在不用react, 就用原生JS来实现这个功能, 你可以停下想想, 你会怎么实现
// 比较nice的方式就是我们可以直接用一个对象来描述一个JSX表达式
// 当然不限制你的想象力, 只要你有任何方式可以描述出上面的JSX表达式
// 最终能够渲染出来, 那都是OK的
const element = {
type: "div",
props: {
title: "foo"
},
children: ["hello, div"], // 这个children为啥是一个array, 因为你想啊, 我们是可以直接在div中写什么span标签, a标签的, 这个时候我们就必须用array来形容他了
}
const container = document.getElementById("root"); // 这个本身原生JS就提供了支撑, 所以我们压根没必要进行转换
// render方法的作用就是将element渲染进container中, 我们暂且不实现render方法本身
// 我们详细如果我们自己要达到将element渲染进container中要怎么做
const divDom = document.createElement(element.type); // 这样我们就创建了一个div节点
divDom.setAttribute("title", "foo"); // 将属性打上去, 其实你可以直接使用div["title"] = "foo"的形式
const textNode = document.createTextNode();
textNode.nodeValue = element.children[0];
divDom.appendChild(textNode);
container.appendChild(divDom);
OK, 到了这一步, 上面的代码就给我们不用React但是实现和react一样的功能提供了技术支撑, 这也是一个简单的基本回顾, 下面我们可以正式进入正题了
createElement函数
// 比如我有一行JSX表达式如下:
const element = (<div className="wrapper">hello, wrapper</div>);
// 他最终会被babel编译成如下形式
const element = React.createElement("div", { class: "wrapper" }, "hello wrapper");
// 至于你要说他的怎么编译的, 他还能咋编译, 字符串替换呗, 这个咱就暂且不论
通过之前的回顾认知代码我们应该也基本了解了, 其实本质上来说, 最终React和真实dom的一个连接点就是我们需要拥有一个具备type
和其他更多属性的一个对象, 而createElement
就是要给我们提供一个element
描述对象
那我们怎么去设计这个函数呢? 你可以停下来想想自己的思路
// 我们先固定参数
// type: 要创建上面说的一个对象, 我们需要知道当前的节点类型
// props: 当前节点上都有什么属性, 是一个对象
// children: 你可以看到我使用了收集运算符, 那就意味着后续的所有剩余参数我都要
// 收集进children作为他的元素存在, 而这样我们也保证了children永远是一个数组
// 当然你也可以强行约束用户手动给你传递一个children, 那这样你的createElement
// 就固定永远只有三个参数了, 不同的写法都可以
function createElement(type, props, ...children) {
// 然后我们根据参数将所有的属性返回出去
// 我这里是把children又塞入了props对象里, 这个也没所谓的
// 你想放哪放哪, 只要保证这个返回的对象里有children就ok拉
return {
type,
props: {
...props,
children
}
}
}
那么我们来尝试写个复杂一点的结构, 看看会不会有什么问题
// 我们有一个JSX表达式如下
const element = (<div className="wrapper">
<span class="title">我是标题</span>
<input placeholder="请输入文字" />
</div>);
// 根据预期的想法, 上面的代码会被babel转换成如下形式
createElement("div", { class: "wrapper" },
React.createElement("span", { class: "title" }, "我是标题"),
React.createElement("input", { placeholder: "请输入文字" }));
其实我们是可以看出一点问题的, 那么就是我们的createElement函数的children去收集到的子节点,他既可以是createElement创建的对象,
又可以是一个原始值(Primitive Value
)比如Number
或者String
, 这样就会造成一个小问题, 日后我们在遍历children的值的时候, 都不能放心的去确定children的子元素是不是一个对象, 需要去做逻辑判定if (Object.getPrototypeOf 子元素) === Object.prototype
, 这样很烦, 所以我们最好是在createElement
函数里就给他搞定了,
让children中的所有元素不管你给我的是啥, 我存的就是一个对象
// 我新建一个createTextNode的方法, 他专门来为我们生成文本节点
// 我们知道只有文本节点才可能是原始值吧, 这里你可以好好想一想
function createTextNode(textValue) {
return {
type: "text",
props: {
nodeValue: textValue,
children: []
}
}
}
// 然后我们稍稍改动一下我们的createElement方法
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map(child => Object.getPrototypeOf(child) === Object.prototype ? child :
createTextNode(child))
}
}
}
我们建立一个自己的myOwnReact
文件夹, 创建一个createElement.js
, render.js
和index.js
文件夹, 分别把我们代码加进去, 后续我们就会引入我们自己的myOwnReact
, 同时因为本身编译JSX是babel协助react去做的一件事情, 所以我们这里不会对babel怎么去编译JSX做过多的描述(其实我们压根不会使用JSX, 我们会假设已经通过了babel的编译变成了createElement
函数了)。
// myOwnReact/createElement
export function createTextNode(textValue) {
return {
type: "text",
props: {
nodeValue: textValue,
children: []
}
}
}
export function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map(child => Object.getPrototypeOf(child) === Object.prototype ? child : createTextNode(child))
}
}
}
// myOwnReact/index.js
export { default as createElement } from "./createElement.js"
import createElement from "./createElement"
export default {
createElement
}
render函数
接下来, 我们就该编写我们的render函数了
其实render函数的作用就是帮助我们将createElement
创建的对象渲染成真实dom
在此之前, 我们需要写一个utils.js
来为我们提供一些工具方法
// /myOwnReact/utils.js
export function checkIsTextNode(node) { // 该方法用来检测一个通过createElement创建出来的节点是不是文本节点
return node.type === "text";
}
// /myOwnReact/render.js
import { checkIsTextNode } from "./utils.js";
// render方法他接受两个参数:
// 1. element: 通过createElement创建的元素对象
// 2. container: 真实的dom容器
function render(element, container) {
const isTextNode = checkIsTextNode(element);
// 首先我们要通过根element的type来创建一个真实节点
// 但是我们需要区分一下节点类型, 如果是text节点的话我们就不要创建element了
const rootDom = isTextNode ? document.createTextNode("") : document.createElement(element.type);
// 这里我们还是要区分一下文本节点和dom节点, 因为文本节点是没有setAttribute方法的
// 我们需要将文本节点直接给到文本节点的nodeValue
// 当然其实在一开始我们创建文本节点的时候你就可以将nodeValue作为参数传递进去了
// 只不过我们上面传的是空串, 这个看个人喜好了
if (isTextNode) {
rootDom.nodeValue = props.nodeValue;
} else {
// 如果是dom节点, 我们要讲所有的props添加到该真实rootDom上, 但是除了children
const { children = [], ...restProps } = element.props;
const attrs = Object.keys(restProps);
attrs.forEach(k => domElement.setAttribute(k, restProps[k]));
// 递归子元素
children.forEach(child => render(child, domElement));
}
container.appendChild(rootDom)
}
至此我们的render方法就写完了
同样我们需要在上面的index.js
中做一个具名导出
// /myOwnReact/index.js
...
export { default as render } from "./render.js"
import render from "./render.js"
export {
...,
render
}
...
到这里, 我们可以创建一个index.html
, 然后书写如下代码, 我们可以看看页面中是不是出现了我们想要的结果
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Build Ur Own React</title>
</head>
<body>
<div id="root"></div>
<script type="module">
import { createElement, render } from "./index.js"
const element = createElement("div", { class: "wrapper" },
createElement("span", { class: "title" }, "我是标题"), createElement("div", { class: "content" }, "我是内容"));
console.log("element", element);
render(element, document.getElementById("root"));
</script>
</body>
</html>
打开live server
, 我们可以看到页面中呈现的效果和浏览器的对element
的打印结果如下:
Concurrent Mode & Fiber
我们来探讨一个一个问题: 当上面的render
方法开始执行以后, 我们能中断render
方法的执行吗?
很显然, 答案是no, 那么这样会造成一个什么问题呢
解决这个问题, React的思路是这样的:
- 将整个渲染过程分解成n多个小单元
- 当每一个小单元渲染完毕以后, 我们就看看现在是否有交互啊, 有没有其他需要中断渲染的操作啊, 如果有的话, 那就中断渲染, 如果没有的话就进行下一个单元的渲染
我们需要改造一下我们的render.js
文件
我们现在在render方法里是直接进行了渲染, 而且一渲染就直接递归把他的所有子节点都跟着渲染了, 这样肯定是不OK的, 根据我们上面的说法, 我们需要将一项庞大的渲染工作拆分成小而独立的UI单元, 这样渲染起来比较轻松
同时在更新render.js
这个文件之前, 我建议大家先去看看requestIdleCallback
这个函数: developer.mozilla.org/zh-CN/docs/…
然后我们再来简单说一说Fiber, 我们都知道Vue中有虚拟dom对把, 而React中也有虚拟dom, 只不过他的名字叫做Fiber
// 我们通过createElement创建的对象还不是一个虚拟dom哦, 他只是一个基本的描述对象
// 简单来说fiber就是一种数据结构
const Fiber = {
type: null, // 该fiber节点对应的标签类型
parent: null, // 父级fiber节点
sibling: null, // 兄弟fiber节点
child: null, // 自己fiber节点
dom: null, // 该fiber节点对应的真实dom元素
props: {}, // 该fiber节点的所有属性
effectTag: null, // 该fiber节点对应的更新状态, 在更新阶段会用到
}
// 里面的每个属性都有自己的用途, 随着我们的书写后续你就知道了
import { checkIsTextNode } from "./utils.js";
let nextUnitOfWork = null; // 我们假设这个变量中存储的就是每一次需要渲染的UI单元, 我们通过不断变动这个变量的值来控制本次渲染的究竟是什么
// 我们现在知道, render他的任务其实还是渲染一整个dom树, 但是我们要改变一下策略, 我们通过render来开启一项自动工作的调度任务
// 该调度任务会源源不断的帮助我们进行dom的渲染, 就像流水线上的一条业务线一样, 但是该调度任务会在没有东西可以渲染的时候停下来
// 同时也会在需要停止的时候停下来(什么时候需要停止? 比如上面我们说的用户高优先级的输入响应操作)
export default function render(element, container) {
// 我们现在把调度开关的开启决断于 nextUnitOfWork有没有值
// 所以我们要开启调度, 那么就给nextUnitOfWork赋值
nextUnitOfWork = { // nextUnitOfWork其实就是一个fiber节点了
type: null,
dom: container,
parent: null, // 父级fiber节点
sibling: null, // 兄弟fiber节点
child: null, // 他的子fiber节点
effectTag: "placement", // 这是在后续更新阶段会使用到的一个fiber标记, placement表示新增节点
props: {
children: [element]
}
}
}
// 那么我们势必是需要一个玩意去感知nextUnitOfWork是不是有值了, 来写个workLoop方法
// 这个deadline是啥玩意: 他就是requestIdleCallback给我们传的一个参数, 我们等会会用这个参数来
// 决定我们是否需要停止下一个单元的渲染
function workLoop(deadline) {
let shouldYield = false; // 我们是否需要停止渲染的一个flag, false就是不需要, true就是需要停止了
while (nextUnitOfWork && !shouldYield) {
// 只要当前要处理的工作对象有值 而且 系统没有让我们停下来(shouldYield为false), 那我们就一直
// 执行任务
// performUnitOfWork是我们下面会补的一个函数
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
// deadline是一些关于浏览器闲暇情况的一个参数, 它里面有一个方法timeRemaining, 该方法
// 的调用会返回一个毫秒数, 代表浏览器当前闲置的一个剩余的估计时间, 比如当浏览器有任务要过来了, 他就会知道
// 并且他会大概计算一下这个任务过来还要大概多久时间, 所以当这个时间不多的时候, 我们需要把渲染任务停止, 、
// 让浏览器去做他该做的事情
shouldYield = deadline.timeRemaining() < 1;
}
// 当停止以后浏览器其实就有空去响应用户的操作了, 但是我们这里还是要记住需要源源不断的开启监听
requestIdleCallback(workLoop);
}
function createDom(fiber) {
// 我们会根据传递进来的fiber节点, 然后构建出属于该fiber节点的唯一的真实dom
const isTextNode = checkIsTextNode(fiber);
const domElement = isTextNode ? document.createTextNode("") : document.createElement(fiber.type);
// 同理, 如果是文本节点, 我们就直接将nodeValue进行赋值
if (isTextNode) domElement.nodeValue = fiber.props.nodeValue;
else {
// 否则就对props进行赋值
const { children = [], ...attrs } = fiber.props;
const keys = Object.keys(attrs);
keys.forEach(k => {
domElement.setAttribute(k, attrs[k]);
})
// 注意: 我们这里不处理子元素, 我们只处理他本人
}
return domElement;
}
function performUnitOfWork(fiber) {
// 这个方法我们要做的事情就几个:
// 1. 根据当前的fiber节点给他创建对应的真实dom节点
fiber.dom == null && (fiber.dom = createDom(fiber))
// 2. 将nextUnitOfWork的dom推入到父级的dom中
if (fiber.parent) {
// 如果该fiber节点有父级fiber元素, 那我们就可以将该fiber推入到父级节点中去
fiber.parent.dom.appendChild(fiber.dom)
}
// 3. 开始构建该fiber的一些兄弟节点, 子节点的关系
const elements = fiber.props.children;
let index = 0;
let prevFiber = null; // 这是我们用来维护整个fiber链表的一个索引入口
// 请注意哈: 这个elements里面装的可全都是通过createElement创建的描述对象哈
while (index < elements.length) {
let newFiber = {
type: elements[index].type,
props: elements[index].props,
parent: fiber, // 他的父级节点是不是就是此次的fiber节点, 这个仔细屡一下
child: null,
sibling: null,
effectTag: "placement"
}
// 然后其实我们本次的fiber节点还没有child属性吧
if (index === 0) fiber.child = newFiber;
else {
prevFiber.sibling = newFiber;
}
prevFiber = newFiber;
index ++;
}
// 4. 我们还需要将下一次调度的nextUnitOfWork返回出去吧, 来保证每次都有新fiber节点可以被渲染
if (fiber.child) return fiber.child;
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
// 注意哦: 这里我们通过requestIdleCallback直接开启调度任务
requestIdleCallback(workLoop);
render phase & commit phase
我们现在不一个一个的塞入dom容器了, 我们将整个fiber tree全部构建完毕以后(代表着这个时候没有任何JS逻辑处理了), 我们再将整个树直接塞入真实dom里(依靠的就是fiber节点中的dom), 我们之前为什么说用户的响应会不及时, 是因为我们在渲染的时候会有非常多的JS逻辑操作, 而我们将dom塞进真实的容器中, 这消耗的时间远没有上面多, 所以我们更希望这样, 那么这里就涉及到两个概念:
- render phase: 代表我们进行JS逻辑处理和构建整个fiber 树的阶段, 在这个阶段如果有用户响应必须由我们处理(特别是在更新的时候), 那么我们将停止目前的工作, 直接去处理优先级更高的用户响应等操作
- commit phase: 代表我们整个fiber tree已经构建完毕了, 正在往真实dom容器里塞入, 这个时候我们是不会去管用户的交互和优先级的, 整个过程不可中断
那么我们就又得改改我们的render.js
// 1. 首先我们现在的每一次nextUnitOfWork都是会不断变化的, 所以我们压根拿不到根节点
// 而在render中对nextUnitOfWork的赋值一定是根节点
...
let nextUnitOfWork = null;
// 我们再多加一个变量
let wipRoot = null; // 代表整个fiber tree的根节点的引用, 因为我们知道要保存一棵树的引用保存他的根节点就OK了
...
function render(element, container) {
// 我们需要更新一下render 方法
wipRoot = {
dom: container,
parent: null,
sibling: null,
child: null,
props: {
children: [element],
},
effectTag: "placement",
type: null
}
nextUnitOfWork = wipRoot;
}
...
// 我们的workLoop方法里我们可以用来提交整个fiber tree
function workLoop(deadline) {
...
// 为什么workLoop里是可以的哈, 主要是因为我们知道, 每一次nextUnitOfWork的值的变换其实都是在workLoop里进行处理的
// 所以我们只能在workLoop里去感知现在的fiber tree是不是已经构建完了
// 我们直接在while 循环的下面进行判断
// 如果这个时候我们nextUtilOfWork为null了, 代表整个fiber tree已经构建完毕了, 所以我们要做的就是直接进入commit phase
if (!nextUnitOfWork && wipRoot) {
// 为啥一定得是nextUnitOfWork为null才行哈, 主要是因为就算不为空也可能会走到这里来
// 因为中断渲染的时候, 这个时候nextUnitOfWork一定还有值, 但是呢 他又一定会走进这个流程
// 我们始终只希望一点, 就是整个fiber 树确定构建完了 我们才会进行提交
commitRoot();
}
// 当停止以后浏览器其实就有空去响应用户的操作了, 但是我们这里还是要记住需要源源不断的开启监听
requestIdleCallback(workLoop);
...
}
// 同时新增以下两个方法, 这两个方法都比较简单, 我就不说了哈
function commitRoot() {
commitWork(wipRoot.child); // 因为wipRoot一定是container这个dom嘛, 所以我们直接从子元素开始提交
wipRoot = null;
}
function commitWork(fiber) {
if (!fiber) return;
fiber.parent.dom.appendChild(fiber.dom);
commitWork(fiber.child);
commitWork(fiber.sibling);
}
Reconciliation
大家一定听说过Vue
的一个diff
算法吧, 同样作为MVVM框架, React
也具备自己内部比对虚拟dom的一个算法, 他叫做Reconciliation
主要的这个流程就是我们需要在每一次更新的时候去对上一次保存的虚拟dom树进行一个比较, 从而决定我们是有哪些节点更新, 哪些节点被删除, 又有哪些新增的节点
我们再次对我们的render.js
文件下手了
// 1. 首先我们在全局加一个currentRoot用来保存之前的fiber tree, 同时加一个deleteGroup来保存本次比对需要删除的dom元素
...
let nextUnitOfWork = null;
let wipRoot = null;
let currentRoot = null; // 我们要保存的本次的整个fiber Tree
let deleteGroup = []; // 被删除的fiber集合
// 然后我们需要在commitRoot里加一些代码
function commitRoot() {
// 因为这里我们是会把wipRoot清空的, 所以在清空之前我们一定要保存一下引用
...
currentRoot = wipRoot;
wipRoot = null;
...
}
...
// 然后就是我们要更新一下我们performUnitOfWork函数
function performUnitOfWork(fiber) {
...
// 2. 开始构建该fiber的一些兄弟节点, 子节点的关系
const elements = fiber.props.children;
// 我们之前在这里写了一大块处理子节点fiber的东西, 我们都不要了, 直接拎出去
// 代码看着怪恶心的
reconciliationChildren(fiber, elements);
...
}
// 然后编写我们的reconciliationChildren方法
// 我们定义一个对子元素进行diff比较的方法
function reconciliateChildren(wipRoot, elements) {
// 1. 首先我们拿到最近保存的一个虚拟dom树
const oldFiber = wipRoot.alternate && wipRoot.alternate.child;
let index = 0;
let prevFiber = null; // 这是我们用来维护整个fiber链表的一个索引入口
// 请注意哈: 这个elements里面装的可全都是通过createElement创建的描述对象哈
// 因为我们这里要进行逐层比对, 而且会对oldFiber进行多次值的修改, 所以我们并不能够以
// index < elements.length为结束手段, 因为如果elements.length没有了
// 但是oldFiber还有 那其实代表的是最新的fiber节点里做了删除操作
while (index < elements.length || oldFiber != null) {
const el = elements[index];
// 如果本次oldFiber和新的el类型相同, 我们就要留存一部分信息以节约性能
const isSameType = el && oldFiber && el.type === oldFiber.type;
let newFiber = null;
if (isSameType) {
// 代表是更新阶段
newFiber = {
type: oldFiber.type,
parent: wipRoot,
sibling: null,
child: null,
props: el.props,
alternate: oldFiber,
effectTag: "update",
dom: oldFiber.dom
}
} else if (oldFiber && !isSameType) {
// 代表做了删除
oldFiber.effectTag = "delete";
deleteGroup.push(oldFiber); // 往本次被删除的集合中添加一个oldFiber
} else if (el && !isSameType) {
// 代表做了新增
newFiber = {
type: elements[index].type,
props: elements[index].props,
parent: wipRoot, // 他的父级节点是不是就是此次的fiber节点, 这个仔细屡一下
child: null,
sibling: null,
effectTag: "placement",
dom: null
}
}
// 然后其实我们本次的fiber节点还没有child属性吧
if (index === 0) wipRoot.child = newFiber;
else {
prevFiber.sibling = newFiber;
}
prevFiber = newFiber;
index ++;
}
}
// 有了上面的reconciliationChildren 方法来比对虚拟dom以后, 其实我们在commitWork的时候就需要区分一下状态了
function commitWork(fiber) {
if (!fiber) return;
if (fiber.effectTag === "placement") {
// 新增
fiber.parent && fiber.parent.dom.appendChild(fiber.dom);
} else if (fiber.effectTag === "update") {
updateDom(dom, fiber.alternate.props, fiber.props, );
} else if (fiber.effectTag === "delete") {
fiber.parent.dom.removeChild(fiber.dom);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
function updateDom(dom, prevProps, nextProps) {
// 1. 首先我要看的是有没有被移除的属性
const withoutChildrenPrevProps = Object.keys(prevProps).filter(k => k !== "children");
const withoutChildrenNextProps = Object.keys(nextProps).filter(k => k !== "children");
// 我要做的就是把旧的属性全部遍历一遍, 如果旧的有 新的直接没有了就remove掉
// 否则就是更新掉
withoutChildrenPrevProps.forEach(k => {
if (k.startsWith("on")) {
// 这代表是事件啊, 事件得悠着点
const legalEventName = k.toLowerCase().substring(2); // 我们知道React里是以onClick这种来标注事件的, 我们只需要小写的click
// 事件其实也分移除还是更新
if(!(k in withoutChildrenNextProps)) {
// 代表都没有了 我还留着干嘛啊
dom.removeEventListener(legalEventName, prevProps[k]);
} else {
// 直接绑定
dom.addEventListener(legalEventName, nextProps[k]);
}
} else if (!(k in withoutChildrenNextProps)) {
// 如果在新的属性里都没有这个key了, 直接拜拜
dom[k] = "";
} else {
// 到这里就一定是更新阶段, 全部以新的为主, 当然你也可以进行深层优化比较
dom[k] = nextProps[k];
}
})
}
到此为止, 我们的reconciliation阶段也完成了
我们最后的render.js文件代码如下:
import { checkIsTextNode } from "./utils.js";
let nextUnitOfWork = null; // 我们假设这个变量中存储的就是每一次需要渲染的UI单元, 我们通过不断变动这个变量的值来控制本次渲染的究竟是什么
let wipRoot = null; // 代表我最后要提交的整个fiber tree
let currentRoot = null; // 我们要保存的本次的整个fiber Tree
let deleteGroup = []; // 被删除的fiber集合
// 我们现在知道, render他的任务其实还是渲染一整个dom树, 但是我们要改变一下策略, 我们通过render来开启一项自动工作的调度任务
// 该调度任务会源源不断的帮助我们进行dom的渲染, 就像流水线上的一条业务线一样, 但是该调度任务会在没有东西可以渲染的时候停下来
// 同时也会在需要停止的时候停下来(什么时候需要停止? 比如上面我们说的用户高优先级的输入响应操作)
export default function render(element, container) {
// 我们现在把调度开关的开启决断于 nextUnitOfWork有没有值
// 所以我们要开启调度, 那么就给nextUnitOfWork赋值
wipRoot = { // nextUnitOfWork其实就是一个fiber节点了
type: null,
dom: container,
parent: null, // 父级fiber节点
sibling: null, // 兄弟fiber节点
child: null, // 他的子fiber节点
effectTag: "placement", // 这是在后续更新阶段会使用到的一个fiber标记, placement表示新增节点
props: {
children: [element]
},
alternate: currentRoot, // 我们在根节点处也留存一下我们上一次的fiber树
}
deleteGroup = []; // 每次render我们都应该置空被删除的fiber数组
nextUnitOfWork = wipRoot;
}
// 那么我们势必是需要一个玩意去感知nextUnitOfWork是不是有值了, 来写个workLoop方法
// 这个deadline是啥玩意: 他就是requestIdleCallback给我们传的一个参数, 我们等会会用这个参数来
// 决定我们是否需要停止下一个单元的渲染
function workLoop(deadline) {
let shouldYield = false; // 我们是否需要停止渲染的一个flag, false就是不需要, true就是需要停止了
while (nextUnitOfWork && !shouldYield) {
// 只要当前要处理的工作对象有值 而且 系统没有让我们停下来(shouldYield为false), 那我们就一直
// 执行任务
// performUnitOfWork是我们下面会补的一个函数
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
// deadline是一些关于浏览器闲暇情况的一个参数, 它里面有一个方法timeRemaining, 该方法
// 的调用会返回一个毫秒数, 代表浏览器当前闲置的一个剩余的估计时间, 比如当浏览器有任务要过来了, 他就会知道
// 并且他会大概计算一下这个任务过来还要大概多久时间, 所以当这个时间不多的时候, 我们需要把渲染任务停止, 、
// 让浏览器去做他该做的事情
shouldYield = deadline.timeRemaining() < 1;
}
// 如果这个时候我们nextUtilOfWork为null了, 代表整个fiber tree已经构建完毕了, 所以我们要做的就是直接进入commit phase
if (!nextUnitOfWork && wipRoot) {
// 为啥一定得是nextUnitOfWork为null才行哈, 主要是因为就算不为空也可能会走到这里来
// 因为中断渲染的时候, 这个时候nextUnitOfWork一定还有值, 但是呢 他又一定会走进这个流程
// 我们始终只希望一点, 就是整个fiber 树确定构建完了 我们才会进行提交
commitRoot();
}
// 当停止以后浏览器其实就有空去响应用户的操作了, 但是我们这里还是要记住需要源源不断的开启监听
requestIdleCallback(workLoop);
}
function commitRoot() {
deleteGroup.forEach(commitWork); // 看看有没有被删除东西
commitWork(wipRoot.child); // 因为wipRoot一定是container这个dom嘛, 所以我们直接从子元素开始提交
// 跑不掉的一定是在commit阶段对本次的一个虚拟dom树进行一个留存
currentRoot = wipRoot;
wipRoot = null;
}
function commitWork(fiber) {
if (!fiber) return;
if (fiber.effectTag === "placement") {
// 新增
fiber.parent && fiber.parent.dom.appendChild(fiber.dom);
} else if (fiber.effectTag === "update") {
updateDom(dom, fiber.alternate.props, fiber.props, );
} else if (fiber.effectTag === "delete") {
fiber.parent.dom.removeChild(fiber.dom);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
function updateDom(dom, prevProps, nextProps) {
// 1. 首先我要看的是有没有被移除的属性
const withoutChildrenPrevProps = Object.keys(prevProps).filter(k => k !== "children");
const withoutChildrenNextProps = Object.keys(nextProps).filter(k => k !== "children");
// 我要做的就是把旧的属性全部遍历一遍, 如果旧的有 新的直接没有了就remove掉
// 否则就是更新掉
withoutChildrenPrevProps.forEach(k => {
if (k.startsWith("on")) {
// 这代表是事件啊, 事件得悠着点
const legalEventName = k.toLowerCase().substring(2); // 我们知道React里是以onClick这种来标注事件的, 我们只需要小写的click
// 事件其实也分移除还是更新
if(!(k in withoutChildrenNextProps)) {
// 代表都没有了 我还留着干嘛啊
dom.removeEventListener(legalEventName, prevProps[k]);
} else {
// 直接绑定
dom.addEventListener(legalEventName, nextProps[k]);
}
} else if (!(k in withoutChildrenNextProps)) {
// 如果在新的属性里都没有这个key了, 直接拜拜
dom[k] = "";
} else {
// 到这里就一定是更新阶段, 全部以新的为主, 当然你也可以进行深层优化比较
dom[k] = nextProps[k];
}
})
}
function createDom(fiber) {
// 我们会根据传递进来的fiber节点, 然后构建出属于该fiber节点的唯一的真实dom
const isTextNode = checkIsTextNode(fiber);
const domElement = isTextNode ? document.createTextNode("") : document.createElement(fiber.type);
// 同理, 如果是文本节点, 我们就直接将nodeValue进行赋值
if (isTextNode) domElement.nodeValue = fiber.props.nodeValue;
else {
// 否则就对props进行赋值
const { children = [], ...attrs } = fiber.props;
const keys = Object.keys(attrs);
keys.forEach(k => {
domElement.setAttribute(k, attrs[k]);
})
// 注意: 我们这里不处理子元素, 我们只处理他本人
}
return domElement;
}
function performUnitOfWork(fiber) {
// 这个方法我们要做的事情就几个:
// 1. 根据当前的fiber节点给他创建对应的真实dom节点
fiber.dom == null && (fiber.dom = createDom(fiber))
// 2. 开始构建该fiber的一些兄弟节点, 子节点的关系
const elements = fiber.props.children;
reconciliateChildren(fiber, elements);
// 3. 我们还需要将下一次调度的nextUnitOfWork返回出去吧, 来保证每次都有新fiber节点可以被渲染
if (fiber.child) return fiber.child;
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}
// 我们定义一个对子元素进行diff比较的方法
function reconciliateChildren(wipRoot, elements) {
// 1. 首先我们拿到最近保存的一个虚拟dom树
const oldFiber = wipRoot.alternate && wipRoot.alternate.child;
let index = 0;
let prevFiber = null; // 这是我们用来维护整个fiber链表的一个索引入口
// 请注意哈: 这个elements里面装的可全都是通过createElement创建的描述对象哈
// 因为我们这里要进行逐层比对, 而且会对oldFiber进行多次值的修改, 所以我们并不能够以
// index < elements.length为结束手段, 因为如果elements.length没有了
// 但是oldFiber还有 那其实代表的是最新的fiber节点里做了删除操作
while (index < elements.length || oldFiber != null) {
const el = elements[index];
// 如果本次oldFiber和新的el类型相同, 我们就要留存一部分信息以节约性能
const isSameType = el && oldFiber && el.type === oldFiber.type;
let newFiber = null;
if (isSameType) {
// 代表是更新阶段
newFiber = {
type: oldFiber.type,
parent: wipRoot,
sibling: null,
child: null,
props: el.props,
alternate: oldFiber,
effectTag: "update",
dom: oldFiber.dom
}
} else if (oldFiber && !isSameType) {
// 代表做了删除
oldFiber.effectTag = "delete";
deleteGroup.push(oldFiber); // 往本次被删除的集合中添加一个oldFiber
} else if (el && !isSameType) {
// 代表做了新增
newFiber = {
type: elements[index].type,
props: elements[index].props,
parent: wipRoot, // 他的父级节点是不是就是此次的fiber节点, 这个仔细屡一下
child: null,
sibling: null,
effectTag: "placement",
dom: null
}
}
// 然后其实我们本次的fiber节点还没有child属性吧
if (index === 0) wipRoot.child = newFiber;
else {
prevFiber.sibling = newFiber;
}
prevFiber = newFiber;
index ++;
}
}
// 注意哦: 这里我们通过requestIdleCallback直接开启调度任务
requestIdleCallback(workLoop);
ok, 你基本已经实现了一个小的react的生态, 从createElement
到render
到fiber
, 再到render phase & commit phase
, 再到reconciliation
你可以思考一下函数组件和类组件应该要怎么处理, 下一次我会出一篇博客专门聊聊他们, 希望本篇博客能够对你有所帮助喽
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!