incremental-dom简析
想必对于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使用节点函数elementOpen
、elementClose
和text
来描述。
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就是静态数组。
赋予样式
使用字符串或对象均可以设置一个元素的样式。当使用对象设置样式时,它的键名格式应为驼峰式。
-
作为字符串:
elementOpen('div', null, null, 'style', 'color: white; background-color: red;'); … elementClose('div');
-
作为对象:
elementOpen('div', null, null, 'style', { color: 'white', backgroundColor: 'red' }); … elementClose('div');
条件渲染
-
if/else
function renderGreeting(date, name) { if (date.getHours() < 12) { elementOpen('strong'); text('Good morning, '); elementClose('strong'); } else { text('Hello '); } text(name); }
-
DOM元素更新/复用
if (condition) { elementOpen('div'); // subtree 'A' elementClose('div'); } elementOpen('div'); // subtree 'B' elementClose('div');
-
逻辑化的特性
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.nodesCreated
和notifications.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相比有如下两个优点:
- 逐个操作(incremental nature)使其在渲染过程中可以有效地减少内存占用,并且具有更加可预测的性能,更适合移动端场景。
- 更容易映射到模板上。可以轻松的在控制与循环语句混入元素与特性声明。
idom是一个小巧的(2.6kB min+gzip)、独立并且灵活的库。用它可以渲染出DOM节点并且设置特性/属性,至于如何组织视图等剩余的工就取决于用户了。比如说,一个Backbone应用可以在传统的模板与手动更新的基础上使用idom来渲染与更新DOM。
例子
这里是一个简单的使用idom和markdown的例子
原理
API
idom所提供的API主要可以分为对元素
和对指针
的操作。
对元素的操作
- 使用
elementOpen
、elementClose
和elementClose
等函数指定所操作的元素(渲染&更新),自动移动指针到该元素内 - 使用
attr
、text
和key
等修改元素的特性、属性或内容 - 使用
patch
函数在指定元素上执行传入的更新函数
对指针的操作
- 使用
currentElement
获取当前打开的元素,使用currentPointer
获取当前idom指向的位置 - 使用
skip
将指针移动到当前打开元素的末尾,使用skipNode
向后跳过一个节点
diff方法
idom所提供的diff方法比较的是键值对数组。在下面的代码中可以看到:
prev
和next
表示更新前与更新后的键值对
,注意是字符串数组类型。{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;
});
...
简析它的过程:
- 首先,使用了
patchFactory
这个工厂函数进行构建,在这个函数中主要进行了一些数据初始化,如上下文、文档、元素路径、父元素等等。 - 使用
enterNode
修改currentParent
为currentNode
,将currentNode
置为null。 - 使用传入的函数及数据进行更新操作。
- 使用
exitNode
清空当前范围的未访问节点,重置currentNode
和currentParent
属性。
在第三步的更新函数中,一般会使用text
、elementOpen
等会在真实DOM上进行修改操作的函数。
模板
可以参考官方的ecosystem
参考
- 原文作者:yrq110
- 原文链接:http://yrq110.me/post/front-end/brief-of-incremental-dom/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。