《礼记·大学》中有一句话:“知其然,亦知其所以然。”
不知不觉接触react也有一段时间了,知道怎么用却不知道为什么这么用的感觉愈发强烈,既然有着一股自虐的勇气,索性就从平时写代码的index文件开始一步步实现了个比较简单的react框架
框架中主体实现了:
- Vdom
- diff更新/删除
- Function Component
- 任务调度器
- Fber架构(从树结构到链表结构转换)
- 点击事件处理及props绑定
- useState
- useEffect
代码链接: gitee.com/yu_jianchen…
官网的 react Api的使用方式
import ReactDOM from 'react-dom/client';
import App from "./App.jsx";
ReactDOM.createRoot(document.querySelector("#root")).render(App);
通过上述这段代码可以得知: React的根文件是通过reactDOM的createRoot事件触发,并在createRoot事件传入一个根节点(就是"#root"),返回一个render函数处理App组件,盲猜App组件在通过jsx转换后给到render函数渲染VDom,最后挂载到根节点上(在我们这里直接pnpm入vite就可以了,具体原理可以到Vite官网瞅瞅,JSX 的转译同样是通过 esbuild)
参考react源码文件层级,结合vite对自己的react文件框架也进行了梳理,具体展现为下图(这里不写vite怎么pnpm了,敲太多字手酸)
mini-react
├── core
│ ├── react.js
│ └── reactDom.js
├── node_module
├── App.jsx
└── index.html
└── main.js
└── pnpm-lock.yaml
└── package.json
1、难事从易起,如何实现reactDom?
通过官网对React Api的使用方式,在reactDom.js中书写以下代码
import React from "./React.js";
const ReactDOM = {
// ReactDOM中包含createRoot,并需要传入一个参数,即根节点
createRoot(container) {
// 返回一个函数render处理第一级组件,最后挂载到根节点上
return {
render(el) {
// 单一原则,核心数据、标签处理事件集中放在react.js文件,ReactDOM文件只起引用意义
React.render(el, container);
},
};
},
};
export default ReactDOM;
2、如何实现创建和渲染元素?
目标: 在网页中展示 hello-react
第一步: 使用document.createElement创建DOM元素
const dom = document.createElement('div')
dom.id = 'app'
document.querySeletor('#root').appendChild(dom)
const textNode = document.createTextNode("")
textNode.nodeValue = "hello-react"
dom.append(textNode)
至此在网页中就出来了hello-react的字样
题外话: 为什么react中设置字体的值用的是nodeValue而不是innerText
- nodeValue是标准的DOM API,跨浏览器和跨端的兼容性更强
- nodeValue直接操作文本节点本身,无需关注文本样式和可见性.而innerText需要借助浏览器渲染模型,会触发布局和渲染计算,有可能会引起重绘重排(涉及浏览器的渲染原理),引起不必要的性能开销
第二步: 创建虚拟DOM
在 React 中,虚拟 DOM 是由一组 JavaScript 对象(通常是 ReactElement)构成的,结构如下
const element = {
type: "div",
props: {
id: "app",
children: [
{
type: "TEXT_ELEMENT",
props: {
nodeValue: "hello-react",
children: []
}
}
]
}
}
提炼一下,将创建标签和创建文本的VDOM分别开来:
const element = {
type: "div",
props: {
id: "app",
children: [textElement]
}
}
const textElement = {
type: "TEXT_ELEMENT",
props: {
nodeValue: "hello-react",
children: []
}
}
使用element和textElement创建页面标签进行展示
const dom = document.createElement(element.type);
dom.id = element.props.id
document.querySeletor('#root').appendChild(dom)
const textNode = document.createTextNode(textElement.type)
textNode.nodeValue = textElement.props.nodeValue
dom.append(textNode)
OK,保存后页面内容展示正确
观察上述代码,发现element和textElement变动的地方,只有type、props以及children,提炼一下写成函数方便调用
function createElement(type, props, ...children) {
type,
props: {
...props,
children: children.map(child => {
return typeof child.type === "Object" ? child : createTextNode(child)
}
)
}
}
function createTextNode(text){
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: []
}
}
在到App文件去调用一下
// const textElement = createTextNode("写那么多字,手好酸")
// const App = createElement('div', {id: "app"}, textElement1)
简化成 const App = createElement('div', {id: "app"}, "写那么多字,手好酸")
const dom = document.createElement(App.type);
dom.id = App.props.id
document.querySeletor('#root').appendChild(dom)
const textNode = document.createTextNode("")
textNode.nodeValue = textElement.props.nodeValue
dom.append(textNode)
渲染成功,最后只需要创建一个render函数,将转化为VDOM的App传入render中,进行解析并挂载到根节点上,就大功告成,代码如下:
function render (el, container) {
const dom = typeof el.type === "TEXT_ELEMENT" ? document.createTextNode("") : document.createElement(el.type)
Object.keys(el.props)?.forEach(item => {
if(item !== "children") {
dom[item] = el.props[item]
}
})
el.props.children.forEach(child => {
render(child, dom)
})
container.append(dom)
}
const App = createElement("div", {id: "app"}, "写那么多字,手好酸")
render(App, document.querySelector("#root"))
至此,动态创建虚拟DOM并递归生成页面内容实现完成!
实现任务调度器
我们知道JavaScript是单线程语言,当DOM过大或者层级过多时,大量的递归DOM对性能的消耗是巨大的,所以就需要对递归任务进行分片处理,借助浏览器空闲的时间将分片处理的一个个小任务进行依次执行,避免卡顿
react中借助了requestIdleCallback来实现任务的分片执行
requestIdleCallback接受一个参数作为回调,回调事件中包含一个deadLine参数,该参数可以获取当前浏览器的空闲时间,所以我们就借助了这个空闲时间进行任务的分片执行.
官网文档:develper.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback
任务调度器实现代码如下:
function workLoop(deadLine) { // 回调事件中包含一个deadLine参数
let shouldYield = false
while(!shouldYield) {
// 在这里就能做我们要做的事情
// 获取当前浏览器的空闲时间,小于1跳出循环重新执行requestIdleCallback获取浏览器空闲时间
sholdYield = deadLine.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop) // requestIdleCallback接受一个参数作为回调
题外话: 为什么react中选择用requestIdleCallback来做任务调度器的关键API,而不是requestAnimationFrame?
- 两者使用场景不同,requestIdleCallback提供浏览器的空闲时间帮助开发者执行一些不影响用户体验的轻量级任务,而requestAnimationFrame提供了一个精确的时机,通常在浏览器的重绘帧前被调用,并保证任务能在该时机前被执行,适用于高优先级的任务(比如动画)
- 延迟容忍度不同,requestIdleCallback中的任务允许其在浏览器的空闲时间逐步完成,但requestAnimationFrame必须在重绘帧前执行,不然会导致网页卡顿或者延迟
3、实现fiber架构
当前已经实现了任务调度器,但是如何确保当前任务是基于上次任务执行的基础上接力执行的呢?
思路: 将dom树从树结构转化成链表结构,确定各层级之间的指向.
转变的方法:
- 先找当前节点的children节点
- 如果当前节点没有children,就找当前节点的sibling节点(即相邻节点)
- 如果当前节点没有children或者sibling节点,就找parent节点的sibling节点
- 如果parent也没有sibling节点,返回undefined,结束递归 思维图如下:
接下来就是代码实现:
React.js
let nextUnitOfWork
function workLoop(deadLine) { // 回调事件中包含一个deadLine参数
let shouldYield = false
// 当下一个任务nextUnitOfWork为undefined时代表DOM执行完毕
while(!shouldYield && nextUnitOfWork) {
// 传一个节点,返回下一个要执行的节点
nextUnitOfWork = performWorkOfUnit(nextUnitOfWork)
// 获取当前浏览器的空闲时间,小于1跳出循环重新执行requestIdleCallback获取浏览器空闲时间
sholdYield = deadLine.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop) // requestIdleCallback接受一个参数作为回调
function performWorkOfUnit(work) {
// 边界值校验,没有DOM才创建DOM
if(!work.dom) {
const dom = (work.dom = work.type !== "TEXT_ELEMENT" ? document.createElement(work.type) : document.createTextNode(""))
// 添加进父级容器
work.parent.dom.append(dom)
// 2. 处理 props
Object.keys(work.props)?.forEach(item => {
// 判断一下是不是children属性 不是则直接赋值
if(item !== "children") {
dom[item] = work.props[item]
}
})
}
// 3. 转换链表 设置好指针
let children = work.props.children || [];
let prevchild = null; // 记录上个节点
children.forEach((child,index) => {
const newWork = {
props: child.props,
type: child.type,
parent: work,
sibling: null,
dom: null
}
if(index === 0) {
// 如果是第一个 就直接放到child 中
work.child = newWork
} else {
// 如果不是第一个,那就是第一个节点的相邻节点或者上一个节点的相邻节点
prevchild.sibling = newWork
}
prevchild = newWork
})
// 4. 返回下一个要执行的任务
if(work.child) {
return work.child // 有孩子节点 返回第一个孩子节点
}
if(work.sibling) {
return work.sibling // 没有孩子节点 返回兄弟节点
}
return work.parent?.sibling // 没有兄弟节点 返回父节点的兄弟节点,调用可选链,如果没有父节点的兄弟节点就返回undefined,结束递归
}
接下来修改下render函数,render函数已经不需要具备逻辑执行的代码了,都集中到了performWorkOfUnit中
react.js
function render (el, container) {
nextUnitOfWork = {
dom: container,
props: {
children: [el]
}
}
}
至此,一个基本的fiber架构实现完毕
4、实现标签批量渲染
问题: 当前DOM任务过多,但是浏览器无法在一个空闲时间内全部处理完毕,就会出现本来要更新5个地方,然后一眨眼只更新了两个地方,然后等到天荒地老,剩下的三个才更新,那用户就很蛋疼. 问题解决办法:
- 判断链表什么时候执行完毕
- 从根节点开始append子节点,实现批量添加,统一更新
直接来代码:
react.js
let root; // 记录根节点
function render (el, container) {
nextUnitOfWork = {
dom: container,
props: {
children: [el]
}
}
root = nextUnitOfWork
}
function workLoop (deadLine) {
let shouldYield = false;
if(!shouldYield && nextUnitOfWork) {
nextUnitOfWork = performWorkOfUnit(nextUnitOfWork)
shouleYield = deadLine.timeRemaining() < 1
}
// 上面我们提到当nextUnitOfWork为undefined时,代表DOM链表执行完毕
if(!nextUnitOfWork && root) {
commitRoot();
}
}
function commitRoot() {
commitWork(root.child);
root = null // 统一渲染一次就行,执行完root为null,就不会在触发workLoop中的commitRoot事件
}
function commitWork (fiber) {
if(!fiber) return;
fiber.parent.dom.append(fiber.dom) // 找到父级把当前dom添加进去
commitWork(fiber.children) // 递归添加子节点
commitWork(fiber.sibling) // 递归添加兄弟节点
}
至此,统一提交统一渲染标签的功能就实现了,就是大量的递归看着挺耗费性能,类似vue2数据双向绑定的Object.defineProperty.
5、支持Function Component
在react中使用Component提炼组件是一件比较稀松平常的事情了,所以这块也实现一下(主要是实现起来也不难哈哈哈) 先把App变成Funtion Component试试 app.jsx
import React from "./core/React.js";
function App() {
return (
<div>
hi-mini-react
<Component />
</div>
);
}
export default App;
写上去执行之后,会发现浏览器报了个错
在react.js定位问题后发现,将App转换成Function Component之后,root节点对应的children会变成一个函数
发现问题就好解决了,只需要在performWorkOfUnit函数遍历children的时候,判断下当前遍历到的children是否是一个函数,如果是函数就执行一下 react.js
function performWorkOfUnit(fiber) {
// 前面的代码省略
children.forEach((child, index) => {
// 判断下当前遍历到的children是否是一个函数,如果是函数就执行一下
const newChild = typeof child === "function" ? child() : child;
const newFiber = {
type: newChild.type,
props: newChild.props,
child: null,
parent: fiber,
sibling: null,
dom: null,
};
if (index === 0) {
fiber.child = newFiber;
} else {
prevChild.sibling = newFiber;
}
prevChild = newFiber;
});
}
这样就正常显示App Component里面的内容了,那如果是App Component里在嵌套一个Function Component呢,页面会报什么错,试试
import React from "./core/React.js";
function Component() {
return <div>我是Component</div>;
}
function App() {
return (
<div>
hi-mini-react
<Component />
</div>
);
}
export default App;
跟随着报错的提示,发现是创建标签的语法报错,将Component的节点打印出来看看
发现type是一个函数,好奇执行之后是什么结果,执行一下
发现执行完毕之后,就是Component内部返回的内容,知道了这些就好办了
function performWorkOfUnit(fiber) {
// 判断fiber.type是否为Function
const isFunctionComponent = fiber.type instanceof Function;
// 如果是Function,不执行Dom创建任务
if (!isFunctionComponent) {
if (!fiber.dom) {
const dom = (fiber.dom = createDom(fiber.type));
// fiber.parent.dom.append(dom);
updateProps(dom, fiber.props);
}
}
// 将执行Function后的结果作为Component组件的children进行处理,并做下数组包裹
const children = isFunctionComponent ? [fiber.type()] : fiber.props.children;
// 开始处理children
initChildren(fiber, children)
}
function initChildren(fiber, children) {
let prevChild = null;
children.forEach((child, index) => {
const newChild = typeof child === "function" ? child() : child;
const newFiber = {
type: newChild.type,
props: newChild.props,
child: null,
parent: fiber,
sibling: null,
dom: null,
};
if (index === 0) {
fiber.child = newFiber;
} else {
prevChild.sibling = newFiber;
}
prevChild = newFiber;
});
}
洗完之后发现页面渲染出来了,但还是报了个错,跟着报错的代码行去看看
发现是执行Component组件的时候,留出了一个fiber.dom为空的手尾
那这就好办了,在append的时候做下处理,如果找不到parent,那就继续向上找,跟JS原型链的原理是一致的,找不到想要的对象就接着往上找
// 标签全部处理好后,会在这个事件里并发渲染
function commitWork(fiber) {
if (!fiber) return;
console.log("fiber.parent.dom", fiber.parent.dom, "fiber.dom", fiber.dom);
let fiberParent = fiber.parent
// 直接做个循环,找不到就接着向上找,这是避免开发者写了好几个组件嵌套引发故障
while(!fiberParent.dom) {
fiberParent = fiberParent.parent
}
fiberParent.dom.append(fiber.dom);
commitWork(fiber.child);
commitWork(fiber.sibling);
}
至此,页面成功渲染,但是页面会有个null,不用想这个就是渲染Component的时候Dom为空的缘故,所以commitWork事件里做个边界校验,dom为空不渲染
// 标签全部处理好后,会在这个事件里并发渲染
function commitWork(fiber) {
if (!fiber) return;
console.log("fiber.parent.dom", fiber.parent.dom, "fiber.dom", fiber.dom);
let fiberParent = fiber.parent
// 直接做个循环,找不到就接着向上找,这是避免开发者写了好几个组件嵌套引发故障
while(!fiberParent.dom) {
fiberParent = fiberParent.parent
}
if(fiber.dom) fiberParent.dom.append(fiber.dom);
commitWork(fiber.child);
commitWork(fiber.sibling);
}
保存看页面,搞定!
5-1、实现Function Component传参(props)
试着给Component进行传参
import React from "./core/React.js";
function Component({ num }) {
return <div>我是Component{num}</div>;
}
function App() {
return (
<div>
hi-mini-react
<Component num={10} />
</div>
);
}
export default App;
发现报错,引导到具体代码行
思考下,如果在执行fiber.type()的过程中,将fiber.props作为参数传进去会打印出来什么,因为fiber.props真实作用于fiber.type()执行之后展示的控件
可以看到,传参被当成children中play的一环了,其实这样也可以,能正确展示到页面就行,参考了react源码也是差不多这样实现
function performWorkOfUnit(fiber) {
// 判断fiber.type是否为Function
const isFunctionComponent = fiber.type instanceof Function;
// 如果是Function,不执行Dom创建任务
if (!isFunctionComponent) {
if (!fiber.dom) {
const dom = (fiber.dom = createDom(fiber.type));
// fiber.parent.dom.append(dom);
updateProps(dom, fiber.props);
}
}
// 将执行Function后的结果作为Component组件的children进行处理,并做下数组包裹
const children = isFunctionComponent ? [fiber.type(fiber.props)] : fiber.props.children;
// 开始处理children
initChildren(fiber, children)
}
至此,Function Component传参就实现了
5-2、实现多个Function Component
- 注册了并列的两个组件看看,页面中缺少了Component2的存在
经过断点后发现问题如下图
function performWorkOfUnit(fiber) {
// 前面代码省略
// 本来这样写
// if(fiber.sibling) {
// return fiber.sibling
//}
//return fiber.parent?.sibling
//现在这样写
let nextFiber = fiber
while(nextFiber) {
if(nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
return null
}
- 组件嵌套子组件中,页面展示也不对,经过断点发现,fiber.type(fiber.props)执行后,会把fiber.props.children给去掉了,莫名其妙....,所以在这里修改下,把fiber.props.children放到fiber.type执行完毕之后
function performWorkOfUnit(fiber) {
//前面省略
const children = isFunctionComponent
? [fiber.type(fiber.props), ...fiber.props.children]
: fiber.props.children;
}
再看看页面
搞定收工,至此,支持Function Component实现完成
5-3、实现绑定事件
这个也是最核心的功能了,在标签上加个点击事件试试 App.jsx
import React from "./core/React.js";
function Component({ num }) {
return <div>我是Component{num}</div>;
}
function Component2() {
return <div>我是Component2</div>;
}
function handleClick() {
console.log('触发了点击事件')
}
function App() {
return (
<div onClick={handleClick}>
hi-mini-react
<Component num={10}>
12331313
<Component2></Component2>
</Component>
<Component2></Component2>
</div>
);
}
export default App;
perfect!点击压根没反应,断点看看,发现触发事件已经绑定到了系统中,但是window原生不认识onClick这个东西,如下图
做下处理
react.js
function updateProps(dom, props) {
Object.keys(props).forEach((key) => {
if (key !== "children") {
if (key.startsWith("on")) {
const eventName = key.slice(2).toLowerCase();
dom.addEventListener(eventName, props[key]);
} else {
dom[key] = props[key];
}
}
});
}
保存再看看
搞定!
6、Diff算法
6-1、DomType相同时,Diff关键点就在于:
- 获取上一个批量处理后的节点 在之前的代码片段中,我们在commitRoot事件里对处理好的节点Root进行并发渲染,所以可以在这个事件中,对并发渲染好的节点创建一个currentRoot单独进行存储,作为下一次并发更新时作为新节点的对比参照
// 创建一个currentRoot
let currentRoot = null;
function commitRoot() {
commitWork(root.child);
// 对并发渲染好的节点单独进行存储
currentRoot = root;
root = null;
}
- 对比新的节点 在这里创建一个update事件,事件中放入老的对比参照物、新的节点以及新的节点props,并更新任务调度器中的root,于是乎,requestIdleCallback在进行下一次事件回调的时候,就会将新的root进行处理更新
function update() {
nextWorkOfUnit = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
root = nextWorkOfUnit;
}
requestIdleCallback(workLoop);
- 找出差异: 如果DomType一致,不额外创建Dom,更新元素即可;如果DomType不一致,则进行Dom创建或者删除操作 找到performWorkOfUnit事件(处理root节点的事件,详情可以看看第2点),首先绑定指定节点的参照老节点,根据各自节点中的type进行判断,如果Dom一致,则进行元素更新操作
function performWorkOfUnit(fiber) {
// 前面代码省略
// 绑定指定节点的参照老节点
let oldFiber = fiber.alternate?.child;
let prevChild = null;
children.forEach((child, index) => {
const newChild = typeof child === "function" ? child() : child;
// 根据各自节点中的type进行判断
const isSameType = oldFiber && oldFiber.type === newChild.type;
let newFiber;
if (isSameType) {
// Dom一致,则进行元素更新操作
newFiber = {
type: newChild.type,
props: newChild.props,
child: null,
parent: fiber,
sibling: null,
dom: oldFiber.dom,
// 给个标识
effectTag: "update",
// 将老的节点也带上,做为更新的对比参照物
alternate: oldFiber,
};
} else {
// Dom不同标签就创建
newFiber = {
type: newChild.type,
props: newChild.props,
child: null,
parent: fiber,
sibling: null,
dom: null,
// 给个标识
effectTag: "placement",
};
if (oldFiber) {
console.log("should delete:", oldFiber);
deleteTions.push(oldFiber);
}
}
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
if (index === 0) {
fiber.child = newFiber;
} else {
prevChild.sibling = newFiber;
}
prevChild = newFiber;
});
}
// 并发渲染事件
function commitWork(fiber) {
if (!fiber) return;
let fiberParent = fiber.parent;
while (!fiberParent.dom) {
fiberParent = fiberParent.parent;
}
// 做个判断,是update就更新
if (fiber.effectTag === "update") {
updateProps(fiber.dom, fiber.props, fiber.alternate.props);
// 不是就做DOM树增加操作
} else if (fiber.effectTag === "placement") {
if (fiber.dom) {
fiberParent.dom.append(fiber.dom);
}
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
function updateProps(dom, nextProps, preProps) {
// 原代码
// Object.keys(props).forEach((key) => {
// if (key !== "children") {
// if (key.startsWith("on")) {
// const eventName = key.slice(2).toLowerCase();
// dom.addEventListener(eventName, props[key]);
// } else {
// dom[key] = props[key];
// }
// }
// });
// 老的有,新的没有,删掉
Object.keys(preProps).forEach((key) => {
if (!(key in nextProps)) {
dom.removeAttribute(key);
}
});
// 新的和老的值对不上或者新的有老的没有
Object.keys(nextProps).forEach((key) => {
if (key !== "children") {
if (nextProps[key] !== preProps[key]) {
if (key.startsWith("on")) {
const eventName = key.slice(2).toLowerCase();
dom.removeEventListener(eventName, preProps[key]);
dom.addEventListener(eventName, nextProps[key]);
} else {
dom[key] = nextProps[key];
}
}
}
});
}
写完之后保存,点击看看组件能不能切换,却发现点击次数越多,执行次数越多,很费解,然后搜ChatGpt才发现:
所以只需要在新节点addeventListen的时候,把旧节点的removeEventListen去掉就行了
这样就实现了相同DomType元素更新的功能啦
6-2、DomType不同时,Diff关键点就在于:
在并发渲染前,把老的节点删掉,给新节点创建让出空间 那是不是可以创建一个数组,里面都是即将要被删除的元素,然后在并发渲染循环一下,统一销毁,销毁完之后再把数组清空
// 创建一个数组
let deleteTions = [];
function initChildren(fiber, children) {
// 前面代码省略
children.forEach((child, index) => {
const newChild = typeof child === "function" ? child() : child;
const isSameType = oldFiber && oldFiber.type === newChild.type;
let newFiber;
if (isSameType) {
// 相同标签就更新
newFiber = {
type: newChild.type,
props: newChild.props,
child: null,
parent: fiber,
sibling: null,
dom: oldFiber.dom,
effectTag: "update",
alternate: oldFiber,
};
} else {
// 不同标签就创建
newFiber = {
type: newChild.type,
props: newChild.props,
child: null,
parent: fiber,
sibling: null,
dom: null,
effectTag: "placement",
};
// 如果走新创建的判断分支,就把老节点push进deleteTions里面
if (oldFiber) {
console.log("should delete:", oldFiber);
deleteTions.push(oldFiber);
}
}
console.log(newFiber,'newFiber')
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
if (index === 0) {
fiber.child = newFiber;
} else {
prevChild.sibling = newFiber;
}
prevChild = newFiber;
});
}
function commitRoot() {
// 并发渲染循环一下,统一销毁
deleteTions.forEach(commitDelete);
commitWork(root.child);
currentRoot = root;
root = null;
deleteTions = [];
}
function commitDelete(fiber) {
fiber.parent.dom.removeChild(fiber.dom);
}
function commitDelete(fiber) {
// 这里遍历是怕如果fiber是一个component,解析后这一层parent是空,所以需要向上继续查找
let fiberParent = fiber.parent;
while (!fiberParent.dom) {
fiberParent = fiberParent.parent;
}
fiberParent.dom.removeChild(fiber.dom);
}
这样就实现了单个组件切换的效果,但是如果老节点有多个组件,而新节点只有一个组件的时候,由于链表遍历是根据新节点来的,这样就存在一个弊端,如果新链表执行完,老链表还有DOM的话,剩下的DOM可能就没做删除处理,所以必须在children遍历之后,在判断下有没有oldFiber,有的话就循环删除
function initChildren(fiber, children) {
let oldFiber = fiber.alternate?.child;
console.log(oldFiber, "oldFiber");
let prevChild = null;
children.forEach((child, index) => {
// 代码省略
if (oldFiber) {
deleteTions.push(oldFiber);
}
}
判断下有没有一个或者多个oldFiber,有的话就循环删除
while (oldFiber) {
deleteTions.push(oldFiber);
oldFiber = oldFiber.sibling;
}
}
在测试一下其他的情况
发现当showBar为false的时候,期望是不展示这个标签,结果展示了个false,等于他还是走了创建标签这一步,在创建标签这一步要做个判断,
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map((child) => {
// child没有就return, 使得该child为undefined
if (!child) return;
return typeof child !== "object" ? createTextNode(child) : child;
}),
},
};
}
function initChildren(fiber, children) {
let oldFiber = fiber.alternate?.child;
console.log(oldFiber, "oldFiber");
let prevChild = null;
children.forEach((child, index) => {
// 到了children循环这里,指定child肯定为undefined,所以isSameType也要加个child是否存在的判断,不然会报错
const isSameType = oldFiber && child && oldFiber.type === newChild.type;
// 省略无关代码
if (isSameType) {
// 相同标签就更新
newFiber = {
type: newChild.type,
props: newChild.props,
child: null,
parent: fiber,
sibling: null,
dom: oldFiber.dom,
effectTag: "update",
alternate: oldFiber,
};
} else {
// child为undefined,必定走创建这条路,做个判断如果newChild存在才给他创建,不然不创建
if (newChild) {
// 不同标签就创建
newFiber = {
type: newChild.type,
props: newChild.props,
child: null,
parent: fiber,
sibling: null,
dom: null,
effectTag: "placement",
};
}
if (oldFiber) {
deleteTions.push(oldFiber);
}
}
}
}
这样就实现了根据布尔值判断标签是否显示的功能,但是根据边界值的测试经验,标签放在两端和放在中间,肯定会有不同的BUG,于是把按钮放到最后,动态显示的标签放在中间,再试试
点击一下发现报错了,最后一个按钮,找不到
断点了下发现,之前写的代码里newFiber哪怕没值,最后也会赋值给prevChild,这会导致下次循环的时候,prevChild其实是undefined,那肯定报错,所以这里改一下,newFiber不为undefined才赋值
至此,Diff部分实现完毕
7、Diff部分的优化
实现之后测试发现,如果页面Dom太多,每次都要从根节点开始更新,非常卡,如果能用类似事件委托的方式,哪里更新就把哪部分放上去做Diff,会不会快一点,那就开动
- 创建一个变量,存储当前节点,然后点击按钮打印一下,看看会不会打印出我们点击的这个按钮节点
let wipRoot = null;
function performWorkOfUnit(fiber) {
wipRoot = fiber;
}
function update() {
console.log(wipRoot,'wipRoot')
nextWorkOfUnit = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
root = nextWorkOfUnit;
}
页面打印没错,那就进入下一步
- 在update事件中把要更新的节点赋值,在传入nextWorkOfUnit给任务调度器进行更新
// 点击更新就获取指定节点,传入nextWorkOfUnit,这里加了闭包引用,闭包具体作用可以搜下百度
function update () {
let currentFiber = wipFiber
return () => {
root = currentFiber;
root.alternate = currentFiber
nextWorkUnit = root
}
}
// 再到任务调度器进行轮询判断,如果下一个要执行的节点类型跟当前指定节点的相邻节点真相等,就直接赋值undefined不在继续执行DOM树的处理
function workLoop(deadline) {
let shouldYield = false;
while (!shouldYield && nextWorkOfUnit) {
nextWorkOfUnit = performWorkOfUnit(nextWorkOfUnit);
if (root?.sibling?.type === nextWorkOfUnit?.type) {
// console.log("hit", wipRoot, nextWorkOfUnit);
nextWorkOfUnit = undefined;
}
shouldYield = deadline.timeRemaining() < 1;
}
if (!nextWorkOfUnit && root) {
commitRoot();
}
requestIdleCallback(workLoop);
}
至此,Diff模块全部搞定
8、实现useState
根据userState的用法来判断,useState接受一个初始值,返回一个数组,数组第一个元素是值,第二个元素是修改值的事件
// 写法 const [a, seta] = useState(10)
// seta((a)=>a+1)
function useState(initState) {
const stateHook = {
state: initState
}
function setState(action) {
// 执行setState并传进stateHook.state,返回一个最终值赋值给stateHook.state
stateHook.state = action(stateHook.state)
}
return [stateHook.state, setState]
}
结构定好后,就是最终数值呈现到页面上
function useState(initState) {
let currentFiber = wipFiber
const stateHook = {
state: initState
}
// 放入当前节点中
currentFiber.stateHook = stateHook
function setState(action) {
// 执行setState并传进stateHook.state,返回一个最终值赋值给stateHook.state
stateHook.state = action(stateHook.state)
// 更新nextWorkOfUnit, 任务调度器自动触发页面更新
root = currentFiber;
root.alternate = currentFiber;
nextWorkOfUnit = root;
}
return [stateHook.state, setState]
}
但是这样点击了之后,数值不会变,然后就断点,发现开发者用了setState后,触发更新继续走useState事件时,stateHook.state还是被赋予了initState这个值,所以无论怎么点击按钮,页面永远展示初始值不会变,所以这里要改下,如果当前节点有alternate对应的老节点时,就要取老节点的stateHook.state作为初始值
function useState(initState) {
let currentFiber = wipFiber;
let oldStateHook = currentFiber.alternate?.stateHook;
const stateHook = {
state: oldStateHook ? oldStateHook?.state : initState,
};
currentFiber.stateHook = stateHook;
function setState(action) {
stateHook.state = action(stateHook.state);
root = currentFiber;
root.alternate = currentFiber;
nextWorkOfUnit = root;
}
return [stateHook.state, setState];
}
再点击数值就正常更新了,那继续思考,如果一个页面有多个useState时该咋办,那就创建个数组,存起来,因为useState是顺序递增执行(react源码里是这么实现,别杠,杠就是你对),所以这里用自增index,但是这块在上一个节点创建完进入下一个任务节点前,数组必须重置,index必须变成0,不然下一个任务节点就沿着上一个节点执行完的下标继续执行,导致报错
let stateHooks;
let stateHookIndex;
function useState(initial) {
let currentFiber = wipFiber;
const oldHook = currentFiber.alternate?.stateHooks[stateHookIndex];
const stateHook = {
state: oldHook ? oldHook.state : initial,
queue: oldHook ? oldHook.queue : [],
};
stateHook.queue.forEach((action) => {
stateHook.state = action(stateHook.state);
});
stateHook.queue = [];
stateHookIndex++;
stateHooks.push(stateHook);
currentFiber.stateHooks = stateHooks;
function setState(action) {
// 将任务也存储起来,批量执行,这块其实应该只执行最后一个事件就行了
stateHook.queue.push(action);
root = {
...currentFiber,
alternate: currentFiber,
};
nextWorkOfUnit = root;
}
return [stateHook.state, setState];
}
function performWorkOfUnit(fiber) {
const isFunctionComponent = fiber.type instanceof Function;
if (!isFunctionComponent) {
if (!fiber.dom) {
const dom = (fiber.dom = createDom(fiber.type));
// fiber.parent.dom.append(dom);
updateProps(dom, fiber.props, {});
}
}
// 创建完DOM后stateIndex、stateHooks必须重置
wipFiber = fiber;
stateHookIndex = 0;
stateHooks = [];
// 省略
}
做下优化
// 前面代码省略
function setState(action) {
// 如果新值与老值相等,不做处理
const eagerState =
typeof action === "function" ? action(stateHook.state) : action;
if (eagerState === stateHook.state) return;
// 处理传入的action不是函数
stateHook.queue.push(typeof action === "function" ? action : () => action);
root = {
...currentFiber,
alternate: currentFiber,
};
nextWorkOfUnit = root;
}
至此,useState实现完毕
9、实现useEffect
相对useState来说,useEffect有固定的执行时机,即在页面渲染完毕后会初次触发,所以可以并发渲染后,加个effect事件
function commitRoot() {
deleteTions.forEach(commitDelete);
commitWork(root.child);
// 在并发渲染事件后加个useEffect事件
commitEffectHook()
currentRoot = root;
root = null;
deleteTions = [];
}
再根据useEffect的语法,我们知道useEffect里面包含了三个东西,callback、dep依赖项和闭包回调
useEffect(() => {
// 在这里可以做一些偷鸡摸狗的事情
return () => {}
}, [])
function useEffect(callBack, deps) {
const effectHook = {
callBack, // 回调
deps, // 依赖项
cleanUp: undefined, // 闭包
};
wipFiber.effectHook = effectHook;
}
function commitEffectHook() {
function run(fiber) {
if (!fiber) return;
fiber.effectHook?.callBack();
run(fiber.child);
run(fiber.sibling);
}
// 从根节点开始找有没有Effect,有就执行回调
run(root);
}
看到页面已经打印Effect的执行事件了,再加个根据依赖项变动触发 那怎么根据判断依赖项有没有变更呢,可以通过fiber.alternate去比对,如果值变换了就是触发了,在执行一次useEffect回调
function commitEffectHook() {
function run(fiber) {
if (!fiber) return;
if (fiber.alternate) {
// 有就肯定不是初始化
// 就涉及到依赖项变更,需要比对下
let oldValue = fiber.alternate?.effectHook;
const needUpdate = oldValue?.deps.some((item, index) => {
return item !== fiber.effectHook.deps[index];
});
needUpdate && fiber.effectHook?.callBack();
} else {
// 没有alternate肯定就是初始化
fiber.effectHook?.callBack();
}
run(fiber.child);
run(fiber.sibling);
}
run(root);
}
如果有多个useEffect怎么办呢,可以参考useState的做法,创建个数组把每个useEffect累计起来,然后批量处理,但是要记住在每个节点的标签创建完成后要置空,不然所有的节点useEffect累计起来,页面会炸,别问为什么会炸,我炸锅
let effectHooks = [];
function useEffect(callBack, deps) {
const effectHook = {
callBack, // 回调
deps, // 依赖项
cleanUp: undefined, // 闭包
};
effectHooks.push(effectHook);
wipFiber.effectHooks = effectHooks;
}
function commitEffectHook() {
function run(fiber) {
if (!fiber) return;
if (fiber.alternate) {
// 有就肯定不是初始化
// 就涉及到依赖项变更,需要比对下
fiber.effectHooks?.forEach((newHook, index) => {
if (newHook.deps.length > 0) {
let oldValue = fiber.alternate?.effectHooks[index];
const needUpdate = oldValue?.deps.some((oldDep, i) => {
return oldDep !== newHook.deps[i];
});
needUpdate && newHook?.callBack();
}
});
} else {
fiber.effectHooks?.forEach((hook) => {
hook.callBack();
});
}
run(fiber.child);
run(fiber.sibling);
}
run(root);
}
实现useEffect的闭包回调,这个闭包执行在绑定新useEffect之前,逻辑也就是在run的时候有执行newHook?.callBack()的地方,就把他赋值给cleanUp存起来,然后在下次执行useEffect时,先跑一遍cleanUp的事件,在跑run
function commitEffectHook() {
function run(fiber) {
if (!fiber) return;
if (fiber.alternate) {
// 有就肯定不是初始化
// 就涉及到依赖项变更,需要比对下
fiber.effectHooks?.forEach((newHook, index) => {
if (newHook.deps.length > 0) {
let oldValue = fiber.alternate?.effectHooks[index];
const needUpdate = oldValue?.deps.some((oldDep, i) => {
return oldDep !== newHook.deps[i];
});
needUpdate && (newHook.cleanUp = newHook.callBack());
}
});
} else {
fiber.effectHooks?.forEach((hook) => {
hook.cleanUp = hook.callBack();
});
}
run(fiber.child);
run(fiber.sibling);
}
function cleanup(fiber) {
if (!fiber) return;
fiber.alternate?.effectHooks?.forEach((hook) => {
if (hook.deps.length > 0) {
hook.cleanUp && hook.cleanUp();
}
});
cleanup(fiber.child);
cleanup(fiber.sibling);
}
cleanup(root);
run(root);
}
执行程序,页面成功打印闭包回调