Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

探讨Visual DOM #14

Open
ZhenHe17 opened this issue May 24, 2019 · 0 comments
Open

探讨Visual DOM #14

ZhenHe17 opened this issue May 24, 2019 · 0 comments

Comments

@ZhenHe17
Copy link
Owner

ZhenHe17 commented May 24, 2019

探讨虚拟DOM

什么是虚拟DOM

提起 React 一般都会提到虚拟DOM
虚拟DOM是一个用于映射真实DOM的JS纯对象(javascript plain object)
大概长这样

const vElement = {
    $$typeof: Symbol(react.element)
    key: "1"
    props: {otherProps: "...", children: "text"}
    ref: null
    type: "div"
};

虚拟DOM在React中的应用过程:

  • React.createElement 生成虚拟DOM(React elements tree)
  • ReactDOM.render 把虚拟DOM转换成真实DOM
  • 当 state 或 props 更新后,render() 返回新的虚拟DOM,通过 diff 新旧DOM生成更新补丁(patch)
  • 根据补丁更新需要更新的DOM元素

为什么要使用虚拟DOM

直接说操作真实DOM成本太高有些简略和不严谨,毕竟使用虚拟DOM最后也会落地到真实DOM

React 隐藏了数据更新到页面渲染期间的细节,数据更新后,React 会生成新的虚拟DOM,通过对新旧虚拟DOM的 diff,生成差异部分再把差异部分 patch 到真实DOM上。开发者只关心数据的变化,不用关心具体修改哪一部分的DOM。虚拟DOM这里作为一个数据到真实DOM的缓冲,也修正了直接操作真实DOM的成本问题。

虚拟DOM不仅可以映射DOM,还可以映射到其他环境

  • 映射成 native 代码 React Native
  • 映射成小程序 Taro.js
    ...
    为其他场景提供了可能

虚拟DOM在React16.x的实现

一个React应用的开始

# /src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

ReactDOM.render(<App />, document.getElementById('root'));

这是 React 应用的入口文件,结构很简单,只调用了ReactDOM.render方法把<App />渲染到了root节点上。其中<App />用到了JSX语法,JSX 语法只是一个语法糖,用于将标签转换为 React.createElement(component, props, ...children)

  <App myProps="value" />

  ===

  React.createElement(Hello, {myProps: 'value'}, null)

  ===

  {
    $$typeof: Symbol(react.element)
    key: "1"
    props: {otherProps: "...", children: "text"}
    ref: null
    type: "div"
  };

React.createElement 最终返回一个对象

组件的渲染和更新

当我们通过render()和 setState() 进行组件渲染和更新的时候,会有以下两个步骤:

render

协调阶段(Reconciler):React 会自顶向下通过递归,遍历新数据生成新的 Virtual DOM,然后通过 Diff 算法,找到需要变更的元素(Patch),放到更新队列(updateQueue)里面去。

渲染阶段(Renderer):遍历更新队列,通过调用宿主环境的API,实际更新渲染对应元素。宿主环境,比如 DOM、Native、WebGL 等。

崭新的 Fiber 架构

以前的协调任务采用的递归的遍历方式,开始后就无法中断。js 将一直占用主线程,一直要等到整棵 Virtual DOM 树计算完成之后,才能把执行权交给渲染引擎,这会导致一些用户交互、动画等任务无法立即得到处理,造成卡顿,从而影响用户体验。

before

为了解决这个问题,React 团队花了两年时间重构了协调算法。把渲染更新过程拆分成多个子任务,每次只做一小部分,做完看是否还有剩余时间,如果有继续下一个任务;如果没有,挂起当前任务,将时间控制权交给主线程,等主线程不忙的时候再继续执行。

按照这个思路,我们需要:

  1. 把渲染更新过程拆分成多个子任务
  2. 主线程不忙的时候继续执行任务

为了能够拆分任务,React 用 Fiber 节点树代替了原来的 React Components Tree

fiber

Fiber 节点树的重要特点是单链表树结构,每个Fiber节点中有三个指针:

{
   return: Fiber | null, // 指向父节点
   child: Fiber | null,// 指向自己的第一个子节点
   sibling: Fiber | null,// 指向自己的兄弟结构,兄弟节点的return指向同一个父节点
}

所有 Fiber Node 连接起来形成 Fiber tree:

fiberTree

因为使用了单链表树结构,React得以在协调任务中实现拆分任务和中断任务的功能。通过Fiber节点内的指针可以快速定位当前执行位置和下一个任务的执行位置。执行顺序如图:

reconcile

在任务执行时,每次做完一个单元任务,就询问是否有更高的优先级任务,主线程不忙的时候继续执行任务。React 使用了一套类似requestIdleCallback 的方法可以在这个空闲期(Idle Period)调用空闲期回调(Idle Callback),继续执行任务。

idle

Diff

diff children 在React里是作为协调(reconcile)children的策略,发生在scheduleWork调度任务的render阶段。
具体在源码中的位置是 react-reconciler/src/ReactChildFiber.js -> reconcileSingleElement ,它返回一个Fiber节点

常规tree diff的算法复杂度是O(n^3),
而React仅比较同级节点,并且提出以下两个假设,给出了独特的策略
1.相同type的元素生成相似的树,不同type的元素生成不同的树
2.对于相同type的元素,可以用key决定子树是否需要重新渲染

对于不同type的元素,React会完全重新渲染,如果是React组件会触发相应生命周期
对于相同type的元素,React会用key来决定子树需不需要重新render

diff

<!-- 不加key的话三个li元素都会重新渲染 -->
<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

<!-- 加key后 React识别出2014是新的,其他两个只是移动了位置 -->
<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

<!-- 如果用index作为key,依然会重新渲染三个li元素 -->
<ul>
  <li key="0">Duke</li>
  <li key="1">Villanova</li>
</ul>

<ul>
  <li key="0">Connecticut</li>
  <li key="1">Duke</li>
  <li key="2">Villanova</li>
</ul>

除了 diff children ,还有 diff 节点属性(attribute) 的过程 ,用的是 ReactDOMComponent.js -> diffProperties 方法,返回一个待更新的数组 updatePayload 。

let updatePayload: null | Array<any> = null;
for (propKey in nextProps) {
  const nextProp = nextProps[propKey];
  const lastProp = lastProps != null ? lastProps[propKey] : undefined;
  if (
    !nextProps.hasOwnProperty(propKey) ||
    nextProp === lastProp ||
    (nextProp == null && lastProp == null)
  ) {
    continue;
  }

  ...

  (updatePayload = updatePayload || []).push(propKey, nextProp);
  return updatePayload;
}

随后通过层层调用,最终在 DOMPropertyOperations.js -> setValueForProperty 方法 中把数组内的更新渲染到DOM上:

if (value === null) {
  node.removeAttribute(attributeName);
} else {
  node.setAttribute(attributeName, '' + (value: any));
}

虚拟DOM在其他框架中的应用

Vue Vue3.0

Vue3.0 实现了目前速度最快的虚拟DOM,主要是使用了静态树提升(Static Tree Hoisting)和静态属性提升(Static Props Hoisting),在更新过程中会跳过不会改变的树和节点。

RN Weex Taro mvpvue

多数跨端框架将输出的虚拟DOM映射成相应端的代码,然后使用native方式渲染
例如:

  • 通过JSCore输出 Virtual DOM,
  • JS-Native Bridge 提供了 callJs 和 callNative 的双向通信的方式进行数据处理
  • native 实现渲染

总结

回顾主题会发现,我们讨论虚拟DOM也是在深入 React 本身。理解其中的细节能让我们在日常开发中更加得心应手。

补充

开启 Fiber 异步渲染模式

想开启 Fiber 异步渲染模式,只需要用AsyncMode组件作为容器

const AsyncMode = React.unstable_AsyncMode;

...

<AsyncMode>
  <App />
</AsyncMode>

生命周期的改动

新的协调过程也导致了生命周期的改动:在生命周期中新增了getDerivedStateFromProps、getSnapshotBeforeUpdate代替即将弃用的三个钩子函数(componentWillMount、componentWillReceivePorps,componentWillUpdate)

因为新的协调算法会重复执行将弃用的三个生命周期,所以用新的生命周期替换了它们。

协调过程

将更新的状态映射到到新的界面的过程称之为协调(reconcile)
协调过程主要在两个阶段执行工作:render 和 commit。

render阶段通过工作循环完成,目的是得到标记了副作用的 Fiber 节点树
performUnitOfWork
beginWork
completeUnitOfWork
completeWork
处理完节点后通过nextUnitOfWork指向下一个节点
只有在完成以子节点开始的所有分支后,才能完成父节点和回溯的工作

render 阶段调用的生命周期列表:
[UNSAFE_]componentWillMount(弃用)
[UNSAFE_]componentWillReceiveProps(弃用)
getDerivedStateFromProps
shouldComponentUpdate
[UNSAFE_]componentWillUpdate(弃用)
render 阶段的工作是可以异步执行的,过程中有优先级策略

在 commit 阶段 React 更新 DOM 并调用生命周期
运行的主要函数是 commitRoot 。它执行如下下操作:
在标记为 Snapshot 副作用的节点上调用 getSnapshotBeforeUpdate 生命周期
在标记为 Deletion 副作用的节点上调用 componentWillUnmount 生命周期
执行所有 DOM 插入、更新、删除操作
将 finishedWork 树设置为 current
在标记为 Placement 副作用的节点上调用 componentDidMount 生命周期
在标记为 Update 副作用的节点上调用 componentDidUpdate 生命周期

commit 阶段执行的:
getSnapshotBeforeUpdate
componentDidMount
componentDidUpdate
componentWillUnmount
commit 阶段始终是同步的
可能会包含副作用,并对 DOM 进行一些操作

优先级策略

每个工作单元运行时有6种优先级:

  • synchronous 与之前的Stack reconciler操作一样,同步执行
  • task 在next tick之前执行
  • animation 下一帧之前执行
  • high 在不久的将来立即执行
  • low 稍微延迟(100-200ms)执行也没关系
  • offscreen 下一次render时或scroll时才执行

synchronous首屏(首次渲染)用,要求尽量快,不管会不会阻塞UI线程。animation通过requestAnimationFrame来调度,这样在下一帧就能立即开始动画过程;后3个都是由requestIdleCallback回调执行的;offscreen指的是当前隐藏的、屏幕外的(看不见的)元素
高优先级的比如键盘输入(希望立即得到反馈),低优先级的比如网络请求,让评论显示出来等等。另外,紧急的事件允许插队
这样的优先级机制存在2个问题:
生命周期函数怎么执行(可能被频频中断):触发顺序、次数没有保证了
starvation(低优先级饿死):如果高优先级任务很多,那么低优先级任务根本没机会执行(就饿死了)

参考文章

Inside Fiber: in-depth overview of the new reconciliation algorithm in React

The how and why on React’s usage of linked list in Fiber

React 源码解析

react 发展过程——react diff算法闲谈

Deep In React之浅谈 React Fiber 架构

@ZhenHe17 ZhenHe17 added the js label May 24, 2019
@ZhenHe17 ZhenHe17 changed the title DOM 和 Visual DOM 的相互转换 探讨Visual DOM May 24, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant