前端框架中的虚拟DOM和渲染器

前端框架中的虚拟DOM和渲染器

目前前端的流行框架Vue和React都引入了虚拟DOM,它的出现了推动了前端框架具备更强大的能力,提升了开发效率,实现了更多的可能性。

虚拟DOM是啥

虚拟DOM,virtual document object model,虚拟文档对象模型,是用存粹的JS对象来描述DOM结点,我们在框架的源码或相关的文章中,也经常用VNode指代虚拟DOM。 snabbdom是一个著名的虚拟DOM库,vue也从中借鉴了相关的思想,下面是一个VNode的定义。

ts 复制代码
interface VNode {
  // 选择器
  sel: string | undefined;
  // 节点数据:属性/样式/事件等
  data: VNodeData | undefined;
  children: Array<VNode | string> | undefined;
  // 记录 vnode 对应的真实 DOM
  elm: Node | undefined;
  text: string | undefined;
  key: Key | undefined;
}
interface VNodeData {
  props?: Props;
  attrs?: Attrs;
  class?: Classes;
  style?: VNodeStyle;
  dataset?: Dataset;
  on?: On;
  attachData?: AttachData;
  hook?: Hooks;
  key?: Key;
  ns?: string; // for SVGs
  fn?: () => VNode; // for thunks
  args?: any[]; // for thunks
  is?: string; // for custom elements v1
  [key: string]: any; // for any other 3rd party module
}

VNodeData 描述结点相关的属性信息。

一个 html 标签有它的名字、属性、事件、样式、子节点等诸多信息,这些内容都需要在 VNode 中体现,我们可以用如下对象来描述一个红色背景的正方形 div 元素:

js 复制代码
const elementVNode = {
  tag: 'div',
  data: {
    style: {
      width: '100px',
      height: '100px',
      backgroundColor: 'red'
    }
  }
}

我们使用 tag 属性来存储标签的名字,用 data 属性来存储该标签的附加信息,比如 style、class、事件等,通常我们把一个 VNode 对象的 data 属性称为 VNodeData。

为了描述子节点,我们需要给 VNode 对象添加 children 属性,如下 VNode 对象用来描述一个有子节点的 div 元素

js 复制代码
const elementVNode = {
  tag: 'div',
  data: null,
  children: [
    {
      tag: 'h1',
      data: null
    },
    {
      tag: 'p',
      data: null
    }
  ]
}

为啥要引入虚拟DOM

如果没有虚拟DOM,我们之前经常用jQuery这种直接操作DOM的框架,开发业务系统。一般通过数据状态的变更,直接操纵DOM结点来更新视图。

后来有了如handlebars、jade这类的模板引擎类的框架,也只能便于初始化数据状态,生成的是一堆HTML字符串,扔给浏览器渲染。无法追踪后续的视图状态。

这样处理的的缺点有

  • 维护成本高,当视图上的数据状态多的时候,程序员的心智负担非常重,随着业务逻辑的增加,导致应用难以维护
  • 性能不高(相对),频繁的操纵DOM,对性能的损耗较大,DOM是浏览器经过一系列的计算、渲染、绘制消耗的资源量非常的。

视图上一个普通的空DIV结点,就有上百个属性,可见操作DOM是件成本比较高的任务。基于上面的缺陷,业界引入了VNode,虚拟DOM的概念,使用原生的JavaScript对象来描述真实的DOM结点。 将业务的数据状态处理成 一颗虚拟DOM树,根据和真实DOM树的对比,寻找差异,只更新变化的部分,通过最小化更改,来降低视图更新的成本。

引入了虚拟DOM层后,我们业务开发人员,就可以不用直接操作DOM,通过上层的库(vue、react)直接维护数据状态即可。当数据发生改变后,交由框架来自动处理,减轻了维护负担。

组件和虚拟DOM

在前端开发中,我们经常打交道的是组件,无论是vue中的模板组件或者是react中的JSX组件,它们是如何关联到虚拟DOM上的呢?

Vue中的处理过程是这样的

最终生成的组件实例如下图所示

组件实例,关联着VNode

我们通过const app = new Vue(option);app.mount('#root');生成的组件实例,就关联到了虚拟DOM上,在程序运行期间一直存在,就像一块内存中的缓存,联系着浏览器中的真实DOM结点和程序的数据状态。

React中的处理过程是这样的,一般都是用JSX写react组件

在早期版本的react中,会转换成createElement的形式,在react v17后,生成_jsxs的形式,其实内涵都是一样的。 解析JSX中的 render 函数(对应类组件) 或 返回的字符串(对应函数组件),生成 虚拟DOM

可以看出,无论在react或vue中,我们在业务中定义的各种组件,都转换成了VNode的形式。形成了一颗虚拟DOM树,关联着真实的DOM结点树

当然,react中不仅有VNode,还有fiber结点的概念,我们这不展开来说了。

渲染器的作用

有了虚拟DOM结点树后,是如何映射到真实DOM树的呢,当数据状态改变的时候,如何及时更新DOM结点呢?这里我们就得引出渲染器的概念了。 渲染器,会接收传递来的VNode,对比新老结点,俗称打补丁(即:patch)的过程。

我们以Vue框架为例,在调用 createApp方法的时候,就会生成渲染器的实例。

js 复制代码
let renderer;
function ensureRenderer() {
    return (renderer ||
        (renderer = createRenderer({
            createElement,
            createText,
            setText,
            setElementText,
            patchProp,
            insert,
            remove,
        })));
}
const createApp = (...args) => {
    console.log('createApp: ', args);
    return ensureRenderer().createApp(...args);
};

在渲染器的内部,有个 patch方法,来对比新旧的VNode结点

js 复制代码
function patch(n1, n2, container = null, anchor = null, parentComponent = null) {
    const { type, shapeFlag } = n2;
    switch (type) {
        case Text:
            processText(n1, n2, container);
            break;
        case Fragment:
            processFragment(n1, n2, container);
            break;
        default:
            if (shapeFlag & 1) {
                console.log("处理 element");
                processElement(n1, n2, container, anchor, parentComponent);
            }
            else if (shapeFlag & 4) {
                console.log("处理 component");
                processComponent(n1, n2, container, parentComponent);
            }
    }
}

我们引入snabbdom库,来说明整个过程。

html 复制代码
<div id="app"></div>
<script type="module">
  // import { h, init } from 'https://cdn.jsdelivr.net/npm/snabbdom@3.5.1/+esm'
  import { h, init } from "./snabbdom@3.5.1.js";

  // 1. hello world
  // 参数:数组,模块
  // 返回值:patch函数,作用对比两个vnode的差异更新到真实DOM
  let patch = init([])
  // 第一个参数:标签+选择器
  // 第二个参数:如果是字符串的话就是标签中的内容
  let vnode = h('div#container', { }, 'hello VNode , ---old')

  let app = document.querySelector('#app')
  // 第一个参数:可以是DOM元素,内部会把DOM元素转换成VNode
  // 第二个参数:VNode
  // 返回值:VNde
  let oldVNode = patch(app, vnode); // 首次挂载
  console.log('oldVNode : ', oldVNode);

  setTimeout(() => {
    // 定义一个新的VNode
    vnode = h('div', 'Hello Virtual DOM  , --- new')

    // 在 3 秒后更新挂载到视图中
    const newVNode = patch(oldVNode, vnode)
    console.log('newVNode : ', newVNode);
  }, 3000);
</script>

可以看到渲染器主要有两个作用

  • 对比新旧的结点,寻找差异,打补丁
  • 将差异化的部分,跟新到真实的视图上

具体patch的实现细节,就是结点Diff,和增、删、改元素结点的内容了,大致的代码逻辑如下:

js 复制代码
// 相同的结点复用
if (sameVnode(oldVnode, vnode)) {
  patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
// 不同的结点,增加或删除
  elm = oldVnode.elm!;
  parent = api.parentNode(elm) as Node;

  createElm(vnode, insertedVnodeQueue);

  if (parent !== null) {
    api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
    removeVnodes(parent, [oldVnode], 0, 0);
  }
}

自定义渲染器

渲染器在更新Web视图的时候,会调用浏览器的DOM API,将数据的变化反映到界面上。

js 复制代码
interface DOMAPI {
  createElement: (
    tagName: any,
    options?: ElementCreationOptions
  ) => HTMLElement;
  /**
   * @experimental
   * @todo Make it required when the fragment is considered stable.
   */
  createDocumentFragment?: () => SnabbdomFragment;
  createTextNode: (text: string) => Text;
  createComment: (text: string) => Comment;
  insertBefore: (
    parentNode: Node,
    newNode: Node,
    referenceNode: Node | null
  ) => void;
  removeChild: (node: Node, child: Node) => void;
  appendChild: (node: Node, child: Node) => void;
  parentNode: (node: Node) => Node | null;
  nextSibling: (node: Node) => Node | null;
  tagName: (elm: Element) => string;
  setTextContent: (node: Node, text: string | null) => void;
  getTextContent: (node: Node) => string | null;
  isElement: (node: Node) => node is Element;
  isText: (node: Node) => node is Text;
  isComment: (node: Node) => node is Comment;
  /**
   * @experimental
   * @todo Make it required when the fragment is considered stable.
   */
  isDocumentFragment?: (node: Node) => node is DocumentFragment;
}

因为有了 VNode 这一个中间的抽象层,我们可以实现自定义的渲染器。通过改造内部的API调用,比如调用原生Native API,来操纵原生的视图。 ReactNative框架就是这样的原理。

js 复制代码
function createElement(
  tagName: any,
  options?: ElementCreationOptions
): HTMLElement {

  if (tagName === 'view') {
    const boxEle = document.createElement('div');
    boxEle.style.backgroundColor = 'gray';
    boxEle.style.borderRadius = '20px';
    boxEle.style.width = '200px';
    boxEle.style.width = '100px';
    return boxEle;
  }
  return document.createElement(tagName, options);
}

调用的时候,使用自定义的标签 view

js 复制代码
// import { h, init } from 'https://cdn.jsdelivr.net/npm/snabbdom@3.5.1/+esm'
import { h, init } from "./snabbdom@3.5.1.js";

let patch = init([])
let vnode = h('view', { }, '自定义的元素')

let app = document.getElementById('app')
let oldVNode = patch(app, vnode); // 首次挂载
console.log('oldVNode : ', oldVNode);

在移动端,如果能够通过 hybrid的方式,调用原生的视图组件,就能够生成媲美原生体验的UI。

虚拟DOM的延展

了解了虚拟DOM,知道了渲染器后,其实这只是很小的一部分。渲染器要处理的工作很多,包括:

  • 控制部分组件生命周期钩子hooks(beforeCreate、created等)的调用 在整个渲染周期中包含了大量的 DOM 操作、组件的挂载、卸载,控制着组件的生命周期钩子调用的时机。

  • 多端渲染的桥梁 渲染器也是多端渲染的桥梁,自定义渲染器的本质就是把特定平台操作"DOM"的方法从核心算法中抽离,并提供一套API规范,以便各个平台实现。

  • 与异步渲染有直接关系 Vue3 的异步渲染是基于调度器的实现,若要实现异步渲染,组件的挂载就不能同步进行,DOM的变更就要在合适的时机,一些需要在真实DOM存在之后才能执行的操作(如 ref)也应该在合适的时机进行。对于时机的控制是由调度器来完成的,但类似于组件的挂载与卸载以及操作 DOM 等行为的入队还是由渲染器来完成的,这也是为什么 Vue2 无法轻易实现异步渲染的原因。

  • 包含最核心的 Diff 算法 Diff 算法是渲染器的核心特性之一,可以说正是 Diff 算法的存在才使得 Virtual DOM 如此成功。

参考链接

github.com/snabbdom/sn...

相关推荐
多客:duokelijie4 小时前
代练订单系统选型思路:跨品类游戏接入、订单分层管理、自动结算逻辑拆解!
数据库·mysql·微信小程序·前端框架·uni-app·php·持续部署
ruanno_top7 小时前
倒计时该用 deadline 还是每秒减一?我把两种写法都跑了一遍
vue.js
徐小夕11 小时前
下载量超 30 万的开源智能体 Prime Agent:它把自己从 TypeScript 重写成了 Rust
前端·vue.js·github
toooooop812 小时前
CRMEB 故障排查笔记:安全网关只放行 GET/POST 怎么办
前端·javascript·vue.js·thinkphp6·crmeb
软件技术NINI12 小时前
基于SpringBoot和Vue的项目评审系统
javascript·vue.js·spring boot·后端·spring·毕业设计·毕设
FRing031 天前
vue2干货总结3(computed计算属性、watch监听器)
开发语言·前端·vue.js·vue2
Java小卷1 天前
JuanForm vs FormCreate!Vue3 表单引擎联动配置 PK!
vue.js·低代码
Highcharts.js1 天前
React 中原生支持使用 JSX 组件配置自定义表格 Highcharts Grid
前端·javascript·react.js·前端框架·jsx·highcharts·自定义表格
小郎君6581 天前
Vue 多标签页的加载态设计:为什么我没有用 Suspense 实现 PageLoading
vue.js·算法
yilaisai1 天前
前端开发遇到的一些细节问题整理
vue.js