React设计原理-React理念

528 阅读4分钟

问题与解决思路

制约web应用程序“快速响应”的因素可以概括为CPU瓶颈I/O瓶颈.

事件循环

渲染进程包括许多线程, 其中主线程需要负责处理DOM、计算样式、处理布局、处理事件响应、执行JS代码等. 事件循环.png 所有参与调度的任务会加入任务队列中. 新任务通过事件循环参与调度, 主线程会在循环语句中执行任务.

let keepRunning = true
// 主线程
function MainThread(){
    while(true) {
        // 循环执行任务队列中的任务
        const task = taskQueue.takeTask();
        processTask(task)
        
        // 执行延迟队列中的任务 setTimeout等
        processDelayTask()
        if(!keepRunning) break
    }
}

任务队列中任务被称为宏任务, 该任务执行上下文的微任务队列中保存着执行过程中产生的微任务. 宏任务执行结束前, 遍历执行微任务队列.

浏览器渲染

宏任务中有一类与渲染相关的任务, 其执行的流程称为渲染流水线:

  • DOM: 将html解析为dom树document
  • Style: 解析cssdocument.styleSheets
  • Layout: 构建布局树, 会移除DOM树中不可见的部分, 并计算可见部分的几何位置
  • Layer: 将页面划分为多个图层, 一些层叠上下文css属性(z-indexopacityposition等)、“由于显示不全被剪裁的内容”等会使dom元素形成独立的图层.
  • Paint: 为每个图层生成包含“绘制信息”的绘制列表, 将绘制列表提交给渲染进程的合成线程用于绘制.

每次执行流水线时, 上述任务不一定全部执行:

  • 当通过js或css修改dom元素的几何属性, 会触发完整的流水线任务(重排)
  • 修改的属性不涉及几何属性(如font)时, 会省略其中的Layout、Layer过程(重绘)
  • 修改不涉及重排、重绘的属性(如transform)时, 会省略其中的Layout、Layer、Paint过程, 仅执行合成线程的绘制工作(合成)

很明显其性能排序: 重排<重绘<合成

“执行JS”与渲染流水线同为宏任务, 如果前者耗时较多, 导致后者绘制图片的速度跟不上屏幕刷新频率, 就会造成页面掉帧.

CPU瓶颈与I/O瓶颈

  1. 造成CPU瓶颈的原因多在于“VDOM相关工作”, 如渲染3000个LI元素;

    Svelte、Vue3利用AOT在编译时减少运行时代码流程.

    React通过将VDOM的执行过程拆分为一个个独立的宏任务, 将每个宏任务的执行时间限制在一定范围内(初始为5ms). 这样一个“会造成掉帧的长任务”被拆解为多个“不会掉帧的短宏任务”, 以减少掉帧的可能性, 即为时间切片.

  2. 造成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. SechedulerReconciler都在内存中进行, 可以随时被中断(如当前切片没有剩余时间、有其他更高优先级任务要执行等), 并不会看到“更新不全的UI”.

Fiber架构

React有三种节点类型: ReactElementReactComponentFiberNode. 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时构建过程如下(组件的首次渲染不包含前两步):

  1. 创建fiberRootNode(负责管理应用中任务的过期时间、任务调度信息、两颗FiberTree切换等全局事宜), 其中fiberRootNode.current指向Current Fiber Tree的根节点.
  2. 创建tag为3的fiberNode(即HostRootFiber), 代表HostRoot.
  3. HostRootFiber开始以DFS(ddepth-first-search 深度优先有所)的顺序生成FiberNode.
  4. 遍历过程中, 为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).