发布订阅模式vue响应式原理的核心之一就是发布订阅模式。它定义的是一种依赖关系,当一个状态发生改变的时候,所有依赖这个状态的对象都会得到通知。比较典型的就是买东西,比如A想买一个小红花,但是它缺货了,于...
发布订阅模式
vue响应式原理的核心之一就是发布订阅模式。它定义的是一种依赖关系,当一个状态发生改变的时候,所有依赖这个状态的对象都会得到通知。
比较典型的就是买东西,比如A想买一个小红花,但是它缺货了,于是A就留下联系方式,等有货了商家就通过A的联系方式通知他。后来又来了B、C、D…,他们也想买小红花,于是他们都留下了联系方式,商家把他们的联系方式都存到小红花的通知列表,等小红花有货了,一并通知这些人。
在上面这个例子中,可以抽象出来发布订阅的两个类:
- Dep类:商家。Dep类有一个数组(小红花的通知列表),来存放订阅信息;还有两个操作:添加订阅者信息、通知订阅者。
- Watcher类: A、B、C、D每个人都是一个Watcher类。Watcher类提供回调函数,也就是收到通知的要做什么。
class Dep {
constructor(){
this.subs = [] //存放订阅者信息
}
addSub(watcher){ //添加订阅者
this.subs.push(watcher)
}
notify(){ //通知所有订阅者
this.subs.forEach((sub) => {
sub.update()
})
}
}
class Watcher{
constructor(cb){
this.cb = cb //订阅者在收到通知要执行的操作
}
update(){
this.cb && this.cb()
}
}
const a = new Watcher(()=>{
console.log('A收到,小红花到货了')
})
const b = new Watcher(()=>{
console.log('B收到,小红花到货了')
})
const dep = new Dep()
dep.addSub(a)
dep.addSub(b)
dep.notify()
数据劫持
在vue中,响应式数据可以类比成上面例子中的小红花,通过发布订阅的模式来监听数据状态的变化,通知视图进行更新。那么,是在何时进行订阅,何时进行发布,这就要用到数据劫持。
vue使用Object.defineProperty()进行数据劫持。
let msg = "hello"
const data = {};
Object.defineProperty(data, 'msg', {
enumerable: true,
configurable: true,
get() { //读取data.msg时会执行get函数
console.log('get msg')
return msg;
},
set(newVal) { //为data.msg赋值时会执行set函数
console.log('set msg')
msg = newVal;
}
});
data.msg //'get msg'
data.msg = 'hi' //'set msg'
通过Object.defineProperty定义的属性,在取值和赋值的时候,我们都可以在它的get、set方法中添加自定义逻辑。当data.msg的值更新时,每一个取值data.msg的地方也需要更新,可视为此处要订阅data.msg,因此 在get方法中添加watcher。data.msg重新赋值时,要通知所有watcher进行相应的更新,因此 在set方法中notify所有watcher。
在vue中,定义在data中的数据都是响应式的,因为vue对data中的所有属性进行了数据劫持。
function initData (vm) {
var data = vm.$options.data;
observe(data, true);
}
function observe (value, asRootData) {
var ob = new Observer(value);
return ob
}
//Observer的作用就是对数据进行劫持,将数据定义成响应式的
var Observer = function Observer (value) {
if (Array.isArray(value)) { //当数据是数组,数组劫持的方式与对象不同
if (hasProto) {
protoAugment(value, arrayMethods);
} else {
copyAugment(value, arrayMethods, arrayKeys);
}
this.observeArray(value);
} else {
//当数据是对象,递归对象,将对象的每一层属性都使用Object.defineProperty劫持,如 {a: {b: {c: 1}}}
this.walk(value);
}
};
使用vue时,data中经常会有数组,和对象不同,它的数据劫持不能通过Object.defineProperty来实现,下面我们分别来简单实现一下。
对象
对象的数据劫持,首先遍历对象的所有属性,对每一个属性使用Object.defineProperty劫持,当属性的值也是对象时,递归。
function observeObject(obj){
//递归终止条件
if(!obj || typeof obj !== 'object') return
Object.keys(obj).forEach((key) => {
let value = obj[key]
//递归对obj属性的值进行数据劫持
observeObject(value)
let dep = new Dep() //每个属性都有一个依赖数组
Object.defineProperty(obj,key,{
enumerable: true,
configurable: true,
get(){
dep.addSub(watcher) //伪代码, 添加watcher
return value
},
set(newVal){
value = newVal
//obj属性重新赋值后,对新赋的值也进行数据劫持,因为新赋的值可能也是一个对象
/ **
let a = {
b: 1
}
a.b = {c: 1}
**/
observeObject(value)
dep.notify() //伪代码, 通知所有watcher进行更新
}
})
})
}
数组
数组状态的变化主要有两种: 一是数组的项的变化,二是数组长度的变化。因此数组的数据劫持也是考虑这两方面。
- 数组项的劫持:
function observeArr(arr){
for(let i=0; i<arr.length; i++){
observe(arr[i]) //伪代码,对每一项进行劫持
}
}
vue对于数组项是简单数据类型的情况没有劫持,这也导致了vue数组使用的一个问题,当数组项是简单数据类型时,修改数据项时视图并不会更新。
<div><span v-for="item in arr">{{item}}</span></div>
<button @click="changeArr">change array</button> <!--点击按钮视图不会更新成523-->
data:{
arr: [1,2,3]
},
methods:{
changeArr(){
this.arr[0] = 5
}
}
- 数组长度变化的劫持是通过重写7个可以改变原数组长度的方法(
push, pop, shift, unshift, splice, sort, reverse
)实现的。
let arrayProto = Array.prototype;
let arrayMethods = Object.create(arrayProto); //arrayMethods继承自Array.prototype
let methodsToPatch = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
methodsToPatch.forEach((method) => { //重写这7个方法
arrayMethods[method] = function(...args) {
let result = arrayProto[method].apply(this,args) //调用原有的数组方法
let inserted;
switch (method) {
case 'push':
case 'unshift':
inserted = args;
break
case 'splice':
inserted = args.slice(2);
break
}
if (inserted) { //push、unshift、splice可能加入新的数组元素,这里也要对新元素进行劫持
observeArray(inserted);
}
dep.notify(); //伪代码, 通知所有watcher进行更新
return result
}
})
arr.__proto__ = arrayMethods //arr是需要进行劫持的数组,修改它原有的原型链方法。
实现一个简单的双向数据绑定
- 第一步,初始化。
class Vue {
constructor(options){
this.$data = options.data
this.$getterFn = options.getterFn
observe(this.$data) // 将定义在options.data中的数据作响应式处理
//options.getterFn是一个取值函数,模拟页面渲染时要做的取值操作
new Watcher(this.$data, this.$getterFn.bind(this), key => {
console.log(key + "已修改,视图刷新")
})
}
}
- 第二步,实现observe方法。主要就是用到上面的发布订阅模式和数据劫持。
function observe(data){
if(!data || typeof data !== 'object') return
let ob;
//为数据创建observer时,会将observer添加到数据属性,如果数据已经有observer,会直接返回该observer
if (data.hasOwnProperty('__ob__') && data.__ob__ instanceof Observer) {
ob = data.__ob__;
}else{
ob = new Observer(data)
}
return ob
}
class Observer {
constructor(data){
this.dep = new Dep() //将dep挂载到observer上,用于处理data是数组的情况
Object.defineProperty(data, '__ob__', { //将observer挂载到要data上,方便通过data访问dep属性和walk、observeArray方法
enumerable: false,
configurable: false,
value: this
})
if(Array.isArray(data)){ //如果是数组,重写数组的7个方法,对数组的每一项作响应式处理
data.__proto__ = arrayMethods
this.observeArray(data)
}else{
this.walk(data)
}
}
walk(data){
let keys = Object.keys(data)
keys.forEach((key) => {
defineReactive(data, key)
})
}
observeArray(data){
data.forEach((val) => {
observe(val)
})
}
}
//重写数组的7个方法
let arrayProto = Array.prototype;
let arrayMethods = Object.create(arrayProto); //arrayMethods继承自Array.prototype
let methodsToPatch = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
methodsToPatch.forEach((method) => {
arrayMethods[method] = function(...args) { //将一个不定数量的参数表示为一个数组
let result = arrayProto[method].apply(this,args) //调用原有的数组方法
let inserted;
switch (method) {
case 'push':
case 'unshift':
inserted = args;
break
case 'splice':
inserted = args.slice(2);
break
}
if (inserted) { //push、unshift、splice可能加入新的数组元素,这里也要对新元素进行劫持
this.__ob__.observeArray(inserted);
}
this.__ob__.dep.notify('array') //触发这个数组dep的notify方法
return result
}
})
function defineReactive(data,key){
let dep = new Dep() //每个属性对应一个dep,来管理订阅
let value = data[key]
//当value是数组时,不会为数组的每个属性添加dep,而是为整个数组添加一个dep。
//当数组执行上面那7个方法时,就触发这个dep的notify方法 this.__ob__.dep.notify('array')
let childOb = observe(value)
Object.defineProperty(data,key,{
enumerable: true,
configurable: true,
get(){
//添加订阅者。Dep.target是一个全局对象。它指向当前的watcher
Dep.target && dep.addSub(Dep.target)
if(Array.isArray(value)) {
Dep.target && childOb.dep.addSub(Dep.target)
}
return value
},
set(newVal){
if(newVal === value) return
value = newVal
observe(value)
dep.notify(key)
}
})
}
何时触发watcher还是明显的。添加watcher就有点不太明显了。这里对watcher的构造函数作了一些修改。
Dep.target = null
class Watcher{
constructor(data,getterFn,cb){
this.cb = cb
Dep.target = this
getterFn()
Dep.target = null
}
update(key){
this.cb && this.cb(key)
}
}
关键就是:
Dep.target = this
getterFn()
Dep.target = null
在new Watcher()
时,就会执行这三行代码。Dep.target = this
将当前创建的watcher赋值给Dep.target
这个全局变量,执行getterFn()
时,会对取vm.$data
中的值,上面已经将vm.$data
作了响应式处理,所以取它值的时候就会执行各属性的get方法
。
get(){
//此时Dep.target指向当前的watcher,此时就将当前watcher添加到这个属性对应的订阅数组里。
Dep.target && dep.addSub(Dep.target)
if(Array.isArray(value)) {
Dep.target && childOb.dep.addSub(Dep.target) //如果属性对应的值是数组,就将当前watcher添加到该数组对应的订阅数组里。
}
return value
},
这样就完成了对需要访问的属性添加watcher的操作,然后将Dep.target还原成null。
测试代码:(渲染视图也是对data里的属性取值,如{{msg.m}},添加watcher,完成订阅。这里我们就简单访问取值来进行模拟)
let vm = new Vue({
el:?'#root',
data:{
msg: {
m: "hello world"
},
arr: [
{a: 1},
{a: 2}
]
},
getterFn(){
console.log(this.$data.msg.m)
this.$data.arr.forEach((item) => {
console.log(item.a)
})
}
})
效果:
可以看到,getterFn访问过的数据,在修改值时就会触发watcher的回调函数。
vue的几种watcher
vue里面主要有三种watcher:
- 渲染watcher: 当渲染用到的data数据变化时,重新渲染页面
- computed watcher: 当data数据变化时,更新computed的值
- user watcher: 当要watch的数据变化时,执行watch定义的回调函数
渲染watcher
渲染watcher是在vm.$mount()方法执行时创建的。
Vue.prototype.$mount = function () {
var updateComponent = function () {
vm._update(vm._render(), hydrating);
};
//updateComponent就是进行视图渲染的函数,对data中数据的取值的操作就是在该函数中完成
new Watcher(vm, updateComponent, noop, options,true);
};
Watcher的构造函数:
var Watcher = function Watcher (vm,expOrFn,cb,options,isRenderWatcher) {
this.vm = vm;
if (options) {
...
this.lazy = !!options.lazy; //主要用于computed watcher
} else {
this.deep = this.user = this.lazy = this.sync = false;
}
this.cb = cb;
if (typeof expOrFn === 'function') {
this.getter = expOrFn; //expOrFn对应上面的updateComponent方法
} else {
this.getter = parsePath(expOrFn);
}
//如果this.lazy为false,就立即执行this.get()
//所以在创建watcher的时候就会执行updateComponent方法
this.value = this.lazy? undefined: this.get();
};
Watcher.prototype.get = function get () {
pushTarget(this); //类比上面简易版的Dep.target = this
var value;
var vm = this.vm;
value = this.getter.call(vm, vm); //执行取值函数,完成watcher订阅
popTarget(); //类比上面简易版的Dep.target = null
return value
};
在渲染watcher创建的时候,就立即执行取值函数,完成响应式数据的依赖收集。可以看出,定义在data中的数据,它们的watcher都是同一个,就是在vm.$mount()方法执行时创建的watcher。watcher的update方法:
Watcher.prototype.update = function update () {
if (this.lazy) {
this.dirty = true;
} else if (this.sync) {
this.run();
} else {
queueWatcher(this); //渲染watcher会走这里的逻辑,其实最终都会执行this.run(),只是这里用队列进行优化
}
};
Watcher.prototype.run = function run () {
var value = this.get(); //又会执行updateComponent方法
}
定义在data中的数据,它们的watcher都是同一个,当data每一次数据中数据更新时,都会执行watcher.update()。渲染watcher的update()最终会执行updateComponent方法
,如果一次性修改N个data属性时,比如下面例子中的change
,理论上会执行N次updateComponent()
,很明显,这是不科学的。
作为优化,维护一个watcher队列,每次执行watcher.update()就尝试往队列里面添加watcher(queueWatcher(this))
,如果当前watcher已经存在于队列中,就不再添加。最后在nextTick
中一次性执行这些watcher的run方法。
这样,如果一次性修改N个data属性时,实际上只会执行一次updateComponent()
data:{
msg: "hello",
msg2: "ni hao"
},
methods:{
change(){
this.msg = "hi"
this.msg2 = "hi"
}
},
computed watcher
data:{
msg: "hello"
},
computed: {
newMsg(){
return this.msg + ' computed'
}
},
<div>{{newMsg}}</div>
当msg更新时,newMsg也会更新。因为computed会对访问到的data数据(这里是msg)进行订阅。
function initComputed (vm, computed) {
var watchers = vm._computedWatchers = Object.create(null);
for (var key in computed) {
var userDef = computed[key];
var getter = typeof userDef === 'function' ? userDef : userDef.get;
watchers[key] = new Watcher( //watcher的取值函数就是我们在computed中定义的函数
vm,
getter || noop,
noop,
computedWatcherOptions // { lazy: true }
);
if (!(key in vm)) {
defineComputed(vm, key, userDef);
}
}
}
在initComputed的时候,创建了watcher,它有个属性lazy: ture
。在watcher的constructor中,lazy: ture
表示创建watcher的时候不会执行取值函数,所以,此时watcher并没有加入msg的订阅数组。
this.value = this.lazy? undefined: this.get();
只有在页面对computed进行取值{{newMsg}}
的时候,watcher才会加入msg的订阅数组。这里主要来看看defineComputed
方法,它的大致逻辑如下:
function defineComputed (target,key,userDef) { // target:vm, key: newMsg
Object.defineProperty(target, key, {
enumerable: true,
configurable: true,
get: function computedGetter () { //当视图对newMsg进行取值的时候会执行这里
var watcher = this._computedWatchers && this._computedWatchers[key];
if (watcher) {
if (watcher.dirty) { //这里要对照Watcher的构造函数来看,默认watcher.dirty = watcher.lazy,首次执行为true
watcher.evaluate(); //会执行watcher.evaluate()
}
if (Dep.target) {
watcher.depend();
}
return watcher.value
}
},
set: userDef.set || noop
});
}
Watcher.prototype.evaluate = function evaluate () {
this.value = this.get(); //执行watcher的取值函数,返回取值函数执行的结果,并将watcher添加到msg的订阅数组
this.dirty = false; //this.dirty置为false,用于缓存。
};
computed watcher有个属性dirty,用于标记是否执行取值函数。
1、初始化watcher时,watcher.dirty = watcher.lazy,值为true
。页面第一次访问newMsg时就会执行watcher.evaluate()
2、取值完成后,watcher.dirty = false
。下一次页面再取值就会直接返回之前计算得到的值 watcher.value 。
3、如果watcher订阅的 msg 发生变化,就会通知执行watcher的 watcher.update()
。lazy属性为true的watcher执行update方法是watcher.dirty = true
,这样页面取值newMsg就会重新执行取值函数,返回新的值。这样就实现了computed的缓存功能。
Watcher.prototype.update = function update () {
if (this.lazy) {
this.dirty = true;
} else if (this.sync) {
this.run();
} else {
queueWatcher(this);
}
};
user watcher
watch:{
msg(newValue,oldValue){
console.log(newValue,oldValue)
}
},
或者这样:
mounted(){
this.$watch('msg',function(newValue,oldValue){
console.log(newValue,oldValue)
})
}
user watcher的核心方法就是vm.$watch:
Vue.prototype.$watch = function (expOrFn,cb,options) {
//核心就是这里
//expOrFn ---> msg
//cb ---> 用户自己定义的回调函数,function(oldValue,newValue){console.log(oldValue,newValue)}
var watcher = new Watcher(vm, expOrFn, cb, options);
};
}
和渲染watcher、 computed watcher的expOrFn
不同,user watcher 的expOrFn
是个表达式。
//watcher的构造函数中
if (typeof expOrFn === 'function') {
this.getter = expOrFn;
} else {
this.getter = parsePath(expOrFn);
}
创建user watcher时,会根据这个表达式完成取值操作,添加watcher到订阅数组。
expOrFn: 'msg' -----> vm.msg
expOrFn: 'obj.a' -----> vm.obj ----->vm.obj.a
deep:true
时,会递归遍历当前属性对应的值,将watcher添加到所有属性上,每一次修改某一个属性都会执行watcher.update()
。
Watcher.prototype.get = function get () {
pushTarget(this);
var value;
var vm = this.vm;
value = this.getter.call(vm, vm);
if (this.deep) {
traverse(value); //递归遍历取值,每次取值都添加该watcher到取值属性的订阅数组。
}
popTarget();
return value
};
vue源码系列文章:
vue2.0的响应式原理
vue编译流程分析
vuex原理之由浅入深手写vuex
vue组件从构建VNode到生成真实节点树
本文标题为:vue的响应式原理
基础教程推荐
- 关于Ajax跨域问题及解决方案详析 2023-02-23
- uniapp开发微信小程序自定义顶部导航栏功能实例 2022-10-21
- 使用ajax跨域调用springboot框架的api传输文件 2023-02-23
- 在实战中可能碰到的几种ajax请求方法详解 2023-02-01
- Ajax+smarty技术实现无刷新分页 2022-12-15
- cocos creator游戏实现loading加载页面,游戏启动加载动画 2022-10-29
- Ajax发送和接收请求 2022-12-15
- Javascript Bootstrap的网格系统,导航栏和轮播详解 2023-08-11
- JS滚动到顶部踩坑解决记录 2023-07-10
- HTML clearfix清除浮动讲解 2022-11-20