Fiber 架构的简单实现

Fiber 架构的简单实现

我们在之前的文章当中梳理了 React Fiber 相关内容,了解了 Fiber 的基本作用以及它内部一些简单的运行原理,所以在本章当中我们来继续深入理解其原理,尝试尝试手动的模拟实现一个简易版本的 Fiber 架构,本文主要参考的是 Build your own React,内容有所调整,主要是为了方便自己理解,更多详细内容可以查看原文,下面我们就先从 JSX 开始看起

完整代码地址可见 Fiber 架构的简单实现

JSX

我们都知道,JSX 是一种特殊的语法,在之前版本的 React 当中如果想要支持 JSX 语法的话还需要一个额外库的来进行支持(JSXTransformer.js),不过后来 JSX 的转换工作全部都集成到了 Babel 当中,比如下面这段简单的代码

1
2
3
4
5
6
7
8
9
10
11
const App = (
<div>
<h1 id="title">Title</h1>
<a href="###">Link</a>
<section>
<p>
Article
</p>
</section>
</div>
)

我们可以通过 Babel在线预览功能 来查看它转化后的样子,它是下面这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var App = React.createElement(
'div',
null,
React.createElement(
'h1',
{
id: 'title',
},
'Title',
),
React.createElement(
'a',
{
href: '###',
},
'Link',
),
React.createElement(
'section',
null,
React.createElement('p', null, 'Article'),
),
)

可以发现,我们书写的 JSX 代码已经被转换成了 React.createElement 的写法,同时从转换后的代码我们也可以发现 React.createElement 是支持多个参数的

  • type,也就是节点类型
  • config,节点上的属性,比如 idhref
  • children,从第三个参数开始就全部是子元素,子元素可以有多个,类型可以是简单的文本,也可以还是 React.createElement,如果是 React.createElement 的话其实就是子节点了,子节点下面还可以有子节点,这样就用 React.createElement 的嵌套关系实现了 HTML 节点的树形结构

而我们上面这段 JSX 代码如果想在 React 框架下运行起来,还需要 React 提供的额外两个库来进行支持,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import React from 'react'
import ReactDOM from 'react-dom'

const App = _ => {
return (
<div>
<h1 id="title">Title</h1>
<a href="###">Link</a>
<section>
<p>
Article
</p>
</section>
</div>
)
}

ReactDOM.render(<App />, document.getElementById('root'))

通过观察我们可以发现,这里面用到了 React 的地方其实就两个,一个是 JSX,也就是 React.createElement,另一个就是 ReactDOM.render,所以下面我们就先来简单的看看 createElementrender 这两个方法

createElement

对于 <h1 id="title">Title</h1> 这样一个简单的节点,我们都知道原生 DOM 会附加一大堆属性和方法在上面,所以我们在 createElement 的时候最好能将它转换为一种比较简单的数据结构,只包含我们需要的元素,比如下面这样

1
2
3
4
5
6
7
{
type: 'h1',
props: {
id: 'title',
children: 'Title'
}
}

有了这个数据结构后,我们对于 DOM 的操作其实可以转化为对这个数据结构的操作,新老 DOM 的对比其实也可以转化为这个数据结构的对比,这样我们就不需要每次操作都去渲染页面,而是等到需要渲染的时候才将这个数据结构渲染到页面上,这其实就是所谓的『虚拟 DOM』(以下我们简称为 vDom),而我们 createElement 就是负责来构建这个 vDom 的方法

这里关于 createElement 的实现就不具体展开了,详细可以参考我们之前已经整理过的 什么是 Virtual DOM 系列文章或是 官方源码 ,核心逻辑并不复杂,这里我们只需要知道它是用来帮助我们构建 vDom 的方法即可

render

在上面的代码中我们使用 createElementJSX 代码转换成了 vDom,但是我们又该如何将 vDom 渲染为真实的 DOM 节点呢?所以我们还需要一个 render 方法来帮助我们实现这个功能,我们通过上面的使用可以发现,render 方法接收两个参数

  • 根组件,其实是一个 JSX 组件,也就是一个 createElement 返回的 vDom
  • 父节点,也就是我们要将这个 vDom 渲染的位置

有了这些了解以后我们就可以来实现我们自己的 render 方法了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function render(vDom, container) {
let dom
if (typeof vDom !== 'object') {
dom = document.createTextNode(vDom)
} else {
dom = document.createElement(vDom.type)
}

// 将 vDom 上除了 children 外的属性都挂载到真正的 DOM 上去
if (vDom.props) {
Object.keys(vDom.props)
.filter(key => key != 'children')
.forEach(item => {
dom[item] = vDom.props[item]
})
}

// 如果还有子元素,递归调用
if (vDom.props && vDom.props.children && vDom.props.children.length) {
vDom.props.children.forEach(child => render(child, dom))
}

container.appendChild(dom)
}

当然,上述代码只是简化版本的 render 方法,我们没有考虑节点具体类型的区别,又或是挂载和更新的不同处理逻辑等,详细内容同样可以参考我们之前已经整理过的 什么是 Virtual DOM 系列文章或是对应的 官方源码 来了解更多,因为这一部分在本文当中不是我们重点关注的内容,所以我们只是简单介绍一二,下面我们主要来看看 Fiber 的相关内容

render 的拆分

我们在上面实现了将 vDom 渲染到页面上的代码,这部分的工作在 React 官方当中被称为 rendererrenderer 是第三方可以自己实现的一个模块,其中有个核心模块叫做 reconsiler,而 reconsiler 的一大功能就是大家熟知的 Diff,它会计算出应该更新哪些页面节点,然后将需要更新的节点 vDom 传递给 rendererrenderer 负责将这些节点渲染到页面上

但是这个流程有个问题,也是我们在 React Fiber 章节开头部分所提到的,那就是虽然 ReactDiff 算法是经过优化的,但是它却是同步的,renderer 负责操作 DOM 的一些操作也是同步的,也就是说如果有大量节点需要更新,JavaScript 线程的运行时间可能会比较长,在这段时间浏览器是不会响应其它事件的,因为 JavaScript 线程和 GUI 线程是互斥的,如果这个时间太长了,用户就可能看到卡顿,这也就是为什么 React 会推出 Fiber 的原因,Fiber 可以将长时间的同步任务拆分成多个小任务,从而让浏览器能够抽身去响应其它事件,等它有空了再回来继续计算

但是我们在上面实现的 render 方法,它是直接递归遍历了整个树,如果我们在中途某一步停下来,下次再调用时其实并不知道上次在哪里停下来的,不知道从哪里开始,即使你将上次的结束节点记下来了,你也不知道下一个该执行哪个,所以之前简单的 vDom 树形结构并不满足中途暂停,下次继续的需求,所以我们就需要改造它的数据结构

而另一个需要解决的问题是,拆分下来的小任务什么时候执行?我们的目的是让用户有更流畅的体验,所以我们最好不要阻塞高优先级的任务,比如用户输入,动画之类,等它们执行完了我们再计算,那我怎么知道现在有没有高优先级任务,浏览器是不是空闲呢?所以总结下来,Fiber 要想达到目的,需要解决两个问题

  • 新的任务调度,有高优先级任务的时候将浏览器让出来,等浏览器空了再继续执行
  • 新的数据结构,可以随时中断,下次进来可以接着执行

所幸,针对这两点我们都已经有了对应的解决方式,也就是 之前文章 当中所提及到的 Fiber 数据结构与 requestIdleCallback 这个 API,这里我们就不过多介绍了,只简单提及一二

requestIdleCallback

requestIdleCallback 接收一个回调,这个回调会在浏览器空闲时调用,每次调用会传入一个 IdleDeadline,可以得到当前还空余多久,options 可以传入参数最多等多久,等到了时间浏览器还不空就强制执行了,使用这个 API 可以解决我们之前提到的任务调度的问题,让浏览器在空闲时才计算 Diff 并渲染,调用方式如下

1
2
3
4
5
// 开启调用
var handle = window.requestIdleCallback(callback[, options])

// 结束调用
Window.cancelIdleCallback(handle)

但是这个 API 还处在实验阶段,兼容性不好,所以 React 官方自己实现了一套,但是在这里我们就不考虑那么多了,还是使用 requestIdleCallback 来进行任务调度,我们进行任务调度的思想是将任务拆分成多个小任务,requestIdleCallback 里面不断的把小任务拿出来执行,当所有任务都执行完或者超时了就结束本次执行,同时要注册下次执行

这里我们可以借住官方的 workLoopSync 实现方式得出大致架子,如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function workLoop(deadline) {
// while 循环会在任务执行完或者时间到了的时候结束
while (nextUnitOfWork && deadline.timeRemaining() > 1) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
}

// 如果任务还没完,但是时间到了,我们需要继续注册 requestIdleCallback
requestIdleCallback(workLoop)
}

// performUnitOfWork 用来执行任务,参数是我们的当前 fiber 任务,返回值是下一个任务
function performUnitOfWork(fiber) {

}

requestIdleCallback(workLoop)

Fiber 数据结构

在上面的代码中,我们完成了任务的拆分,下面我们就来看看如果能让我们的任务可以随时中断,并且下次进来的时候还可以接着执行,上面示例中的 performUnitOfWork 方法我们暂时还没有实现,但是从上面的结构可以看出来,它接收的参数是一个小任务,同时通过这个小任务还可以找到它的下一个小任务,而 Fiber 构建的就是这样一个数据结构,而我们之前的 vDom 的数据结构是一棵树,父节点的 children 指向了子节点,但是只有这一个指针是不能实现中断继续的,所以我们需要对之前的结构进行一定的调整,可以参考官方演讲当中的方式,如下

我们可以发现和之前 vDom 当中父节点指向所有子节点不同,这里有三个指针

  • child,父节点指向第一个子元素的指针
  • sibling,从第一个子元素往后,指向下一个兄弟元素
  • return,所有子元素都有的指向父元素的指针

有了这几个指针后,我们可以在任意一个元素中断遍历并恢复,比如在上图 List 处中断了,恢复的时候可以通过 child 找到他的子元素,也可以通过 return 找到他的父元素,如果他还有兄弟节点也可以用 sibling 找到,Fiber 这个结构外形看着还是棵树,但是没有了指向所有子元素的指针,父节点只指向第一个子节点,然后子节点有指向其他子节点的指针,所以可以发现这其实是个『链表结构』

这里需要注意的是,真正的 Fiber 结构并不仅仅只多了这三个节点,但是这里我们只使用这三个节点来理解其原理就足够了,更多 Fiber 节点相关内容可见 Fiber 节点

Fiber 的实现

有了以上内容的铺垫以后,下面我们就可以来实现一下我们自己的 Fiber 了,我们需要将我们之前的 vDom 结构转换为 Fiber 的数据结构,同时需要能够通过其中任意一个节点返回下一个节点,其实就是遍历这个链表

遍历的时候从根节点出发,先找子元素,如果子元素存在,直接返回,如果没有子元素了就找兄弟元素,找完所有的兄弟元素后再返回父元素,然后再找这个父元素的兄弟元素,整个遍历过程其实是个『深度优先遍历』,从上到下,然后最后一行开始从左到右遍历

比如下图从 div1 开始遍历的话,遍历的顺序就应该是 div1 ==> div2 ==> h1 ==> a ==> div2 ==> p ==> div1,可以看到这个序列中,当我们 return 父节点时,这些父节点会被第二次遍历,所以我们在设计的时候,return 的父节点不会作为下一个任务返回,只有 siblingchild 才会作为下一个任务返回

同样我们可以参考官方当中的 performUnitOfWork 来进行实现,它的作用是用来执行任务,参数是我们当前的 Fiber 任务,返回值是下一个任务,我们这里只是简单的模拟实现,官方版本当中的实现方式远比我们要复杂很多

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
function performUnitOfWork(fiber) {
if (!fiber.dom) { // 根节点的 dom 就是 container,如果没有这个属性,说明当前 fiber 不是根节点
fiber.dom = createDom(fiber) // 创建一个DOM挂载上去
}
if (fiber.return) { // 如果有父节点,将当前节点挂载到父节点上
fiber.return.dom.appendChild(fiber.dom)
}
const elements = fiber.children // 将我们前面的 vDom 结构转换为 fiber 结构
let prevSibling = null
if (elements && elements.length) {
for (let i = 0; i < elements.length; i++) {
const element = elements[i]
const newFiber = {
type: element.type,
props: element.props,
return: fiber,
dom: null
}
if (i === 0) { // 父级的 child 指向第一个子元素
fiber.child = newFiber
} else { // 每个子元素拥有指向下一个子元素的指针
prevSibling.sibling = newFiber
}
prevSibling = newFiber
}
}

// 这个函数的返回值是下一个任务,这其实是一个深度优先遍历
// 先找子元素,没有子元素了就找兄弟元素,兄弟元素也没有了就返回父元素
// 然后再找这个父元素的兄弟元素,最后到根节点结束
// 这个遍历的顺序其实就是从上到下,从左到右
if (fiber.child) {
return fiber.child
}

let nextFiber = fiber

while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.return
}
}

统一提交

上面我们的 performUnitOfWork 一边构建 Fiber 结构一边操作 DOMappendChild),但是如果某次我们同时更新了好几个节点,而且在操作了第一个节点之后就中断了,那么我们可能只会看到第一个节点渲染到了页面,后续几个节点要等到浏览器空了才会去陆续渲染

为了避免这种情况,我们应该将 DOM 操作都搜集起来,最后统一执行,而这就是 commit 操作,为了能够记录位置,我们还需要一个全局变量 workInProgressRoot 来记录根节点,然后在 workLoop 检测如果任务执行完了,就统一 commit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function workLoop(deadline) {
// while 循环会在任务执行完或者时间到了的时候结束
while (nextUnitOfWork && deadline.timeRemaining() > 1) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
}

// 任务做完后统一渲染
if (!nextUnitOfWork && workInProgressRoot) {
commitRoot()
}

// 如果任务还没完,但是时间到了,我们需要继续注册 requestIdleCallback
requestIdleCallback(workLoop)
}

因为我们是在 Fiber 树完全构建后再执行的 commit,而且有一个变量 workInProgressRoot 指向了 Fiber 的根节点,所以我们可以直接把 workInProgressRoot 拿过来递归渲染就行了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 统一操作 DOM
function commitRoot() {
commitRootImpl(workInProgressRoot.child) // 开启递归
workInProgressRoot = null // 操作完后将 workInProgressRoot 重置
}

function commitRootImpl(fiber) {
if (!fiber) {
return
}

const parentDom = fiber.return.dom
parentDom.appendChild(fiber.dom)

// 递归操作子元素和兄弟元素
commitRootImpl(fiber.child)
commitRootImpl(fiber.sibling)
}

协调

协调其实就是 vDomDiff 操作,需要添加新的节点,删除不需要的节点和更新修改过的节点,为了能在中断后能回到工作位置,我们还需要一个变量 currentRoot,然后在 fiber 节点里面添加一个属性 alternate,这个属性指向上一次运行的根节点,也就是 currentRoot

currentRoot 会在第一次 render 后的 commit 阶段赋值,也就是每次计算完后都会把当次状态记录在 alternate 上,后面更新了就可以把 alternate 拿出来跟新的状态做 Diff,然后 performUnitOfWork 里面需要添加协调子元素的代码,所以我们可以新增一个比对函数 reconcileChildren 来将老节点跟新节点进行对比,逻辑如下

  1. 如果新老节点类型一样,复用老节点 DOM,更新 props
  2. 如果类型不一样,而且新的节点存在,创建新节点替换老节点
  3. 如果类型不一样,没有新节点,有老节点,删除老节点

注意删除老节点的操作是直接将 oldFiber 加上一个删除标记就行,同时用一个全局变量 deletions 记录所有需要删除的节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
function reconcileChildren(workInProgressFiber, elements) {
// 构建 fiber 结构
let oldFiber = workInProgressFiber.alternate
&& workInProgressFiber.alternate.child // 获取上次的 fiber 树
let prevSibling = null

let index = 0
if (elements && elements.length) {
if (!oldFiber) { // 第一次没有 oldFiber,那全部是 REPLACEMENT
for (let i = 0; i < elements.length; i++) {
const element = elements[i]
const newFiber = buildNewFiber(element, workInProgressFiber)
if (i === 0) { // 父级的 child 指向第一个子元素
workInProgressFiber.child = newFiber
} else {
prevSibling.sibling = newFiber // 每个子元素拥有指向下一个子元素的指针
}
prevSibling = newFiber
}
}

while (index < elements.length && oldFiber) {
let element = elements[index]
let newFiber = null
const sameType = oldFiber // 对比 oldFiber 和当前 element(检测类型是否一样)
&& element
&& oldFiber.type === element.type
if (sameType) { // 先比较元素类型,如果类型一样,复用节点,更新 props
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
return: workInProgressFiber,
alternate: oldFiber, // 记录下上次状态
effectTag: 'UPDATE' // 添加一个操作标记
}
} else if (!sameType && element) { // 如果类型不一样,有新的节点,创建新节点替换老节点
newFiber = buildNewFiber(element, workInProgressFiber)
} else if (!sameType && oldFiber) { // 如果类型不一样,没有新节点,有老节点,删除老节点
oldFiber.effectTag = 'DELETION' // 添加删除标记
deletions.push(oldFiber) // 一个数组收集所有需要删除的节点
}
oldFiber = oldFiber.sibling // 循环处理兄弟元素
if (index === 0) { // 父级的child指向第一个子元素
workInProgressFiber.child = newFiber
} else {
prevSibling.sibling = newFiber // 每个子元素拥有指向下一个子元素的指针
}
prevSibling = newFiber
index++
}
}
}

然后就是在 commit 阶段处理真正的 DOM 操作,具体的操作是根据我们的 effectTag 来进行判断的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function commitRootImpl(fiber) {
if (!fiber) {
return
}
const parentDom = fiber.return.dom
if (fiber.effectTag === 'REPLACEMENT' && fiber.dom) {
parentDom.appendChild(fiber.dom)
} else if (fiber.effectTag === 'DELETION') {
parentDom.removeChild(fiber.dom)
} else if (fiber.effectTag === 'UPDATE' && fiber.dom) {
// 更新 DOM 属性
updateDom(fiber.dom, fiber.alternate.props, fiber.props)
}
// 递归操作子元素和兄弟元素
commitRootImpl(fiber.child)
commitRootImpl(fiber.sibling)
}

替换和删除的 DOM 操作都比较简单,更新属性的会稍微麻烦点,需要再写一个辅助函数 updateDom 来实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 更新 DOM 的操作
function updateDom(dom, prevProps, nextProps) {
// 1、过滤 children 属性
// 2、老的存在,新的没了,取消
// 3、新的存在,老的没有,新增
Object.keys(prevProps)
.filter(name => name !== 'children')
.filter(name => !(name in nextProps))
.forEach(name => {
if (name.indexOf('on') === 0) {
dom.removeEventListener(name.substr(2).toLowerCase(), prevProps[name], false)
} else {
dom[name] = ''
}
})
Object.keys(nextProps)
.filter(name => name !== 'children')
.forEach(name => {
if (name.indexOf('on') === 0) {
dom.addEventListener(name.substr(2).toLowerCase(), nextProps[name], false)
} else {
dom[name] = nextProps[name]
}
})
}

这里我们只是简单处理了 on 开头的一些事件,并且兼容性可能也会存在问题,而且 prevPropsnextProps 可能会遍历到相同的属性,有重复赋值,但是在这里我们也就不多做处理了,这里的主要目的其实是为了让我们了解其原理,更为完整的实现方式可以参考我们之前已经整理过的 渲染器的核心 Diff 算法

下面我们来看看如何支持函数组件

函数组件

函数组件是 React 里面很常见的一种组件,但是我们之前的 fiber 节点上的 type 都是 DOM 节点的类型,比如 h1 之类的,而函数组件的节点 type 应该就是一个函数了,所以我们需要对这种节点进行单独处理,首先需要在更新的时候检测当前节点是不是函数组件,如果是的话那么 children 的处理逻辑会稍微有些不太一样,我们首先来调整一下我们的 performUnitOfWork 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function performUnitOfWork(fiber) {

// ...

const isFunctionComponent = fiber.type instanceof Function
if (isFunctionComponent) {
updateFunctionComponent(fiber)
} else {
updateHostComponent(fiber)
}

// ...

}

function updateFunctionComponent(fiber) {
// 函数组件的 type 就是个函数,直接拿来执行可以获得 DOM 元素
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}

// updateHostComponent 就是之前的操作,只是单独抽取了一个方法
function updateHostComponent(fiber) {
if (!fiber.dom) {
// 创建一个 DOM 挂载上去
fiber.dom = createDom(fiber)
}

// 将我们前面的 vDom 结构转换为 fiber 结构
const elements = fiber.props.children

// 协调子元素
reconcileChildren(fiber, elements)
}

然后在我们提交 DOM 操作的时候因为函数组件没有 DOM 元素,所以需要注意两点

  1. 获取父级 DOM 元素的时候需要递归往上找到真正的 DOM
  2. 删除节点的时候需要递归往下找到真正的节点

所以我们来修改下 commitRootImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function commitRootImpl(fiber) {
if (!fiber) {
return
}

// 不再直接获取,而是向上查找真正的 DOM
// const parentDom = fiber.return.dom
let parentFiber = fiber.return
while (!parentFiber.dom) {
parentFiber = parentFiber.return
}
const parentDom = parentFiber.dom

if (fiber.effectTag === 'REPLACEMENT' && fiber.dom) {
parentDom.appendChild(fiber.dom)
} else if (fiber.effectTag === 'DELETION') {
// 这里也不再使用 parentDom.removeChild(fiber.dom)
commitDeletion(fiber, parentDom)
} else if (fiber.effectTag === 'UPDATE' && fiber.dom) {
// 更新DOM属性
updateDom(fiber.dom, fiber.alternate.props, fiber.props)
}

// 递归操作子元素和兄弟元素
commitRootImpl(fiber.child)
commitRootImpl(fiber.sibling)
}

function commitDeletion(fiber, domParent) {
if (fiber.dom) {
// DOM 存在,是普通节点
domParent.removeChild(fiber.dom)
} else {
// DOM 不存在,是函数组件,向下递归查找真实 DOM
commitDeletion(fiber.child, domParent)
}
}

现在我们可以传入函数组件了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React from './myReact'
const ReactDOM = React

function App(props) {
return (
<div>
<h1 id="title">{props.title}</h1>
<a href="xxx">Jump</a>
<section>
<p>
Article
</p>
</section>
</div>
)
}

ReactDOM.render(
<App />,
document.getElementById('root')
)

实现 useState

useStateReact Hooks 里面的一个 API,相当于之前 Class Component 里面的 state,用来管理组件内部状态,现在我们已经有一个简化版的 React 了,我们也可以尝试下来实现这个 API

简单版

我们还是从用法入手来实现最简单的功能,我们一般使用 useState 是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function App(props) {
const [count, setCount] = React.useState(1)
const onClickHandler = () => {
setCount(count + 1)
}
return (
<div>
<h1>Count: {count}</h1>
<button onClick={onClickHandler}>Count + 1</button>
</div>
)
}

ReactDOM.render(
<App />,
document.getElementById('root')
)

上述代码可以看出,我们的 useState 接收一个初始值,返回一个数组,里面有这个 state 的当前值和改变 state 的方法,但是需要注意的是 App 作为一个函数组件,每次 render 的时候都会运行,也就是说里面的局部变量每次 render 的时候都会重置,那我们的 state 就不能作为一个局部变量,而是应该作为一个全部变量来进行存储

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let state = null
function useState(init) {
state = state === null ? init : state
const setState = value => { // 修改 state 的方法
state = value
workInProgressRoot = { // 只要修改了 state,我们就需要重新处理节点
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot
}
// 修改 nextUnitOfWork 指向 workInProgressRoot,这样下次就会处理这个节点了
nextUnitOfWork = workInProgressRoot
deletions = []
}
return [state, setState]
}

这样其实我们就可以使用了

支持多个 state

但是上面的代码当中只有一个 state 变量,如果我们有多个 useState 怎么办呢?为了能支持多个 useState,我们的 state 就不能是一个简单的值了,我们可以考虑把他改成一个数组,多个 useState 按照调用顺序放进这个数组里面,访问的时候通过下标来访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let state = []
let hookIndex = 0
function useState(init) {
const currentIndex = hookIndex
state[currentIndex] = state[currentIndex] === undefined ? init : state[currentIndex]
const setState = value => {
state[currentIndex] = value
workInProgressRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot
}
nextUnitOfWork = workInProgressRoot
deletions = []
}
hookIndex++
return [state[currentIndex], setState]
}

支持多个组件

上面的代码虽然我们支持了多个 useState,但是仍然只有一套全局变量,如果有多个函数组件,每个组件都来操作这个全局变量,那相互之间不就是污染了数据了吗?所以我们数据还不能全都存在全局变量上面,而是应该存在每个 Fiber 节点上,处理这个节点的时候再将状态放到全局变量用来通讯

1
2
3
4
5
// 申明两个全局变量,用来处理 useState
// wipFiber 是当前的函数组件 fiber 节点
// hookIndex 是当前函数组件内部 useState 状态计数
let wipFiber = null
let hookIndex = null

因为 useState 只在函数组件里面可以用,所以我们之前的 updateFunctionComponent 里面需要初始化处理 useState 变量

1
2
3
4
5
6
7
8
function updateFunctionComponent(fiber) {
wipFiber = fiber // 支持 useState,初始化变量
hookIndex = 0
wipFiber.hooks = [] // hooks 用来存储具体的 state 序列

// ...

}

因为 Hooks 队列放到 Fiber 节点上去了,所以我们在 useState 取之前的值时需要从 fiber.alternate 上取,完整代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function useState(init) {
const oldHook = wipFiber.alternate // 取出上次的 Hook
&& wipFiber.alternate.hooks
&& wipFiber.alternate.hooks[hookIndex]
const hook = { // Hook 数据结构
state: oldHook ? oldHook.state : init // state 是每个具体的值
}
wipFiber.hooks.push(hook) // 将所有 useState 调用按照顺序存到 fiber 节点上
hookIndex++
const setState = value => { // 修改 state 的方法
hook.state = value
workInProgressRoot = { // 只要修改了 state,我们就需要重新处理这个节点
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot
}
// 修改 nextUnitOfWork 指向 workInProgressRoot,这样下次 requestIdleCallback 就会处理这个节点了
nextUnitOfWork = workInProgressRoot
deletions = []
}
return [hook.state, setState]
}

在上面的代码当中可以看出,我们在将 useState 和存储的 state 进行匹配的时候是用的 useState 的调用顺序匹配 state 的下标,如果这个下标匹配不上了,state 就错了,所以在 React 文档当中特意强调了不要在判断当中使用 useState

1
2
3
if (something) {
const [state, setState] = useState(1)
}

比如上述代码就不能保证每次 something 都满足,可能导致 useState 这次 render 执行了,下次又没执行,这样新老节点的下标就匹配不上了,对于这种代码,React 就会直接报错

用 Hooks 模拟 Class 组件

这个算是一个扩展功能,通过前面实现的 Hooks 来模拟实现 Class 组件,我们可以写一个方法将 Class 组件转化为前面的函数组件

1
2
3
4
5
6
7
8
9
10
function transfer(Component) {
return function (props) {
const component = new Component(props)
let [state, setState] = useState(component.state)
component.props = props
component.state = state
component.setState = setState
return component.render()
}
}

然后就可以写 Class 了,这个 Class 长得很像我们在 React 里面写的 Class,有 statesetStaterender

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import React from './myReact'

class Count {
constructor(props) {
this.props = props
this.state = {
count: 1
}
}

onClickHandler = () => {
this.setState({
count: this.state.count + 1
})
}

render() {
return (
<div>
<h3>Class Component Count: {this.state.count}</h3>
<button onClick={this.onClickHandler}>Count + 1</button>
</div>
)
}
}

// export 的时候用 transfer 包装下
export default React.transfer(Count)

然后使用的时候直接使用 <Count> 就行了

1
2
3
<div>
<Count></Count>
</div>

当然你也可以在 React 里面建一个空的 Class Component,让 Count 继承它,这样就更像了

完整代码

汇总以后的代码如下,完整代码地址可见 Fiber 架构的简单实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
// 最基本的 vDom
function createTextVDom(text) {
return {
type: 'TEXT',
props: {
nodeValue: text,
children: []
}
}
}

function createElement(type, props, ...children) {
// 核心逻辑不复杂,将参数都房到一个对象上返回就行
// 将 children 放到 props 当中,这样我们在组件里面就能通过 this.props.children 拿到子元素
return {
type,
props: {
...props,
children: children.map(child => {
return typeof child === 'object' ? child : createTextVDom(child)
})
}
}
}

// 创建 DOM 的操作
function createDom(vDom) {
let dom
// 检查当前节点是文本还是对象
if (vDom.type === 'TEXT') {
dom = document.createTextNode(vDom.props.nodeValue)
} else {
dom = document.createElement(vDom.type)
// 将 vDom 上除了 children 外的属性都挂载到真正的 DOM 上去
if (vDom.props) {
Object.keys(vDom.props)
.filter(key => key !== 'children')
.forEach(item => {
if (item.indexOf('on') === 0) {
dom.addEventListener(item.substr(2).toLowerCase(), vDom.props[item], false)
} else {
dom[item] = vDom.props[item]
}
})
}
}
return dom
}

// 更新 DOM 的操作
function updateDom(dom, prevProps, nextProps) {
// 1. 过滤 children 属性
// 2. 老的存在,新的没了,取消
// 3. 新的存在,老的没有,新增
Object.keys(prevProps)
.filter(name => name !== 'children')
.filter(name => !(name in nextProps))
.forEach(name => {
if (name.indexOf('on') === 0) {
dom.removeEventListener(name.substr(2).toLowerCase(), prevProps[name], false)
} else {
dom[name] = ''
}
})
Object.keys(nextProps)
.filter(name => name !== 'children')
.forEach(name => {
if (name.indexOf('on') === 0) {
dom.addEventListener(name.substr(2).toLowerCase(), nextProps[name], false)
} else {
dom[name] = nextProps[name]
}
})
}

// 统一操作 DOM
function commitRoot() {
deletions.forEach(commitRootImpl) // 执行真正的节点删除
commitRootImpl(workInProgressRoot.child) // 开启递归
currentRoot = workInProgressRoot // 记录一下 currentRoot
workInProgressRoot = null // 操作完后将 workInProgressRoot 重置
}

function commitDeletion(fiber, domParent) {
if (fiber.dom) {
domParent.removeChild(fiber.dom) // DOM 存在,是普通节点
} else {
commitDeletion(fiber.child, domParent) // DOM 不存在,是函数组件,向下递归查找真实 DOM
}
}

function commitRootImpl(fiber) {
if (!fiber) {
return
}

// 不再直接获取,而是向上查找真正的 DOM
// const parentDom = fiber.return.dom
let parentFiber = fiber.return
while (!parentFiber.dom) {
parentFiber = parentFiber.return
}
const parentDom = parentFiber.dom

if (fiber.effectTag === 'REPLACEMENT' && fiber.dom) {
parentDom.appendChild(fiber.dom)
} else if (fiber.effectTag === 'DELETION') {
// 这里也不再使用 parentDom.removeChild(fiber.dom)
commitDeletion(fiber, parentDom)
} else if (fiber.effectTag === 'UPDATE' && fiber.dom) {
// 更新 DOM 属性
updateDom(fiber.dom, fiber.alternate.props, fiber.props)
}

// 递归操作子元素和兄弟元素
commitRootImpl(fiber.child)
commitRootImpl(fiber.sibling)
}

// 任务调度,使用 workLoop 用来调度任务
let nextUnitOfWork = null
let workInProgressRoot = null
let currentRoot = null
let deletions = null
function workLoop(deadline) {
while (nextUnitOfWork && deadline.timeRemaining() > 1) {
// 这个 while 循环会在任务执行完或者时间到了的时候结束
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
}

// 任务做完后统一渲染
if (!nextUnitOfWork && workInProgressRoot) {
commitRoot()
}

// 如果任务还没完,但是时间到了,我们需要继续注册 requestIdleCallback
requestIdleCallback(workLoop)
}

function buildNewFiber(fiber, workInProgressFiber) {
return {
type: fiber.type,
props: fiber.props,
dom: null, // 构建 fiber 时没有 DOM,下次 perform 这个节点是才创建 DOM
return: workInProgressFiber,
alternate: null, // 新增的没有老状态
effectTag: 'REPLACEMENT' // 添加一个操作标记
}
}

function reconcileChildren(workInProgressFiber, elements) {
// 构建 fiber 结构
let oldFiber = workInProgressFiber.alternate
&& workInProgressFiber.alternate.child // 获取上次的 fiber 树
let prevSibling = null

let index = 0
if (elements && elements.length) {
if (!oldFiber) { // 第一次没有 oldFiber,那全部是 REPLACEMENT
for (let i = 0; i < elements.length; i++) {
const element = elements[i]
const newFiber = buildNewFiber(element, workInProgressFiber)
if (i === 0) { // 父级的 child 指向第一个子元素
workInProgressFiber.child = newFiber
} else {
prevSibling.sibling = newFiber // 每个子元素拥有指向下一个子元素的指针
}
prevSibling = newFiber
}
}

while (index < elements.length && oldFiber) {
let element = elements[index]
let newFiber = null
const sameType = oldFiber // 对比 oldFiber 和当前 element(检测类型是否一样)
&& element
&& oldFiber.type === element.type
if (sameType) { // 先比较元素类型,如果类型一样,复用节点,更新 props
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
return: workInProgressFiber,
alternate: oldFiber, // 记录下上次状态
effectTag: 'UPDATE' // 添加一个操作标记
}
} else if (!sameType && element) { // 如果类型不一样,有新的节点,创建新节点替换老节点
newFiber = buildNewFiber(element, workInProgressFiber)
} else if (!sameType && oldFiber) { // 如果类型不一样,没有新节点,有老节点,删除老节点
oldFiber.effectTag = 'DELETION' // 添加删除标记
deletions.push(oldFiber) // 一个数组收集所有需要删除的节点
}
oldFiber = oldFiber.sibling // 循环处理兄弟元素
if (index === 0) { // 父级的child指向第一个子元素
workInProgressFiber.child = newFiber
} else {
prevSibling.sibling = newFiber // 每个子元素拥有指向下一个子元素的指针
}
prevSibling = newFiber
index++
}
}
}

// 申明两个全局变量,用来处理 useState
// wipFiber 是当前的函数组件 fiber 节点
// hookIndex 是当前函数组件内部 useState 状态计数
let wipFiber = null
let hookIndex = null
function useState(init) {
const oldHook = wipFiber.alternate // 取出上次的 Hook
&& wipFiber.alternate.hooks
&& wipFiber.alternate.hooks[hookIndex]
const hook = { // Hook 数据结构
state: oldHook ? oldHook.state : init // state 是每个具体的值
}
wipFiber.hooks.push(hook) // 将所有 useState 调用按照顺序存到 fiber 节点上
hookIndex++
const setState = value => { // 修改 state 的方法
hook.state = value
workInProgressRoot = { // 只要修改了 state,我们就需要重新处理这个节点
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot
}
// 修改 nextUnitOfWork 指向 workInProgressRoot,这样下次 requestIdleCallback 就会处理这个节点了
nextUnitOfWork = workInProgressRoot
deletions = []
}
return [hook.state, setState]
}

function updateFunctionComponent(fiber) {
wipFiber = fiber // 支持 useState,初始化变量
hookIndex = 0
wipFiber.hooks = [] // Hooks 用来存储具体的 state 序列
const children = [fiber.type(fiber.props)] // 函数组件的 type 就是个函数,直接拿来执行可以获得 DOM 元素
reconcileChildren(fiber, children)
}

// updateHostComponent 就是之前的操作,只是单独抽取了一个方法
function updateHostComponent(fiber) {
if (!fiber.dom) {
// 创建一个 DOM 挂载上去
fiber.dom = createDom(fiber)
}

// 将我们前面的 vDom 结构转换为 fiber 结构
const elements = fiber.props.children

// 协调子元素
reconcileChildren(fiber, elements)
}

// performUnitOfWork 用来执行任务,参数是我们的当前 fiber 任务,返回值是下一个任务
function performUnitOfWork(fiber) {
// 检测函数组件
const isFunctionComponent = fiber.type instanceof Function
if (isFunctionComponent) {
updateFunctionComponent(fiber)
} else {
updateHostComponent(fiber)
}

// 这个函数的返回值是下一个任务,这其实是一个深度优先遍历
// 先找子元素,没有子元素了就找兄弟元素,兄弟元素也没有了就返回父元素
// 然后再找这个父元素的兄弟元素,最后到根节点结束
// 这个遍历的顺序其实就是从上到下,从左到右
if (fiber.child) {
return fiber.child
}

let nextFiber = fiber

while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.return
}
}

// 使用 requestIdleCallback 开启 workLoop
requestIdleCallback(workLoop)

function render(vDom, container) {
workInProgressRoot = {
dom: container,
props: {
children: [vDom]
},
alternate: currentRoot
}
deletions = []
nextUnitOfWork = workInProgressRoot
}

class Component {
constructor(props) {
this.props = props
}
}

function transfer(Component) {
return function (props) {
const component = new Component(props)
let [state, setState] = useState(component.state)
component.props = props
component.state = state
component.setState = setState
return component.render()
}
}

export default {
createElement,
render,
useState,
Component,
transfer
}

总结

我们简单总结一下上面涉及到的一些知识点

  1. 我们写的 JSX 代码最终会被 Babel 转化成了 React.createElement() 的形式
  2. React.createElement() 返回的其实就是虚拟 DOM 结构
  3. ReactDOM.render 方法是将 vDom 渲染到页面上
  4. vDom 的协调和渲染可以简单粗暴的递归,但是这个过程是同步的,如果需要处理的节点过多,可能会阻塞用户输入和动画播放,造成卡顿
  5. 为了解决这个问题,React 引入了 Fiber 结构,目的是将同步的协调变成异步的
  6. Fiber 改造了 vDom 的结构,形成具有『父元素 ==> 第一个子元素』,『子元素 ==> 兄弟元素』,『子元素 ==> 父元素』这样的链表结构,有了这几个指针,可以从任意一个 Fiber 节点找到其他节点
  7. Fiber 将整棵树的同步任务拆分成了每个节点可以单独执行的异步执行结构
  8. Fiber 可以从任意一个节点开始遍历,遍历是深度优先遍历,顺序是『父元素 ==> 子元素 ==> 兄弟元素 ==> 父元素』,也就是从上往下,从左往右
  9. Fiber 的协调阶段可以是异步的小任务,但是提交阶段(commit)必须是同步的,因为异步的 commit 可能让用户看到节点一个一个接连出现,体验不好
  10. 函数组件其实就是这个节点的 type 是个函数,直接将 type 拿来运行就可以得到 vDom
  11. useState 是在 Fiber 节点上添加了一个数组,数组里面的每个值对应了一个 useStateuseState 调用顺序必须和这个数组下标匹配,不然会报错(所以不能在判断当中使用 useState

参考

# React

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×