前言
Facebook 的研发能力真是惊人, Fiber
架构给 React 带来了新视野的同时,将调度一词介绍给了前端,然而这个架构实在不好懂,比起以前的 Vdom
树,新的 Fiber
树就麻烦太多。
可以说,React 16 和 React 15 已经是技巧上的分水岭,但是得益于 React 16 的 Fiber
架构,使得 React 即使在没有开启异步的情况下,性能依旧是得到了提高。
经过两个星期的痛苦研究,终于将 React 16 的渲染脉络摸得比较清晰,可以写文章来记录、回顾一下。
如果你已经稍微理解了 Fiber
架构,可以直接看代码:仓库地址
什么是 React Fiber ?
React Fiber
并不是所谓的纤程(微线程、协程),而是一种基于浏览器的单线程调度算法,背后的支持 API 是大名鼎鼎的: requestIdleCallback
,得到了这个 API 的支持,我们便可以将 React 中最耗时的部分放入其中。
回顾 React 历年来的算法都知道,reconcilation
算法实际上是一个大递归,大递归一旦进行,想要中断还是比较不好操作的,加上头大尾大的 React 15 代码已经膨胀到了不可思议的地步,在重重压力之下,React 使用了大循环来代替之前的大递归,虽然代码变得比递归难懂了几个梯度,但是实际上,代码量比原来少了非常多(开发版本 3W 行压缩到了 1.3W 行)
那问题就来了,什么是 Fiber
:一种将 recocilation
(递归 diff
),拆分成无数个小任务的算法;它随时能够停止,恢复。停止恢复的时机取决于当前的一帧( 16ms
)内,还有没有足够的时间允许计算。
React 异步渲染流程图
- 用户调用
ReactDOM.render
方法,传入例如<App />
组件,React 开始运作<App />
-
<App />
在内部会被转换成RootFiber
节点,一个特殊的节点,并记录在一个全局变量中,TopTree
- 拿到
<App />
的RootFiber
,首先创建一个<App />
对应的 Fiber ,然后加上 Fiber 信息,以便之后回溯。随后,赋值给之前的全局变量 TopTree - 使用
requestIdleCallback
重复第三个步骤,直到循环到树的所有节点 - 最后完成了
diff
阶段,一次性将变化更新到真实DOM
中,以防止 UI 展示的不连续性
其中,重点就是 3
和 4
阶段,这两个阶段将创建真实 DOM 和组件渲染 ( render
)拆分为无数的小碎块,使用 requestIdleCallback
连续进行。在 React 15 的时候,渲染、创建、插入、删除等操作是最费时的,在 React 16 中将渲染、创建抽离出来分片,这样性能就得到了极大的提升。
那为什么更新到真实 DOM 中不能拆分呢?理论上来说,是可以拆分的,但是这会造成 UI 的不连续性,极大的影响体验。
递归变成了循环
以简单的组件为例子:
- 从顶端的
div#root
向下走,先走左子树 -
div
有两个孩子span
,继续走左边的 - 来到
span
,之下只有一个hello
,到此,不再继续往下,而是往上回到span
- 因为
span
有一个兄弟,因此往兄弟span
走去 - 兄弟
span
有孩子luy
,到此,不继续往下,而是回到luy
的老爹span
-
luy
的老爹span
右边没有兄弟了,因此回到其老爹div
-
div
没有任何的兄弟,因此回到顶端的div#root
每经过一个 Fiber
节点,执行 render
或者 document.createElement
(或者更新 DOM
)的操作
Fiber 数据结构
一个 Fiber
数据结构比较复杂
const Fiber = { tag: HOST_COMPONENT,type: 'div',return: parentFiber,child: childFiber,sibling: null,alternate: currentFiber,stateNode: document.createElement('div') | instance,props: { children: [],className: 'foo' },partialState: null,effectTag: PLACEMENT,effects: [] }
这是一个比较完整的 Fiber object
,他复杂的原因是因为一个 Fiber
就代表了一个「正在执行或者执行完毕」的操作单元。这个概念不是那么好理解,如果要说得简单一点就是:以前的 VDOM
树节点的升级版。让我们介绍几个关键属性:
- 由「 递归改循环 」我们可以得知,当我们循环的遍历树到达底部时,需要回到其父节点,那么对应的就是
Fiber
中的return
属性(以前叫parent
)。child
和sibling
类似,代表这个Fiber
的子Fiber
和兄弟Fiber
-
stateNode
这个属性比较特殊,用于记录当前Fiber
所对应的真实DOM
节点 或者 当前虚拟组件的实例,这么做的原因第一是为了实现Ref
,第二是为了实现DOM
的跟踪 -
tag
属性在新版的React
中一共有 14 种值,分别代表了不同的JSX
类型。 -
effectTag
和effects
这两个属性为的是记录每个节点Diff
后需要变更的状态,比如删除,移动,插入,替换,更新等...
alternate
属性我想拿出来单独说一下,这个属性是 Fiber
架构新加入的属性。我们都知道,VDOM
算法是在更新的时候生成一颗新的 VDOM
树,去和旧的进行对比。在 Fiber
架构中,当我们调用 ReactDOM.render
或者 setState
之后,会生成一颗树叫做:work-in-progress tree
,这一颗树就是我们所谓的新树用来与我们的旧树进行对比,新的树和旧的树的 Fiber
是完全不一样的,此时,我们就需要 alternate
属性去链接新树和旧树。
司徒正美的研究中,一个 Fiber
和它的 alternate
属性构成了一个联婴体,他们有共同的 tag
,type
,stateNode
属性,这些属性在错误边界自爆时,用于恢复当前节点。
开始写代码:Component 构造函数
讲了那么多的理论,大家一定是晕了,但是没办法,Fiber
架构已经比之前的简单 React 要复杂太多了,因此不可能指望一次性把 Fiber
的内容全部理解,需要反复多看。
当然,结合代码来梳理,思路旧更加清晰了。我们在构建新的架构时,老的 Luy 代码大部分都要进行重构了,先来看看几个主要重构的地方:
export class Component { constructor(props,context) { this.props = props this.context = context this.state = this.state || {} this.refs = {} this.updater = {} } setState(updater) { scheduleWork(this,updater) } render() { throw 'should implement `render()` function' } } Component.prototype.isReactComponent = true
- 这就是
React.Component
的代码 - 构造函数中,我们都进两个参数,一个是外部的
props
,一个是context
- 内部有
state
,refs
,updater
,updater
用于收集setState
的信息,便于之后更新用。当然,在这个版本之中,我并没有使用。 -
setState
函数也并没有做队列处理,只是调用了scheduleWork
这个函数 -
Component.prototype.isReactComponent = true
,这段代码表饰着,如果一个组件的类型为function
且拥有isReactComponent
,那么他就是一个有状态组件,在创建实例时需要用new
,而无状态组件只需要fn(props,context)
调用
const tag = { HostComponent: 'host',ClassComponent: 'class',HostRoot: 'root',HostText: 6,FunctionalComponent: 1 } const updateQueue = [] export function render(Vnode,Container,callback) { updateQueue.push({ fromTag: tag.HostRoot,stateNode: Container,props: { children: Vnode } }) requestIdleCallback(performWork) //开始干活 } export function scheduleWork(instance,partialState) { updateQueue.push({ fromTag: tag.ClassComponent,stateNode: instance,partialState: partialState }) requestIdleCallback(performWork) //开始干活 }
我们定义了一个全局变量 updateQueue
来记录我们所有的更新操作,每当 render
和 scheduleWork (setState)
触发时,我们都会往 updateQueue
中 push
一个状态,然后,进而调用大名鼎鼎的 requestIdleCallback
进行更新。在这里与之前的 react 15 最大不同是,更新阶段和首次渲染阶段得到了统一,都是使用了 updateQueue
进行更新。
实际上这里还有优化的空间,就是多次 setState
的时候,应该合并成一次再进行 requestIdleCallback
的调用,不过这并不是我们的目标,我们的目标是搞懂 Fiber
架构。requestIdleCallback
调用的是 performWork
函数,我们接下来看看
performWork 函数
const EXPIRATION_TIME = 1 // ms async 逾期时间 let nextUnitOfWork = null let pendingCommit = null function performWork(deadline) { workLoop(deadline) if (nextUnitOfWork || updateQueue.length > 0) { requestIdleCallback(performWork) //继续干 } } function workLoop(deadline) { if (!nextUnitOfWork) { //一个周期内只创建一次 nextUnitOfWork = createWorkInProgress(updateQueue) } while (nextUnitOfWork && deadline.timeRemaining() > EXPIRATION_TIME) { nextUnitOfWork = performUnitOfWork(nextUnitOfWork) } if (pendingCommit) { //当全局 pendingCommit 变量被负值 commitAllwork(pendingCommit) } }
熟悉 requestIdleCallback
的同学一定对这两个函数并不陌生,这两个函数其实做的就是所谓的异步调度。
performWork
函数主要做了两件事,第一件事就是拿到 deadline
进入我们之前所谓的大循环,也就是正式进入处理新旧 Fiber
的 Diff
阶段,这个阶段比较的奇妙,我们叫他 workLoop
阶段。workLoop
会一次处理 1 个或者多个 Fiber
,具体处理多少个,要看每一帧具体还剩下多少时间,如果一个 Fiber
消耗太多时间,那么就会等到下一帧再处理下一个 Fiber
,如此循环,遍历整个 VDOM
树。
在这里我们注意到,如果一个
Fiber
消耗太多时间,可能会导致一帧时间的逾期,不过其实没什么问题啦,也仅仅是一帧逾期而已,对于我们视觉上并没有多大的影响。
workLoop
函数主要是三部曲:
-
createWorkInProgress
这个函数会构建一颗树的顶端,赋值给全局变量nextUnitOfWork
,通过迭代的方式,不断更新nextUnitOfWork
直到遍历完所有树的节点。 -
performUnitOfWork
函数是第二步,不断的检测当前帧是否还剩余时间,进行WorkInProgress
tree 的迭代 - 当
WorkInProgress
tree 迭代完毕以后,调用commitAllWork
,将所有的变更全部一次性的更新到DOM
中,以保证 UI 的连续性
所有的 Diff
和创建真实 DOM
的操作,都在 performUnitOfWork
之中,但是插入和删除是在 commitAllWork
之中。接下来,我们逐一分析三部曲的内部操作。
第一步:createWorkInProgress
export function createWorkInProgress(updateQueue) { const updateTask = updateQueue.shift() if (!updateTask) return if (updateTask.partialState) { // 证明这是一个setState操作 updateTask.stateNode._internalfiber.partialState = updateTask.partialState } const rootFiber = updateTask.fromTag === tag.HostRoot ? updateTask.stateNode._rootContainerFiber : getRoot(updateTask.stateNode._internalfiber) return { tag: tag.HostRoot,stateNode: updateTask.stateNode,props: updateTask.props || rootFiber.props,alternate: rootFiber // 用于链接新旧的 VDOM } } function getRoot(fiber) { let _fiber = fiber while (_fiber.return) { _fiber = _fiber.return } return _fiber
这个函数的主要作用就是构建 workInProgress
树的顶端并赋值给全局变量 nextUnitOfWork。
首先,我们先从 updateQueue
中获取一个任务对象 updateTask
。随后,进行判断是否是更新阶段。然后获取 workInProgress
树的顶端。如果是第一次渲染, RootFiber
的值是空的,因为我们并没有构建任何的树。
最后,我们将返回一个 Fiber
对象,这个 Fiber
对象的标识符( tag
)是 HostRoot
。
第二步:performUnitOfWork
// 开始遍历 function performUnitOfWork(workInProgress) { const nextChild = beginWork(workInProgress) if (nextChild) return nextChild // 没有 nextChild,我们看看这个节点有没有 sibling let current = workInProgress while (current) { //收集当前节点的effect,然后向上传递 completeWork(current) if (current.sibling) return current.sibling //没有 sibling,回到这个节点的父亲,看看有没有sibling current = current.return } }
我们调用 performUnitOfWork
处理我们的 workInProgress
。
整个函数做的事情其实就是一个左遍历树的过程。首先,我们调用 beginWork
,获得一个当前 Fiber
下的第一个孩子,如果有直接返回出去给 nextUnitOfWork
,当作下一个处理的节点;如果没有找到任何孩子,证明我们已经到达了树的底部,通过下面的 while
循环,回到当前节点的父节点,将当前 Fiber
下拥有 Effect
的孩子全部记录下来,以便于之后更新 DOM
。
然后查找当前节点的父亲节点,是否有兄弟,有就返回,当成下一个处理的节点,如果没有,就继续回溯。
整个过程用图来表示,就是:
在讨论第三部之前,我们仍然有两个迷惑的地方:
-
beginWork
是如何创建孩子的 -
completeWork
是如何收集effect
的接下来,我们就来一起看看
beginWork
function beginWork(currentFiber) { switch (currentFiber.tag) { case tag.ClassComponent: { return updateClassComponent(currentFiber) } case tag.FunctionalComponent: { return updateFunctionalComponent(currentFiber) } default: { return updateHostComponent(currentFiber) } } } function updateHostComponent(currentFiber) { // 当一个 fiber 对应的 stateNode 是原生节点,那么他的 children 就放在 props 里 if (!currentFiber.stateNode) { if (currentFiber.type === null) { //代表这是文字节点 currentFiber.stateNode = document.createTextNode(currentFiber.props) } else { //代表这是真实原生 DOM 节点 currentFiber.stateNode = document.createElement(currentFiber.type) } } const newChildren = currentFiber.props.children return reconcileChildrenArray(currentFiber,newChildren) } function updateFunctionalComponent(currentFiber) { let type = currentFiber.type let props = currentFiber.props const newChildren = currentFiber.type(props) return reconcileChildrenArray(currentFiber,newChildren) } function updateClassComponent(currentFiber) { let instance = currentFiber.stateNode if (!instance) { // 如果是 mount 阶段,构建一个 instance instance = currentFiber.stateNode = createInstance(currentFiber) } // 将新的state,props刷给当前的instance instance.props = currentFiber.props instance.state = { ...instance.state,...currentFiber.partialState } // 清空 partialState currentFiber.partialState = null const newChildren = currentFiber.stateNode.render() // currentFiber 代表老的,newChildren代表新的 // 这个函数会返回孩子队列的第一个 return reconcileChildrenArray(currentFiber,newChildren) }
beginWork
其实是一个判断分支的函数,整个函数的意思是:
- 判断当前的
Fiber
是什么类型,是class
的走class
分支,是stateless
的走stateless,是原生节点的走原生分支
- 如果没有
stateNode
,则创建一个stateNode
- 如果是
class
,则创建实例,调用render
函数,渲染其儿子;如果是原生节点,调用DOM API
创建原生节点;如果是stateless
,就执行它,渲染出VDOM
节点 - 最后,走到最重要的函数,
recocileChildrenArray
函数,将其每一个孩子进行链表的链接,进行diff
,然后返回当前Fiber
之下的第一个孩子
我们来看看比较重要的 classComponent
的构建流程
function updateClassComponent(currentFiber) { let instance = currentFiber.stateNode if (!instance) { // 如果是 mount 阶段,构建一个 instance instance = currentFiber.stateNode = createInstance(currentFiber) } // 将新的state,newChildren) } function createInstance(fiber) { const instance = new fiber.type(fiber.props) instance._internalfiber = fiber return instance }
如果是首次渲染,那么组件并没有被实例话,此时我们调用 createInstance
实例化组件,然后将当前的 props
和 state
赋值给 props
、state
,随后我们调用 render
函数,获得了新儿子 newChildren
。
渲染出新儿子之后,来到了新架构下最重要的核心函数 reconcileChildrenArray
.
reconcileChildrenArray
const PLACEMENT = 1 const DELETION = 2 const UPDATE = 3 function placeChild(currentFiber,newChild) { const type = newChild.type if (typeof newChild === 'string' || typeof newChild === 'number') { // 如果这个节点没有 type,这个节点就可能是 number 或者 string return createFiber(tag.HostText,null,newChild,currentFiber,PLACEMENT) } if (typeof type === 'string') { // 原生节点 return createFiber(tag.HOST_COMPONENT,newChild.type,newChild.props,PLACEMENT) } if (typeof type === 'function') { const _tag = type.prototype.isReactComponent ? tag.CLASS_COMPONENT : tag.FunctionalComponent return { type: newChild.type,tag: _tag,props: newChild.props,return: currentFiber,effectTag: PLACEMENT } } } function reconcileChildrenArray(currentFiber,newChildren) { // 对比节点,相同的标记更新 // 不同的标记 替换 // 多余的标记删除,并且记录下来 const arrayfiyChildren = arrayfiy(newChildren) let index = 0 let oldFiber = currentFiber.alternate ? currentFiber.alternate.child : null let newFiber = null while (index < arrayfiyChildren.length || oldFiber !== null) { const prevFiber = newFiber const newChild = arrayfiyChildren[index] const isSameFiber = oldFiber && newChild && newChild.type === oldFiber.type if (isSameFiber) { newFiber = { type: oldFiber.type,tag: oldFiber.tag,stateNode: oldFiber.stateNode,alternate: oldFiber,partialState: oldFiber.partialState,effectTag: UPDATE } } if (!isSameFiber && newChild) { newFiber = placeChild(currentFiber,newChild) } if (!isSameFiber && oldFiber) { // 这个情况的意思是新的节点比旧的节点少 // 这时候,我们要将变更的 effect 放在本节点的 list 里 oldFiber.effectTag = DELETION currentFiber.effects = currentFiber.effects || [] currentFiber.effects.push(oldFiber) } if (oldFiber) { oldFiber = oldFiber.sibling || null } if (index === 0) { currentFiber.child = newFiber } else if (prevFiber && newChild) { // 这里不懂是干嘛的 prevFiber.sibling = newFiber } index++ } return currentFiber.child }
这个函数做了几件事
- 将孩子
array
化,这么做能够使得react
的render
函数返回数组 -
currentFiber
是新的workInProgress
上的一个节点,是属于新的VDOM
树 ,而此时,我们必须要找到旧的VDOM
树来进行比对。那么在这里,Alternate
属性就起到了关键性作用,这个属性链接了旧的VDOM
,使得我们能够获取原来的VDOM
- 接下来我们进行对比,如果新的节点的
type
与原来的相同,那么我们将新建一个Fiber
,标记这个Fiber
为UPDATE
- 如果新的节点的
type
与原来的不相同,那我们使用PALCEMENT
来标记他 - 如果旧的节点数量比新的节点少,那就证明,我们要删除旧的节点,我们把旧节点标记为
DELETION
,并构建一个effect list
记录下来 - 当前遍历的是组件的第一个孩子,那么我们将他记录在
currentFiber
的child
字段中 - 当遍历的不是第一个孩子,我们将 新建的
newFiber
用链表的形式将他们一起推入到currentFiber
中 - 返回当前
currentFiber
下的第一个孩子
看着比较啰嗦,但是实际上做的就是构建链表和 diff
孩子的过程,这个函数有很多优化的空间,使用 key
以后,在这里能提高很多的性能,为了简单,我并没有对 key
进行操作,之后的 Luy
版本一定会的。
completeWork: 收集 effectTag
// 开始遍历 function performUnitOfWork(workInProgress) { const nextChild = beginWork(workInProgress) if (nextChild) return nextChild // 没有 nextChild,我们看看这个节点有没有 sibling let current = workInProgress while (current) { //收集当前节点的effect,然后向上传递 completeWork(current) if (current.sibling) return current.sibling //没有 sibling,回到这个节点的父亲,看看有没有sibling current = current.return } } //收集有 effecttag 的 fiber function completeWork(currentFiber) { if (currentFiber.tag === tag.classComponent) { // 用于回溯最高点的 root currentFiber.stateNode._internalfiber = currentFiber } if (currentFiber.return) { const currentEffect = currentFiber.effects || [] //收集当前节点的 effect list const currentEffectTag = currentFiber.effectTag ? [currentFiber] : [] const parentEffects = currentFiber.return.effects || [] currentFiber.return.effects = parentEffects.concat(currentEffect,currentEffectTag) } else { // 到达最顶端了 pendingCommit = currentFiber } }
这个函数做了两件事,第一件事情就是收集当前 currentFiber
的 effectTag
,将其 append
到父 Fiber
的 effectlist
中去,通过循环一层一层往上,最终到达顶端 currentFiber.return === void 666
的时候,证明我们到达了 root
,此时我们已经把所有的 effect
收集到了顶端的 currentFiber.effect
上,并把它赋值给 pendingCommit
,进入 commitAllWork
阶段。
第三步:commitAllWork
终于,我们已经通过不断不断的调用 requestIdleCallback
和 大循环,将我们的所有变更都找出来放在了 workInProgress tree
里,我们接下来就要做最后一步:将所有的变更一次性的变更到真实 DOM
中,注意,这个阶段里我们不再运行创建 DOM
和 render
,因此,虽然我们一次性变更所有的 DOM
,但是性能来说并不是太差。
function commitAllwork(topFiber) { topFiber.effects.forEach(f => { commitWork(f) }) topFiber.stateNode._rootContainerFiber = topFiber topFiber.effects = [] nextUnitOfWork = null pendingCommit = null }
我们直接拿到 TopFiber
中的 effects list
,遍历,将变更全部打到 DOM
中去,然后我们将全局变量清理干净。
function commitWork(effectFiber) { if (effectFiber.tag === tag.HostRoot) { // 代表 root 节点没什么必要操作 return } // 拿到parent的原因是,我们要将元素插入的点,插在父亲的下面 let domParentFiber = effectFiber.return while (domParentFiber.tag === tag.classComponent || domParentFiber.tag === tag.FunctionalComponent) { // 如果是 class 就直接跳过,因为 class 类型的fiber.stateNode 是其本身实例 domParentFiber = domParentFiber.return } //拿到父亲的真实 DOM const domParent = domParentFiber.stateNode if (effectFiber.effectTag === PLACEMENT) { if (effectFiber.tag === tag.HostComponent || effectFiber.tag === tag.HostText) { //通过 tag 检查是不是真实的节点 domParent.appendChild(effectFiber.stateNode) } // 其他情况 } else if (effectFiber.effectTag == UPDATE) { // 更新逻辑 只能是没实现 } else if (effectFiber.effectTag == DELETION) { //删除多余的旧节点 commitDeletion(effectFiber,domParent) } } function commitDeletion(fiber,domParent) { let node = fiber while (true) { if (node.tag == tag.classComponent) { node = node.child continue } domParent.removeChild(node.stateNode) while (node != fiber && !node.sibling) { node = node.return } if (node == fiber) { return } node = node.sibling } }
这一部分代码是最好理解的了,就是做的是删除和插入或者更新 DOM
的操作,值得注意的是,删除操作依旧使用的链表操作。
最后来一段测试代码:
import React from './Luy/index' import { Component } from './component' import { render } from './vdom' class App extends Component { state = { info: true } constructor(props) { super(props) setTimeout(() => { this.setState({ info: !this.state.info }) },1000) } render() { return ( <div> <span>hello</span> <span>luy</span> <div>{this.state.info ? 'imasync' : 'iminfo'}</div> </div> ) } } render(<App />,document.getElementById('root'))
我们来看看动图吧!当节点 mount
以后,过了 1 秒,就会更新,我们简单的更新就到此结束了
再看以下调用栈,我们的 requestIdleCallback
函数已经正确的运行了。
git clone https://github.com/Foveluy/Luy.git cd Luy npm i --save-dev npm run start
目前我能找到的所有资料都放在仓库中:资料
回顾本文几个重要的点
一开始我们就使用了一个数组来记录 update
的信息,通过调用 requestIdleCallback
来将更新一个一个的取出来,大部分时间队列里只有一个。
取出来以后,使用从左向右遍历的方式,用链表链接一个一个的 Fiber
,并做 diff
和创建,最后一次性的 patch
到真实 DOM
中去。
现在 react 的架构已经变得极其复杂,而本文也只是将 React 的整体架构通篇流程描述了一遍,里面的细节依旧值得我们的深究,比如,如何传递 context
,如何实现 ref
,如何实现错误边界处理,声明周期的处理,这些都是很大的话题,在接下去的文章里,我会一步一步的将这些关系讲清楚。
最后,感谢支持我的迷你框架项目:Luy ,现在正在向 Fiber
晋级!如果你喜欢,请给我一点 star 原文链接:https://www.f2er.com/react/301536.html