问题与解决思路
制约web应用程序“快速响应”的因素可以概括为CPU瓶颈和I/O瓶颈.
事件循环
渲染进程包括许多线程, 其中主线程需要负责处理DOM、计算样式、处理布局、处理事件响应、执行JS代码等.
所有参与调度的任务会加入任务队列中. 新任务通过事件循环参与调度, 主线程会在循环语句中执行任务.
let keepRunning = true
// 主线程
function MainThread(){
while(true) {
// 循环执行任务队列中的任务
const task = taskQueue.takeTask();
processTask(task)
// 执行延迟队列中的任务 setTimeout等
processDelayTask()
if(!keepRunning) break
}
}
任务队列中任务被称为宏任务, 该任务执行上下文的微任务队列中保存着执行过程中产生的微任务. 宏任务执行结束前, 遍历执行微任务队列.
浏览器渲染
宏任务中有一类与渲染相关的任务, 其执行的流程称为渲染流水线:
- DOM: 将html解析为dom树
document - Style: 解析css
document.styleSheets - Layout: 构建布局树, 会移除DOM树中不可见的部分, 并计算可见部分的几何位置
- Layer: 将页面划分为多个图层, 一些层叠上下文css属性(
z-index、opacity、position等)、“由于显示不全被剪裁的内容”等会使dom元素形成独立的图层. - Paint: 为每个图层生成包含“绘制信息”的绘制列表, 将绘制列表提交给渲染进程的合成线程用于绘制.
每次执行流水线时, 上述任务不一定全部执行:
- 当通过js或css修改dom元素的几何属性, 会触发完整的流水线任务(重排)
- 修改的属性不涉及几何属性(如
font)时, 会省略其中的Layout、Layer过程(重绘) - 修改不涉及重排、重绘的属性(如
transform)时, 会省略其中的Layout、Layer、Paint过程, 仅执行合成线程的绘制工作(合成)
很明显其性能排序: 重排<重绘<合成
“执行JS”与渲染流水线同为宏任务, 如果前者耗时较多, 导致后者绘制图片的速度跟不上屏幕刷新频率, 就会造成页面掉帧.
CPU瓶颈与I/O瓶颈
-
造成CPU瓶颈的原因多在于“VDOM相关工作”, 如渲染3000个LI元素;
Svelte、Vue3利用AOT在编译时减少运行时代码流程.
React通过将VDOM的执行过程拆分为一个个独立的宏任务, 将每个宏任务的执行时间限制在一定范围内(初始为5ms). 这样一个“会造成掉帧的长任务”被拆解为多个“不会掉帧的短宏任务”, 以减少掉帧的可能性, 即为时间切片.
-
造成I/O瓶颈的原因多在于网络延迟.
优先处理用户更易感知的操作, 在一定程度上可以减少延迟对用户的影响.
- 为不同操作造成的“自变量变化”赋予不同优先级
- 统一调度, 优先处理高优先级更新
- 如果更新正在进行(即进入VDOM相关工作), 此时有更高优先级的更新, 则中断当前更新, 优先处理高优先级的更新.
时间切片将长宏任务拆分为多个短宏任务, 这样在下一个短宏任务开始前, 就可以检查是否需要中断.
底层架构的演进
React在v15升级到v16后重构了整个架构(实现了 Time Slice).
新旧架构介绍
Reactv15架构分为Reconciler(协调器)和Renderer(渲染器), 前者主要是VDOM的实现, 负责根据自变量变化计算出UI变化, 后者负责将UI变化渲染至宿主环境.
Reactv16架构分为Secheduler(调度器)、Reconciler(协调器)和Renderer(渲染器). 调度器负责任务调度, 高优先级任务优先进入Reconciler. 新架构中Reconciler的更新流程从递归(更新子节点)变为“可中断的循环过程”.
function workLoopConcurrent(){
// 执行任务直到完成或被中断
while(workInProgess !== null && !shouldYield()) {
performUnitOfWork(workInProgess)
}
}
// 判断当前Time Slice是否有剩余时间
function shouldYield(){
// deadline = getCurrentTime() + yieldInterval
// yieldInterval调度器预设间隔5ms
return getCurrentTime() >= deadline
}
Secheduler将调度后的任务交给Reconciler(为vdom添加各种副作用flags), 当Reconciler工作完成后进入Renderer. Secheduler和Reconciler都在内存中进行, 可以随时被中断(如当前切片没有剩余时间、有其他更高优先级任务要执行等), 并不会看到“更新不全的UI”.
Fiber架构
React有三种节点类型: ReactElement、ReactComponent、FiberNode.
Fiber是React中vdom的实现.
FiberNode的含义
- 作为“静态数据结构”, 每个FiberNode对应一个react元素, 用于保存类型、对应的dom元素等信息
- 作为“动态工作单元”, 每个FiberNode用于保存“本次更新该react元素变化的数据、要执行的工作(增、删、改、副作用等)等”
- 作为架构, 多个FiberNode组成树状结构,
Reconciler基于其实现Fiber Reconciler.
// 每个FiberNode对应一个react元素, 用于保存类型、对应的dom元素等信息
function FiberNode (tag, pendingProps, key, mode){
this.tag = tag
this.key = key
this.elementType = null
// Fiber/null 执行完当前工作返回的Fiber (父节点)
this.return = null
// 当前Fiber的最左侧子Fiber
this.child = null
// 同级Fiber
this.sibling = null
}
双缓存机制
Fiber架构中存在“真实UI对应的FiberTree”(Current Fiber Tree可以理解为前缓冲区)和“正在内存中构建的FiberTree”(Wip(WorkInProgress) Fiber Tree可以理解为后缓冲区)两颗FiberTree. 类似显卡的工作原理(显卡负责合并图像写入后缓冲区, 一旦写入前后缓冲区就会互换, 显示器从前缓冲区读取图像), 将数据保存在缓冲区再替换.
mount与update时FiberTree的构建
mount分为整个应用的首次渲染和某个组件的首次渲染(其父组件可能处于update流程).
mount时构建过程如下(组件的首次渲染不包含前两步):
- 创建
fiberRootNode(负责管理应用中任务的过期时间、任务调度信息、两颗FiberTree切换等全局事宜), 其中fiberRootNode.current指向Current Fiber Tree的根节点. - 创建
tag为3的fiberNode(即HostRootFiber), 代表HostRoot. - 从
HostRootFiber开始以DFS(ddepth-first-search 深度优先有所)的顺序生成FiberNode. - 遍历过程中, 为
FiberNode标记“代表不同副作用的flags”, 以便在renderer中使用.
update时生成一颗新的Wip Fiber Tree, renderer完成后再次切换fiberRootNode.current
function App(){
const [num, add] = useState(0);
return <p onClick={ () => add(num + 1) }>{num}</p>
}
// rootElement - hostRoot代表宿主环境挂载的根节点
const rootElement = document.createElementById('root')
// hostRootFiber代表 hostRoot对应的FiberNode
ReactDOM.createRoot(rootElement).render(<App />)
总结
前端框架需要突破CPU与IO瓶颈. React从运行时着手, 从“同步更新的Stack Reconciler”升级为支持Time Slice的“Fiber Reconciler”. 采用双缓存机制, 每个应用同时存在两颗Fiber Tree(Current 与 wip).