一套代码小程序&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
之前我们完成了简陋的从模板到虚拟DOM从虚拟DOM到HTML的代码,我们这里图简单没有对属性和样式做特殊处理,还是按照一般的模板方式进行的解析,后续看看这块怎么处理吧,今天我们的任务是完成setData时候同步更新我们的HTML的操作,这里首先我们来看看一般的MVVM中数据变化更新是怎么完成的,在这个基础上进行后续的代码可能各位看得更清晰。
一般的MVVM双向绑定
一般来说,我们数据变化的时候都是一个发布订阅模式,我们调用setData的时候会执行类似这样的代码:
1 function setData(data) { 2 //做下数据变更 3 //...... 4 5 //会通知对应数据对象数据发生变化了,这个数据对应的所有dom节点都会发生改变 6 this.notifyAll(); 7 }
而在vue中我们是直接做这种操作,dom就发生了变化:
this.name = '叶小钗';
这个是因为,他使用了访问器属性:
1 var obj = { }; 2 // 为obj定义一个名为 name 的访问器属性 3 Object.defineProperty(obj, "name", { 4 5 get: function () { 6 console.log('get', arguments); 7 }, 8 set: function (val) { 9 console.log('set', arguments); 10 } 11 }) 12 obj.name = '叶小钗' 13 console.log(obj, obj.name) 14 /* 15 set Arguments ["叶小钗", callee: ƒ, Symbol(Symbol.iterator): ƒ] 16 get Arguments [callee: ƒ, Symbol(Symbol.iterator): ƒ] 17 */
如果这里写这样的代码:
1 <div id="a"> 2 </div> 3 <input type="text" id="b"> 4 5 <script type="text/javascript" > 6 7 function setData(data) { 8 //做下数据变更 9 //...... 10 //会通知对应数据对象数据发生变化了,这个数据对应的所有dom节点都会发生改变 11 this.notifyAll(); 12 } 13 14 function getElById(id) { 15 return document.getElementById(id); 16 } 17 18 var obj = {}; 19 // 为obj定义一个名为 name 的访问器属性 20 Object.defineProperty(obj, "name", { 21 set: function (val) { 22 getElById('a').innerHTML = val; 23 getElById('b').value = val; 24 } 25 }) 26 27 getElById('b').addEventListener('input', function(e) { 28 obj.name = e.target.value; 29 }); 30 31 </script>
文本框中的字符串和div的便会同步更新,这个便是最简化的双向绑定代码了,真实情况下我们的代码可能是这样的:
① 将data中的数据(这里是name属性),与两个dom对象进行映射一个是input另一个是空字符串(可以想象为span)
② 当data中name字段发生变化,或者view中导致name发生变化(控制台或者事件监听)
③ data数据变化时,文本节点同步发生变化(不管是控制台js脚本导致还是输入变化)
PS:我们这里与小程序保持一致,真正做更新时候采用setData方法进行
这里便开始引入编译过程:
1 <div id="app"> 2 <input type="text" v-model="name"> 3 {{name}} 4 </div> 5 6 <script type="text/javascript" > 7 8 function getElById(id) { 9 return document.getElementById(id); 10 } 11 12 //这块代码仅做功能说明,不用当真 13 function compile(node, vm) { 14 let reg = /\{\{(.*)\}\}/; 15 16 //节点类型 17 if(node.nodeType === 1) { 18 let attrs = node.attributes; 19 //解析属性 20 for(let i = 0, l = attrs.length; i < l; i++) { 21 if(attrs[i].nodeName === 'v-model') { 22 let name = attrs[i].nodeValue; 23 node.value = vm.data[name] || ''; 24 //此处不做太多判断,直接绑定事件 25 node.addEventListener('input', function (e) { 26 //赋值操作 27 let newObj = {}; 28 newObj[name] = e.target.value; 29 vm.setData(newObj); 30 }); 31 32 break; 33 } 34 } 35 } else if(node.nodeType === 3) { 36 37 if(reg.test(node.nodeValue)) { 38 let name = RegExp.$1; // 获取匹配到的name 39 name = name.trim(); 40 node.nodeValue = vm.data[name] || ''; 41 } 42 } 43 } 44 45 //获取节点 46 function nodeToFragment(node, vm) { 47 let flag = document.createDocumentFragment(); 48 let child; 49 50 while (child = node.firstChild) { 51 compile(child, vm); 52 flag.appendChild(child); 53 } 54 55 return flag; 56 } 57 58 function MVVM(options) { 59 this.data = options.data; 60 let el = getElById(options.el); 61 this.$dom = nodeToFragment(el, this) 62 this.$el = el.appendChild(this.$dom); 63 64 // this.$bindEvent(); 65 } 66 67 MVVM.prototype.setData = function (data) { 68 for(let k in data) { 69 this.data[k] = data[k]; 70 } 71 //执行更新逻辑 72 } 73 74 let mvvm = new MVVM({ 75 el: 'app', 76 data: { 77 name: '叶小钗' 78 } 79 }) 80 81 </script>
这个时候input输入更改,对应属性也会发生变化,但是我们属性发生变化并没有引起所有的dom发生变化,这个是不对的,这里我们便需要劫持所有的数据对象,这里引入发布订阅模式:
1 <div id="app"> 2 <input type="text" v-model="name"> 3 {{name}} 4 </div> 5 6 <script type="text/javascript" > 7 8 function getElById(id) { 9 return document.getElementById(id); 10 } 11 12 //主体对象,存储所有的订阅者 13 function Dep () { 14 this.subs = []; 15 } 16 17 //通知所有订阅者数据变化 18 Dep.prototype.notify = function () { 19 for(let i = 0, l = this.subs.length; i < l; i++) { 20 this.subs[i].update(); 21 } 22 } 23 24 //添加订阅者 25 Dep.prototype.addSub = function (sub) { 26 this.subs.push(sub); 27 } 28 29 let globalDataDep = new Dep(); 30 31 //观察者,框架会接触data的每一个与node相关的属性, 32 //如果data没有与任何节点产生关联,则不予理睬 33 //实际的订阅者对象 34 //注意,只要一个数据对象对应了一个node对象就会生成一个订阅者,所以真实通知的时候应该需要做到通知到对应数据的dom,这里不予关注 35 function Watcher(vm, node, name) { 36 this.name = name; 37 this.node = node; 38 this.vm = vm; 39 if(node.nodeType === 1) { 40 this.node.value = this.vm.data[name]; 41 } else if(node.nodeType === 3) { 42 this.node.nodeValue = this.vm.data[name] || ''; 43 } 44 globalDataDep.addSub(this); 45 46 } 47 48 Watcher.prototype.update = function () { 49 if(this.node.nodeType === 1) { 50 this.node.value = this.vm.data[this.name ]; 51 } else if(this.node.nodeType === 3) { 52 this.node.nodeValue = this.vm.data[this.name ] || ''; 53 } 54 } 55 56 //这块代码仅做功能说明,不用当真 57 function compile(node, vm) { 58 let reg = /\{\{(.*)\}\}/; 59 60 //节点类型 61 if(node.nodeType === 1) { 62 let attrs = node.attributes; 63 //解析属性 64 for(let i = 0, l = attrs.length; i < l; i++) { 65 if(attrs[i].nodeName === 'v-model') { 66 let name = attrs[i].nodeValue; 67 if(node.value === vm.data[name]) break; 68 69 // node.value = vm.data[name] || ''; 70 new Watcher(vm, node, name) 71 72 //此处不做太多判断,直接绑定事件 73 node.addEventListener('input', function (e) { 74 //赋值操作 75 let newObj = {}; 76 newObj[name] = e.target.value; 77 vm.setData(newObj, true); 78 }); 79 80 break; 81 } 82 } 83 } else if(node.nodeType === 3) { 84 85 if(reg.test(node.nodeValue)) { 86 let name = RegExp.$1; // 获取匹配到的name 87 name = name.trim(); 88 // node.nodeValue = vm.data[name] || ''; 89 new Watcher(vm, node, name) 90 } 91 } 92 } 93 94 //获取节点 95 function nodeToFragment(node, vm) { 96 let flag = document.createDocumentFragment(); 97 let child; 98 99 while (child = node.firstChild) { 100 compile(child, vm); 101 flag.appendChild(child); 102 } 103 104 return flag; 105 } 106 107 function MVVM(options) { 108 this.data = options.data; 109 let el = getElById(options.el); 110 this.$dom = nodeToFragment(el, this) 111 this.$el = el.appendChild(this.$dom); 112 113 // this.$bindEvent(); 114 } 115 116 MVVM.prototype.setData = function (data, noNotify) { 117 for(let k in data) { 118 this.data[k] = data[k]; 119 } 120 //执行更新逻辑 121 // if(noNotify) return; 122 globalDataDep.notify(); 123 } 124 125 let mvvm = new MVVM({ 126 el: 'app', 127 data: { 128 name: '叶小钗' 129 } 130 }) 131 132 </script>
mvvm.setData({name: 'hello world'})
这段短短的代码,基本将数据变化如何引起的dom变化说的比较清楚了,几个关键流程是:
① 设置全局的发布订阅模式
② 在模板编译的时候,一旦碰到数据节点与dom节点发生关系时,则新增一个订阅者,我们这里的发布者没有状态概念,真实的情况应该是以data为一个集合的分组,这样可以做到安data进行更新
③ 数据变化时候执行setData,底层调用发布者除非对应订阅者更新数据,这里只是简单的属性&文本更新,真实情况会复杂的多,我们这里为保持小程序逻辑,没有实现访问器属性部分代码
有了以上代码的理解,我们再回到我们昨天的代码继续完成这个流程便会清晰的多
完成setData代码
根据之前的学习,我们知道添加订阅者一定是发生在编译时期,data跟node产生关联的时候,但是我们这里需要发布订阅者相关代码,由于我们这里的诉求还要简单一些并不想去考虑属性样式这些特殊性,所以我们对TextParser做点改造,先实现之:
注意这里的核心是,每次数据改变的时候都会触发观察者的update,这样会引起重新生成虚拟树(vnode),但是到底要不要重新渲染,怎么渲染后面会直接由snabbdom接手,我们只是将这种关系完成,代码比较分散大家可以到github上面看:https://github.com/yexiaochai/wxdemo/tree/master/mvvm
然后今天的学习到此为止,我们明天开始处理事件部分的代码,感觉代码逐渐有些慢了,等组件部分完成后我们画点流程图重新梳理下逻辑