你了解render函数吗?一文带你理解虚拟dom实现原理

1,017 阅读7分钟

使用vnode的优点

  1. 手动操作dom比较复杂,还需要考虑浏览器的兼容性问题,虽然有jquery等库简化dom操作,但是随着项目越来越复杂,dom操作复杂提升
  2. Virtual DOM的好处是当状态改变时不需要立即更新dom,只需要创建一个虚拟树来描述dom,Virtual DOM内部将弄清楚如何有效(diff)的更新dom。

Snabbdom基本用法

为什么选择snabbdom呢,因为Snabbdom对于vnode的实现的非常精简,源代码实现不到200行,比较有利于读者对源码的理解和解析。同时 VueJS 的 virtual dom 部分基于snabbdom改造,理解了snabbdom有利于理解VueJS。

安装snabbdom

  • 安装snabbdom yarn add snabbdom

导入 snabbdom

  • snabbdom的官网demo中导入使用的是commonjs模块化语法,我们使用更流行的es6模块化的语法 import
  • 关于模块化的语法请参考阮一峰老师的Modules的语法
  • ES6模块与CommonJS模块的差异

最简单的入门示例:

import { init, h, thunk} from 'snabbdom'

let patch = init([])
let vnode = h('div#container', [
    h('h1', 'Hello Snabbdom'),
    h('p', '这是一个p标签'),
]);  

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

模块

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

常用模块

官方提供了6个模块

1、 attribute

  • 设置dom元素的属性,使用setAttribute()
  • 处理布尔类型的属性

2、 props

  • 和attribute模块相似,设置dom属性element[attr] = value
  • 不处理布尔类型的属性

3、 class

  • 切换类样式
  • 注意:给元素设置类样式是通过sel选择器

4、 dataset

  • 设置'data-*'的自定义属性

5、 eventlisteners

  • 注册和移除事件

6、 style

  • 设置行内样式,支持动画
  • delayed/remove/destory

模块使用

导入模块 init()注册模块 使用h()函数创建vnode的时候,可以把第二个参数设置为对象,其他参数往后移,如:

import { h, init }  from 'snabbdom';
import style from 'snabbdom/modules/style';
import eventlisteners from 'snabbdom/modules/eventlisteners';

let patch = init([style, eventlisteners])
let vnode = h('div', {
    style: {
        backgroundColor: 'red',
        fontSize: '14px',
    },
    on: {
        click: eventHandler
    }
}, [
    h('h1', 'hello snabbdom'),
    h('p', '这是p标签'),
])

function eventHandler(){
    console.log('点击我了')
}
let app = document.querySelector('#app');
patch(app, vnode);

snabbdom源码

在这里分享阅读源码时的几个方法:

  • 先宏观了解组件实现的功能以及用法
  • 将目标分解,带如特定的功能去看源码
  • 看源码的过程不求甚解(先把主线逻辑走通,排除其他分支干扰)
  • 编写示例子,进行断点调试

snabbdom的核心

所以学习源码的第一步我们需要先从整体上了解snabbdom这个组件实现怎样的功能。我们从上文已经介绍了snabbdom的基本用法与模块的用法。可以将snabbdom核心总结为一下几点:

  • 从snabbdom库中导入h函数,和init函数
  • 使用h函数创建JavaScript对象(vnode)描述真实dom。
  • init() 设置模块,创建patch()。
  • patch()比较新旧两个vnode。
  • 把变化的内容更新到真实的dom上。

snabbdom源码

接下来需要将源码clone下来,了解源码目录。

此次分析的源码版本是v0.7.4

── h.ts   创建vnode的函数
── helpers
 └── attachto.ts
── hooks.ts  定义钩子
── htmldomapi.ts   操作dom的一些工具类
── is.ts   判断类型
── modules  模块
 ├── attributes.ts
 ├── class.ts
 ├── dataset.ts
 ├── eventlisteners.ts
 ├── hero.ts
 ├── module.ts
 ├── props.ts
 └── style.ts
── snabbdom.bundle.ts 入口文件
── snabbdom.ts  初始化函数
── thunk.ts  分块
── tovnode.ts   dom元素转vnode
── vnode.ts  虚拟节点对象

分析源码实现

一般做法是从入口函数开始解析主线逻辑,所以可以从h函数开始解析。

1、h()

h函数创建虚拟节点(vnodes),函数接收一个字符串形式的标签/选择器、一个可选的数据对象、一个可选的字符串或数组作为子代。

函数重载:

  • 参数个数或类型不同的函数
  • javascript中没有重载的概念
  • typescript中与重载,不过重载的实现还是通过代码调整参数

在ts中通过调整代码实现了函数重载,通过调用vnode函数传入不同的参数值和参数类型,返回一个vnode节点。

// h 函数的重载
export function h(sel: string): VNode;
export function h(sel: string, data: VNodeData): VNode;
export function h(sel: string, children: VNodeChildren): VNode;
export function h(sel: string, data: VNodeData, children: VNodeChildren): VNode;
export function h(sel: any, b?: any, c?: any): VNode {
  var data: VNodeData = {}, children: any, text: any, i: number;
  // 处理参数,实现重载的机制
  if (c !== undefined) {  // 处理三个参数的情况:sel,data,children/text
    data = b;
    if (is.array(c)) { children = c; }         // 如果c是数组,c代表了当前节点的子节点
    else if (is.primitive(c)) { text = c; }    // 如果c是字符串或数字,代表了文本值
    else if (c && c.sel) { children = [c]; }   // 如果c是vnode, 会被处理成数组
  } else if (b !== undefined) {              //处理两个参数的情况
    if (is.array(b)) { children = b; }       // 如果b是数组,b代表了当前节点的子节点
    else if (is.primitive(b)) { text = b; }  // 如果b是字符串或数字,代表了文本值
    else if (b && b.sel) { children = [b]; } // 如果b是vnode, 会被处理成数组
    else { data = b; }                       // 否则,b作为data
  }
  if (children !== undefined) {
    for (i = 0; i < children.length; ++i) {
      if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined);
    }
  }
  if (
    sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
    (sel.length === 3 || sel[3] === '.' || sel[3] === '#')
  ) {       //  处理选择器是svg的情况
    addNS(data, children, sel);   // 给节点和节点的子节点递归添加命名空间
  }
  return vnode(sel, data, children, text, undefined);  // 调用vnode()函数返回一个vnode
};
export default h;  // 将h函数导出为默认模块

2、vnode()

vnode定义了一种vnode数据对象,作为对真实dom的描述。 vnode = vnode(sel,data,children,text,elm)

export function vnode(sel: string | undefined,
                      data: any | undefined,
                      children: Array<VNode | string> | undefined,
                      text: string | undefined,
                      elm: Element | Text | undefined): VNode {
  let key = data === undefined ? undefined : data.key;
  return {sel, data, children, text, elm, key};
}
vnode属性
  • sel: 选择器
  • data: 模块数据
  • children: 子节点
  • text: 记录vnode对应的真实dom
  • elm: 和children互斥
  • key: 优化用

3、init()

init接收一个模块列表,并返回一个使用指定模块集的patch函数

用法:patch() = init(modules: Array<Partial<Module>>, domApi?: DOMAPI);

高阶函数:一个函数里面返回另一个函数 高阶函数的作用: 形成闭包:内部函数可以访问外部函数upvalue, patch()可以访问到modules和domAp等外部函数参数;

init()实现过程:

  1. domapi给定默认值
  2. 把传入的所有模块的钩子函数,统一存储到cbs对象中
  3. init 内部返回 patch, 把vnode渲染成真实dom,并返回vnode。

代码实现:

export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
  let i: number, j: number, cbs = ({} as ModuleHooks);
  // 初始化转换虚拟节点的api
  const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;
  
  // 把传入的所有模块的钩子函数,统一存储到cbs对象中
  // 最终构建的cbs对象形式 cbs={creat:[fn1,fn2], update:[], ...}
  for (i = 0; i < hooks.length; ++i) {
    // cbs.create = [], cbs.update=[]...
    cbs[hooks[i]] = [];
    for (j = 0; j < modules.length; ++j) {
      // modules 传入的模块数组
      // 获取模块中的hook函数
      // hook= modules[0][create]...
      const hook = modules[j][hooks[i]];
      if (hook !== undefined) {
        (cbs[hooks[i]] as Array<any>).push(hook);
      }
    }
  }
  ... // 一系列辅助函数

  // init 内部返回 patch, 把vnode渲染成真实dom,并返回vnode
  return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
     ... // patch内部函数实现
  }

4、patch()

init返回的patch函数需要接受两个参数。第一个是表示当前视图的DOM元素或vnode。第二个是表示更新后的新视图的vnode。

用法:patch(oldVnode, newVnode) 作用:打补丁,把新节点中变化的内容渲染到真实的dom,最后返回新节点作为下一次处理的旧节点。 实现过程:

  • 对比新旧节点是否相同节点(节点的key和sel相同)
  • 如果不是相同节点,删除之前的内容,重新渲染
  • 如果是相同的节点,在判断新的vnode是否有text,如果有并且和oldVnode的text不同,直接更新文本
  • 如果新的vnode有children,判断子节点是否有变化,判断子节点的过程使用的就是diff算法
  • diff的过程只进行同层比较。

代码实现:

return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
   let i: number, elm: Node, parent: Node;

   // 保存新插入节点的队列,为了触发钩子函数
   const insertedVnodeQueue: VNodeQueue = [];

   // 执行模块中pre函数
   for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();

   // 如果oldVnode不是vnode,创建vnode并设置elm
   if (!isVnode(oldVnode)) {
   // 把dom元素转换成空的 vnode
   oldVnode = emptyNodeAt(oldVnode);
   }

   // 如果新旧节点相同
   if (sameVnode(oldVnode, vnode)) {
   // 找节点差异并更新dom
   patchVnode(oldVnode, vnode, insertedVnodeQueue);
   } else {
   // 如果节点不同,vnode创建对应的dom
   // 获取当前节点的dom元素
   elm = oldVnode.elm as Node;
   parent = api.parentNode(elm);

   // 创建vnode对应的dom元素,并触发init/create 钩子函数
   createElm(vnode, insertedVnodeQueue);

   if (parent !== null) {
      // 如果父节点不为空,把vnode对应的dom插入到文档中
      api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm));
      // 移除老节点
      removeVnodes(parent, [oldVnode], 0, 0);
   }
   }

   // 执行用户设置的insert钩子函数
   for (i = 0; i < insertedVnodeQueue.length; ++i) {
   (((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);
   }
   // 执行模块的post函数
   for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
   // 返回vnode
   return vnode;
};

总结

针对snabbdom源码,笔者对于真实dom到虚拟dom的相互转化的过程整理总结成脑图。希望对于读者理解snabbdom的实现有一点帮助。