想必对于virtual-dom(以下简称vdom)已经耳熟能详了,react和vue均使用了vdom,在更新与DOM时具有效率高、速度快的特点(相比于直接操作dom)。那么incremental-dom又是什么呢?

incremental-dom(以下简称idom)的一些特点:

  • idom是用于表现更新DOM的库,由google开发。
  • idom与vdom相比最大的区别是,它不会构建作为中间层的虚拟dom树。当数据变化时,diff操作会在真实DOM上逐节点执行,而不是在虚拟DOM树之间,内存占用更少。
  • idom的目标并不是直接使用的,而是为更高层的库或框架所提供。

基础

介绍一下idom的基本使用,主要来源于官方文档

渲染DOM

所渲染的DOM使用节点函数elementOpenelementClosetext来描述。

function renderPart() {
  elementOpen('div');
    text('Hello world');
  elementClose('div');
}

会被渲染成

<div>
  Hello world
</div>

patch函数中使用上的renderPart函数可以在已存在的元素(Element)或文档(Document,包含ShadowDOM)上更新期望的节点。调用patch函数会在DOM树上根据所需的节点变动、更新属性和创建/移除进行局部更新。

patch(document.getElementById('someId'), renderPart);

特性和属性的设置

除了创建DOM节点外,有时还需要在元素上添加/删除特性(attribute)和属性(property)。它们被指定为可变的参数,通过attribute/property键值对来改变。传入的对象与函数类型会被设为属性,其他类型会被设为特性。

ps: attribute是html标签上的特性,只能是字符串。property是DOM上的属性,是JS对象。

属性设置的一个用法是储存一个事件代理的回调函数。当你可以在DOM节点上分配任何属性时,甚至可以分配一些on*事件的处理器,比如onClick。

elementOpen('div', null, null,
    'class', 'someClass',
    'onclick', someFunction);
  
elementClose('div');

静态数组

很多时候DOM节点的一些属性是不会改变的,比如<input type="text">的type特性。idom提供了一个shortcut来避免已知不变的特性/属性比较。

elementOpen的第三个参数表示不会改变的特性数组,为了避免每次参数传入都分配一个数组,可以在闭包外声明数组,使其仅执行一次。

在提供静态数组的同时还需要提供key,这确保了idom永远不会重用那些有着相同标签但不同静态数组的元素。

function render() {
  const s1 = [ 'type', 'text', 'placeholder', '…'];

  return function(isDisabled) {
    elementOpen('input', '1', s1,
        'disabled', isDisabled);
    elementClose('input');
  };
}

在上面的代码中,1就是key,s1就是静态数组。

赋予样式

使用字符串或对象均可以设置一个元素的样式。当使用对象设置样式时,它的键名格式应为驼峰式。

  1. 作为字符串:

    elementOpen('div', null, null,
        'style', 'color: white; background-color: red;');
          
    elementClose('div');
    
  2. 作为对象:

    elementOpen('div', null, null,
        'style', {
          color: 'white',
          backgroundColor: 'red'
        });
      
    elementClose('div');
    

条件渲染

  1. if/else

    function renderGreeting(date, name) {
      if (date.getHours() < 12) {
        elementOpen('strong');
        text('Good morning, ');
        elementClose('strong');
      } else {
        text('Hello ');
      }
    
      text(name);
    }
    
  2. DOM元素更新/复用

    if (condition) {
      elementOpen('div');
        // subtree 'A'
      elementClose('div');
    }
    
    elementOpen('div');
      // subtree 'B'
    elementClose('div');
    
  3. 逻辑化的特性

    elementOpenStart('div');
      for (const key in obj) {
        attr(key, obj[key]);
      }
    elementOpenEnd('div');
    

钩子

值的设置

idom提供了用来自定义如何处理传入值的钩子。attributes对象允许你提供一个函数来决定:当一个特性传入到elementOpen或其他函数中时要做的事情。下面的例子中使得idom总会将value作为属性(property)来设置。

import {
  attributes,
  applyProp,
  applyAttr
} from 'incremental-dom';

attributes.value = applyProp;

若想更一步的控制设置值的方式,可以设置自定义执行更新的函数:

attributes.value = function(element, name, value) {
  
};

若未给键名指定函数,那么一个默认函数会被用来执行那些在属性和特性中的值,这个函数可以通过给symbols.default指定函数来修改。

import {
  attributes,
  symbols
} from 'incremental-dom';

attributes[symbols.default] = someFunction;

添加/移除节点

通过指定notifications.nodesCreatednotifications.nodesDeleted上的函数来让idom在节点被添加和移除时发出通知。若在patch操作的过程中添加或移除节点,则会在patch添加或移除节点的操作完成后调用对应的钩子函数。

import { notifications } from 'incremental-dom';

notifications.nodesCreated = function(nodes) {
  nodes.forEach(function(node) {
    // node may be an Element or a Text
  });
};

优点

idom跟vdom相比有如下两个优点:

  1. 逐个操作(incremental nature)使其在渲染过程中可以有效地减少内存占用,并且具有更加可预测的性能,更适合移动端场景
  2. 更容易映射到模板上。可以轻松的在控制与循环语句混入元素与特性声明。

idom是一个小巧的(2.6kB min+gzip)、独立并且灵活的库。用它可以渲染出DOM节点并且设置特性/属性,至于如何组织视图等剩余的工就取决于用户了。比如说,一个Backbone应用可以在传统的模板与手动更新的基础上使用idom来渲染与更新DOM。

例子

这里是一个简单的使用idom和markdown的例子

原理

API

idom所提供的API主要可以分为对元素和对指针的操作。

对元素的操作

  1. 使用elementOpenelementCloseelementClose等函数指定所操作的元素(渲染&更新),自动移动指针到该元素内
  2. 使用attrtextkey等修改元素的特性、属性或内容
  3. 使用patch函数在指定元素上执行传入的更新函数

对指针的操作

  1. 使用currentElement获取当前打开的元素,使用currentPointer获取当前idom指向的位置
  2. 使用skip将指针移动到当前打开元素的末尾,使用skipNode向后跳过一个节点

diff方法

idom所提供的diff方法比较的是键值对数组。在下面的代码中可以看到:

prevnext表示更新前与更新后的键值对,注意是字符串数组类型。{key1: value1, key2: value2}对象对应的是形如: ['key1', 'value1', 'key2', 'value2']的数组,偶数索引为键,奇数索引为值。

src/diff.ts

...
function calculateDiff<T>(
  // diff时传入的参数,其中更新的上下文为泛型T,在外部指定类型
    prev: string[], next: string[], updateCtx: T,
    updateFn: (ctx: T, x: string, y: {}|undefined) => undefined) {
  
  // 1.首先判断是否为新添加的数据
  const isNew = !prev.length;
  let i = 0;

  // 2. 遍历更新后的键值对
  for (; i < next.length; i += 2) {
    // 2.1 比较是否有不同的键名
    const name = next[i];
    if (isNew) {
      // 更新prev
      prev[i] = name;
    } else if (prev[i] !== name) {
      // 一旦遇到不同的键名,则终止循环
      break;
    }
    // 2.2 比较值
    const value = next[i + 1];
    if (isNew || prev[i + 1] !== value) {
      // 若为新数据或对应索引的值不同,则更新prev并执行更新函数
      prev[i + 1] = value;
      updateFn(updateCtx, name, value);
    }
  }

  // 当更新前与更新后的键名及顺序完全相同或更新前数据为空,则不会进行下面这步

  // 3. 键值对中项的排列顺序可能与之前的并不完全一样,需要确保旧的项被移除,这种情况比较少见,比如
  // pre:  ['key1', 'value1', 'key2', 'value2', 'key4', 'value4', 'key3', 'value3']
  // next: ['key1', 'value1', 'key3', 'value3', 'key2', 'value2']
  if (i < next.length || i < prev.length) {
    const startIndex = i;
    // 3.1 暂存剩余的prev键值对
    for (i = startIndex; i < prev.length; i += 2) {
      prevValuesMap[prev[i]] = prev[i + 1];
    }
    // 3.2 遍历next键值对
    for (i = startIndex; i < next.length; i += 2) {
      const name = (next[i]) as string;
      const value = next[i + 1];
      // 若对应prev键名的值与next值不同,则执行更新函数
      if (prevValuesMap[name] !== value) {
        updateFn(updateCtx, name, value);
      }
      // 更新prev
      prev[i] = name;
      prev[i + 1] = value;
      // 删除prevValuesMap对象中已比对的键值对
      delete prevValuesMap[name];
    }

    // 4. 进行去尾操作,删除超过next长度的项,即已不存在的项
    truncateArray(prev, next.length);

    // 5. 若prev中存在的值在next中已不存在,则传入undefined执行更新函数
    for (const name in prevValuesMap) {
      updateFn(updateCtx, name, undefined);
      delete prevValuesMap[name];
    }
  }
}
...

该方法的变体同样用在了elementOpen函数中的attribute diff操作:

src/virtual_elements

...
const elementOpen = function(tag, key, statics, var_args) {
  ...
  for (; i < arguments.length; i += 2, j += 2) {
    const attr = arguments[i];
    if (isNew) {
      attrsArr[j] = attr;
      newAttrs[attr] = undefined;
    } else if (attrsArr[j] !== attr) {
      break;
    }

    const value = arguments[i + 1];
    if (isNew || attrsArr[j + 1] !== value) {
      attrsArr[j + 1] = value;
      updateAttribute(node, attr, value);
    }
  }

  if (i < arguments.length || j < attrsArr.length) {
    ...
  }

  return node;
};

patch方法

在自定义更新指定元素时,最关键的无疑是patch函数,那么idom的patch函数做了什么呢,如下所示:

src/core.ts

...
const patchInner = patchFactory((node, fn, data) => {
  currentNode = node;

  enterNode();
  fn(data);
  exitNode();
  ...
  return node;
});
...

简析它的过程:

  1. 首先,使用了patchFactory这个工厂函数进行构建,在这个函数中主要进行了一些数据初始化,如上下文、文档、元素路径、父元素等等。
  2. 使用enterNode修改currentParentcurrentNode,将currentNode置为null。
  3. 使用传入的函数及数据进行更新操作。
  4. 使用exitNode清空当前范围的未访问节点,重置currentNodecurrentParent属性。

在第三步的更新函数中,一般会使用textelementOpen等会在真实DOM上进行修改操作的函数。

模板

可以参考官方的ecosystem

参考