Snabbdom 官方demo reorder-animation解说

1,531 阅读6分钟

引言

snabbdom是目前最流行的virtual dom库之一。

它的github仓库有关于它的一切。

本篇博客介绍Snabbdom的一个官方demo => reorder-animation,并解说其基本实现,以方便理解Snabbdom基本用法。

废话不多说直接上图:

reorder-animation功能

这个demo演示了snabbdom对于列表渲染、列表项增删和排序的操作。

这个demo在哪里?

demo目录

进入snabbdom的github仓库,clone到本地。在/examples/reorder-animation目录下找到它。文件夹下script.js为demo源码,build.js为打包后代码。将index.html文件06行 代码替换为:

<script type="text/javascript" src="script.js"></script>

使用打包工具parcel启动index.html

parcel index.html --open

代码结构

index.html文件中指定了挂在的元素节点div#container,引入了js文件,以及页面相关的css代码。所有视图和功能的实现均位于script.js

<body>
    <div id="container"></div>
</body>

1. 引入

script.js首先引入了snabbdom,并使用snabbdom.init()初始化了patch方法。init方法接受了一个Array类型的参数,注入了classpropsstyleeventlisteners模块,h方法用于生成虚拟DOM(VNode)。

var snabbdom = require('../../snabbdom.js');
var patch = snabbdom.init([
  require('../../modules/class').default,
  require('../../modules/props').default,
  require('../../modules/style').default,
  require('../../modules/eventlisteners').default,
]);
var h = require('../../h.js').default;

var vnode;

其中,patch方法把新节点中变化的内容渲染到真实DOM,返回当前的虚拟DOM对象(VNode)。patch(oldVnode, newVnode)方法接受两个参数,分别为旧节点和新VNode(也可以为DOM对象)。patch方法首先会对比新旧VNode是否位于相同的节点(节点keysel属性相同)。以下展示源码vnode.d.ts中对VNode接口的声明:

export interface VNode {
    sel: string | undefined; // 选择器
    data: VNodeData | undefined;
    children: Array<VNode | string> | undefined;
    elm: Node | undefined; // 元素
    text: string | undefined;
    key: Key | undefined;
}

如果不是相同的节点,会删除之前的内容,重新渲染。如果是相同的节点,patch会再判断新的VNode是否text属性,如果有并且和oldVnodetext不同,就会直接将新的文本内容渲染至页面,如果新的VNodechildren,则会通过diff算法判断子节点是否变化,diff的过程只进行同层级比较。具体细节可以参考这篇博客=>vue的Virtual Dom实现- snabbdom解密

2. 渲染操作

windowDOMContentLoaded事件发生时,执行初次的渲染操作:

var vnode; // 当前patch返回的VNode
var data = [ // 列表数据
  {
    rank: 1,
    title: 'The Shawshank Redemption',
    desc:
      'Two imprisoned men bond over a number of years, finding solace and eventual redemption through acts of common decency.',
    elmHeight: 0, // 元素offsetHeight
  },
    ...
]

window.addEventListener('DOMContentLoaded', () => {
  var container = document.getElementById('container');
  // 执行patch方法,此时列表高度为0
  vnode = patch(container, view(data));
  // 执行render函数后,列表拥有高度
  render(); // 渲染函数,本demo核心部分
});

这一段代码包含了demo核心部分:render()方法和view()方法。这里或许会问道:为什么执行了patch后再一次执行了render?首先,第一次patchdata通过view(data)方法渲染到页面,view方法我倾向于理解为vue组件的render()方法,具体实现如下:

var totalHeight = 0; // 列表高度

function view(data) {
  return h('div', [
    h('h1', 'Top 10 movies'),
    h('div', [
      h('a.btn.add', {on: {click: add}}, 'Add'),
      'Sort by: ',
      h('span.btn-group', [
        h('a.btn.rank', {class: {active: sortBy === 'rank'}, on: {click: [changeSort, 'rank']}}, 'Rank'),
        h('a.btn.title', {class: {active: sortBy === 'title'}, on: {click: [changeSort, 'title']}}, 'Title'),
        h('a.btn.desc', {class: {active: sortBy === 'desc'}, on: {click: [changeSort, 'desc']}}, 'Description'),
      ]),
    ]),
    h('div.list', {style: {height: totalHeight+'px'}}, data.map(movieView)),
  ]);
}

function movieView(movie) {
  return h('div.row', {
    key: movie.rank, // 该DOM元素的唯一标识,相当于Vue中的key
    style: {opacity: '0', transform: 'translate(-200px)',
            delayed: {transform: `translateY(${movie.offset}px)`, opacity: '1'},
            remove: {opacity: '0', transform: `translateY(${movie.offset}px) translateX(200px)`}},
    hook: {insert: (vnode) => { movie.elmHeight = vnode.elm.offsetHeight; }},
  }, [
    h('div', {style: {fontWeight: 'bold'}}, movie.rank),
    h('div', movie.title),
    h('div', movie.desc),
    h('div.btn.rm-btn', {on: {click: [remove, movie]}}, 'x'),
  ]);
}

列表DOM元素通过data.map方法获得,具体实现在movieView中,h()方法第一个参数接受一个选择器字符串,第二个参数接受init()方法注入的属性,第三个参数接受字符串作为text或数组作为子元素。

style属性除了指定相关样式外,还可以指定delayedremove属性,分别指定了该元素在下一frame和销毁时的样式,配合transitiontransform可以实现炫酷的动画效果。这里需要指出,key属性对于实现demo动画效果至关重要,不信邪的小伙伴可以注掉key试试:)

keyhook属性依赖于注入init中的props模块。key为元素的唯一标识,类似于Vue中的:keyhook为该元素的声明周期钩子,demo中使用的insert钩子在该元素插入到DOM并完成patch后触发。demo用来获取每个列表项目元素的元素高度offsetHeight,并将这个高度赋值到每个movie对象(就是要渲染的那个item)中。(其他的钩子建议去文档观摩观摩)。

以上属性皆可在snabbdom文档中ctrl+f细细品味。

h方法源码的声明代码如下,采用了函数重载:

import { VNode, VNodeData } from './vnode';
export declare type VNodes = Array<VNode>;
export declare type VNodeChildElement = VNode | string | number | undefined | null;
export declare type ArrayOrElement<T> = T | T[];
export declare type VNodeChildren = ArrayOrElement<VNodeChildElement>;
export declare function h(sel: string): VNode;
export declare function h(sel: string, data: VNodeData): VNode;
export declare function h(sel: string, children: VNodeChildren): VNode;
export declare function h(sel: string, data: VNodeData, children: VNodeChildren): VNode;
export default h;

执行完上述代码后,此时页面中列表高度为0,不过一轮操作下来已经拿到了所有元素的高度参数。接下来执行render()方法展开列表,并且带上了流畅的css3动画。

function render() {
  data = data.reduce((acc, m) => {
    // debugger;
    var last = acc[acc.length - 1];
    // debugger;
    m.offset = last ? last.offset + last.elmHeight + margin : margin;
    return acc.concat(m);
  }, []);
  totalHeight = data[data.length - 1].offset + data[data.length - 1].elmHeight;
  vnode = patch(vnode, view(data)); // 更新视图
}

render()方法通过reduce()累加遍历为每个列表项设置了显示高度offset,使其位于每个last元素的高度之下(demo中列表项使用绝对定位将所有列表项定位在顶部,列表项排列通过translateY实现,见movieView方法style属性)。之后,render()拿到了totalHeight,也就是整个列表的高度。最后,在完成reduce循环后触发patch方法更新视图。

3. 排序操作

以上讲完了本demo最核心的部分,以下是demo中的排序方法,没有什么难点和重点,不过我挺喜欢这段代码的风格,贴出来就不一一介绍啦。不过有一点需要解释,在添加了新的元素后,要执行两遍render(),不然第一个元素以下的所有元素其高度都不能正确地渲染出来

var sortBy = 'rank'; // 排序方式有rank、title和desc

function changeSort(prop) {
  sortBy = prop;
  data.sort((a, b) => {
    if (a[prop] > b[prop]) {
      return 1;
    }
    if (a[prop] < b[prop]) {
      return -1;
    }
    return 0;
  });
  render();
}

function add() {
  var n = originalData[Math.floor(Math.random() * 10)];
  data = [{rank: nextKey++, title: n.title, desc: n.desc, elmHeight: 0}].concat(data);
  render();
  render();
}

function remove(movie) {
  data = data.filter((m) => { return m !== movie; });
  render();
}