写这个项目,主要目的是想要通过手写实现 React 的方式,来帮助理解: React 是如何将一个组件渲染页面
Note: index.js
需要搭配 index.html
使用,因为存在多个版本,所以需要保证两个文件名相同!
一、JSX 编译
以最基本的使用:index1.js 与 index1.html 为例
配置缘故,在 index.js 中写 JSX,默认使用原生 React 的 createElement
,尽管引入的是自己的 React,但是并不会使用我们自己写的 createElement
因此我们需要先借助 Babel 将 JSX 编译为 JS ,这样,就可以显式地调用我们自己写的 createElement
,一起看个一段写在 index1.js 中的代码:
let style = {border: '2px solid skyblue', margin: '5px', borderRadius: '7px'}
// JSX语法 先转化为JS语法,然后通过JS的createElement转化为虚拟DOM —— 使用JSX语法并不会调用我们自己在React中写的createElement方法
let element = (
<div id='A1' style={style}>A1
<div id='B1' style={style}>B1
<div id='C1' style={style}>C1</div>
<div id='C2' style={style}>C2</div>
</div>
<div id='B2' style={style}>B2</div>
</div>
)
JSX 部分经过 Babel
编译过后,变成了下面的样子
"use strict";
/*#__PURE__*/
React.createElement("div", {
id: "A1",
style: style
}, "A1", /*#__PURE__*/React.createElement("div", {
id: "B1",
style: style
}, "B1", /*#__PURE__*/React.createElement("div", {
id: "C1",
style: style
}, "C1"), /*#__PURE__*/React.createElement("div", {
id: "C2",
style: style
}, "C2")), /*#__PURE__*/React.createElement("div", {
id: "B2",
style: style
}, "B2"));
可以很明显地看到,我们向 React.createElement
中传递的参数,既是标签的各种参数
整理一下,就变成了我们需要的 JS 代码:
let element = React.createElement("div", {id: "A1",style: style}, "A1",
React.createElement("div", {id: "B1",style: style}, "B1",
React.createElement("div", {id: "C1",style: style}, "C1"),
React.createElement("div", {id: "C2",style: style}, "C2")
),
React.createElement("div", {id: "B2",style: style}, "B2")
);
1. createElement 的作用
根据传递的参数,创建一个 虚拟DOM
function createElement(type, config, ...children) {
// 标签没有属性时,config为null
if (config) {
delete config._self;
delete config._source;
}
return {
type,
props: {
...config,
children: children.map(child => {
return typeof child === 'object' ?
child : // 虚拟 DOM 对应着一个对象
{ // TEXT 对应着一个字符串
type: ELEMENT_TEXT,
props: { text: child, children: [] }
}
})
}
}
}
该 虚拟 DOM
包括:
- type: 标签名
- props: 标签处倘若写了一个{null},那么 props 就为 null
1 . style: 样式
2 . onClick: 绑定的事件
3 . … (标签属性)
4 . children: [子虚拟DOM1
、子虚拟DOM2
、… 、标签文本 ]
可以看到,调用 createElement
时,传入的第 2~n 个参数,最终都被整合到了 props 中
其中,标签文本也被作为 children 属性值保存了起来。
二、render
组件创建完成后,将组件和组件所挂载的 DOM 容器传递到 render
函数中去,像这样:
ReactDOM.render(
element,
document.getElementById('root')
)
首先,我们需要先明确
1. 为什么需要引入 fiber?
v16 之前,更新过程是同步的,从调用各个组件的生命周期函数、计算、比对虚拟 DOM,到最后更新 DOM 树,整个过程必须要一气呵成。
随着时间的流逝,页面变得日益复杂,有时更新完页面中的所有组件,甚至需要花上数百毫秒的时间。这期间,用户与页面的任何交互将不会有任何反馈。这样的体验必然是很不友好的。
我们知道 JavaScript 是单线程语言,一个任务花费太长的时间,就为导致其他任务无响应。因此,解决这个问题就显得尤为突出!
解决 JavaScript 中同步操作时间过长的方法——分片。片,也就是 fiber。
2. fiber 是什么?
fiber
是一个用来描述节点的对象,相较于虚拟 DOM,它包含的节点信息更加丰富。一起来看一下初次渲染时创建的 fiber
对象:
newFiber = {
tag,
type: sonVDOM.type,
props: sonVDOM.props,
stateNode: null,
return: fiber,
updateQueue: new UpdateQueue(),
effectTag: PLACEMENT,
nextEffect: null
}
- tag: 节点类型,值包括为:
- TAG_TEXT:文本类型,标签之间的文本即为该类型
- TAG_HOST:原生节点类型,例如:div 标签、span 标签等
- TAG_CLASS:类式组件
- TAG_FUNCTION:函数式组件
- type:调用 createElement 时传入的第一个参数:‘div’、‘span’、‘h1’…
- props:标签属性:{id=“A1” style={style} onClick=()=>{} …}
- stateNode:
fiber
对应的真实 DOM - return:指向
fiber
的父fiber
- updateQueue: 更新队列。每一个
fiber
都有一个updateQueue
。该属性只在 “类式组件” 与 “类式组件” 中有实际意义(下文会详细介绍) - effectTag:副作用标识,标识 DOM 发生了什么样的变化,值包括:
- PLACEMENT:新增
- DELETE:删除
- UPDATE:更新
- nextEffect:
effect list
是一个单链表,该链表上保存着所有的 “发生了变化” 的 DOM 对应的fiber
。我们知道,React 并不会在每遇到一个变化,就去更新一次页面。而是将所有变化的 DOM 对应的fiber
收集起来,最终只做一次更新(暂不考虑 offsetLeft、clientTop 等需要实时获取最新数据的属性),来降低由于频繁重绘重排来带的巨大性能开销 - alternate:指向上一次渲染时的
fiber树
中对之应的fiber
节点 - child:指向
fiber
的第一个子fiber
,与 sibling、return 属性一同用于构建fiber树
- sibling:指向
fiber
的弟弟fiber
3. fiber 树是什么样的结构?
所有借助 createElement 创建的虚拟 DOM,都会对应一个 fiber
节点;每个组件也会对应一个根 fiber
。根据层级关系,借助child
、sibling
、return
将所有的 fiber
连接起来,形成了最终的 fiber树
。所以说 fiber树
是一个链表结构,但并非单链表。
4. render 函数有什么作用?
- 创建一个根 fiber:
rootFiber
1 . 将 id 为 root 的真实 DOM 通过stateNode
属性绑定在rootFiber
上
2 . 将 createElement 创建出来的虚拟 DOM 通过 props 属性绑定在rootFiber
上 - 将
rootFiber
做为参数调用函数scheduleRoot
三、scheduleRoot
页面可能会被无限次重新渲染,但维护的 fiber 树,就只有两棵:
- 一棵为此次渲染正在构建的 fiber 树,其根节点用全局变量
workInProgressRootFiber
来保存 - 另一棵为页面上次渲染时构建的 fiber 树,根节点用全局变量
currentRenderRootFiber
来保存
自第二次渲染结束后,页面再次重新渲染,便开始复用这两棵 fiber 树,可以同时节省创建大量的 fiber 对象所消耗的时间与存储空间。
这就是 React 优化核心之一的:双缓冲机制
export function scheduleRoot(rootFiber) {
if (currentRenderRootFiber && currentRenderRootFiber.alternate) { // 第3、4、5 ... 次渲染
workInProgressRootFiber = currentRenderRootFiber.alternate;
workInProgressRootFiber.alternate = currentRenderRootFiber;
if (rootFiber) workInProgressRootFiber.props = rootFiber.props;
} else if (currentRenderRootFiber) { // 第2次渲染
if (rootFiber) {
rootFiber.alternate = currentRenderRootFiber;
workInProgressRootFiber = rootFiber;
} else {
workInProgressRootFiber = {
...currentRenderRootFiber,
alternate: currentRenderRootFiber
}
}
} else { // 第1次渲染
workInProgressRootFiber = rootFiber;
}
workInProgressRootFiber.firstEffect = workInProgressRootFiber.lastEffect = workInProgressRootFiber.nextEffect = null;
currentFiber = workInProgressRootFiber;
}
scheduleRoot 的任务:
- 第 1 次渲染 标志 :
currentRenderRootFiber
为空
让 workInProgressRootFiber
指向传入的第一个根 fiber;渲染过后,把 workInProgressRootFiber
的值赋给 currentRenderRootFiber
(操作位于 commitRoot 中)。currentRenderRootFiber
也就指向了第一个根 fiber。
- 第 2 次渲染 标志:
currentRenderRootFiber
非空,但currentRenderRootFiber
上并不存在 alternate 属性
把 currentRenderRootFiber
指向的第一个根 fiber,赋给刚传进来的第二个根 fiber 的 alternate 属性;然后把第二个根 fiber 赋给 workInProgressRootFiber
,此时,workInProgressRootFiber.alternate
指向第一个根 fiber;渲染过后,把 workInProgressRootFiber
的值赋给 currentRenderRootFiber
(操作位于 commitRoot 中)。这样 currentRenderRootFiber.alternate
也就指向第一棵 fiber 树的根 fiber。
- 第 3、4… 次渲染 标志:
currentRenderRootFiber
非空,且currentRenderRootFiber.alternate
属性也非空。
此时只需要将 currentRenderRootFiber.alternate
指向的上上一个根 fiber(也就是第一个根 fiber)拿过来,然后赋给 workInProgressRootFiber
,这样就完成了对第一个根 fiber 的复用;然后再把 currentRenderRootFiber
中保存的上一棵 fiber 树的根 fiber,赋给当前 fiber 树的 alternate 属性,就完成了与上一棵 fiber 树的关联。
可以看到,其实 scheduleRoot
始终在做一件事情:更新 workInProgressRootFiber
、currentRenderRootFiber
存储的根节点,及其 alternate 属性的指向。
四、workLoop
阅读代码可以发现,scheduleRoot 像一座孤岛一般。我们调用了 scheduleRoot 函数,但 scheduleRoot 并没有调用 scheduler.js
中的其余函数,那其他函数是怎么被使用的呢?
梳理一下代码的逻辑,可以看到,其余函数的调用起点在 workLoop
,该函数只在 requestIdleCallback
中被调用了:
requestIdleCallback(workLoop, { timeout: 500 });
因此,问题就变成了:
requestIdleCallback
函数是做什么的?它在什么时候被调用?
答:在浏览器一帧的剩余空闲时间内,执行优先度相对较低的任务
简单来说,在当前的场景中,就是让浏览器执行完别的任务时,判断时间片是否还有剩余的时间,如果有,就执行传入的 workLoop
任务。假如连续 500ms 都没有执行 workLoop
,就强制执行该任务。
但是原生的 requestIdleCallback
每秒只有 20 帧。
(20帧 / 1000ms = 1帧 / 50ms、60帧 / 1000ms = 1帧 / 16.7ms)
也就是说,每个时间片是 50ms,隔 50ms 才会刷新一次,远比我们感觉流畅的每 16.7ms 刷新一次的频率要低很多。所以,在 React 的源码中,需要实现了一个更加流畅的 requestIdleCallback
。(该函数不是我们关注的重点,暂时就不实现了)
function workLoop(deadline) {
let shouldYield = false; // false表示不需要让出时间片/控制权
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork); // performUnitOfWork: 执行一个任务,返回下一个任务
shouldYield = deadline.timeRemaining() < 1; // 剩余时间小于1ms时,没有剩余时间,shouldYield置为true,表示需要让出控制权
}
if (!nextUnitOfWork && workInProgressRootFiber) { // 时间片到期后,还有任务还尚未完成,就需要请求浏览器再次调度
console.log('render阶段结束');
commitRoot();
}
requestIdleCallback(workLoop, { timeout: 500 }); // 不论是否有任务,都去请求调度。每一帧在浏览器完成自己的任务后,如果有剩余时间,就执行一次workLoop,确保在有任务时,能够被及时执行
}
我们来一起梳理一下 workLoop
的任务:
- 调用
performUnitOfWork
执行一个 “任务”,返回它的下一个 “任务”(可中断的 “任务”,即为该 “任务”) - 判断是否存在下一个 “任务”,并且当前时间片还有剩余的时间
1 . 如果二者都满足,就循环地执行 “任务”,返回下一个 “任务”,执行 “任务”,返回下一个 “任务”…
2 . 如果是 “任务” 执行完毕,没有了下一个 “任务”,就表示rereconcileChildren阶段
结束,接下来就开始调用commitRoot
去修改 DOM
3 . 如果是时间片的时间被使用完毕,就再次调用requestIdleCallback
,下次执行 workLoop 函数,会延续上次被搁置的 “任务”,继续循环执行,直到所有的 “任务” 都被执行完毕
下面,我们就需要用倒叙的方式,来梳理其余函数的职能
首先,我们需要了解一个关键的函数,reconcileChildren
五、reconcileChildren
function reconcileChildren(fiber, vDOMArrOfChildrenOfFiber)
该函数接收两个参数
- 第一个参数为一个 fiber 节点,也就是下面说的父 fiber
- 第二个参数为该 fiber 节点的子虚拟 DOM 组成的数组
reconcileChildren 的功能:
1. 为虚拟 DOM 创建对应的 fiber
我们一起来看一下具体过程:
首先,借助 fiber.alternate
,找到 fiber 节点在上一棵 fiber 树中,与之对应的 oldFiber
。然后通过 child 属性获得 oldFiber 的第一个子 fiber:oldSonFiber
,oldSonFiber
与 vDOMArrOfChildrenOfFiber 的第一个子虚拟 DOM 相对应。
在 scheduleRoot 中,我们已经知道了如何区分一个节点是第1、2、3 … 次渲染。
const sameType = oldSonFiber && sonVDOM && oldSonFiber.type === sonVDOM.type;
sameType
为 true:
- 判断
oldSonFiber
上是否存在alternate
属性,若存在,就表示该节点已经是第 3、4、5…次渲染了,那么我们就可以直接将上上次被渲染的节点拿过来使用(可复用)。复用 fiber 时,并没有为 fiber 的 stateNode 属性重新赋值,这就表示其对应的真实 DOM 节点也是可以复用的。 oldSonFiber
上不存在alternate
属性,这时一定是第 2 次渲染,也需要重新创建一个 fiber,注意:第二次渲染一个节点时,我们需要为该节点添加alternate
属性,并让其指向oldSonFiber
(不可复用)
(原生的 React 对是否可复用的判定逻辑及处理,会更加复杂一些,比如还会添加对 key
的判断等等)
sameType
为 false:
- 可能是由于不存在
oldSonFiber
,也就是说:在此之前不存在与之对应的 fiber 节点,此时可以确定当前节点一定是第 1 次渲染(不可复用) - 为 false 也有可能由于
oldSonFiber
与sonVDOM
所指向的节点标签名不同,此时只能确定该节点至少已经渲染过一次,这两种情况都需要创建新的 fiber(不可复用) sonVDOM
为 null 时sameType
也为 false,这种情况不需要新建 fiber(不可复用)
到这里,我们就成功地为一个虚拟 DOM 创建出其对应的 fiber 节点,reconcileChildren
的第一个任务完成。
2. 收集被删除的 fiber
sameType
为 false 的三种情况,因为都不存在对 fiber 的复用,还需要将 oldSonFiber
的 effectTag
属性标记为删除,同时将其推入删除数组 deletions
中。在渲染前,会先遍历该数组,将上一棵 fiber 树中对应的节点进行删除。这样在下次复用该树时,就可以避免一些干扰了。
3. 将子 fiber 进行连接
接下来,需要确定该 fiber 是父 fiber 的第几个子 fiber,通过 vDOMArrOfChildrenOfFiber 的下标 arrIndex
就可以直接判断出来
- arrIndex === 0 表示第一个子 fiber,为父 fiber 添加
child
属性,将其挂载到父 fiber 的child
属性上 - 否则表示不是第一个子 fiber,为前一个子 fiber 添加
sibling
属性,并将其挂载到前一个子 fiber 的sibling
属性上
成功处理完了一个虚拟 DOM,借助 oldSonFiber = oldSonFiber.sibling;
取出 oldSonFiber 的兄弟节点;同时让 arrIndex
+1,取出下一个虚拟 DOM,再次执行上述方法,继续为虚拟 DOM 创建或复用 fiber。直到成功处理完 vDOMArrOfChildrenOfFiber 中所有的虚拟 DOM。
六、beginWork
fiber 的 tag 标识着 fiber 的种类,beginWork 的任务:
1. TAG_ROOT
:根 fiber(rootFiber
):
这种情况下,fiber 对应的真是 DOM 其实已经存在了,也就是 id 为 root 的真实 DOM 容器。我们已经在 render 中将其挂载到了 rootFiber
的 stateNode 上了。那么在这里,我们就只需要取出其子虚拟 DOM 数组,由 reconcileChildren 将虚拟 DOM 转化为 fiber 并连接起来。
2. TAG_TEXT
:文本节点
文本节点不存在后代,也就不需要调用 reconcileChildren。只需要根据文本内容,借助document.createTextNode(fiber.props.text)
创建出对应的文本节点,然后绑定到 fiber 的 stateNode 属性上即可。
3. TAG_HOST
:标签节点
标签节点需要借助 document.createElement(fiber.type)
创建出对应的标签。标签还有可能包含一些属性,如 style、onClick 以及一些一般属性,他们都存储在 fiber 的 props 属性上。
function updateDOM(DOM, oldProps, newProps) {
if (DOM && DOM.setAttribute){
for (let key in oldProps) {
if (key !== 'children') {
if (newProps.hasOwnProperty(key)) { // 1. 原来有,现在也有 - 更新
setProps(DOM, key, newProps[key]);
} else {
DOM.removeAttribute(key); // 2. 原来有,现在没 - 删除
}
}
}
for (let key in newProps) {
if (key !== 'children') {
if (!oldProps.hasOwnProperty(key)) { // 3. 原来没,现在有 - 增加
setProps(DOM, key, newProps[key]);
}
}
}
}
}
function setProps(DOM, key, value) {
if (/^on/.test(key)) { // 事件
DOM[key.toLowerCase()] = value;
} else if (key === 'style') { // 样式
if (value) {
for (let styleName in value) {
DOM.style[styleName] = value[styleName];
}
}
} else { // 一般属性
DOM.setAttribute(key, value);
}
}
我们就需要判断一个属性在 fiber 复用前,是不是已经存在了
- 原来没有该属性
- 现在有
- 属性是事件:借助
DOM[key.toLowerCase()] = value;
为 DOM 添加一个事件名同名的属性,值为事件对应的函数体 - 属性是样式:借助
DOM.style[styleName] = value[styleName];
将属性添加到 DOM 的 style 属性中 - 一般属性:借助
DOM.setAttribute(key, value);
将属性添加到 stateNode 指向的标签中
- 属性是事件:借助
- 现在有
- 原来有该属性
- 现在也有,只需要再走一遍 “原来没有,现在有” 时,对属性类型的判断逻辑,将属性值更新一下即可
- 现在没有,借助
DOM.removeAttribute(key);
删除属性
此时,我们已经将 DOM 的属性更新完成,下一步就是将 DOM 挂载到 fiber 的 stateNode 属性上。
最后依旧是拿出 fiber.props.children 存储的虚拟 DOM 数组,然后交给 reconcileChildren。
4. TAG_CLASS
:类式组件
在类式组件中,需要我们实例化一个 组件实例
,在实例化时,将组件参数作为实例化时的参数,传入 constructor 的 props 中,虚拟 DOM 就可以直接通过 this.props.xxx 获取到这些属性。
// fiber.stateNode指向组件实例,组件实例的internalFiber指向fiber对象
fiber.stateNode = new fiber.type(fiber.props);
fiber.stateNode.internalFiber = fiber;
然后将 组件实例
绑定到 fiber 的 stateNode 属性上,再将 fiber 绑定到 组件实例
的 internalFiber
属性上。
setState(payload) { // payload可能是对象,也可能是函数
let update = new Update(payload); // 将payload挂载到update对象上
this.internalFiber.updateQueue.addUpdate(update); // updateQueue放在类组件的fiber节点internalFiber上
scheduleRoot();
}
我们调用 setState
更新状态时,就是通过 组件实例.internalFiber
先拿到 fiber,然后就可以通过 fiber.updateQueue.addUpdate(update)
将封装好的 state 放入更新队列中。
这里就可以解释,为什么 setState 会异步地更新 state?
其实就是因为我们在调用 setState
时,并没有立即对 state 进行更新。而是先通过 new Update(payload);
将更新的 state 封装进一个对象中。然后将这个对象通过 addUpdate
添加至 fiber 的 updateQueue
也就是 更新队列
中。
export class Update {
constructor(payload) {
this.payload = payload;
}
}
我们与页面进行一次交互,可能会多次触发 setState
,所有的 setState
参数在封装过后,都会被添加至 更新队列
中。更新队列
是一个链表结构。最后在我们为类式组件构建 fiber 时,执行一次 fiber.stateNode.state = fiber.updateQueue.forceUpdate(fiber.stateNode.state);
,就可以通过遍历链表,一次性的将所有的 setState
执行完毕,然后将最新的 state 赋值给 组件实例
的 state 对象。
总结:类式组件的执行逻辑
在 index3.js
中,我们调用了 ReactDOM.render
,参数分别为类式组件和容器 DOM。
此时类式组件并没有被执行,紧接着就进入到了 react-dom.js
的 render
方法,创建 rootFiber
,stateNode 当然关联的依旧是容器 DOM,props.children
中存储着类式组件。
注意!只有调用类组件实例的 render
方法,才会将虚拟 DOM 返回!因此我们在判定组件为类式组件时,虚要手动调用 fiber.stateNode.render();
方法,为的就是拿到类式组件 调用 render 函数后,return 的虚拟 DOM,这样才能再去执行 reconcileChildren 函数。
类式组件 return 的虚拟 DOM 是一个对象,而 reconcileChildren 处理的是虚拟 DOM 数组,所以我们还需要将其放至一个数组中,然后再传入 reconcileChildren。
5. TAG_FUNCTION
:函数式组件
首先,我们需要指定两个全局变量:funComponentFiber
、hookIndex
,作用我们后面再讨论。
函数式组件不需要为其实例化对象,所以由 begin 进入对应处理函数 updateFunctionComponent
时,需要做的事情也就很少。只需要将 “函数组件对应的 fiber” 赋给 funComponentFiber
。
其次,需要为每个函数式组件添加一个 hooks
属性,用来存储组件中添加的一个个 hook。
紧接着需要初始化 hookIndex
,hooks
是一个数组,里面的每一个 hook 都和 hookIndex
相对应。下次渲染时都需要先对 hookIndex
初始化,才能依次拿到初次渲染时创建的 hook。
我们知道,调用函数式组件中的函数,即可拿到被返回的虚拟 DOM。 调用的同时,将函数组件的参数传递到函数中,这样虚拟 DOM 就可以通过 props.xxx 拿到对应的参数值。
拿到了被返回的虚拟 DOM,剩下的任务依旧是交给 reconcileChildren。
核心:hooks
分析完了函数式组件,不如趁热打铁,顺带分析一下 hooks 中的 useReducer
是如何工作的。
useState
基于 useReducer
,这里我们就重点分析一下 useReducer
。
我们一起来回忆一下 useReducer
的用法:
const ADD = 'ADD';
function reducer(state, action) {
switch (action.type) {
case ADD:
return {count: state.count+1};
default:
return state;
}
}
function FunctionCounter(props){
const [countState, dispatch] = React.useReducer(reducer, {count: 0});
return (
<div>
<div>{countState.number}</div>
<button onClick={dispatch({ type: ADD })}>戳一下 +1</button>
</div>
)
}
ReactDOM.render(
<FunctionCounter name="计数器"/>,
document.getElementById('root')
)
useReducer
接收两个参数:
- 能够根据 “行为” 对 state 进行处理的
reducer
- 初始状态
initialValue
从使用来看,可以知道 useReducer
返回了一个数组,数组中一定包含这两个数据:
- 状态
- 能够改变状态的 dispatch 函数
拿到 dispatch 后,可以通过向 dispatch 传递指定的 “行为”,来改变 countState 的值。
我们来分析一下 useReducer
具体是如何实现的:
let hook = funComponentFiber.alternate && // 第一次渲染时 hook 值为 undefined
funComponentFiber.alternate.hooks &&
funComponentFiber.alternate.hooks[hookIndex];
if (hook) { // 第2、3...次渲染
hook.state = hook.updateQueue.forceUpdate(hook.state);
} else { // 第1次渲染
hook = {
state: initialValue,
updateQueue: new UpdateQueue()
}
}
第一次渲染时,用 funComponentFiber
存储:为函数式组件创建的第一个 fiber,此时的 funComponentFiber
并没有 alternate 属性;
第二次渲染时,funComponentFiber
存储为函数式组件创建的第二个 fiber,此时 funComponentFiber.alternate
指向了在第一次渲染时,为函数式组件创建的第一个 fiber。
这样,通过 alternate 属性我们就可以知道当前是否为第一次渲染。
假设我们在组件中调用了两次 useReducer,为组件添加了两个 hook
const [countState1, dispatch] = React.useReducer(reducer, {count1: 0}); // 第一个 hook
const [countState2, dispatch] = React.useReducer(reducer, {count2: 0}); // 第二个 hook
页面初次渲染,执行至组件第一次调用 React.useReducer
,于是进入 useReducer
函数,通过 alternate 判定为组件第一次被渲染。首先新建一个 updateQueue
更新队列
,与初始的 state 一并封装进一个名为 hook
的对象中。
然后通过 funComponentFiber.hooks[hookIndex++] = hook;
将该 hook
对象添加至函数式组件对应 fiber 的 hooks 属性中,同时让 hookIndex +1
。此时 hookIndex = 0
就与第一个 hook
绑定。
最后返回一个数组,数组第一个元素即为我们需要的 state,此时它里面存储着初始化的 state:{count1: 0};第二个元素为函数 dispatch
。
紧接着,由于我们又调用了一次 React.useReducer
,进入 useReducer
函数后,通过 alternate 发现组件依旧是第一次被渲染,那么再创建一个 hook
对象。
此时 hookIndex
= 1,通过 funComponentFiber.hooks[hookIndex++] = hook;
将第二个 hook
对象添加至函数式组件的 hooks 属性中。hookIndex = 1
就与第二个 hook
绑定。
const dispatch = action => { // action: {type: ADD}
// reducer:
// function reducer(state, action) {
// switch (action.type) {
// case ADD:
// return {count: state.count+1};
// default:
// return state;
// }
// }
let payload = reducer ? reducer(hook.state, action) : action; // 传入reducer时,就根据reducer和对应的action计算出对应的state
hook.updateQueue.addUpdate(
new Update(payload)
);
scheduleRoot();
}
点击第二个 hook
关联的按钮,触发点击事件。首先,向 dispatch
传入一个 “行为” { type: ADD } 并调用该函数,函数首先通过 reducer
根据 “行为” 计算出更改后的 state,保存在变量 payload
中。然后借助 addUpdate
将该 state 添加至 更新队列
中,同时让第二个 hook
的 firstUpdate
、lastUpdate
均指向该 state。最后借助 scheduleRoot();
重新渲染一下页面。
进入第二次渲染
调用 scheduleRoot
函数会更新 workInProgressRootFiber
与 nextUnitOfWork
的值。
requestIdleCallback(workLoop, { timeout: 500 });
调用 workLoop
时发现存在 nextUnitOfWork
,于是开始通过 performUnitOfWork
调用 beginWork
。
当前组件为函数式组件,于是通过 beginWork
进入 updateFunctionComponent
函数,在该函数中将 hookIndex 置为 0,执行到 const vDOMArrOfChildrenOfFiber = [fiber.type(fiber.props)];
时,开始调用函数式组件。
注意!虽然我们只点击了绑定第二个 hook
的按钮,似乎与第一个 useReducer
无关,但由于触发第二次渲染时,执行的是 “调用函数式组件”,所以依旧会走两遍 React.useReducer
。
首次调用 React.useReducer
在 useReducer
中,通过 alternate 发现当前并非第一次渲染,于是,先借助 funComponentFiber.alternate.hooks[0];
拿到上一次为函数式组件创建的 fiber,其 hooks 属性中存储的第一个 hook
。用 hook 变量存储。
那么 hook.state 就表示上次渲染时的 state,将其传入 forceUpdate
函数。我们并未点击第一个 useReducer
对应的按钮,所以不会触发第一个 hook
的 addUpdate
方法,因此其 firstUpdate
属性为 null。那么在 forceUpdate
函数中就会直接借助 return state; 将老的 state 返回,并将其存储在 hook
的 state 属性中。
然后借助 funComponentFiber.hooks[hookIndex++] = hook;
将上面处理好的 hook
放到:第二次渲染为函数式组件创建的 fiber 的 hooks 中,同时让 hookIndex + 1,然后 return。
注意!此时仅仅改变了第一个 useReducer
中的 state。
第二次调用 React.useReducer
第二次调用 React.useReducer
时 hookIndex 已从 0 变为 1,因此获取的就是第二个 hook
。执行 forceUpdate
时发现 hook.firstUpdate
并不为空,其次,我们知道 payload
是一个对象,其内部存储着点击按钮时,计算出的最新的 state,此时就需要将 state 赋给 nextState,然后借助 state = { ...state, ...nextState }
对点击按钮前后的 state 进行一个合并,return 出去后赋给 hook.state,最后将 hook 存放至:第二次渲染时,为函数式组件创建的 fiber,其 hooks 下标为 1 的位置。
分析完了 useReducer
,useState
的实现就显得十分简单了:
export function useState(initialValue) {
return useReducer(null, initialValue);
}
useState
在初次、再次渲染时的执行流程,就作为一道思考题留给大家了,相信屏幕前的你一定可以完美地分析出来~
八、performUnitOfWork
上面我们讲,由 performUnitOfWork
来完成一个任务,然后返回下一个任务。到这里我们就明白了,一个任务,其实就是指:先为传入的 fiber 创建其对应的真实 DOM,然后将其所有的子虚拟 DOM 转化为 fiber 并连接起来,最后将第一个子 fiber 返回。
严格来说,我们应该这么概述这一过程:传入一个 fiber 即分片,处理完该分片后返回下一个分片。
function workLoop(deadline) {
let shouldYield = false; // false表示不需要让出时间片/控制权
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork); // performUnitOfWork: 执行一个任务,返回下一个任务
shouldYield = deadline.timeRemaining() < 1; // 剩余时间小于1ms时,没有剩余时间,shouldYield置为true,表示需要让出控制权
}
...
}
function performUnitOfWork(fiber) {
beginWork(fiber); // beginWork每执行一次,就会将一个fiber的子虚拟DOM全部转化为fiber并借助child、sibling将所有的fiber连接起来
if (fiber.child) {
return fiber.child;
}
while (fiber) {
completeUnitOfWork(fiber); // 将产生了变化的fiber用firstEffect、nextEffect、lastEffect连接起来
if (fiber.sibling) {
return fiber.sibling; // 然后找弟弟节点
}
fiber = fiber.return; // 没有弟弟,先回溯到父亲,就可以让父亲完成
}
}
通过截取的这两段代码可以看的出来
performUnitOfWork的第一个任务:
为传入的 fiber 创建其对应的真实 DOM,然后将其所有的子虚拟 DOM 转化为 fiber 并连接起来,然后将第一个子 fiber 返回。
在时间片剩余时间宽裕的情况下,workLoop 的 while 循环会一直通过 performUnitOfWork 遍历获取 fiber 的第一个子 fiber,直到遇到某个 fiber 其不存在子元素,第一个任务结束!
performUnitOfWork的第二个任务:
接下来就需要进入到 performUnitOfWork 的 while 循环中,下面我们一起来分析一下这里都做了什么:
首先,fiber 被交给了:completeUnitOfWork
九、completeUnitOfWork
function completeUnitOfWork(fiber) {
let returnFiber = fiber.return;
// ①
if (returnFiber) {
// ②
if (!returnFiber.firstEffect) {
returnFiber.firstEffect = fiber.firstEffect;
}
// ③
if (fiber.lastEffect) {
// ④
if (returnFiber.lastEffect) {
returnFiber.lastEffect.nextEffect = fiber.firstEffect;
}
// ⑤
returnFiber.lastEffect = fiber.lastEffect;
}
const effectTag = fiber.effectTag;
// ⑥
if (effectTag) {
// ⑦
if (returnFiber.lastEffect) {
returnFiber.lastEffect.nextEffect = fiber;
// ⑧
} else {
returnFiber.firstEffect = fiber;
}
// ⑨
returnFiber.lastEffect = fiber;
}
}
}
该函数是我认为设计的最绝美的一处!
以该图为例,执行到 fiber E 时,发现其 child 为 null,于是开始进入 while 循环,E 首先被传入 completeUnitOfWork
① E 存在父 fiber C
② C 不存在 firstEffect,将A的firstEffect undefined 赋给C的firstEffect
⑥ E 发生变化
⑧ C 不存在 lastEffect,C的firstEffect指向E
⑨ C 的 lastEffect 指向 E
completeUnitOfWork 执行完毕:
E 存在弟弟节点 F,F 被返回。
F 作为 performUnitOfWork 的参数,因为不存在子元素,所以beginWork为其创建了一个真实DOM后,就完成了工作。F的 child属性为null,于是进入while循环,将F作为参数执行completeUnitOfWork:
① F 存在父 fiber C
⑥ E 发生变化
⑦ C 的 lastEffect 指向 E,让E的nextEffect指向F
⑨ C 的 lastEffect 指向 F
completeUnitOfWork 执行完毕:
F不存在弟弟节点,回溯到F的父节点C。将C传入completeUnitOfWork:
① C 存在父 fiber A
② A 不存在 firstEffect 属性,让A的firstEffect指向 C的firstEffect指向的E
③ C 存在lastEffect属性
⑤ 让A的lastEffect指向 C的lastEffect指向的F
⑥ C 发生更改
⑦ A的lastEffect指向F,让F的nextEffect指向C
⑨ 让 A 的 lastEffect 指向C
completeUnitOfWork 完成:
C 存在弟弟节点D,于是将D返回。
将D作为参数传递到performUnitOfWork中,beginWork将D的子虚拟DOM G、H 创建出对应的fiber借助child、sibling连接起来。
D的child指向G,将G返回,为G创建真实DOM后,发现其child为null,再次进入performUnitOfWork的while循环,首先将G传入completeUnitOfWork:
① G存在父fiberD
② D不存在firstEffect属性,让其firstEffect指向G的firstEffect undefined
⑧ D 不存在lastEffect属性,让D的firstEffect指向G
⑨ 让D的lastEffect指向G
completeUnitOfWork(G)完成,G存在弟弟节点H,返回H。
H进入beginWork,创建完真实DOM后,发现其child为null,进入performUnitOfWork的while循环,首先将G传入completeUnitOfWork:
① H存在父节点D,
⑦ D 的 lastEffect 指向G,让G的nextEffect指向H
⑨ 让D的lastEffect指向H
completeUnitOfWork(H)完成,H不存在弟弟节点,回溯到父亲节点D。
将D传入completeUnitOfWork:
① D存在父fiberA
③ D 的lastEffect指向H
④ A 的 lastEffect 指向C,让C的nextEffect指向D的firstEffect G
⑤ 让A的lastEffect指向D的lastEffect H
⑦ A的lastEffect指向H,让H的nextEffect指向 D
⑨ 让A的lastEffect指向D
completeUnitOfWork(D)完成,D不存在弟弟节点,回溯到父亲节点A。
将A传入completeUnitOfWork:
① A存在父节点 R
② R不存在firstEffect,让R的firstEffect指向A的firstEffect指向的E
③ A存在lastEffect
⑤ 让R的lastEffect指向AlastEffect指向的D
⑦ R的lastEffect指向D,让D的nextEffect指向A
⑨ 让R的lastEffect指向A
completeUnitOfWork(A)完成,A不存在弟弟节点,回溯到父亲节点R。
将R传入completeUnitOfWork:
由于R不存在父节点,函数执行完毕。
最终得到了一个这样的Effect List链表:
删除一些不必要的线,就得到了真正的Effect List链表:
fiber.return为undefined,退出performUnitOfWork的while循环,performUnitOfWork执行完毕,此次执行完毕并没有返回任何fiber,退出workLoop的while循环,打印render阶段结束,开始执行commitRoot
九、commitRoot
首先遍历deletions数组,删除上一棵fiber中所有被标记删除的fiber节点。
从根节点firstEffect指向的节点开始,根据fiber.type即DOM操作方式,来更新DOM树。
完成!