一套代码小程序&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>