React自从2013年5月开源以来,一路披襟斩棘到前端最热门框架之一,框架本身具有以下特性。
- Declarative(声明式)
- Component-Based(组件式)
- Learn Once, Write Anywhere(多端渲染式)
除此之外还有快速高效等特点,主要得益于Virtual Dom的应用,虚拟Dom是一种HTML DOM节点的抽象描述,存在JS中的结构对象中,当渲染时通过Diff算法,找到需要变更的节点进行更新,这样就节省了不必要的更新。
React快速响应主要制约于CPU瓶颈
,比如以下栗子所示:
function App() {
const len = 3000;
return (
<ul>
{Array(len).fill(0).map((_, i) => <li>{i}</li>)}
</ul>
}
const rootEl = document.querySelector("#root");
ReactDOM.render(<App/>, rootEl);
当需要被渲染的节点很多时,有存在大量的JS计算,因为GUI渲染线程
和JS执行线程
是互斥的,所以在JS计算的时候就会停止浏览器界面渲染行为,导致页面感觉卡顿。
主流浏览器刷新频率为60Hz,即每(1000ms / 60Hz)16.6ms浏览器刷新一次,也就说渲染一帧的时间必须控制在16ms内才能保证不掉帧。
这段时间内需要完成以下操作:
- 脚本执行(JavaScript)
- 样式计算(CSS Object Model)
- 布局(Layout)
- 重绘(Paint)
- 合成(Composite)
即JS->Style->Layout->Paint->Composite
过程
既然JS执行比较耗时,能不能中断或暂停JS的执行,把执行权交回给渲染线程呢?
首先看一下React是怎么去做这事的
// react/packages/scheduler/src/forks/SchedulerHostConfig.default.js
// Scheduler periodically yields in case there is other work on the main
// thread, like user events. By default, it yields multiple times per frame.
// It does not attempt to align with frame boundaries, since most tasks don't
// need to be frame aligned; for those that do, use requestAnimationFrame.
let yieldInterval = 5;
let deadline = 0;
从源码中可以看到,React每次会利用这部分时间(5ms)更新组件,当超过这个时间React就会将执行权就还给浏览器由浏览器自主分配执行权,React本身则等待下一帧时间来继续被中断的工作,这就引入了一个时间切片
的概念。将耗时的长任务拆分到每一帧中,一次执行小块任务。总结来说就是将 同步的更新变成可中断的异步更新
React v15 Stack Reconciler
ReactDOM.render(<App />, rootEl);
React DOM将<App />传递给Reconciler,此时Reconciler将会检查App是 函数
or 类
?
- 【函数】 -> App(props)
- 【类】 -> new App(props) 来实例化 App, 并调用生命周期方法 componentWillMount(),之后调用 render() 方法来获取渲染的元素
tips: 面试过程中通常会问函数组件和类组件,两者是否都被实例化?答案就在上面
此过程是基于树的深度遍历的递归过程(遇到自定义组件就会一直的递归下去,直到最原始的HTML标签),Stack Reconciler 的递归一旦进入调用栈就无法中断或暂停,如果当组件嵌套很深或数量极多,在16ms内无法完成就势必造成浏览器丢帧导致卡顿。
刚在上面也提过解决方案就是将 同步的更新变成可中断的异步更新,但15版本架构不支持异步更新,所以React团队决定撸起袖子重写,折腾了两年多终于在2017/3发布了可用版本。
React Fiber
在首次渲染中构建出虚拟dom树,后续更新时(setState)通过diff虚拟dom树得到dom change,最后将dom change应用到真实dom树中,Stack Reconciler自顶向下递归(mount/update)无法中断导致主线程上的布局/动画/交互响应无法及时得到处理,引起卡顿。
这些问题Fiber Reconciler 能够解决。
Fiber原意纤维,工作最小单元,每次通过ReactDOM.render首次构建时都会生成一个FiberNode,接下来具体看下FiberNode结构。
function FiberNode(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
) {
// Instance
this.tag = tag; // FiberNode类型,目前总有25种类型,常用的就是FunctionComponent 和 ClassComponent
this.key = key; //和组件Element中的key一致
this.elementType = null;
this.type = null; //Function|String|Symbol|Number|Object
this.stateNode = null; //FiberRoot|DomElement|ReactComponentInstance等绑定的其他对象
// Fiber
this.return = null; // FiberNode|null 父级FiberNode
this.child = null; // FiberNode|null 第一个子FiberNode
this.sibling = null;// FiberNode|null 相邻的下一个兄弟节点
this.index = 0; //当前父fiber中的位置
this.ref = null; //和组件Element中的ref一致
this.pendingProps = pendingProps; // Object 新的props
this.memoizedProps = null; // Object 处理后的新props
this.updateQueue = null; // UpdateQueue 即将要变更的状态
this.memoizedState = null; //Object 处理后的新state
this.dependencies = null;
this.mode = mode; // number
// 普通模式,同步渲染,React15-16的生产环境使用
// 并发模式,异步渲染,React17的生产环境使用
// 严格模式,用来检测是否存在废弃API,React16-17开发环境使用
// 性能测试模式,用来检测哪里存在性能问题,React16-17开发环境使用
// Effects
this.flags = NoFlags;
this.subtreeFlags = NoFlags;
this.deletions = null; // render阶段的diff过程检测到fiber的子节点如果有需要被删除的节点
this.lanes = NoLanes; //如果fiber.lanes不为空,则说明该fiber节点有更新
this.childLanes = NoLanes; //判断当前子树是否有更新的重要依据,若有更新,则继续向下构建,否则直接复用已有的fiber树
this.alternate = null; //FiberNode|null 候补节点,缓存之前的Fiber节点,与双缓存机制相关,后续讲解
}
所有fiber对象都是FiberNode实例,通过tag来标识类型。通过createFiber初始化FiberNode节点,代码如下
const createFiber = function(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
): Fiber {
// $FlowFixMe: the shapes are exact here but Flow doesn't like constructors
return new FiberNode(tag, pendingProps, key, mode);
};
Fiber解决这个问题的解法是把渲染/更新过程拆分成一系列小任务,每次执行一小块,再看是否有剩余时间继续下一个任务,有则继续,无则挂起,将执行线程归还。
Fiber Tree
通过虚拟dom树,react会再创建一个Fiber Tree,不同的Element类型对应不同类型的Fiber Node,在后续的更新过程中每次重新渲染都会重新创建Element,但是Fiber不会重新创建,只会更新自身属性。
顾名思义,通过多个Fiber Node组成了一个Fiber Tree,也是为了满足Fiber增量更新的特性才拓展出了Fiber Tree结构。
首先每个节点是统一的,会有两个属性
FirstChild
及NextSibiling
,第一个指向节点第一个儿子节点,第二个指向下一个兄弟节点,Fiber这种单链表结构就可以把整个树串联起来。同时Fiber Tree在Instance层又新增了额外三个实例:
- effect:每个workInProgress tree节点上都有一个effect list 存放diff结果,更新完毕后updateQueue进行收集
- workInProgress: reconcile过程中的快照,工作过程节点,用户不可见
- fiber:用来描述增量更新所需的上下文信息
这里我们着重来理解一下 workInProgress
到底起了什么作用?首先通过代码来看下它是如何被创建的
export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
let workInProgress = current.alternate;
if (workInProgress === null) {
workInProgress = createFiber(
current.tag,
pendingProps,
current.key,
current.mode,
);
// 以下两句很关键
workInProgress.alternate = current;
current.alternate = workInProgress;
// do something else ...
} else {
// do something else ...
}
// do something else ...
return workInProgress;
}
首先workInProgress一个Fiber节点,当前节点的alternate为空时,通过createFiber创建,每次状态更新都会产生新的workInProgress Fiber树,通过current与workInProgress的替换完成dom更新, 简单来说当workInProgress Tree内存中构建完成后直接替换Fiber Tree的做法,就是刚刚提到的双缓冲机制
当内存中的workInProgress树直接构建完成后,直接替换了页面需要渲染的Fiber树,这是mount的过程。
当页面其中一个node节点发生变更时,会开启一次新的render阶段并构建一颗心的workInProgress树,
这里有个优化点就是 因为每个node节点都有一个alternate属性互相指向,在构建时会尝试复用当前current Fiber树已有的节点内属性,是否复用取决于diff算法判断。
workInProgress-update
在更新过程中,React在filbert tree中实际发生改变的fiber上创建effect,所有effect构成effect list链表,在commit阶段执行,实现了只对实际发生改变的fiber做dom更新,避免了遍历整个fiber tree造成性能浪费。每当一个Fiber节点的flags字段不为NoFlags时,就会把此Fiber节点添加到effect list中,根据每一个effect的effectTag类型执行对应的dom树更改。
递归Fiber节点
Fiber架构下的每个节点都会经历递
及 归
两个过程,即beginWork/completeWork。
1、beginWork
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
// do something else
}
- current: 当前组件上一次更新的Fiber节点,workInProgress.alternate
- workInProgress: 当前组件内存的Fiber节点
- renderlanes: 相关优先级
由于双缓存机制的存在,我们可以通过current === null 来判断组件是处于mount还是uplate,当mount时会根据fiber.tag创建不同类型的子Fiber节点,当update时 didReceiveUpdate === false就可以直接复用前一次更新的子Fiber节点,具体判断如下:
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (
oldProps !== newProps ||
hasLegacyContextChanged() ||
(__DEV__ ? workInProgress.type !== current.type : false)
) {
didReceiveUpdate = true;
} else if (!includesSomeLane(renderLanes, updateLanes)) {
didReceiveUpdate = false;
switch (workInProgress.tag) {
// do something else
}
return bailoutOnAlreadyFinishedWork(
current,
workInProgress,
renderLanes,
);
} else {
didReceiveUpdate = false;
}
} else {
didReceiveUpdate = false;
}
2、completeWork
function completeWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
const newProps = workInProgress.pendingProps;
switch (workInProgress.tag) {
case IndeterminateComponent:
case LazyComponent:
case SimpleMemoComponent:
case FunctionComponent:
case ForwardRef:
case Fragment:
case Mode:
case Profiler:
case ContextConsumer:
case MemoComponent:
return null;
case ClassComponent: {
// do something else
return null;
}
case HostRoot: {
// do something else
updateHostContainer(workInProgress);
return null;
}
case HostComponent: {
// do something else
return null;
}
// do something else
}
传入参数和beginWork
一致,不做过多讲解,completeWork会根据tag不同调用不同的处理逻辑。对于处理的当前节点是mount还是update阶段同样可以使用current === null 来做判断。由于completeWork属于“归”阶段的函数,每次调用appendAllChildren都会将已生成的子孙节点插入当前生成的dom节点,这样就一个完整的dom树了。
3、effectList
每个执行完completeWork并且存在effectTag的Fiber节点都会保存在effectList单向链表中,同时effectList第一个和最末个Fiber节点会分别保存在fiber.firstEffect /fiber.lastEffect属性中。
effectList.png
effectList使得commit阶段只需要遍历effectList就可以了,提高了运行性能, 至此 render阶段告一段落。
写在最后
我觉得React Fiber是一种解决问题的理念架构,从React16架构来说分为三层:
Scheduler/Reconciler/Renderer
它利用浏览器的空闲时间完成循环模拟递``归
过程,所有操作都在内存中进行,只有所有组件完成Rconciler工作,才会走Renderer一次渲染展示,提升效率。
篇幅不长,知识点零零总总,性能优化没有最好,只有更好,所以我们一直在路上...
网友评论