文章目录
- fiber架构是什么?它解决了什么问题?
- fiber 的核心思想,实现 fiber 我们需要做到什么?如何做?
- Fiber reconcile
- Commit 阶段
- 实现useState管理状态更新
此篇文章是在学习一步一步实现
fiber
架构的同时,从另外一个由总到分的角度来总结 fiber
架构的实现思路。文章末尾有一些学习参考文章可以借鉴。 fiber架构是什么?它解决了什么问题? 当我们项目过于复杂,渲染树过于庞大的时候,那么我们的递归渲染会耗时很长,而且很难被中断,fiber 的主要原理就是让我们在 diff 的过程中可以被中断,去处理更高优先级的事件如:用户事件或者动画,这样让浏览器的渲染更加流畅。
fiber 的核心思想,实现 fiber 我们需要做到什么?如何做?
React 16.0
之前,我们在渲染的过程中,通过去遍历一整棵 虚拟 dom
树来更新变化,我们很难中断,并且无法标记中断来持续工作。那么 fiber 架构将递归 diff 拆分成一个一个小任务,并且随时可中断,利用浏览器的空闲时间来执行,当处理完更高优先级任务后回到中断点继续执行;要实现这样的机制,fiber做了什么,这里我总结了几点:
- 将原有的 vdom 树结构变成一个新的链表结构的树,每个节点都标记了它的(
child,sibling,return,
分别代表节点的第一个字节点、兄弟节点、父节点),这样可以随时中断,下次从中断处继续执行; - fiber的核心思想是将一个庞大的任务拆分成一个个小的任务块,利用浏览器的空闲时间来执行,那么如何拆分,如何协调?
React
以虚拟dom
节点为维度对任务进行拆分,即一个虚拟dom节点对应一个任务,采用深度优先遍历的规则进行协调; - 从根节点开始调度和渲染的过程可以分为两个阶段:
render()
,和commit()
; render 阶段会根据v-dom找出所有节点的变更(增删更新),然后构建出一棵Fiber 树,这个阶段是可以中断的。commit阶段就是将构建的Fiber three渲染成真实的dom, 这个阶段是不可中断的。 - 这里我们通过window.requestIdleCallback 来实现浏览器空闲时执行低优先级任务。
React Fiber
其实就是通过遍历将 VDom 转换成了 Fiber three,其中每个 Fiber都具有 child、singling、return属性;遍历遵循深度优先遍历,自上而下,自左向右;从根节点出发,找到他的第一个子元素,找到则返回,没有则找他的兄弟元素,如果无兄弟元素,则直接返回其父元素, 父 ——> 第一个子 ——> 兄弟 ——> 父亲;
在遍历生成Fiber three 的时候根据节点的变更收集
effect list
, 通过tag(UPDATE、DELETE、PLACEMENT)
,直到没有下一个任务,commit 到DOM树上。在此之前,我们的
vdom
是一颗树,它在 diff 的过程中是没法中断的,于是将其改造成一个链表结构,之前是只有 children 进行递归遍历,现在是包含了父——>子, 子——> 父, 子——> 兄弟这几层关系的链表。Fiber reconcile 至此,我们可以跟着思路来实现 fiber, 说到 fiber, 它其实就是一个具有各种标识的对象,如:
{
dom: null, // 真实dom,这里function 组件的dom是null
type,
props,
child,
return,
sibling,
alternate: null, // 旧值,用于比对更新
effectTag: 'PLACEMENT'
}
正题来了,首先我们还是来实现
createElement(type, config, ...children)
最终返回 虚拟dom 树,这里不是重点,所以不过多介绍,详细可以查看createElement原理,直接贴代码:/**
* jsx语法糖,接受三个参数,返回v-dom(js对象)
* @param {*} type 元素类型:native HTML | Function | Class
* @param {*} config 属性
* @param{...any} children 子元素
* @returns v-dom 对象
*/
function createElement(type, config, ...children) {
delete config.__self
delete config.__source
const { key, ref, ...rest } = config
const vdom = {
$$typeof: Symbol('react.element'),
type,
props: {
...rest,
children: children.map(c => typeof c === 'object' ? c : createTextNode(c))
}
}
return vdom
}
/**
* 创建文本节点对象
* @param {*} nodeValue 文本值
* @returns v-dom 对象
*/
function createTextNode(nodeValue) {
return {
type: 'TEXT',
props: {
nodeValue,
children: []
}
}
}
我们的核心是 任务拆解和任务协调,任务协调(
reconcile
)我们利用浏览器 API requestIdleCallback 来实现,React 实现了自己的任务调度函数,它接受一个callback(idleDeadline => {}) ,
利用 idleDeadline
我们能判断浏览器是否处于空闲时间来调度我们的任务。/**
* 任务循环
* @param {*} idleDeadline 参数参考:https://developer.mozilla.org/zh-CN/docs/Web/API/IdleDeadline
*/
function workLoop(idleDeadline) {
// 当前还有空闲时间,可以设置超时 && 有任务可执行
while (idleDeadline.timeRemaining() > 1 && nextUnitOfWork) {
// 执行当前任务单元,并返回下一个待执行任务单元
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
// render循环结束的条件:无可执行任务,当前 effect list 已经生成, 开始commit了
if (!nextUnitOfWork && wipRoot) {
commitRoot(wipRoot)
}
// 否则(当前忙碌,但是还有任务待执行), 继续监听等待执行
requestIdleCallback(workLoop);
}
// 立即执行
requestIdleCallback(workLoop);
【React高级|React 之 简易实现 Fiber架构】通过
idleDeadline.timeRemaining()
判断当前是否出于空闲时间,并且有下一个任务nextUnitOfWork
可以执行, 循环执行当前任务,并返回下一个待执行任务;直到所有任务都执行完毕,我们将生成的 effect list
渲染成真正的 dom 树。当然,在有更高优先级事件正在处理的时,而我们还有 nextUnitOfWork
未完成,那么继续调用 requestIdleCallback
。由此可见,真正可中断的是 协调 (reconcile)阶段,在渲染阶段(commit)是不可中断的;
那么接下来我们要搞清楚的是如何对任务进行拆分?看下
performUnitOfWork(nextUnitOfWork)
做了啥?/**
* performUnitOfWork 实质是通过遍历当前 fiber 节点的 children 来构建一颗小的Fiber three,最后根据遍历规则,如果有child,将其当成下一个任务返回
* 否则,向上回溯到有sibling的父节点(return),作为 nextUnitOfWork。此时这里应该有一张图 TODO:
* 这里的逻辑需要考虑首次渲染和更新操作,对Fiber进行effect标记。
* @param {*} fiber
* @returns 下一个任务
*/
function performUnitOfWork(fiber) {
// 这里需要区分 Native HTMl 和 函数组件, 类组件的实现后续会迭代更新TODO:,这里先用函数classTransferToFun进行转换
if (fiber.type instanceof Function) {
// 构建fiber three
updateFunctionFiberThree(fiber)
} else {
updateNativeFiberThree(fiber)
}
// 至此,我们拿到了一棵小的Fiber three
if (fiber.child) {
return fiber.child
}
let parentFiber = fiber;
while (parentFiber) {
if (parentFiber.sibling) {
return parentFiber.sibling
}
parentFiber = parentFiber.return // 属性 return 其实就是父节点
}
return null
}
performUnitOfWork
的任务是接受一个fiber 节点,遍历其 children,深度遍历构建一棵小的fiber 链表, 将树上的每个 fiber 进行标识(child、sibling、return),最后依据“如果有第一个字节点,则返回子节点,否则返回其兄弟节点”的规则,来返回下一个任务的指定。/**
* 函数组件调度 children
* @param {*} fiber
*/
function updateFunctionFiberThree(fiber) {
// 状态重置
// .... 后面补充
// 函数组件的type对应的就是函数fn,直接调用返回vdom
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
/**
* HTML 元素 调度 children
* @param {*} fiber
*/
function updateNativeFiberThree(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
reconcileChildren(fiber, fiber.props.children)
}
我们补齐了不同 type 的函数调度,其核心就是拿到其
children
进行遍历,构建fiber 节点,重点看 reconcileChildren
函数:let deletions = [];
// 存储需要标记删除的 fiber
/**
* 通过 fiber 的 children,构建Fiber three
* @param {*} fiber
* @param {*} children
*/
function reconcileChildren(fiber, children) {
// 如果有旧的Fiber,找到其子节点
let oldFiber = fiber.alternate && fiber.alternate.child;
let index = 0
// 记录上一个 fiber, 用来构建 兄弟关系
let prevFiber = null
// 如果有子元素 或者 旧 Fiber 子元素存在
while (index < children.length || oldFiber !== null) {
let newFiber = null
const node = children[index]
// 对比新旧节点类型
const sameType = oldFiber && node && oldFiber.type === node.type
if (sameType) {
newFiber = {
dom: oldFiber.dom,
type: node.type,
props: node.props,
return: fiber,
alternate: oldFiber,
effectTag: 'UPDATE' // 标记状态
}
}
// 类型不同,新节点直接替换
if (!sameType && node) {
newFiber = {
dom: null,
type: node.type,
props: node.props,
return: fiber,
alternate: null,
effectTag: 'PLACEMENT'
}
}
// 类型不同,旧节点存在, 将旧节点标记为删除
if (!sameType && oldFiber) {
oldFiber.effectTag = 'DELETE'
deletions.push(oldFiber)
}if (index === 0) {
fiber.child = newFiber
} else {
// 将上一个节点的sibling指向当前节点
prevFiber.sibling = newFiber;
}
prevFiber = newFiber;
// 移动指针,将oldFiber指向他的兄弟节点
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
index++;
}
}
这里考虑了节点的替换、删除和更新,通过字段
effectTag
进行标识,在更新的同时通过字段alternate
记录上一次的fiber 节点,声明全局变量 deletions
用于存储删除的 fiber 节点,这些个字段均是用于再 commit 阶段用于判断 dom 如何挂载;OK,至此,我们的整个
reconcile
流程还差一步,设定初始 unitWork, 我们定义了一些全局变量,wipRoot
用于装载 整个 fiber 链表树 ,currentWipRoot
用于记录上一次更新的 fiber 链表树,用于更新比对,nextUnitOfWork
为下一个待执行任务。定义
render(vnode, container)
函数,将容器元素作为第一个执行单元,重置 deletions// 定义一些全局变量
let wipRoot = null;
// 当前执行 Fiber three 的根节点,首次执行时为 document.getElementById('root') 对应的 Fiber
let currentWipRoot = null;
// 记录上一次生成的 Fiber three,也可以说是触法更新之前的 Fiber three, 方便此次更新比对
let nextUnitOfWork = null;
// 下一个待执行的任务块/**
* render 函数
* @param {*} vdom
* @param {*} container 容器
*/
function render(vnode, container) {
wipRoot = {
dom: container,
props: {
children: [vnode]
},
alternate: currentWipRoot // 用于记录上一次的状态,渲染时做比对
}
deletions = []
nextUnitOfWork = wipRoot
}
这里会留有一些疑问,
currentWipRoot
什么时候赋值的?循环执行直到最后一个
nextUnitOfWork
执行完毕,此时 wipRoot
已经构建完毕,接下来进入 commit 阶段,正式构建真实 dom 树。Commit 阶段 commit 阶段主要做的就是 dom 元素的 新增、删除和更新。
这里需要注意的事,在dom操作过程中,由于函数组件dom为 null, 通过return向上查找,直到找到存在dom的父节点为止,通过fiber 对象中的dom 字段可以拿到当前 fiber 的真实 dom。最后我们存储本次构建的 wipRoot, 用于下次更新进行比对。
/**
* 渲染DOM树
* @param {*} wipRoot 此次构建的 Fiber Three
*/
function commitRoot(fiberThree) {
// 主要做了 件事:
// 1. 将需要删除的deletions中的元素删除
// 2. 更新变更,插入替换的新元素
// 3. 将此次构建的 Fiber three 缓存在 currentWipRoot 中,下次触发更新时可做对比, 重置 wipRoot
deletions.forEach(commitWorker)
commitWorker(fiberThree.child)
currentWipRoot = fiberThree
wipRoot = null
}/**
* fiber 的变更操作
* @param {*} fiber
*/
function commitWorker(fiber) {
if (!fiber) {
return
}
let parent = fiber.return
while (!parent.dom) {
parent = parent.return
}
const parentDom = parent.dom;
if (fiber.effectTag === 'DELETE' && fiber.dom) {
deleteDom(fiber, parentDom)
}
if (fiber.effectTag === 'PLACEMENT' && fiber.dom) {
parentDom.appendChild(fiber.dom)
}
if (fiber.effectTag === 'UPDATE' && fiber.dom) {
updateDom(fiber.dom, fiber.alternate.props, fiber.props)
}commitWorker(fiber.child);
commitWorker(fiber.sibling);
}
补气 dom 的新增、删除和更新函数,这里只是做了一些代表型的处理,想更详细的了解可以看官方源码
/**
* 生成dom
* @param {*} fiber
*/
function createDom(fiber) {
const dom = fiber.type === 'TEXT' ? document.createTextNode('') : document.createElement(fiber.type)
updateDom(dom, {}, fiber.props);
return dom;
}/**
* 更新dom,遍历旧节点属性,将新节点中没有的属性删除; 遍历新节点,将旧节点属性更新,并新增旧节点没有的属性。
* @param {*} dom 元素对象
* @param {*} prevProps
* @param {*} nextProps
*/
function updateDom(dom, prevProps, nextProps) {
// 过滤掉 children , 得到新节点中没有的属性
Object.keys(prevProps)
.filter(pName => pName !== 'children')
.filter(pName => !(pName in nextProps))
.forEach(pName => {
// 这里考虑事件处理函数的解绑,暂时只对click函数做处理
if (pName.slice(0, 2) === 'on') {
dom.removeEventListener(pName.slice(2).toLocaleLowerCase(), prevProps[pName], false)
} else {
dom[pName] = ''
}
})
Object.keys(nextProps)
.filter(pName => pName !== 'children')
.forEach(pName => {
if (pName.slice(0, 2) === 'on') {
dom.addEventListener(pName.slice(2).toLocaleLowerCase(), nextProps[pName], false)
} else {
dom[pName] = nextProps[pName]
}
})
}/**
* 删除子元素,这里需要考虑 Function 组件Fiber无dom
* @param {*} fiber
* @param {*} parentDom
*/
function deleteDom(fiber, parentDom) {
if (fiber.dom) {
parentDom.removeChild(fiber.dom)
} else {
deleteDom(fiber.child, parentDom)
}
}
好了,到目前为止,我们来写一个简单的示例,跑起来试试
import React from './fiber'
const ReactDOM = Reactfunction Counter(props) {
return (简易fiber架构实现
{props.count})
}
ReactDOM.render( , document.getElementById('root'))
完美,页面正常显示
文章图片
太简单了吧,但是我们还缺少对状态的更新处理,接下来我们就来实现hook 函数 useState, 来管理状态更新;
实现useState管理状态更新 首先我们声明两个全局变量
wipFiber
变量与函数组件一对一,将当前执行组件的 fiber 赋值给此变量,同时扩展 hooks 字段来对函数组件中多个状态进行存储,用 hookIndex
来标识顺序,保证每次访问到正确的 state。let wipFiber = null;
// 与函数组件一一对应,存储组件fiber 和 hooks
let hookIndex = null;
// 记录当前执行hook的指针,如组件中有多个useState hook /**
* 函数组件调度 children
* @param {*} fiber
*/
function updateFunctionFiberThree(fiber) {
// 状态重置
wipFiber = fiber;
wipFiber.hooks = [];
hookIndex = 0;
// TODO: 这里我觉得可以直接 用fiber.props.children, 应该是旧的?
const children = [fiber.type(fiber.props)] // 函数组件的type对应的就是函数fn,直接调用返回vdom
reconcileChildren(fiber, children)
}
我们在
updateFunctionFiberThree
中追加对wipFiber 和 hookIndex
状态的重置,以此来隔离组件间的状态。当前时刻只有一个组件正在执行。现在我们来实现 useState, 我们看看它在函数中如何使用
const [count, setCount] = useState(props.count)
可以看出,useState 接受一个初始值,在第一次执行时赋初始值,下次直接返回当前state的值;返回一个数组,分别是状态值和改变状态的函数
/**
* 状态管理
* 函数组件中可以通过声明多个 useState 来管理多个状态,我们通过顺序来管理多个状态
* @param {*} defaultValue 初始值
* @returns
*/
function useState(defaultValue) {
// 是否存在旧 hook
const oldHook = wipFiber?.alternate?.hooks && wipFiber.alternate.hooks[hookIndex];
const hook = {
// 读取状态的值,如果有,直接返回,如果是第一次,则初始化为 defaultValue
state: oldHook?.state || defaultValue,
queue: [] // 更新栈
}
const actions = oldHook?.queue || [];
actions.forEach(action => {
if (typeof action === 'function') {
hook.state = action(hook.state)
} else {
hook.state = actions
}
})
const setState = (action) => {
// 订阅更新操作
hook.queue.push(action);
// 设置 nextUnitWork 触发重新渲染
wipRoot = {
dom: currentWipRoot.dom,
props: currentWipRoot.props,
alternate: currentWipRoot
}
nextUnitOfWork = wipRoot
deletions = []
}
wipFiber.hooks.push(hook)
console.log(wipFiber)
hookIndex++return [hook.state, setState]
}
我们通过 fiber.alternate 可以拿到上次render 时的 olderFiber, 同时即可拿到 hooks, 在setState 函数中只对action进行了push 操作,并设置 nextUnitWork 触发页面更新;
页面重新render, 函数再次执行,我们通过遍历执行
oldHook?.queue
来更新state 并返回。ok,我们对示例追加状态变更
import React from './fiber'
const ReactDOM = Reactfunction Counter(props) {
const [count, setCount] = React.useState(props.count)
const handleClick = () => setCount(c => c + 1)
return (简易fiber架构实现
{count}
)
}ReactDOM.render( , document.getElementById('root'))
可以看到能正常更新;
完整代码后续会整理到github。
参考文章
- 手写React的Fiber架构,深入理解其原理
推荐阅读
- React高级|一文让你不再困惑setState之getState(上)
- 笔记|手机也有生产力,手把手教你用手机开发APP
- react|react中this指向的问题
- react|React hook useEffect 与 计时器 setInterval
- 极客日报|滴滴6月或发布造车计划;英特尔顶级专家Mike Burrows跳槽AMD;Android 13开发者预览版2发布|极客头条
- react|react 递归遍历四层树结构 遍历分支中的最后一个节点_图解(数据结构中的 6 种树,你心中有数吗(...))
- react|react-transition-group的使用方法
- react|route上使用react-transition-group
- react|react-transition-group小结