项目地址:https://github.com/4401271/MyReact
Note: index.js
需要搭配 index.html
使用,因为存在多个版本,所以需要保证两个文件前缀名相同!
鲁迅说过:需求就像奶牛的奶,只要愿挤,总会是有的。可是!我负责的项目,已经有一段时间没有产出过什么新需求了(看来,是时候加草投料了~)。
于是!大臂一挥,pia!
天天与这 React 挤眉弄眼、眉来眼去。不如~ 去其体肤,察其核核(hú)。
尝试着自己实现一个缩减版的 React。通过这篇文章,捋一下自己的实现逻辑,以及代码执行流程。
文章若有错,大佬莫留情!肆意地挥舞起你的手指,我已经做好了接招的准备
第一步,当然还是要从创建一个组件开始:
一、JSX 编译
以 index1.js 与 index1.html 这两个文件为例:
配置缘故,在 index1.js 中写 JSX,默认使用原生 React 的 createElement
,尽管引入的是自己的 React,但并不会使用我们自己写的 createElement
。
因此我们需要先借助 Babel 将 JSX 编译为 JS ,这样,就可以显式地调用我们自己写的 createElement
,这是一段 index1.js 中的代码:
let style = {border: '2px solid skyblue', margin: '5px', borderRadius: '7px'}
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
编译后,变成了这样
整理一下,就变成了我们需要的 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")
);
可以很清除地看到我们向 React.createElement
传递的各个参数,即标签的各种属性。
强迫症患者看到这段代码没有一种很爽的感觉?反正我看完感觉很舒服~
createElement
作用:根据参数,创建对应的
虚拟DOM
。
export const ELEMENT_TEXT = Symbol.for('ELEMENT_TEXT');
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: [] }
}
})
}
}
}
借助 createElement
创建出来的虚拟 DOM
虚拟 DOM
各个参数的具体含义如下:
type
: ‘div’(标签名);文本节点 type 值对应'Symbol(ELEMENT_TEXT)'
props
: 标签处倘若写了一个 {null},那么 props 就为 nullstyle
: {border: “2px solid skyblue”}(样式)onClick
: ()=>{}(绑定的事件)id
=“btn”:(标签属性)children
: [text="A1"
(标签文本)、{子虚拟DOM1}
、{子虚拟DOM2}
、{子虚拟DOM3}
、… ]
可以看到,调用 createElement
时,传入的第 2~n 个参数,最终都被整合到了 props 中。其中,标签文本也被作为 children 的一个元素保存了起来。
到这里,我们学会了如何去创建一个虚拟 DOM,也了解了虚拟DOM的结构。接下来,就需要一步一步地探索,这虚拟 DOM 是干啥用的。
欲知后事如何,请看下文:
二、render
组件创建完成后,将组件和 DOM 容器传递到 render
函数中去,像这样:
ReactDOM.render(
element,
document.getElementById('root')
)
render 函数有什么作用?
function render(element, container) {
let rootFiber = {
tag: TAG_ROOT,
stateNode: container,
props: { children: [element] }
}
scheduleRoot(rootFiber);
}
- 创建一个根 fiber:
rootFiber
- 将 id 为 root 的真实 DOM 通过
stateNode
属性绑定在rootFiber
上 - 将 createElement 创建出来的虚拟 DOM 通过 props 属性绑定在
rootFiber
上 - 将
rootFiber
做为参数调用函数scheduleRoot
这里引入了一个概念:fiber。首先,我们需要先明确
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 -
updateQueue
: 更新队列。每一个fiber
都有一个updateQueue
。该属性只在 “类式组件” 与 “类式组件” 中有实际意义(下文会详细介绍) -
effectTag
:副作用标识,标识 DOM 发生了什么样的变化,值包括:- PLACEMENT:新增
- DELETE:删除
- UPDATE:更新
-
nextEffect
:effect list
是一个单链表,该链表上保存着所有的 “发生了变化” 的 DOM 对应的fiber
。我们知道,React 并不会在每遇到一个变化,就去更新一次页面。而是将所有变化的 DOM 对应的fiber
收集起来,最终只做一次更新(暂不考虑 offsetLeft、clientTop 等需要实时获取最新数据的属性),来降低由于频繁重绘重排来带的巨大性能开销 -
alternate
:指向上一次渲染时的fiber树
中对之应的fiber
节点 -
return
:指向fiber
的 父fiber,与 child、sibling 属性一同用于构建fiber树
-
child
:指向fiber
的 第一个 子fiber -
sibling
:指向fiber
的 弟弟fiber
3. fiber 树是什么样的结构?
所有借助 createElement 创建的虚拟 DOM,都会对应一个 fiber
节点;每个组件也会对应一个根 fiber
。根据层级关系,借助 fiber
节点的 child
、sibling
、return
属性,将所有的 fiber
连接起来,形成了最终的 fiber树
。所以说 fiber树
是一个链表结构,但并非单链表。
三、scheduleRoot
scheduleRoot
只做一件事情:更新workInProgressRootFiber
、currentRenderRootFiber
存储的根节点,及其alternate
属性的指向。
页面可能会被无限次重新渲染,但维护的 fiber 树,就只有两棵:
- 一棵为此次渲染正在构建的 fiber 树,其根节点用全局变量
workInProgressRootFiber
来保存 - 另一棵为页面上次渲染时构建的 fiber 树,根节点用全局变量
currentRenderRootFiber
来保存
第二次渲染结束后,页面需要再次重新渲染时,直接复用上上次构建或更新的 fiber 树,这样就可以节省出大量的:由于创建 fiber 对象所消耗的时间与空间。
这就是 React 优化核心之一的:双缓冲机制
整体的流程大概是这样:
- 第一、二次渲染,各构建一棵新的 fiber 树;
- 第三次渲染,直接拿第一次构建的 fiber 树来用,同时让 fiber 节点的 alternate 属性,指向第二次构建的 fiber 树中的对应节点;
- 第四次渲染时,拿来第二次构建的 fiber 树,修改 fiber 节点的 alternate 属性,让其指向第三次渲染时更新的 fiber 树的对应节点;
- 第五次渲染,拿第三次渲染更新的 fiber 树来用 …
export function scheduleRoot(rootFiber) {
// 第 3、4、5 ... 次渲染
if (currentRenderRootFiber && currentRenderRootFiber.alternate) {
workInProgressRootFiber = currentRenderRootFiber.alternate;
workInProgressRootFiber.alternate = currentRenderRootFiber;
if (rootFiber) workInProgressRootFiber.props = rootFiber.props;
// 第 2 次渲染
} else if (currentRenderRootFiber) {
if (rootFiber) {
rootFiber.alternate = currentRenderRootFiber;
workInProgressRootFiber = rootFiber;
} else {
workInProgressRootFiber = {
...currentRenderRootFiber,
alternate: currentRenderRootFiber
}
}
// 第 1 次渲染
} else {
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,赋给 workInProgressRootFiber
,这样就完成了对第一个根 fiber 的复用;然后再把 currentRenderRootFiber
中保存的上一个根 fiber,赋给当前 fiber 树的 alternate 属性,就完成了与上一棵 fiber 树的关联。
四、requestIdleCallback
阅读代码可以发现,scheduleRoot
就像一座孤岛,我们调用了 scheduleRoot
函数,但 scheduleRoot
却没有调用其他任何函数,那么其他函数是怎么被使用的呢?
梳理一下代码的逻辑,可以看到,其余函数被调用的起点在 workLoop
,该函数只在 requestIdleCallback
中被调用了:
requestIdleCallback(workLoop, { timeout: 500 });
于是,问题就变成了: requestIdleCallback
在什么时候被调用?
答:浏览器每刷新一帧,就会被调用一次。在一帧存在空闲时间时,执行传递的回调函数。
区别于 requestAnimationFrame
,requestAnimationFrame
的回调会在每一帧确定执行,属于高优先级任务;而 requestIdleCallback
的回调则不一定,属于低优先级任务。
总的来说,在当前的场景中,
requestIdleCallback
就是让浏览器在执行完别的任务时,判断时间片是否还有剩余的时间,如果有,就执行传入的workLoop
任务。假如连续 500ms 都没有执行workLoop
,就强制执行该任务。
但是原生的 requestIdleCallback
每秒只有 20 帧。
(20帧 / 1000ms = 1帧 / 50ms、60帧 / 1000ms = 1帧 / 16.7ms)
也就是说,每个时间片是 50ms,隔 50ms 才会刷新一次,其流畅程度远比我们感觉标准的 16.7ms 要低很多。所以,在 React 的源码中,需要实现了一个更加流畅的 requestIdleCallback
。(该函数不是我们关注的重点,暂时就不实现了)
五、workLoop
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
执行一个 “任务”,返回它的下一个 “任务”(可中断的 “任务”,即为该 “任务”) - 判断是否存在下一个 “任务”,并且当前时间片还有剩余的时间
- 如果二者都满足,就循环地执行 “任务”,返回下一个 “任务”,执行 “任务”,返回下一个 “任务”…
- 如果是 “任务” 执行完毕,没有了下一个 “任务”,就表示
rereconcileChildren阶段
结束,接下来就开始调用commitRoot
去修改 DOM - 如果是时间片的时间被使用完毕,就再次调用
requestIdleCallback
,在下次获得执行权时,延续上次被搁置的 “任务”,继续循环执行,直到所有的 “任务” 都被执行完毕
“任务” 的具体内容,相对较为复杂,需要通过下面的函数来一点一点地分析。在 八、performUnitOfWork
中,会对 “任务” 进行一个概括。
六、reconcileChildren
function reconcileChildren(fiber, vDOMArrOfChildrenOfFiber)
从使用可以看到,该函数接收两个参数:
- 第一个参数为一个 fiber 节点,也就是下面说的父 fiber
- 第二个参数为该 fiber 节点的子虚拟 DOM 组成的数组
reconcileChildren 的功能:
创建出所有的子虚拟 DOM 对应的 fiber 节点,父 fiber (reconcileChildren 的第一个参数)通过 child 属性与第一个子 fiber 相连接,每一个子 fiber 再通过 sibling 属性与下一个子 fiber 连接。
1. 为虚拟 DOM 创建对应的 fiber
我们一起来看一下具体过程:
首先,借助 fiber.alternate
,找到父 fiber 在上一棵 fiber 树中,与之对应的 fiber 节点: oldFiber
。然后通过 child 属性获得 oldFiber 的第一个子 fiber:oldSonFiber
。oldSonFiber
与 vDOMArrOfChildrenOfFiber 的第一个子虚拟 DOM 相对应,并且,oldSonFiber
正是我们要复用的 fiber 对象。
const sameType = oldSonFiber && sonVDOM && oldSonFiber.type === sonVDOM.type;
若 sameType
为 true,判断 oldSonFiber
上是否存在 alternate
属性:
- 若存在,就表示该节点已经是第 3、4、5…次渲染,直接将上上次被渲染的节点拿过来使用(可复用)。复用 fiber 时,并没有为 fiber 的 stateNode 属性重新赋值,这就表示其对应的真实 DOM 节点也是可以复用的。
- 若不存在,一定是第 2 次渲染,也需要重新创建一个 fiber(不可复用),注意:第二次渲染一个节点时,我们需要为该节点添加
alternate
属性,并让其指向oldSonFiber
(原生的 React 对是否可复用的判定逻辑及处理,会更加复杂一些,比如还会添加对 key
的判断等等)
sameType
为 false:
- 可能是由于不存在
oldSonFiber
,也就是说:在此之前不存在与之对应的 fiber 节点,此时可以确定当前节点一定是第 1 次被渲染(不可复用) - 也有可能由于
oldSonFiber
与sonVDOM
所指向的节点标签名不同,此时只能确定该节点至少已经渲染过一次,这两种情况都需要创建新的 fiber(不可复用) sonVDOM
为 null 时sameType
也为 false,这种情况不需要新建 fiber(不可复用)
到这里,我们就成功地为一个虚拟 DOM 创建出其对应的 fiber 节点,reconcileChildren
的第一个任务完成。
2. 收集被删除的 fiber
sameType
为 false 表示:当前 fiber 树上并不存在与 oldSonFiber
相对应的虚拟 DOM。因此还需要将 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 的任务:
根据 tag 的不同,为 fiber 创建出对应的真实 DOM,挂载到 fiber 的 stateNode 属性上。
取出 fiber 的子虚拟 DOM 数组,交给 reconcileChildren 来将所有虚拟 DOM 转化为 fiber 并通过 child、sibling 进行连接。
1. TAG_ROOT
:根 fiber(rootFiber
):
这种情况下,fiber 对应的真实 DOM,其实就是 id 为 root 的 DOM 容器。我们已经在 render 中将其挂载到了 rootFiber
的 stateNode 属性上。这里,我们就只需要取出其子虚拟 DOM 数组,交给 reconcileChildren。
2. TAG_TEXT
:文本节点
文本节点不存在后代,也就不需要调用 reconcileChildren。只需要根据文本内容,借助document.createTextNode(fiber.props.text)
创建出对应的文本节点,然后绑定到 fiber 的 stateNode 属性上。
3. TAG_HOST
:标签节点
标签节点需要借助 document.createElement(fiber.type)
创建出对应的标签。标签可能包含一些如 id、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) { // value: {border: '2px solid skyblue', margin: '5px', borderRadius: '7px'}
for (let styleName in value) {
DOM.style[styleName] = value[styleName]; // 为DOM的style属性添加名为border的样式,值为value对象中border对应的值
}
}
} 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
:类式组件
// fiber.stateNode指向组件实例,组件实例的internalFiber指向fiber对象
fiber.stateNode = new fiber.type(fiber.props);
fiber.stateNode.internalFiber = fiber;
在类式组件中,需要我们 new 一个 组件实例
,同时,将组件参数作为实例化时的参数,传至 constructor 的 props 中,虚拟 DOM 就可以直接通过 this.props.xxx 获取到这些属性。
然后将 组件实例
绑定到 fiber 的 stateNode 属性上,再将 fiber 绑定到 组件实例
的 internalFiber
属性上。
setState(payload) { // payload可能是对象,也可能是函数
let update = new Update(payload); // 将payload挂载到update对象上
this.internalFiber.updateQueue.addUpdate(update); // updateQueue放在类组件的fiber节点的internalFiber上
scheduleRoot();
}
export class Update {
constructor(payload) {
this.payload = payload;
}
}
我们调用 setState
更新状态时,就是通过 组件实例.internalFiber
先拿到 fiber,然后就可以通过 fiber.updateQueue.addUpdate(update)
将封装好的 state 放入更新队列中。
这里就可以解释,为什么 setState 更新 state 是异步的
其实,就是因为我们在调用 setState
时,并没有立即对 state 进行更新。而是先通过 new Update(payload);
将更新的 state 封装进一个对象中。然后将这个对象通过 addUpdate
添加至 fiber 的 updateQueue
也就是 更新队列
中。
我们与页面进行一次交互,可能会触发多个 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
,作用我们后面再讨论。
funComponentFiber = fiber;
hookIndex = 0;
funComponentFiber.hooks = [];
函数式组件不需要为其实例化对象,所以由 begin 进入对应处理函数 updateFunctionComponent
时,需要做的事情也就很少:
首先,将 “函数组件对应的 fiber” 赋给 funComponentFiber
;
其次,为每个函数组件添加一个 hooks
属性,用来存储组件中添加的一个个 hook;
紧接着,初始化 hookIndex
,每次渲染时,都需要先对 hookIndex
初始化,才能依次拿到初次渲染时创建的 hook,依次进行操作。hooks
是一个数组,里面的每一个 hook 都和 hookIndex
相对应。
const vDOMArrOfChildrenOfFiber = [fiber.type(fiber.props)];
我们知道,调用 函数式组件 内的函数,即可拿到被返回的虚拟 DOM。 调用的同时,将函数组件的参数传递到函数中,这样虚拟 DOM 就可以通过 props.xxx 拿到对应的参数值。
拿到了被返回的虚拟 DOM,剩下的任务依旧是交给 reconcileChildren。
6. useReducer
分析完了函数式组件,不如趁热打铁,顺带分析一下 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 函数中的 dispatch 函数。
触发点击事件,首先执行 dispatch 函数,该函数末尾的 scheduleRoot(); 导致函数式组件被再次渲染。
再次渲染时会和初次渲染一样,将 useReducer 整个函数除 dispatch 都走一遍,而非有些文章说的:再次渲染时不执行 useReducer 函数!
分析完了 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; // 没有弟弟,先回溯到父亲,就可以让父亲完成
}
}
通过截取的这两段代码可以看的出来
1. performUnitOfWork
的第一个任务
借助 beginWork
为传入的 fiber 创建其对应的真实 DOM,然后将其所有的子虚拟 DOM 转化为 fiber 并连接起来,然后将第一个子 fiber 返回。
workLoop
的 while 循环会一直通过 performUnitOfWork
遍历获取 fiber 的第一个子 fiber,直到遇到某个 fiber 其不存在子元素,第一个任务结束!
2. performUnitOfWork
的第二个任务
接下来就需要进入到 performUnitOfWork
的 while 循环中,交由 completeUnitOfWork
将 fiber 正确地添加进 Effect List
链表中,具体的添加方式在 九、completeUnitOfWork
会详细介绍。while 循环在 fiber 存在弟弟节点时,返回弟弟节点,被返回的弟弟节点会先进入到 performUnitOfWork
的 beginWork
中,为其创建真实 DOM 及连接子 fiber;如果没有弟弟节点,就先回溯到父节点,将父节点通过 completeUnitOfWork
链入 Effect List
,然后判断父节点是否存在弟弟节点,没有继续向上回溯…
九、completeUnitOfWork
任务:调整节点的 firstEffect、lastEffect、nextEffect 指向
(调用一次 completeUnitOfWork,可能同时涉及到对多个节点的调整)
firstEffect:指向以该节点为root,所在树的第一个child为null的节点
lastEffect:指向以该节为root,所在树下一层的最后一个节点
nextEffect:指向以深度优先遍历方式,遍历整棵树时,其遍历的下一个节点。注意,nextEffect 不会连接根节点
举个例子,以 D 节点为 root,这棵树就包括 D、G、H,D.firstEffect
指向这棵树中第一个 child 为 null 的 G,D.lastEffect
指向下一层最后一个节点 H。以 A 节点为 root,这棵树包括 A、C、D、E、F、G、H,A.firstEffect
指向这棵树中第一个 child 为 null 的 E,A.lastEffect
指向下一层最后一个节点 D。
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,于是开始进入 performUnitOfWork
的 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 存在父 fiber D
② 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 存在父 fiber A
③ 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 指向 A.lastEffect 指向的 D
⑦ R 的 lastEffect 指向 D,让 D 的 nextEffect 指向A
⑨ 让 R 的 lastEffect 指向A
completeUnitOfWork(A)
完成,A 不存在弟弟节点,回溯到父亲节点 R。
将 R 传入 completeUnitOfWork
:
由于 R 不存在父节点,函数执行完毕。
得到了一个这样的链表:
删除一些不必要的线,就得到了最终的 Effect List
:
fiber.return 为 undefined,退出 performUnitOfWork
的 while 循环,performUnitOfWork
执行完毕,此次执行完毕并没有返回任何 fiber,于是退出 workLoop
的 while 循环,打印 “render 阶段结束”,开始执行 commitRoot
。
十、commitRoot
deletions.forEach(commitWork); // deletions中 所有fiber的effectTag 均被标记为DELETION
首先,遍历 deletions
数组,删除上一棵 fiber 树中所有 effectTag
被标记为 DELETION
的 fiber 节点。我们已经知道,上一棵 fiber 树会在下一次渲染被复用,清除了被删除的节点,在下一次复用时,就可以减少很多干扰。
然后从根节点 firstEffect
指向的叶子节点开始,根据 fiber.type
即 DOM 操作方式,更新 DOM 树。
完成!
恭喜你!文章到这里就结束了~
自从画出 Effect List
,React 的执行流程及原理
就基本算是完美掌握了,不知屏幕前的你,是否还健在?