Virtual DOM的实现原理

454 阅读3分钟

一、Virtual DOM介绍

1. 什么是Virtual DOM

  • Virtual DOM(虚拟DOM),是由普通的JS对象描述DOM对象,因为不是真实的DOM对象,所以叫做Virtual DOM
  • 真实的DOM成员 非常非常多,所以创建一个DOM对象的成本非常高
  • 可以通过Virtual DOM来描述真实DOM,示例:
{
  sel: 'div',
  data: {},
  text: 'Hello Virtual DOM',
  elm: undefined,
  key: undefined
}
  • Virtual DOM对象是非常小的,我们创建一个Virtual DOM的成本比创建一个真实DOM的成本小很多

2. 为什么使用Virtual DOM

  • 手动操作DOM比较麻烦,还需要考虑浏览器兼容性问题,虽然有jQuery等库简化DOM操作,但是随着项目的复杂 DOM操作复杂提升
  • 为了简化DOM的复杂操作于是出现了各种MVVM框架,MVVM 框架解决了视图和状态的同步问题
  • 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题,于是Virtual DOM出现了 Virtual DOM的好处是当状态改变时不需要立即更新DOM,只需要创建一个虚拟树来描述DOM, Virtual DOM内部 将弄清楚如何有效(diff)的更新DOM
  • 参考github上virtual-dom的描述 虚拟DOM可以维护程序的状态,跟踪上一次的状态 通过比较前后两次状态的差异更新真实DOM

3. 虚拟DOM的作用

  • 维护视图和状态的关系
  • 复杂视图情况下提升渲染性能
  • 除了渲染DOM以外,还可以实现SSR(Nuxt.js/Next.js)、原生应用(Weex/React Native )、小程序(mpvue/uni-app)等

4. Virtual DOM库

  • Snabbdom

    • Vue2.x内部使用的Virtual DOM 就是改造的Snabbdom
    • 大约200SLOC(Single Line Of Code)
    • 通过模块可扩展
    • 源码使用TypeScript开发
    • 最快的Virtual DOM之一
  • Virtual DOM

二、Snabbdom基本使用

1. 创建项目

    1. 打包工具为了方便使用Parcel
    1. 创建项目,并安装Parcel
mkdir snabbdom-demo
# 进入项目目录
cd snabbdom-demo
# 创建package.json
yarn init -y
# 本地安装Parcel
yarn add parcel-bundler
# 安装snabbdom@2.1.0
yarn add snabbdom@2.1.0

- 3. 配置package.json的script

"scripts": {
  "dev": "parcel index.html --open",
  "build": "parcel build index.html"
}

    1. 创建目录结构
|-index.html
|-package.json
|-src
   |-01.basicusage.js

2. 导入Snabbdom

  • Snabbdom的官网demo中导入使用的是CommonJS模块化语法,我们使用的是更流行的ES6模块化的语法import

  • 关于模块化的语法可以参考阮一峰老师的《Module的语法》

  • ES6模块化与CommonJS模块的差异

  • import { init } from 'snabbdom/build/package/init'

  • import { h } from 'snabbdom/build/package/h'

  • Snabbdom的核心仅提供最基本的功能,只导出了三个函数init()、h()、thunk()

  • init()是一个高阶函数,返回patch()

  • h()返回虚拟节点VNode,这个函数我们在使用Vue.js的时候用过

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')
  • thunk()是一种优化策略,可以在处理不可变数据时使用

  • 注意:导入时候不能使用import snabbdom from 'snabbdom' 原因:node_modules/src/snabbdom.ts末尾导出使用的语法是export导出API,没有使用export default导出默认导出

3. Snabbdom的基本用法

import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'

const patch = init([])

第一个参数: 标签+选择器 第二个参数: 如果是字符串就是标签中的文本内容

let vnode = h('div#container.cls', 'Hello World')
let app = document.querySelector('#app')

第一个参数:旧的VNode, 可以是DOM 元素 第二个参数:新的VNode 返回新的VNode

let oldVnode = patch(app, vnode)

vnode = h('div#container.xxx', 'Hello Snabbdom')
patch(oldVnode, vnode)


import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'

const patch = init([])

let vnode = h('div#container', [
  h('h1', 'Hello World'),
  h('p', '这是一个p')
])

let app = document.querySelector('#app')
let oldVonde = patch(app, vnode)

setTimeout(() => {
  // vnode = h('div#containwe', [
  //   h('h1', 'Hello Snabbdom'),
  //   h('p', 'Hello p')
  // ])
  // patch(oldVonde, vnode)
  // 清楚div中的内容
  patch(oldVonde, h('!'))
}, 2000)


import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'

1.导入模块

import { styleModule } from 'snabbdom/build/package/modules/style'
import { eventListenersModule } from 'snabbdom/build/package/modules/eventlisteners'
  1. 注册模块
const patch = init([
  styleModule,
  eventListenersModule
])

3.使用h() 函数的第二个参数传入模块中使用的数据(对象)

let vnode = h('div', [
  h('h1', { style: { backgroundColor:'red'}}, 'Hello World'),
  h('p', { on: { click: enentHandle }}, 'Hell P')
])

function enentHandle() {
  console.log('别点我, 疼')
}

let app = document.querySelector('#app')
patch(app, vnode)

4. Snabbdom中的模块

Snabbdom的核心库并不能处理元素的属性/样式/事件等,如果需要处理的话,可以使用模块

  1. 官方提供了6个模块

      1. attributes 设置了DOM元素的属性,使用setAttribute() 处理布尔类型的属性
      1. props 和attributes模块相似,设置DOM元素的属性element[attr] = value 不处理布尔类型的属性
      1. class 切换类样式 注意:给元素设置类样式是通过set选择器
      1. dataset 设置data-*的自定义属性
    • 5.eventlisteners 注册和移除事件
      1. style设置行内样式,支持动画 delayed/remove/destroy
    1. 模块使用

      1. 导入需要的模块
      1. init()中注册模块
      1. 使用h()函数创建VNode的时候,可以把第二个参数设置为对象,其他参数往后移
  import { init } from 'snabbdom/build/package/init'
  import { h } from 'snabbdom/build/package/h'

  // 1.导入模块
  import { styleModule } from 'snabbdom/build/package/modules/style'
  import { eventListenersModule } from 'snabbdom/build/package/modules/eventlisteners'
  // 2. 注册模块
  const patch = init([
    styleModule,
    eventListenersModule
  ])
  // 3.使用h() 函数的第二个参数传入模块中使用的数据(对象)
  let vnode = h('div', [
    h('h1', { style: { backgroundColor:'red'}}, 'Hello World'),
    h('p', { on: { click: enentHandle }}, 'Hell P')
  ])

  function enentHandle() {
    console.log('别点我, 疼')
  }

  let app = document.querySelector('#app')
  patch(app, vnode)

5. Snabbdom源码解析

    1. 如何学习源码

    • 先宏观了解
    • 带着目标看源码
    • 看源码的过程要不求甚解
    • 调试
    • 参考资料
    1. Snabbdom的核心

    • 使用h()函数创建JavaScript对象(VNode)描述真实DOM
    • init设置模块,创建patch()
    • patch()比较新旧两个VNode
    • 把变化的内容更新到真实DOM树上
    1. Snabbdom源码

h函数

  • h函数介绍

    • 在使用Vue的时候见过h函数 new Vue({ router, store, render: h => h(App) }).$mount('#app')
    • h函数最早见于hyperscript,使用JavaScript创建超文本
    • Snabbdom中的h函数不是用来创建超文本,而是创建VNode
  • 函数重载

    • 概念

      • 参数个数或者类型不同的函数
      • JavaScript中没有重载的概念
      • TypeScript中有从在,不过重载的实现还是通过代码调整参数
    • 重载的示意

function add (a, b) {
  console.log(a + b)
}

function add (a, b, c) {
  console.log(a + b + c)
}

add(1, 2)
add(1, 2, 3)

patch函数

  • patch(oldVnode, newVnode)
  • 打补丁,把新节点中变化的内容渲染到真实DOM,最后返回新节点作为下一次处理的旧节点
  • 对比新旧VNode是否相同节点(节点的key和sel相同)
  • 如果不是相同节点,删除之前的内容,重新渲染
  • 如果是相同节点,再判断新的VNode是否有text, 如果有并且和oldVnode的text不同,直接更新文本内容
  • 如果新的VNode有children,判断子节点是否有变化,判断子节点的过程使用的就是diff算法
  • diff 过程只进行同层级比较