React16性能改善的原理(二)

前情提要

上一篇我们提到如果 setState 之后,虚拟 dom diff 比较耗时,那么导致浏览器 FPS 降低,使得用户觉得页面卡顿。那么 react 新的调度算法就是把原本一次 diff 的过程切分到各个帧去执行,使得浏览器在 diff 过程中也能响应用户事件。接下来我们具体分析下新的调度算法是怎么回事。

原虚拟DOM问题

假设我们有一个 react 应用如下:

class App extends React.Component {
  render() {
    return (
      <div>
        <div>{this.props.name}</div>
        <ul>
          <li>{this.props.items[0]}</li>
          <li>{this.props.items[1]}</li>
        </ul>
      </div>
    );
  }
}

整个 app 的虚拟 dom 大致是这样的:

var rootHost = {
  type: 'div',
  children: [ {
    type: 'div',
    children: [ {type: 'text'} ]
  }. {
    type: 'ul',
    children: [
      { type: 'li', children:[ {type: 'text'} ] },
      { type: 'li', children:[ {type: 'text'} ] }
    ]
  } ]
}

当更新发生 diff 两棵新老虚拟 dom 树的时候是递归的逐层比较(如下图)。这个过程是一次完成的,如果要按上一篇我们说的把 diff 过程切割成好多时间片来执行,难度是如何记住状态且恢复现场。譬如说你 diff 到一半函数返回了,等下一个时间片继续 diff。如果只记住上次递归到哪个节点,那么你只能顺着他的 children 继续 diff,而它的兄弟节点就丢失了。如果要完美恢复现场保存的结构估计得挺复杂。所以 react16 改造了虚拟dom的结构,引入了 fiber 的链表结构。


image.png

现在解决方案 - fiber

fiber 节点相当于以前的虚拟 dom 节点,结构如下:

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: []
};

先讲重要的几个属性: return 存储的是当前节点的父节点(元素),child 存储的是第一个子节点(元素),sibling 存储的是他右边第一个的兄弟节点(元素)。alternate 保存是当更新发生时候同一个节点带有新的 props 和 state 生成的新 fiber 节点。 那么虚拟 dom 的存储结构用链表的形式描述了整棵树。


image.png

从顶层开始左序深度优先遍历如下图所示:


image.png

我们在遍历 dom 树 diff 的时候,即使中断了,我们只需要记住中断时候的那么一个节点,就可以在下个时间片恢复继续遍历并 diff。这就是 fiber 数据结构选用链表的一大好处。我先用文字大致描述下 fiber diff 算法的过程再来看代码。从跟节点开始遍历,碰到一个节点和 alternate 比较并记录下需要更新的东西,并把这些更新提交到当前节点的父亲。当遍历完这颗树的时候,再通过 return 回溯到根节点。这个过程中把所有的更新全部带到根节点,再一次更新到真实的 dom 中去。


image.png

从根节点开始:

  1. div1 通过 child 到 div2。
  2. div2 和自己的 alternate 比较完把更新 commit1 通过 return 提交到 div1。
  3. div2 通过 sibling 到 ul1。
  4. ul1 和自己的 alternate 比较完把更新 commit2 通过 return 提交到 div1。
  5. ul1 通过 child 到 li1。
  6. li1 和自己的 alternate 比较完把更新 commit3 通过 return 提交到 ul1。
  7. li1 通过 sibling 到 li2。
  8. li2 和自己的 alternate 比较完把更新 commit4 通过 return 提交到 ul1。
  9. 遍历完整棵树开始回溯,li2 通过 return 回到 ul1。
  10. 把 commit3 和 commit4 通过 return 提交到 div1。
  11. ul1 通过 return 回到 div1。
  12. 获取到所有更新 commit1-4,一次更新到真是的 dom 中去。

使用fiber算法更新的代码实现

React.Component.prototype.setState = function( partialState, callback ) {
  updateQueue.pus( {
    stateNode: this,
    partialState: partialState
  } );
  requestIdleCallback(performWork); // 这里就开始干活了
}

function performWork(deadline) {
  workLoop(deadline)
  if (nextUnitOfWork || updateQueue.length > 0) {
    requestIdleCallback(performWork) //继续干
  }
}

setState 先把此次更新放到更新队列 updateQueue 里面,然后调用调度器开始做更新任务。performWork 先调用 workLoop 对 fiber 树进行遍历比较,就是我们上面提到的遍历过程。当此次时间片时间不够遍历完整个 fiber 树,或者遍历并比较完之后 workLoop 函数结束。接下来我们判断下 fiber 树是否遍历完或者更新队列 updateQueue 是否还有待更新的任务。如果有则调用 requestIdleCallback 在下个时间片继续干活。nextUnitOfWork 是个全局变量,记录 workLoop 遍历 fiber 树中断在哪个节点。

function workLoop(deadline) {
  if (!nextUnitOfWork) {
    //一个周期内只创建一次
    nextUnitOfWork = createWorkInProgress(updateQueue)
  }

  while (nextUnitOfWork && deadline.timeRemaining() > EXPIRATION_TIME) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
  }

  if (pendingCommit) {
    //当全局 pendingCommit 变量被负值
    commitAllwork(pendingCommit)
  }
}

刚开始遍历的时候判断全局变量 nextUnitOfWork 是否存在?如果存在表示上次任务中断了,我们继续,如果不存在我们就从更新队列里面取第一个任务,并生成对应的 fiber 根节点。接下来我们就是正式的工作了,用循环从某个节点开始遍历 fiber 树。performUnitOfWork 根据我们上面提到的遍历规则,在对当前节点处理完之后,返回下一个需要遍历的节点。循环除了要判断是否有下一个节点(是否遍历完),还要判断当前给你的时间是否用完,如果用完了则需要返回,让浏览器响应用户的交互事件,然后再在下个时间片继续。workLoop 最后一步判断全局变量 pendingCommit 是否存在,如果存在则把这次遍历 fiber 树产生的所有更新一次更新到真实的 dom 上去。注意 pendingCommit 在完成一次完整的遍历过程之前是不会有值的。

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
}

createWorkInProgress 拿出更新队列 updateQueue 第一个任务,然后看触发这个任务的节点是什么类型。如果不是根节点,则通过循环迭代节点的 return 找到最上层的根节点。最后生成一个新的 fiber 节点,这个节点就是当前 fiber 节点的 alternate 指向的,也就是说下面会在当前节点和这个新生成的节点直接进行 diff。

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 做的工作是 diff 当前节点,diff 完看看有没有子节点,如果没有子节点则把更新先提交到父节点。然后再看有没有兄弟节点,如果有则返回出去当作下次遍历的节点。如果还是没有,说明整个 fiber 树已经遍历完了,则进入到回溯过程,把所有的更新都集中到根节点进行更新真实 dom。

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
  }
}

我们看到 completeWork 中当判断到当前节点是根节点的时候才赋值 pendingCommit 整个全局变量。

function commitAllwork(topFiber) {
  topFiber.effects.forEach(f => {
    commitWork(f)
  })

  topFiber.stateNode._rootContainerFiber = topFiber
  topFiber.effects = []
  nextUnitOfWork = null
  pendingCommit = null
}

当回溯完,有了 pendingCommit,则 commitAllwork 会被调用。它做的工作就是循环遍历根节点的 effets 数据,里面保存着所有要更新的内容。commitWork 就是执行具体更新的函数,这里就不展开了(因为这篇主要想讲的是 fiber 更新的调度算法)。

所以你们看遍历 dom 数 diff 的过程是可以被打断并且在后续的时间片上接着干,只是最后一步 commitAllwork 是同步的不能打断的。这样 react 使用新的调度算法优化了更新过程中执行时间过长导致的页面卡顿现象。

参考文献

  1. 为 Luy 实现 React Fiber 架构 - 更详细的代码实现可以看这片文章。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,333评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,812评论 1 298
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,016评论 0 246
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,293评论 0 214
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,650评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,788评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,003评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,741评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,462评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,681评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,168评论 1 262
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,528评论 3 258
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,169评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,119评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,902评论 0 198
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,846评论 2 283
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,719评论 2 274

推荐阅读更多精彩内容

  • 我的今日关注:20171128第270天 灰犀牛 一、概念 “灰犀牛”是指那些大家都看见...
    牛革工舍阅读 358评论 0 1
  • 亲人包括爱人,不只是亲戚的意思。 对每一个离家的游子来说,即使没有过多的言语,也能感受到父母的温度。 对每一个守望...
    王子木阅读 951评论 0 0
  • 还有喂饭机器,它可以喂人吃饭,做法是这样:做一个机器的模样,往里面装上一把勺子和一根保姆的头发,就做成了喂饭...
    路绘很nice阅读 271评论 1 0
  • 宝宝践行:1早上起床磨耳朵听了My dad Dear zoo time for bed. 听了歌曲的也听了美语的。...
    Lynn_1f06阅读 135评论 0 0
  • 本来是要陪姐姐好好逛逛的,结果王经理一个留言,下午得去曲江汇报。不过今天收货颇丰,发现逛博物馆也不是一件无聊的事。...
    桐华韵锦阅读 204评论 0 0