一套代码小程序&Web&Native运行的探索05——snabbdom

Posted 叶小钗

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一套代码小程序&Web&Native运行的探索05——snabbdom相关的知识,希望对你有一定的参考价值。

接上文:一套代码小程序&Web&Native运行的探索04——数据更新

对应Git代码地址请见:https://github.com/yexiaochai/wxdemo/tree/master/mvvm

参考:

https://github.com/fastCreator/MVVM(极度参考,十分感谢该作者,直接看Vue会比较吃力的,但是看完这个作者的代码便会轻易很多,可惜这个作者没有对应博客说明,不然就爽了)

https://www.tangshuang.net/3756.html

https://www.cnblogs.com/kidney/p/8018226.html

http://www.cnblogs.com/kidney/p/6052935.html

https://github.com/livoras/blog/issues/13

根据最近的学习,离我们最终的目标还有一段距离,但是对于Vue实现原理却慢慢有了体系化的认识,相信本系列结束后,如果能完成我们跨端代码,哪怕是demo的实现,都会对后续了解Vue或者React这里源码提供深远的帮助,平时工作较忙,这次刚好碰到假期,虽然会耽搁一些时间,我们试试这段时间运气可好,能不能在这个阶段取得不错的进展,好了我们继续完成今天的学习吧

到目前的地步,其中一些代码比较散乱,没有办法粘贴出来做讲解了,我这边尽量写注释,这里文章记录的主要目的还是帮助自己记录思路

昨天,我们完成了最简单的模板到DOM的实现,以及执行setData时候页面重新渲染工作,只不过比较粗暴还没有引入snabbdom进行了重新渲染,今天我们来完成其中的事件绑定部分代码

这里我们先不去管循环标签这些的解析,先完成事件绑定部分代码,这里如果只是想实现click绑定便直接在此处绑定事件即可:

 1 class Element {
 2   constructor(tagName, props, children, vm) {
 3     this.tagName = tagName;
 4     this.props = props;
 5     this.children = children || [];
 6     this.vm = vm.vm;
 7   }
 8   render() {
 9     //拿着根节点往下面撸
10     let el = document.createElement(this.tagName);
11     let props = this.props.props;
12     let scope = this;
13 
14     let events = this.props.on;
15 
16     for(let name in props) {
17       el.setAttribute(name, props[name]);
18     }
19 
20     for(name in events) {
21       let type = Object.keys(this.props.on);
22       type = type[0];
23       el.addEventListener(type, function (e) {
24         scope.vm.$options.methods[scope.props.on[type]] && scope.vm.$options.methods[scope.props.on[type]].call(scope.vm, e);
25       })
26     }
27 
28     let children = this.children;
29 
30     for(let i = 0, l = children.length; i < l; i++) {
31       let child = children[i];
32       let childEl;
33       if(child instanceof Element) {
34         //递归调用
35         childEl = child.render();
36       } else {
37         childEl = document.createTextNode(child);
38       }
39       el.append(childEl);
40     }
41     return el;
42   }
43 }

显然,这个不是我们要的最终代码,事实上,事件如何绑定dom如何比较差异渲染,我们这块不需要太多关系,我们只需要引入snabbdom即可,这里便来一起了解之

snabbdom

前面我们对snabbdom做了初步介绍,暂时看来MVVM框架就我这边学习的感觉有以下几个难点:

① 第一步的模板解析,这块很容易出错,但如果有志气jQuery源码的功底就会比较轻易

② 虚拟DOM这块,要对比两次dom树的差异再选择如何做

只要突破这两点,其他的就会相对简单一些,而这两块最难也最容易出错的工作,我们全部引用了第三方库HTMLParser和snabbdom,所以我们都碰上了好时代啊......

我们很容易将一个dom结构用js对象来抽象,比如我们之前做的班次列表中的列表排序:

这里出发的因子就有出发时间、耗时、价格,这里表示下就是:

1 let trainData = {
2   sortKet: \'time\', //耗时,价格,发车时间等等方式排序
3   sortType: 1, //1升序,2倒叙
4   oData: [], //服务器给过来的原生数据
5   data: [], //当前筛选条件下的数据
6 }

这个对象有点缺陷就是不能与页面映射起来,我们之前的做法就算映射起来了,也只会跟一个跟节点做绑定关系,一旦数据发生变化便全部重新渲染,这个还是小问题,比较复杂的问题是半年后筛选条件增加,这个页面的代码可能会变得相当难维护,其中最难的点可能就是页面中的dom关系维护,和事件维护

而我们想要的就是数据改变了,DOM自己就发生变化,并且以高效的方式发生变化,这个就是我们snabbdom做的工作了,而之前我们用一段代码说明过这个问题:

var element = {
  tagName: \'ul\', // 节点标签名
  props: { // DOM的属性,用一个对象存储键值对
    id: \'list\'
  },
  children: [ // 该节点的子节点
    {tagName: \'li\', props: {class: \'item\'}, children: ["Item 1"]},
    {tagName: \'li\', props: {class: \'item\'}, children: ["Item 2"]},
    {tagName: \'li\', props: {class: \'item\'}, children: ["Item 3"]},
  ]
}
1 <ul id=\'list\'>
2   <li class=\'item\'>Item 1</li>
3   <li class=\'item\'>Item 2</li>
4   <li class=\'item\'>Item 3</li>
5 </ul>

真实的虚拟DOM会翻译为这样:

class Element {
  constructor(tagName, props, children) {
    this.tagName = tagName;
    this.props = props;
    this.children = children;
  }
}

function el(tagName, props, children)  {
  return new Element(tagName, props, children)
}

el(\'ul\', {id: \'list\'}, [
  el(\'li\', {class: \'item\'}, [\'Item 1\']),
  el(\'li\', {class: \'item\'}, [\'Item 2\']),
  el(\'li\', {class: \'item\'}, [\'Item 3\'])
])

这里很快就能封装一个可运行的代码出来:

<!doctype html>
<html>
<head>
  <title>起步</title>
</head>
<body>

<script type="text/javascript">
  //***虚拟dom部分代码,后续会换成snabdom
  class Element {
    constructor(tagName, props, children) {
      this.tagName = tagName;
      this.props = props;
      this.children = children;
    }
    render() {
      //拿着根节点往下面撸
      let root = document.createElement(this.tagName);
      let props = this.props;

      for(let name in props) {
        root.setAttribute(name, props[name]);
      }

      let children = this.children;

      for(let i = 0, l = children.length; i < l; i++) {
        let child = children[i];
        let childEl;
        if(child instanceof Element) {
          //递归调用
          childEl = child.render();
        } else {
          childEl = document.createTextNode(child);
        }
        root.append(childEl);
      }

      this.rootNode = root;
      return root;
    }
  }

  function el(tagName, props, children)  {
    return new Element(tagName, props, children)
  }

  let vnode = el(\'ul\', {id: \'list\'}, [
    el(\'li\', {class: \'item\'}, [\'Item 1\']),
    el(\'li\', {class: \'item\'}, [\'Item 2\']),
    el(\'li\', {class: \'item\'}, [\'Item 3\'])
  ])

  let root = vnode.render();

  document.body.appendChild(root);

</script>

</body>
</html>

我们今天要做的事情,便是把这段代码写的更加完善一点,就要进入第二步,比较两颗虚拟树的差异了,而这块也是snabbdom的核心,当然也比较有难度啦

PS:这里借鉴:https://github.com/livoras/blog/issues/13

实际代码中,会对两棵树进行深度优先遍历,这样会给每个节点一个唯一的标志:

在深度优先遍历的时候,每到一个节点便与新的树进行对比,如果有差异就记录到一个对象中:

 1 //遍历子树,用来做递归的
 2 function diffChildren(oldNodeChildren, newNodeChildren, index, patches) {
 3 
 4   let leftNode = null;
 5   let curNodeIndex = index;
 6 
 7   for(let i = 0, l = oldNodeChildren.length; i < l; i++) {
 8     let child = oldNodeChildren[i];
 9     let newChild = newNodeChildren[i];
10 
11     //计算节点的标识
12     curNodeIndex = (leftNode && leftNode.count) ? curNodeIndex + leftNode.count + 1 : curNodeIndex + 1;
13     dfsWalk(child, newChild)
14     leftNode = child;
15   }
16 }
17 
18 //对两棵树进行深度优先遍历,找出差异
19 function dfsWalk(oldNode, newNode, index, patches) {
20   //将两棵树的不同记录之
21   patches[index] = [];
22   diffChildren(oldNode.children, newNode.children, index, patches);
23 }
24 
25 //对比两棵树的差异
26 function diff(oldTree, newTree) {
27   //当前节点标志
28   let index = 0;
29   //记录每个节点的差异
30   let patches = {};
31   //深度优先遍历
32   return patches;
33 }
patches[0] = [{difference}, {difference}, ...] // 用数组存储新旧节点的不同

这里已经做好了工具流程遍历节点得出差异,而我们的差异有:

① 替换原来的节点,例如把div换成section

② 移动、删除、新增子节点,例如把p与ul顺序替换

③ 这个比较简单,修改节点属性

④ 这个也比较简单,修改文本内容

这里给这几种类型的定义:

let REPLACE = 0
let REORDER = 1
let PROPS = 2
let TEXT = 3

节点替换首先判断tagname是否一致即可:

patches[0] = [{
  type: REPALCE,
  node: newNode // el(\'section\', props, children)
}]

如果给div新增属性,便记录之:

patches[0] = [{
  type: REPALCE,
  node: newNode // el(\'section\', props, children)
}, {
  type: PROPS,
  props: {
    id: "container"
  }
}]

如果是文本节点便记录之:

patches[2] = [{
  type: TEXT,
  content: "Virtual DOM2"
}]

以上都比较常规,不会做太大改变,情况比较多的是REODER(Reorder重新排列),比如将这里div的子节点顺序变成了div-p-ul,这个该如何对比,其实这个情况可能会直接被替换掉,这样DOM开销太大,这里牵扯到了列表对比算法,有点小复杂:

假如现在对英文字母进行排序,久的顺序:

a b c d e f g h i

然后对节点进行了一系列的操作,新增j节点,删除e节点,移动h节点,于是有了:

a b c h d f g i j

知道了新旧顺序,现在需要我们写一个算法计算最小插入、删除操作(移动是删除+插入),这块具体我们不深入,有兴趣移步至,这里代码,我们最终形成的结果是:

patches[0] = [{
  type: REORDER,
  moves: [{remove or insert}, {remove or insert}, ...]
}]

于是我们将这段寻找差异的代码放入前面的遍历代码:

function patch (node, patches) {
  var walker = {index: 0}
  dfsWalk(node, walker, patches)
}

function dfsWalk (node, walker, patches) {
  var currentPatches = patches[walker.index] // 从patches拿出当前节点的差异

  var len = node.childNodes
    ? node.childNodes.length
    : 0
  for (var i = 0; i < len; i++) { // 深度遍历子节点
    var child = node.childNodes[i]
    walker.index++
    dfsWalk(child, walker, patches)
  }

  if (currentPatches) {
    applyPatches(node, currentPatches) // 对当前节点进行DOM操作
  }
}

function applyPatches (node, currentPatches) {
  currentPatches.forEach(function (currentPatch) {
    switch (currentPatch.type) {
      case REPLACE:
        node.parentNode.replaceChild(currentPatch.node.render(), node)
        break
      case REORDER:
        reorderChildren(node, currentPatch.moves)
        break
      case PROPS:
        setProps(node, currentPatch.props)
        break
      case TEXT:
        node.textContent = currentPatch.content
        break
      default:
        throw new Error(\'Unknown patch type \' + currentPatch.type)
    }
  })
}

这个就是我们snabbdom中重要的patch.js的实现,而Virtual DOM算法主要就是:
① 虚拟DOM element的定义

② 差异的定义与实现

③ 将差异部分代码补足形成新树的patch部分

// 1. 构建虚拟DOM
var tree = el(\'div\', {\'id\': \'container\'}, [
    el(\'h1\', {style: \'color: blue\'}, [\'simple virtal dom\']),
    el(\'p\', [\'Hello, virtual-dom\']),
    el(\'ul\', [el(\'li\')])
])

// 2. 通过虚拟DOM构建真正的DOM
var root = tree.render()
document.body.appendChild(root)

// 3. 生成新的虚拟DOM
var newTree = el(\'div\', {\'id\': \'container\'}, [
    el(\'h1\', {style: \'color: red\'}, [\'simple virtal dom\']),
    el(\'p\', [\'Hello, virtual-dom\']),
    el(\'ul\', [el(\'li\'), el(\'li\')])
])

// 4. 比较两棵虚拟DOM树的不同
var patches = diff(tree, newTree)

// 5. 在真正的DOM元素上应用变更
patch(root, patches)

有了以上知识,我们现在来开始使用snabbdom,相比会得心应手

应用snabbdom

var snabbdom = require("snabbdom");
var patch = snabbdom.init([ // 初始化补丁功能与选定的模块
  require("snabbdom/modules/class").default, // 使切换class变得容易
  require("snabbdom/modules/props").default, // 用于设置DOM元素的属性(注意区分props,attrs具体看snabbdom文档)
  require("snabbdom/modules/style").default, // 处理元素的style,支持动画
  require("snabbdom/modules/eventlisteners").default, // 事件监听器
]);
//h是一个生成vnode的包装函数,factory模式?对生成vnode更精细的包装就是使用jsx
//在工程里,我们通常使用webpack或者browserify对jsx编译
var h = require("snabbdom/h").default; // 用于创建vnode,VUE中render(createElement)的原形

var container = document.getElementById("container");

var vnode = h("div#container.two.classes", {on: {click: someFn}}, [
  h("span", {style: {fontWeight: "bold"}}, "This is bold"),
  " and this is just normal text",
  h("a", {props: {href: "/foo"}}, "I\\"ll take you places!")
]);
// 第一次打补丁,用于渲染到页面,内部会建立关联关系,减少了创建oldvnode过程
patch(container, vnode);
//创建新节点
var newVnode = h("div#container.two.classes", {on: {click: anotherEventHandler}}, [
  h("span", {style: {fontWeight: "normal", fontStyle: "italic"}}, "This is now italic type"),
  " and this is still just normal text",
  h("a", {props: {href: "/bar"}}, "I\\"ll take you places!")
]);
//第二次比较,上一次vnode比较,打补丁到页面
//VUE的patch在nextTick中,开启异步队列,删除了不必要的patch
//nextTick异步队列解析,下面文章中会详解
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state

这里可以看到,我们传入h的要求是什么样的格式,依次有什么属性,这里还是来做一个demo:

 1 <div id="container">
 2 </div>
 3 
 4 <script type="module">
 5   "use strict";
 6   import { patch, h, VNode } from \'./libs/vnode.js\'
 7   var container = document.getElementById("container");
 8   function someFn(){ console.log(1)}
 9   function anotherEventHandler(){ console.log(2)}
10 
11   var oldVnode = h("div", {on: {click: someFn}}, [
12     h("span", {style: {fontWeight: "bold"}}, "This is bold"),
13     " and this is just normal text",
14     h("a", {props: {href: "/foo"}}, "I\\"ll take you places!")
15   ]);
16 
17   // 第一次打补丁,用于渲染到页面,内部会建立关联关系,减少了创建oldvnode过程
18   let diff = patch(container, oldVnode);
19   //创建新节点
20   var newVnode = h("div", {on: {click: anotherEventHandler}}, [
21     h("span", {style: {fontWeight: "normal", fontStyle: "italic"}}, "This is now italic type"),
22     " and this is still just normal text",
23     h("a", {props: {href: "/bar"}}, "I\\"ll take you places!")
24   ]);
25   //第二次比较,上一次vnode比较,打补丁到页面
26   //VUE的patch在nextTick中,开启异步队列,删除了不必要的patch
27   //nextTick异步队列解析,下面文章中会详解
28   patch(oldVnode, newVnode); // Snabbdom efficiently updates the old view to the new state
29   function test() {
30     return {
31       oldVnode,newVnode,container,diff
32     }
33   }
34 </script>

所以我们现在工作变得相对简单起来就是根据HTML模板封装虚拟DOM结构即可,如果不是我们其中存在指令系统甚至可以不用HTMLParser,所以我们改下之前的代码,将我们自己实现的丑陋vnode变成snabbdom,这里详情还是看github:https://github.com/yexiaochai/wxdemo/tree/master/mvvm。接下来,我们来解决其中的指令

指令系统

这里所谓的指令用的最多的也就是:

① if

② for

对应到小程序中就是:

<block wx:for="{{[1, 2, 3]}}">
  <view> {{index}}: </view>
  <view> {{item}} </view>
</block>
<block wx:if="{{true}}">
  <view> view1 </view>
  <view> view2 </view>
</block>

Vue中的语法是:

<ul id="example-1">
  <li v-for="item in items">
    {{ item.message }}
  </li>
</ul>