总述
vue是采用数据劫持配合发布者-订阅者的模式的方式,通过Object.defineProperty()
来劫持各个属性的getter和setter,在数据变动时,set方法就会发布消息给依赖收集器(dep中的subs),去通知(notify)观察者,做出对应的回调函数,去更新视图
MVVM作为绑定的入口,整合Observer(数据监听器),Compile(指令解析器)和Watcher(观察者)三者,通过Observer来监听model数据变化,通过Compile来解析编译模板指令,最终利用Watcher搭起Observer,Compile之间的通信桥路,达到数据变化=>视图更新;视图交互变化=>数据model变更的双向绑定效果。
详细(以Vue为例,这里是一个Vue的简单实现)
class Vue {
constructor(options) {
this.$data = options.data;
this.$el = options.el;
this.$options = options;//属性中也会有methods
// 如果这个根元素存在开始编译模板
if (this.$el) {
// 1.先实现一个数据监听器Observe, 对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅器Dep,触发一个通知的方法,通知观察者更新视图
// Object.definerProperty()来定义
new Observer(this.$data);
// 把数据获取操作 vm上的取值操作 都代理到vm.$data上
this.proxyData(this.$data);
// 2.实现一个指令解析器Compile
//(如:解析v-html="a.b"的标签(指令)和值(a.b)),数据监听绑定完成后,
//指令解析就会解析每个指令的值, 解析值(或代码中取值)的时候就会通过watcher的一个获取旧值的方法, 这个方法通过获取值触发了data属性的get方法, 给每个属性一个订阅器, 通过订阅器添加了观察者
//每个观察者都有一个旧值, 每当属性值改变时,触发set方法,订阅器就会通知观察者去更新
new Compile(this.$el, this);
}
}
// 做个代理,代理的作用是在methods中修改data的属性只需要this.a=1就可更改而不是需要this.$data.a=1才能更改
proxyData(data) {
for (const key in data) {
Object.defineProperty(this, key, {
get() {
return data[key];
},
set(newVal) {
data[key] = newVal;
}
})
}
}
}
首先, 需要实现一个数据监听器类Observer
// 创建一个数据监听者 劫持并监听所有数据的变化
class Observer {
constructor(data) {
this.observe(data);
}
/**
* 遍历data中的属性,设置每个属性的监听
* @param {*} data
*/
observe(data) {
// 如果当前data是一个对象才劫持并监听
if (data && typeof data === 'object') {
// 遍历对象的属性做监听
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key]);
})
}
}
/**
* 监听属性
* @param {*} obj 对象
* @param {*} key
* @param {*} value
*/
defineReactive(obj, key, value) {
// 循环递归 对所有层的数据进行观察
this.observe(value);//这样obj也能被观察了
const dep = new Dep();//每一个属性都设置一个订阅器
Object.defineProperty(obj, key, {
get() {
//订阅数据变化,往Dep中添加观察者,观察数据是否有变化
Dep.target && dep.addSub(Dep.target);
return value;
},
set: (newVal) => {
//(即: data中的一个属性如果由一个值变为一个对象, 若不重新观察,则该对象的属性不会被监听到)
this.observe(newVal);
if (newVal !== value) {
// 如果外界直接修改对象 则对新修改的值重新观察
value = newVal;
}
// 通知变化
dep.notify();
}
})
}
}
这个数据监听器就实现了数据的劫持, 给data中的每一个层级属性都绑定了一个观察者, 当属性发生变化时, 订阅器(Dep)会有一个依赖收集器(Dep中的subs), 去通知(notify)观察者,做出对应的回调函数,去更新视图
数据监听器有了, 那么接下来就要实现下订阅器类和观察者
//观察者类
class Watcher {
constructor(vm, expr, cb) {
// 观察新值和旧值的变化,如果有变化 更新视图
this.vm = vm;
this.expr = expr;//指令内容
this.cb = cb;//callback
// 先把旧值存起来
this.oldVal = this.getOldVal();
}
//获取旧值
getOldVal() {
Dep.target = this;//Watcher挂载到订阅器中
let oldVal = compileUtil.getVal(this.expr, this.vm);
Dep.target = null;//挂载后target删除掉,如果target还有值, 则一个属性多次改变就会有多个watcher,会难以维护
return oldVal;
}
// 更新操作 数据变化后 Dep会发生通知 告诉观察者更新视图
update() {
console.log('旧值', this.oldVal)
let newVal = compileUtil.getVal(this.expr, this.vm);//获取新值
if (newVal !== this.oldVal) {//如果旧值!==新值,则触发callback
this.cb(newVal);
}
}
}
// 订阅器
class Dep {
constructor() {
this.subs = [];//依赖收集器
}
// 搜集观察者
addSub(watcher) {
this.subs.push(watcher);
}
// 通知观察者去更新
notify() {
// 观察者中的update方法 来更新视图
console.log("观察者", this.subs)
this.subs.forEach(w => {//遍历watcher,去更新
w.update()
});
}
}
这里, 订阅器的作用是收集watcher依赖,
待data中数据发生变化时, 触发属性的set方法, 然后会调用Dep的 notify方法去通知观察者更新
观察者是怎么添加的, 什么啥时候添加的观察者? 这里就需要指令解析类(Compile)
Vue中的模板指令 即 v-html, v-model等, 都是一个个自定义属性, Vue将这些指令做了解析, 解析时获取了data中的属性, 触发了get方法, 在yi'la中添加了观察者
// 指令解析器
class Compile {
constructor(el, vm) {
// 判断el参数是否是一个元素节点,如果是直接赋值,如果不是 则获取赋值
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
// 因为每次匹配到进行替换时,会导致页面的回流和重绘,影响页面的性能
// 所以需要创建文档碎片来进行缓存,减少页面的回流和重绘
// 1.获取文档碎片对象
const fragment = this.node2Fragment(this.el);
// 2.编译模板
this.compile(fragment)
// 3.把子元素的所有内容添加到根元素中
this.el.appendChild(fragment);
}
compile(fragment) {
// 1.获取子节点
const childNodes = fragment.childNodes;
// 2.遍历子节点
[...childNodes].forEach(child => {
// 3.对子节点的类型进行不同的处理
if (this.isElementNode(child)) {
// 是元素节点
// 编译元素节点
// console.log('我是元素节点',child);
this.compileElement(child);
} else {
// console.log('我是文本节点',child);
this.compileText(child);
// 剩下的就是文本节点
// 编译文本节点
}
// 4.一定要记得,递归遍历子元素
if (child.childNodes && child.childNodes.length) {
this.compile(child);
}
})
}
// 编译文本的方法
compileText(node) {
const content = node.textContent;
// 匹配{{xxx}}的内容
if (/\{\{(.+?)\}\}/.test(content)) {
// 处理文本节点
compileUtil['text'](node, content, this.vm)
}
}
//获取文档碎片
node2Fragment(el) {
const fragment = document.createDocumentFragment();
// console.log(el.firstChild);
let firstChild;
将dom对象的节点加入到文档碎片中,从dom元素的第一个节点开始加入到文档碎片中, 直到没有为止
while (firstChild = el.firstChild) {
fragment.appendChild(firstChild);
}
return fragment
}
//判断el是否是一个dom对象
isElementNode(el) {
return el.nodeType === 1;
}
//编译元素节点
compileElement(node) {
// 获取该节点的所有属性
const attributes = node.attributes;
// 对属性进行遍历
[...attributes].forEach(attr => {
const { name, value } = attr; //v-text v-model v-on:click @click
// 看当前name是否是一个指令
if (this.isDirective(name)) {
//对v-text进行操作
const [, directive] = name.split('-'); //text model html
// v-bind:src
const [dirName, eventName] = directive.split(':'); //对v-on:click 进行处理
// 更新数据
compileUtil[dirName] && compileUtil[dirName](node, value, this.vm, eventName);
// 移除当前元素中的属性
node.removeAttribute('v-' + directive);
} else if (this.isEventName(name)) { // 对事件进行处理 在这里处理的是@click
let [, eventName] = name.split('@');
compileUtil['on'](node, value, this.vm, eventName)
}
})
}
// 是否是@click这样事件名字
isEventName(attrName) {
return attrName.startsWith('@')
}
//判断是否是一个指令
isDirective(attrName) {
return attrName.startsWith('v-')
}
}
// 编译模板工具
const compileUtil = {
/**
* 获取指令标签的值的方法,此时触发了get方法,添加了观察者
* @param {*} expr 指令标签的值:如 v-model="a.b", expr就是a.b
* @param {*} vm vue实例
*/
getVal(expr, vm) {
return expr.split('.').reduce((data, currentVal) => {
return data[currentVal]
}, vm.$data)
},
/**
* 获取指令标签的值的方法,视频的方法是错误的.....
* @param {*} expr 指令标签的值:如 v-model="a.b", expr就是a.b
* @param {*} vm vue实例
*/
setVal(vm, expr, val) {
return expr.split('.').reduce((data, currentVal, index, arr) => {
if (index == arr.length - 1) {
data[currentVal] = val;
}
return data[currentVal];
}, vm.$data)
// var value = vm.$data
// expr.split('.').forEach(item => {
// value = value[item];
// })
// return value
},
//获取新值 对{{a}}--{{b}} 这种格式进行处理
getContentVal(expr, vm) {
return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
return this.getVal(args[1], vm);
})
},
text(node, expr, vm) {
let val;
if (expr.indexOf('{{') !== -1) {//expr 可能是 {{obj.name}}--{{obj.age}}
val = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {//取到双花括号内部的data属性字符串
console.log(args)
//绑定watcher,当数据发生变化的时候, 触发回调, 从而更新视图
new Watcher(vm, args[1], () => {//args[1]为比配到的字符串,即指令设置的data属性
this.updater.textUpdater(node, this.getContentVal(expr, vm));//若获取到的expr为{a}---{b},则进一步获取指令值
})
return this.getVal(args[1], vm);
})
} else { //也可能是v-text='obj.name' v-text='msg'
val = this.getVal(expr, vm);
}
this.updater.textUpdater(node, val);
},
html(node, expr, vm) {
// html处理 非常简单 直接取值 然后调用更新函数即可
let val = this.getVal(expr, vm);
// 订阅数据变化时 绑定watcher,从而更新函数
new Watcher(vm, expr, (newVal) => {
this.updater.htmlUpdater(node, newVal);
})
this.updater.htmlUpdater(node, val);
},
model(node, expr, vm) {
const val = this.getVal(expr, vm);
// 订阅数据变化时 绑定更新函数 更新视图的变化
// 数据==>视图
new Watcher(vm, expr, (newVal) => {
this.updater.modelUpdater(node, newVal);
})
// 视图==>数据
node.addEventListener('input', (e) => {
// 设置值
debugger
this.setVal(vm, expr, e.target.value);
}, false);
this.updater.modelUpdater(node, val);
},
/**
* 对事件进行处理
* @param {*} node 节点
* @param {*} expr 事件的函数(@click="handlerClick")中的 handlerClick
* @param {*} vm vm实例
* @param {*} eventName 事件名
*/
on(node, expr, vm, eventName) {
// 获取事件函数
let fn = vm.$options.methods && vm.$options.methods[expr];
// 添加事件 因为我们使用vue时 都不需要关心this的指向问题,这是因为源码的内部帮咱们处理了this的指向
node.addEventListener(eventName, fn.bind(vm), false);
},
// 绑定属性 简单的属性 已经处理 类名样式的绑定有点复杂 因为对应的值可能是对象 也可能是数组 大家根据个人能力尝试写一下
bind(node, expr, vm, attrName) {
let attrVal = this.getVal(expr, vm);
this.updater.attrUpdater(node, attrName, attrVal);
},
updater: {
attrUpdater(node, attrName, attrVal) {
node.setAttribute(attrName, attrVal);
},
modelUpdater(node, value) {
node.value = value;
},
textUpdater(node, value) {
node.textContent = value;
},
htmlUpdater(node, value) {
node.innerHTML = value;
}
}
}