之前代码存在很多问题,最大的问题是,实现的并不是一个严格意义上的观察者模式(发布-订阅模式),因为默认订阅了所有数据,且不能取消订阅。 在《JavaScript设计模式与开发实践》中,使用售楼处的例子来类比观察者模式:
售楼处属于发布者,小明(需要购房的人)是订阅者,小明向售楼处订阅了房子开售的信息,售楼处就把小明的信息写入记录表,当房子开售时,售楼处遍历记录表通知到小明(和其他记录表上的人)。
把这个例子和 vue 的实现类比:
创建实例时,发布者(售楼处)会把渲染函数(购房者)添加到发布列表(记录表)中,数据变化(房子开售)时,发布者(售楼处)会调用渲染函数(通知购房者)。
而且不同的数据可以类比于不同户型的房子。不同的数据(不同户型的房子)改变(发售)时,可以通知不同的渲染函数(购买者)。
1 2 3 4 5 6 7 8 9 10 var vm = new Vue({ data: { web: 'my web' , books: ['first' , 'second' ], person: { name: 'ltaoo' , age: 23 } } })
我们写下这样一段代码后,Vue 做了什么呢?
实现 watcher/observer ? 参考文章来对我们之前的代码进行修改,文章中对传进来的数据对象进行修改,而不是之前的复制。我们传入 data,对 data 的属性遍历:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 Object .keys(data).forEach(function (key ) { Object .defineProperty(data, key, { enumerable: true , configurable: true , get: function ( ) { return data[key] }, set: function (newVal ) { if (typeof newVal === 'object' ) { data[key] = newVal } } }) })
为了让代码清晰,分为useForEachAddSetAndGet
函数,用来使用 forEach 循环执行addSetAndGet
函数,watch
函数(暴露给外部的函数,可以给对象属性添加 get 和 set 的方法)实例化 Watcher 对象。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 export default class Watcher { constructor (value ) { this .value = value this .useForEachAddSetAndGet(value) } useForEachAddSetAndGet (value) { Object .keys(value).forEach(function (key ) { addSetAndGet(value, key, value[key]) }) } } export function addSetAndGet (obj, key, val ) { watch(val) Object .defineProperty(obj, key, { enumerable: true , configurable: true , get: ()=> { console .log('get value: ' , val) return val }, set: newVal => { if (newVal === val) { return } val = newVal watch(newVal) } }) } export function watch (value, vm ) { if (!value || typeof value !== 'object' ) { return } return new Watcher(value) }
1 2 3 4 init ( ) { var watcher = new Watcher(this .data) }
根据实际情况来解读上面的代码。new Vue
后,将data
传入 Watcher 来添加 set 和 get:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 addSetAndGet (data, 'person' , {name:'ltaoo' , age: '23' } ) { set: function (newVal ) { val = newVal } watch(val) set: function (newVal ) { val = newVal watch(newVal) } }
广播 但是页面上没有数据了,因为我们并没有将 render 函数放到 set 中。如果还是直接放在 set 中,万一用户不想监听数据了,不可能修改源代码,所以我们需要拿出来,通过其他方式来实现相同的效果。
不过即使是用其他方式,也还是需要放一个函数到 set 中,利用这个函数来告诉订阅者(现在还没有)数据改变了。命名为 notify()
函数。
回想一下售楼处的例子,当房子开售时,会读取记录表来发送信息。所以在这里,是当数据改变时,notify()
会读取订阅者数组来执行对应的函数。先大概根据这个逻辑来写这个函数:
1 2 3 4 5 6 7 function notify ( ) { listeners.forEach(function (fn ) { fn.call() }) }
listener
是一个全局的数组,放着订阅者。问题是怎么将订阅者放到这个数组中?订阅者又是什么?这样吗?
1 2 3 addToListeners('web' , function ( ) { console .log('I watch "web" change' ) })
可以实现不同属性的变化触发不同的处理函数(不像之前都是触发render
)。
1 2 3 4 5 6 7 8 9 10 11 function addToListeners (name, cb ) { listeners.push(cb) listeners[name] = cb } function notify (key ) { listeners[key].call() }
Dep 就是售楼处(发布对象),因为是售楼处将购房人的信息写入记录表,也是由售楼处来通知购房人 整理一下,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 export default class Dep { constructor ( ) { this .listeners = {} } addToListeners (name, cb) { this .listeners[name] = cb } removeListener (name) { delete this .listeners[name] } notify (name ) { Object .keys(this .listeners).forEach((key )=> { if (key && key === name) { this .listeners[key].call() } }) } }
现在问题是,在什么地方使用Dep
,由于this.listeners
需要全局唯一,就只能实例化一次Dep
,而在watch.js
中,会调用多次Watcher
,所以就只能在viewModel.js
中实例化Dep
了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 export default class Watcher { constructor (value, dep ) { this .value = value this .dep = dep this .useForEachAddSetAndGet(this .value) console .log(this .dep.listeners) } useForEachAddSetAndGet (value) { var watcher = this Object .keys(value).forEach(function (key ) { watcher.addSetAndGet(value, key, value[key]) watcher.dep.addToListeners(key, function ( ) { console .log('set ' , key) }) }) } addSetAndGet (obj, key, val ) { if (val.constructor == Object ) { this .useForEachAddSetAndGet(val) } Object .defineProperty(obj, key, { enumerable: true , configurable: true , get: ()=> { return val }, set: newVal => { if (newVal === val) { return } val = newVal if (val.constructor == Object ) { this .useForEachAddSetAndGet(newVal) } this .dep.notify(key) } }) } } import Dep from './dep.js' constructor (options ) { this .data = options.data; this .$data = {}; this .dep = new Dep() this .watcher = new Watcher(this .data, this .dep) }
测试 可以在index.js
中测试是否成功:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 var vm = new Vue({ data: { web: 'my web' , books: ['first' , 'second' ], person: { name: 'ltaoo' , age: 23 } } }) var person = vm.data.personperson.name = 'ltooo' vm.watcher.dep.removeListener('name' ) person.name = 'loooo' vm.watcher.dep.addToListeners('name' , function ( ) { console .log('这是很特殊的处理' ) }) person.name = 'ooooo'
浏览器控制台只打印一次 set name
,打印一次这是很特殊的处理
,表示成功!
render 模块 为了能让render()
函数可以被其他模块调用,将 render()写成一个单独的模块,所以代码是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 import { render } from './render.js' export default class Vue { constructor (options ) { this .data = options.data; this .$data = {}; this .dep = new Dep() this .watcher = new Watcher(this .data, this .dep) render(this .data); } } export function render (data ) { var app = document .getElementById('app' ); if (app.hasChildNodes()) { app.childNodes.forEach(function (node ) { var key = node.getAttribute('v-bind' ); if (key && typeof data[key] === 'object' ) { var content = '' ; data[key].forEach(function (value ) { content += '<li>' + value + '</li>' ; }) node.innerHTML = content; }else if (key && typeof data[key] === 'string' ) { node.innerHTML = data[key]; } }) } }
查看一下页面,OK,能够显示数据。
不同的处理函数 OK,现在再把render
加入到订阅列表中:
1 2 3 4 5 watcher.dep.addToListeners(key, function ( ) { console .log('set ' , key) render() })
render()
是要接收一个参数,函数根据这个参数才能够渲染出页面,不过我们其实也不想任何数据改变都重新渲染整个页面,而是什么属性改变就渲染对应的地方,比如:
当web
字段改变时,就重新渲染这一部分,其他的不会改变。所以我们需要一个新的函数,暂时命名为renderSingle()
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 export function renderSingle (key, value ) { var app = document .getElementById('app' ); if (app.hasChildNodes()) { app.childNodes.forEach(function (node ) { var name = node.getAttribute('v-bind' ); if (name === key) { node.innerHTML = value[key]; } }) } }
这个函数放在了数据变化的回调函数中。
1 2 3 4 5 watcher.dep.addToListeners(key, function ( ) { console .log('set ' , key) renderSingle(key, value) })
OK,终于实现了之前就实现的效果,不过增加了很多东西,方便之后的拓展。
render.js 的拓展 这里就要提到“指令”了,我们只能处理有指令的节点。现在只有v-bind
,表示会往这个节点里面添加数据。我们还需要v-model
、v-for
等,实现方式是,获取节点,读取节点上的属性,如果有v
,就是我们的指令了,就执行这个指令对应的函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 operation = { 'v-bind' : function (node, data, value ) { console .log(node) node.innerHTML = data[value] }, 'v-model' : function (node, data, val ) { node.value = data[val] node.oninput = function ( ) { data[val] = node.value } }, 'v-for' : function (node, data, value ) { var content = '' ; data[value].forEach(function (value ) { content += '<li>' + value + '</li>' ; }) node.innerHTML = content } } render (data ) { var app = document .getElementById('app' ); if (app.hasChildNodes()) { var render = this app.childNodes.forEach(function (node ) { if (node.nodeType === 1 && node.hasAttributes()) { var atr = node.attributes Object .keys(atr).forEach(function (name ) { var key = atr[name].name var value = atr[name].value if (key.indexOf('v' ) > -1 ) { render.operation[key].call(null , node, render.vm.data, value) } }) } }) } }
这样就能够实现页面初始化了。我们将代码进行整理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 export default class Render { constructor (vm ) { this .vm = vm this .operation = { 'v-bind' : function (node, data, value ) { console .log(node) node.innerHTML = data[value] }, 'v-model' : function (node, data, val ) { node.value = data[val] node.oninput = function ( ) { data[val] = node.value } }, 'v-for' : function (node, data, value ) { var content = '' ; data[value].forEach(function (value ) { content += '<li>' + value + '</li>' ; }) node.innerHTML = content } } } render (data ) { var app = document .getElementById('app' ); if (app.hasChildNodes()) { var render = this app.childNodes.forEach(function (node ) { if (node.nodeType === 1 && node.hasAttributes()) { var atr = node.attributes Object .keys(atr).forEach(function (name ) { var key = atr[name].name var value = atr[name].value if (key.indexOf('v' ) > -1 ) { render.operation[key].call(null , node, render.vm.data, value) } }) } }) } } renderSingle (key, value ) { var app = document .getElementById('app' ); var render = this if (app.hasChildNodes()) { app.childNodes.forEach((node )=> { if (node.nodeType === 1 && node.hasAttributes()) { var atr = node.attributes Object .keys(atr).forEach((name )=> { var attrName = atr[name].name var value = atr[name].value if (key === value) { render.operation[attrName].call(null , node, render.vm.data, value) } }) } }) } } }
重点是在于v-model
的处理函数,可以看到这里对节点的oninput
进行监听,将每一次的输入都赋值给 Vue 的 data,这样就实现了双向绑定,即使用v-model
指令替代了我们之前一直使用的在index.js
中手动监听。
这里涉及到一个 “值的传递”,在 render.js 这个文件中我们要获取到 data
,而data
又是挂载在Vue
这个类上面,所以我们将这个类传递给Render()
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import Watcher from './watch.js' import Dep from './dep.js' import Render from './render.js' export default class Vue { constructor (options ) { this .data = options.data; this .dep = new Dep() this .render = new Render(this ) this .watcher = new Watcher(this .data, this .dep, this .render) this .render.render(this .data, this ); } }
总结 现在我们有了viewModel.js
、render.js
、watch.js
、dep.js
,四个类,实现了我们的简单的 Vue 实现。
dep 是售楼处
watch 是什么?在 watch 中数据设置了 set 就表示有购房意愿,dep 才可以把数据加入到记录表
render 是购房者
不知道这种比喻是否恰当,等对 Vue 的源码进行阅读后应该会有更深的认识。
对象的传递 由于每个类只能实例化一次,所以在模块间“通信”是使用了将类作为参数传递的方式。尝试画流程图来将整个流程理清楚,数据是从哪到哪。
在画图的过程中,意识到如果按照顺序来实例化,将实例化后的对象挂载在起点(viewModel)上,只需要传递 viewModel 一个参数即可。
1 2 3 4 this .data = options.datathis .render = new Render(this )this .dep = new Dep() this .watcher = new Watcher(this )
这一次学习中,对“观察者模式”有了更深的理解,也对 Node 的相关 API 进行了更多的了解,很多之前没有用过的方法、属性都在这次学习中出现。
一个前端框架,不仅仅要熟悉 JavaScript 语言,还要对浏览器环境下的 JavaScript 有很深的了解。最重要的还是代码的组织,即设计模式(架构?)非常重要。
参考