转:https://www.qiyuandi.com/zhanzhang/zonghe/16899.html
如果各位同学想系统的了解 Fiber,我个人还是强烈推荐 React 团队 Lin Clark 在 youtube 上的视频,这个是理解 React Fiber 最好的一手资源。
这篇文章会努力从一个不一样的风格去讲述 Fiber,把这个复杂的系统通过最简单最直接的语言表达出来。但是,在看本文之前,我希望各位读者能对 React 15 的 diff 算法和虚拟 dom 有比较基本的了解。
为什么要学习 Fiber
对于一个前端工程师而言,无论从功利的角度还是非功利的角度,你都有足够多的动机学习 Fiber。从功利的角度而言:
- Fiber 被高级别前端工程师职位频繁考察
- 是否了解 Fiber 是互联网公司考察程序员是否有深入理解某一事物意愿的重要指标之一
从非功利的角度而言: Fiber 可以帮你把前端岗位的三个重要知识系统化,体系化:
- 浏览器渲染机制
- Js 运行在单线程上
- 前端性能优化
为什么要有 Fiber
一言以蔽之,Fiber 是用来优化 React 性能的。首先,我们先来看看 React15 和 React16 的对比。
React 15
[图片上传失败...(image-2ad861-1660720729968)]
React 16
[图片上传失败...(image-9ebcca-1660720729968)]
在深入理解其原理之前,我们有必要在这里做一些知识铺垫:我们都知道,js 是一门单线程语言,准确的说,是 js 运行在浏览器渲染进程 (render process) 的主线程上 (main thread)。但是这个主线程不仅仅是为 js 服务,它还负责其他的事情,比如响应用户的输入,以及页面的渲染,一个人做三件事,每两个事都不能并行,这就产生了一个问题,假如说任何一件事抢占主线程过多的时间,另外两个事就只能等着。换句话说,假如有一个计算密集型 (cpu密集型) 的 js 任务霸占着主线程,那么浏览器是一定没有时间响应用户输入以及渲染页面的,那么这个东西就会导致你的页面性能不好,也就是,卡。 不幸的是,对于 react 15 而言,当你使用 react 并且有一棵非常庞大的虚拟 dom 树的时候,你的 diff 算法就是这样一个连续不可被打断的计算密集型 js 任务。
Fiber 是如何优化的性能
stack reconciler 面临的难题
浏览器渲染进程有一个主线程,这个主线程既负责运行 js,也负责页面渲染(布局,绘制,合并图层)。所以你要么运行 js,要么渲染页面,不能两个同时进行。在 React 15 中,假如一棵虚拟 dom 树很大很大,你进行 diff 的时候就要有大量的 js 运算,这种 cpu 密集型的操作会占住主线程不放,这时你的页面就会卡,用户的输入也得不到响应。
[图片上传失败...(image-64d7f3-1660720729968)]
Fiber 扮演的角色
fiber 就好比给 react 加了一个操作系统。 告诉它什么时候 diff,什么时候渲染,什么时候响应用户输入。这好比操作系统的时间片轮转法,也很像 generator 允许中断这种机制。
由于有了 Fiber,React 在每一帧当中都有时间响应用户输入和进行页面渲染,因此看起来不卡。
[图片上传失败...(image-a1c287-1660720729968)]
Fiber Reconciler 的全过程
知识铺垫
浏览器的一帧
[图片上传失败...(image-14ef0f-1660720729968)]
这篇文章对浏览器原理不会有详尽的展开,但是,一些必要的知识背景对理解 Fiber 事至关重要的。我们可以看到,浏览器渲染进程 Renderer Process 的主线程 Main Thread 在一帧内会有很多步骤,解析 HTML 成 dom 树,计算并往 dom 树里面嵌入样式,计算布局(各个 dom 节点在屏幕的什么位置),生成操作系统层面的绘制命令,为每一个图层的绘制生成顺序并生成绘制命令的序列,然后进行图层的合成(注意,这一步不一定是主线程做,有可能是合成帧线程做的)。如果主线程在一帧内还有额外的时间,那么,它会去执行 requestIdleCallback 里面的回调函数。
requestIdleCallback
requestIdleCallback 这个 API 是 Fiber 中非常重要的一个概念。我们已经知道浏览器渲染进程的主线程负责很多件事,下图中所有的事情都是主线程来完成的,如果主线程还有时间,它会去执行 requestIdleCallback 里面的任务。如果没有时间,requestIdleCallback 里面的回调会被延迟到下一帧执行,如果下一帧还没时间,就继续再往后推。
[图片上传失败...(image-2376d7-1660720729968)]
我们可以和 requestAmimationFrame 这个函数做一个对比实验。实验的目的是验证 rIC 并不是每一帧都会被执行。
num = 300
function f() {
console.log('print')
num -= 1
if (num > 0)
requestIdleCallback(f) // 换成 requestAmimationFrame 试试
}
f()
Js
复制
你会发现,对于 rAF,程序会在 5s 后停止,但对于 rIC,程序的运行时间远远大于 5s。
react 会使用 requestIdleCallback 来逐个更新 Fiber Node 节点,如果浏览器没时间留给 requestIdleCallback,那么更新过程会被暂停,让出主线程。
更新流程概述
从 diff 到更新真实 dom,这个过程可以被分成 4 步。
- 生成 UpdateQueue
- 更新 WIP Tree
- 生成 EffectList
- 更新真实 dom
前三个阶段并成为 render 阶段,最后一个阶段为 commit 阶段。大家可以先思考一个问题,对于 Fiber 算法而言,哪一个阶段可以使用 requestIdleCallback,也就是可以被打断?
[图片上传失败...(image-d769c3-1660720729968)]
详尽的更新流程
代码
import React, { Component, PureComponent } from 'react';
class List extends Component {
constructor(props) {
super(props);
this.state = {
list:[1,2,3]
}
}
render() {
const {list}=this.state
return ( <div>
<button onClick={()=>{
let {list}=this.state
for(let i=0;i<list.length;i++){
list[i]*=list[i]
}
this.setState({
list
})
}}>^2</button>
<Item num={list[0]} key={0}/>
<Item num={list[1]} key={1}/>
<Item num={list[2]} key={2}/>
</div> );
}
}
export default List;
Js
复制
class Item extends PureComponent {
constructor(props) {
super(props);
}
render() {
return (<div>
{this.props.num}
</div> );
}
}
export default Item;
Js
复制
代码概述:
父组件
- 组件名:List
- state:
list = [1,2,3]
- props:无
子组件:
- 组件名:Item
- state:无
- props:
num = this.state.list[i]
[图片上传失败...(image-6e2b03-1660720729967)]
List 组件的 setState
react 会把 setState 以某种数据结构注入到 updateQueue 里面
[图片上传失败...(image-3b6f17-1660720729967)]
当我们点击 button 触发 setState 以后,[1,2,3] 分别乘上自己变成 [1,4,9]。
Fiber 树
在 List 组件第一次渲染的时候,react 会用 jsx 生成好一棵 Fiber 树放在内存里面,这个 Fiber 树长什么样?长下面这样:
[图片上传失败...(image-826609-1660720729967)]
了解过 React 15 的同学应该知道,这个数据结构和虚拟 dom 树并没有本质的区别,它最重要的区别就是它的指针变得更多了,但本质上还是一个树形结构。至此,我们得到 Fiber 结构的第一条信息:Fiber 是一棵链表树。
Fiber 里面的每一个节点有指向它儿子,父节点和兄弟节点的指针。
[图片上传失败...(image-569f9a-1660720729967)]
为什么是链表树
递归不好保护现场。事实上,react 团队也用 es6 generator 做过一些尝试,最后以失败告终。结果证明,链表这种数据结构是最好做中断恢复的,这是因为链表在循环过程中,容易停止并保护现场,让主线程去响应用户输入,去做页面渲染。
Work In Progress 树
react 会根据 current tree 和 update queue 生成 work in progress tree。你可以简单的把 current tree 和 work in progress tree 理解为一次 setState 前后虚拟 dom 的 snapshot。
React 会从触发 setState 的组件开始更新,由于 List 的 state 被改变,List 节点对应的 effectTag 会置为 update,effectList 会生成一个链表节点(为了方便,我这里用数组表示,大家知道 react 实际上用的是链表就可以了)
[图片上传失败...(image-2b754a-1660720729967)]
[{
instance: List
type: ‘update’
property: ‘state.list’
value: [1,4,9]
}]
Js
复制
button 是 List 中的元素,但是它并没有任何属性的更新,WIP Tree 直接从 current Tree 复制节点。
第一个 Item 由于 props.num 并没有发生改变,shouldComponentUpdate 里面的逻辑可以让该组件不去触发更新,Item 节点直接从 current tree 复制过来就可以了。
[图片上传失败...(image-2d5eea-1660720729967)]
由于第一个 Item 组件不需要触发更新,那么其子节点可以完全从 current Tree 复制过来。若 rIC 时间片未到期,React 会继续更新 FiberNode。此时,React 发现,第二个 Item 的父元素的 state 发生了改变,也就是 Item 的 props 发生了改变,同时,Item 里面 div 元素的 innerText 属性发生了改变,因此第二个 Item 的 FiberNode 的 effectList 变成
[{
instance: Item,
type: ‘update’,
property: ‘props.num’
value: 4
}]
Js
复制
div 的 FiberNode 对应的 effectList 变为
[{
name: div,
type: ‘update’,
property: ‘innerText’
value: 4
}]
Js
复制
[图片上传失败...(image-26ed27-1660720729967)]
被打断
如果此时 requestIdleCallback 的时间片到期,那么 react 会把控制权交给浏览器,浏览器此时去响应用户输入,渲染页面。
下一帧,React 再次在 rIC 拿到了控制权,它继续更新第三个 Item,查看父元素的 state 后得知自己的 props 有了更新,Item 的 effectList 变成
[{
instance: Item,
type: ‘update’,
property: ‘props.num’
value: 9
}]
Js
复制
发现其子元素 div 的 innerText 发生了改变,因此这个 div 的 effectList 变成
[{
instance: div,
type: ‘update’,
property: ‘innerText’
value: 9
}]
Js
复制
[图片上传失败...(image-6afdd7-1660720729967)]
effect List 如何拼接
会按照 dfs 的顺序拼接 effectList,子节点的 effectList 在返回时拼接到父节点的头部,兄弟节点会把第一个兄弟节点的 effectList 当作头部按顺序进行拼接,在本例中,
- 第二个 div 返回,拼到第二个 Item 上,
- div -> Item (1)
- 第三个 div 返回,拼到第三个 Item 上
- div -> Item (2)
- 和 (2) 拼接起来
- div -> Item -> div -> Item
- 返回 List
- div -> Item -> div -> Item -> List
[图片上传失败...(image-8c31c0-1660720729967)]
事实上,就如上一节所说,React 会收集所有被标记了 effectTag 元素的 effectList,但为了抓主要矛盾,在上面的讲述中,我重点讲述两个 div 的 effectList
[[
instance: div,
type: ‘update’,
property: ‘innerText’
value: 4
},
{
instance: div,
type: ‘update’,
property: ‘innerText’
value: 9
}]
Js
复制
React 会用 effectList 批量的更新真实 dom,这一阶段被称为 commit 阶段。它会一次性的调用更新真实 dom 的方法,在本例中,为 dom.innerText = ‘xxx’
之前问题的答案
至此为止,我想有的同学应该可以猜出 render 阶段和 commit 阶段到底谁可以被打断。答案是显而易见的,render 阶段可以被打断,commit 阶段不可以被打断。请注意,这里的 render 和 react 内部的那个 render 并不是一个概念。而 commit 才是去更新真实 dom 的阶段。如果 commit 被打断,这就意味着 dom 的更新不是一次性的,那么,1,2,3 可能会被更新成 1,4,3 而中途被打断。这个对于使用者而言,显然是不可以接受的。
尤大对 React Fiber 的评价
虽然 React Fiber 这一设计被国外很多人认为是高技术含量的体现,但是对 Fiber 的评价却褒贬不一,尤雨溪在的 Vue Conf 说过:如果我们可以把更新做得足够快的话,理论上就不需要时间分片了。他说:React Fiber 本质上是为了解决 React 更新低效率的问题,不要期望 Fiber 能给你现有应用带来质的提升, 如果性能问题是自己造成的,自己的锅还是得自己背.
参考资料
【1】Lin Clark - A Cartoon Intro to Fiber - React Conf 2017 www.youtube.com/watch?v=ZCu…
【2】React Fiber Architecture github.com/acdlite/rea…
【3】Facebook announces React Fiber, a rewrite of its React framework techcrunch.com/2017/04/18/…
【4】这可能是最通俗的 React Fiber(时间分片) 打开方式 juejin.cn/post/684490…
【5】React Fiber 原理介绍 segmentfault.com/a/119000001…
网友评论