React高级|React 之 简易实现 Fiber架构


文章目录

  • fiber架构是什么?它解决了什么问题?
  • fiber 的核心思想,实现 fiber 我们需要做到什么?如何做?
  • Fiber reconcile
  • Commit 阶段
  • 实现useState管理状态更新

此篇文章是在学习一步一步实现 fiber架构的同时,从另外一个由总到分的角度来总结 fiber架构的实现思路。文章末尾有一些学习参考文章可以借鉴。
fiber架构是什么?它解决了什么问题? 当我们项目过于复杂,渲染树过于庞大的时候,那么我们的递归渲染会耗时很长,而且很难被中断,fiber 的主要原理就是让我们在 diff 的过程中可以被中断,去处理更高优先级的事件如:用户事件或者动画,这样让浏览器的渲染更加流畅。
fiber 的核心思想,实现 fiber 我们需要做到什么?如何做? React 16.0 之前,我们在渲染的过程中,通过去遍历一整棵 虚拟 dom 树来更新变化,我们很难中断,并且无法标记中断来持续工作。那么 fiber 架构将递归 diff 拆分成一个一个小任务,并且随时可中断,利用浏览器的空闲时间来执行,当处理完更高优先级任务后回到中断点继续执行;
要实现这样的机制,fiber做了什么,这里我总结了几点:
  1. 将原有的 vdom 树结构变成一个新的链表结构的树,每个节点都标记了它的(child,sibling,return,分别代表节点的第一个字节点、兄弟节点、父节点),这样可以随时中断,下次从中断处继续执行;
  2. fiber的核心思想是将一个庞大的任务拆分成一个个小的任务块,利用浏览器的空闲时间来执行,那么如何拆分,如何协调?React虚拟dom节点为维度对任务进行拆分,即一个虚拟dom节点对应一个任务,采用深度优先遍历的规则进行协调;
  3. 从根节点开始调度和渲染的过程可以分为两个阶段: render(),和 commit(); render 阶段会根据v-dom找出所有节点的变更(增删更新),然后构建出一棵Fiber 树,这个阶段是可以中断的。commit阶段就是将构建的Fiber three渲染成真实的dom, 这个阶段是不可中断的。
  4. 这里我们通过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'))

完美,页面正常显示
React高级|React 之 简易实现 Fiber架构
文章图片

太简单了吧,但是我们还缺少对状态的更新处理,接下来我们就来实现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架构,深入理解其原理

    推荐阅读