在MVVM中,数据、视图是相互影响的。
vue中通过数据劫持,实现双向数据绑定。
function MVVM(options={}){
this.$options = options;// 将属性挂载到MVVM实例
var data = this._data= this.$options.data;
}
踩坑笔记
在一开始,实现数据劫持时,我是采用以下方式:
function observe(data){
if(typeof data !== 'object' ){
return;
}
for(let key in data){
let val = data[key]
Object.defineProperty(data,key,{
configurable:false,
enumerable:true,
get(){
return val
},
set(newVal){
if(val === newVal){
return;
}//如果新值和旧值相等,那就什么都不做
val = newVal
observe(val)
}
})
}
}
var obj = {a:1,b:2,c:3,d:4}
observe(obj)
原因解析
因为我们实在for循环中使用Object.defineProperty(),而val是会随着循环不断发生变化的,再者,我们再get()中返回的是val,所以会造成值覆盖问题
var obj = {a:1,b:2,c:3,d:4}
observe(obj)
在这段代码中,obj[a],obj[b],obj[c],obj[d]的输出结果都是4。验证了我们的结论。
也许你会说,我们可以在get中retrun obj[key],也就是get(){retrun obj[key]}
这样总没问题了吧,但是,少年,你还是太天真了。执行obj[key]时会自动执行get()方法,因此这样会出现无限递归的情形,也是不可取的。
解决方案
使用let声明变量,而不使用var
MVVM.prototype.observe = function (data,key,val){
if(typeof data !== 'object' ){
return;
}
for(var key in data){
Object.defineProperty(this,key,{
configurable:false,
enumerable:true,
get(){
return val
},
set(newVal){
if(val === newVal){
return;
}//如果新值和旧值相等,那就什么都不做
val = newVal
observe(val)
}
})
}
}
那我们应该怎么做呢?数据代理帮我们很好的解决了这个问题。
数据代理
那么什么是数据代理呢?数据代理就是把data中的属性和属性值都复制到vm实例中。我们来看下面代码:
var vm = new Vue({
el:'#app',
data:{name:'XXX',age:18}
})
//在`vue`实例中,我们之所以可以通过`this.data`直接访问到data中的`name`,`age`属性,就是因为`vue`实例代理了`data`
那我们该怎么实现数据代理呢?其实也很简单,实现方式如下:
MVVM.prototype.dataProxy = function (data) {
for (var key in data) {
Object.defineProperty(this, key, {
enumerable: true,
get() {
return this._data[key]
},
set(newVal) {
this._data[key] = newVal
}
})
}
}
数据劫持
上面的代码看似完美了,其实也存在一个问题,因为我们方法是以表达式的方式声明的,在递归调用时因为observe()还没有被赋值,会报错。
解决办法
1.在类的内部声明该方法
2.在类的外部声明改方法并传递this参数。
在这里,我们采用第一种方式
function MVVM(options={}){
this.$options = options;// 将属性挂载到MVVM实例
var data = this._data= this.$options.data;
dataProxy(data)
observe(data)
// 数据劫持
function observe(data,key,val){
if(typeof data !== 'object' ){
return;
}
for(var key in data){
Object.defineProperty(this,key,{
configurable:false,
enumerable:true,
get(){
return val
},
set(newVal){
if(val === newVal){
return;
}//如果新值和旧值相等,那就什么都不做
this._data[key] = newVal
observe(this._data[key])
}
})
}
}
}
function MVVM(options={}){
this.$options = options;// 将属性挂载到MVVM实例
var data = this._data= this.$options.data;
Observe(data)
// 把data绑定到this-->MVVM实例上,实现数据代理
dataProxy(data)
}
function compile(el,mvvm){
var $el = document.querySelector(el);
var fragment =
}
createDocumentFragment() 用于创建一个文档片段作为容器,其中可以包含多个dom节点。这里有两点需要特别注意的地方:
当把文档片段插入DOM树的时候,只会把它的子节点插进去,它作为容器本身是不会进入DOM树的。 当把DOM树种的节点插入文档片段的时候,这些节点,会真的从DOM树种消失。我们也把这个过程叫做劫持。
实现发布订阅模式
所谓发布订阅模式,分为两个主体,一个是发布者,一个是订阅者。当发布者的内容发生变化后,会通知所有订阅者。
function Dep(){
this.subs = []
}
Dep.prototype.addSub = function(sub){
this.subs.push(sub)
}
Dep.prototype.notify = function(){
this.subs.forEach(sub=>sub.update())
}
function Watcher(fn){
this.fn = fn;
}
Watcher.prototype.update = function(){
this.fn()
}
绑定数据和视图
何时实现绑定?
在前面的Compiler中的replace()中,我们已经实现了将{{express}}替换为我们自己的数据。同样的,在数据发生变化时(也就是{{express}}中的值),应该将旧值替换新值,然后再修改node.textContent。
思路:
- 匹配到
{{express}}节点-->匹配结果 - 之前我们已经进行了数据劫持,但我们我们仅仅是监听了数据的变化,而并没有采取其他措施。如果要绑定数据与视图,则必须在数据改变时加入一些处理逻辑。数据发生改变时自动执行
set()方法,因此,我们需要在set方法中实现对页面的更新操作。 - 页面更新操作其实也就是修改我们的匹配结果。
function fn(vm,exp,callback(){
node.textContent = text.replace(exp,newVal)
})
....
Object.defineProperty(data, key, {
enumerable: true,
get() {
return val
},
set(newVal) {
if (val === newVal) {
return;
}//如果新值和旧值相等,那就什么都不做
val = newVal
observe(val)
fn()
}
})
- 现在剩下的问题是将
fn与set方法挂钩,将他们联系起来。 - 这就要用到我们前面实现的发布订阅模式了。
- 所谓发布订阅模式,分为两个主体,一个是发布者,一个是订阅者。当发布者的内容发生变化后,会通知所有订阅者。
- 数据本身就是发布者,而使用这些数据的地方就是订阅者。
- 在代码中的体现就是谁
get了数据,谁就是数据的订阅者。 - 那么还有一个问题,那怎么知道谁
get了数据呢? - 我们再回头来看一下我们实现的
replace()方法
function replace(fragment,vm) {
Array.prototype.slice.call(fragment.childNodes).forEach(function (node) {
let text = node.textContent;
let reg = /\{\{(.*)\}\}/
if (node.nodeType === Node.TEXT_NODE && reg.test(text)) { // 是空白字符失配
let arr = RegExp.$1.split('.')
let val = vm; // 为什么不直接从vm中获取数据
arr.forEach(function (key) {
val = val[key]
});
new Watcher(vm,RegExp.$1,function(newVal){
node.textContent = text.replace(reg, val)
})
node.textContent = text.replace(reg, val)
}
if (node.childNodes) {
replace(node,vm)
}
})
}
我们在将插值表达式替换成值时,必定会调用get方法。
nice!知道了谁调用get方法,就知道谁是订阅者了。因此,我们在replace方法中,new了一个Watcher- 那么一切就好办了。因为发布者一定是数据源。于是我们的代码变成这个样子:
function observe(data) {
console.log(data)
if (!data || typeof data !== 'object') {
return;
}
let dep = new Dep()
for (let key in data) {
let val = data[key]
Object.defineProperty(data, key, {
enumerable: true,
get() {
....
dep.addSub(watcher)//
return val
},
set(newVal) {
if (val === newVal) {
return;
}//如果新值和旧值相等,那就什么都不做
val = newVal
observe(newVal)
...// 执行watcher中所有的回调函数
dep.notify()
}
})
}
}
- 那么现在的问题是watcher如何传递进去?
- 一个简单的想法是直接在
dep.addSub(new Watcher()),这样存在很大的问题,如果采用这种方式,我们必须深入代码内部,而且可维护性十分低下 - 那么就想办法从外部传入喽...
- 从外部传入的方式一般的话我们想到的是参数传递,但这里有一种十分巧妙的方法
function Watcher(vm,exp,fn){
this.fn = fn;
this.vm = vm;
this.exp = exp;
Dep.target = this; // 使用Dep对象的target属性指向this-->watcher实例
this.mustache()
Dep.target = null;
}
Watcher.prototype.mustache = function(){
let val = this.vm
let arr = this.exp.split('.')
arr.forEach(function(key){ // 取出值
val = val[key]
})
return val
}
为什么说这样很巧妙呢?Dep是一个全局变量,我们在任何地方都可以访问到它。最后一步Dep.target=null,是为了避免同一个watcher的回调被执行多次。
function observe(data) {
if (!data || typeof data !== 'object') {
return;
}
let dep = new Dep()
for (let key in data) {
let val = data[key]
Object.defineProperty(data, key, {
enumerable: true,
get() {
Dep.target&&dep.addSub(Dep.target)//
return val
},
set(newVal) {
if (val === newVal) {
return;
}//如果新值和旧值相等,那就什么都不做
val = newVal
observe(newVal)
dep.notify()
}
})
}
}
实现双向数据绑定
实现思路:
- 实现双向绑定,则进行双向绑定的元素必定是
input标签 - 实现双向绑定需要绑定一个属性,在这里我定义为
x-model,其属性值是vm上的定义的数据 也就是说其dom元素表现为以下形式(name为data中定义的数据)
<input type="text" x-model="name">
- 判断是否存在双向绑定的标识,也就是是否存在
x-model属性 - 如果存在
x-model属性,则将其属性值填到input中, - 当
input里面的内容发生变化时,将input的value赋值给name属性
function parse(fragment,vm) {
Array.prototype.slice.call(fragment.childNodes).forEach(function (node) {
let text = node.textContent;
let reg = /\{\{(.*)\}\}/
if (node.nodeType === Node.TEXT_NODE && reg.test(text)) { // 是空白字符失配
let arr = RegExp.$1.split('.')
let val = vm; // 为什么不直接从vm中获取数据
arr.forEach(function (key) {
val = val[key]
});
new Watcher(vm,RegExp.$1,function(newVal){
node.textContent = text.replace(reg, newVal)
})
node.textContent = text.replace(reg, val)
}
if(node.nodeType === Node.ELEMENT_NODE){
let attrs = node.attributes;// 获取节点属性
Array.prototype.slice.call(attrs).forEach(function(attr){
let name = attr.name;
let exp = attr.value;
if(name.indexOf('x-')===0){//y因为node必定是`input`所以可以有`node.value`
node.value = vm[exp]
}
new Watcher(vm,exp,function(newVal){
node.value = newVal // 自动将内容放入到输入框内
})
node.addEventListener('input',function(e){
let newVal = e.target.value;
vm[exp] = newVal
})
})
}
if (node.childNodes) {
parse(node,vm)
}
})
}
实现computed计算属性
计算属性的实现原理:因为计算属性是一个对象,遍历computed的key,将其定义在mvvm实例上.
if (node.nodeType === Node.TEXT_NODE && reg.test(text)) { // 是空白字符失配
let arr = RegExp.$1.split('.')
let val = vm; // 为什么不直接从vm中获取数据
arr.forEach(function (key) {
val = val[key]
});
new Watcher(vm,RegExp.$1,function(newVal){
node.textContent = text.replace(reg, newVal)
})
node.textContent = text.replace(reg, val)
}
MVVM.prototype.initComputed = function () {
let vm = this;
let computed = this.$options.computed
Object.keys(computed).forEach(function(key){
Object.defineProperty(vm,key,{
get:typeof computed[key]==='function'?computed[key]:computed[key].get
})
})
}