前言
在我们正式地进入响应式的学习前,我们需要先明确mvvm
,双向绑定,响应式这三者的概念和关系。
mvvm
分开了数据和视图,通过中间层viewmodel
将两者结合在一起,实现当数据改变时页面变化,当页面改变时数据也发生改变。viewmodel
通常要实现一个数据监听器observer
,当数据改变时,viewmodel
能够监听到数据的变化,然后通知相应的视图做自动更新,而当用户操作视图的时候,viewmodel
也能监听到视图的变化,然后通知数据做改动。
双向绑定
基于mvvm
模式实现数据更新,视图变化。视图变化,数据也能同样得到更新。
响应式
数据发生变化重新对页面进行渲染。
应用
vue2和vue3中关于响应式的区别
以下会直接介绍vue2
和vue3
关于响应式的一些不同表现。
vue2
中不能监听动态添加的属性,举个例子来看
let vm = new Vue({
el:'#app',
data:{
msg:'hello'
}
})
vm.person = 'Mary'
console.log(vm.msg)
console.log(vm.person)
请问person
会是响应式吗?
可以发现只有msg
是响应式的,当更改vm.person
时也没有任何变化。官方文档中也特别说明了vue
不允许动态添加根级别的响应式属性,但是可以通过Vue.set
来设置,但是有时候我们还是会迷惑什么时候使用Vue.set
。
vue2
中不能监听属性的删除操作
页面也没有任何变化,msg
依旧被渲染出来,再看vue3
中
const app = Vue.createApp({
data() {
return { msg: 'hello' }
}
})
const vm = app.mount('#app')
console.log(vm.$data.msg)
-
vue2
中不能监听数组索引和length
属性具体来说就是直接通过索引去修改数组,虽然数组里更新了,但是,它不会响应到DOM里,虽然把整个数组更新了,但是,它的样式依旧没变。有两种解决办法,一种同样使用
Vue.set
,一种是数组的方法,如pop
,push
,shift
,unshift
,splice
,sort
,reverse
这七个方法。为什么这七个方法可以呢?因为vue2
内部将这些方法做了处理,在保留这些方法功能的同时,也就是每次调用了该方法后进行派发更新.看起来
vue2
只实现一部分的响应式,而vue3的响应式解决了这些问题,怎么解决呢?
原理
proxy VS defineProperty
先来看看proxy
和defineProperty
的基本使用,关于详细使用可以mdn
上自行学习
先来看defineProperty
// 遍历data中的所有属性 this是vue实例
Object.keys(data).forEach(key =>{
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get(){
return data[key]
},
set(newValue){
if(newValue == data[key]) return
data[key] = newValue
}
})
})
再看proxy
// proxy代理的是整个对象 不用循环遍历属性,性能更好
let vm = new Proxy(data, {
get(target, key){
return target[key]
},
set(target, key, newValue){
if(target[key] == newValue) return
target[key] = newValue
}
})
可以看到区别,vue2
中需要去遍历属性,拦截的是修改obj[key]
和读取obj[key]
,而proxy
代理的是整个对象,不需要给定key
值,这就是为什么能够动态新增属性的原因。还可以在Proxy
中拦截更多的操作符,定义一些其他被代理对象上的自定义行为,如deleteProperty
拦截delete
操作符,这样便可以监听属性的删除操作。
实现
实现核心
不论是vue2
还是vue3
,响应式的实现离不开两个核心
- 数据劫持(在
vue3
中称为数据代理更为贴切,这里暂不做区分) - 观察者模式
数据劫持我们已经分析过defineProperty
和proxy
的区别以及它们所产生的现象不同了,下面再来说说观察者模式。我一开始总是分不清观察者模式和订阅模式,在实现eventbus
以及vue2
响应式原理时才略知一二。
观察者模式与发布订阅模式
- 共同点:都有发布者和订阅者
- 不同点:观察者模式中没有信号中心,而发布订阅模式中有一个信号中心
发布订阅模式
有一个信号中心,某个任务执行完毕,就向信号中心发布一个信号,其他任务可以向信号中心订阅到这个信号,从而知道什么时候自己可以执行。
- 订阅者
- 发布者
- 信号中心
vue
中自定义事件使用的就是发布订阅模式,兄弟组件通信中也使用到eventbus
事件中心,隔离了发布者和订阅者,使用起来更加灵活。下面是一个简单的eventbus
模拟实现。
// 事件中心
class eventEmitter{
constructor(){
this.subs = Object.create(null)
}
// 注册事件
$on(eventType, handler){
this.subs[eventType]= this.subs[eventType] || []
this.subs[eventType].push(handler)
}
// 触发事件
$emit(eventType){
if(this.subs[eventType]){
this.subs[eventType].forEach(handler => {
handler()
});
}
}
}
let em = new eventEmitter()
em.$on('click',()=>{
console.log('click1')
})
em.$on('click',()=>{
console.log('click2')
})
em.$emit('click')
观察者模式
- 订阅者:
watcher
- 发布者:
dep
,需要知道订阅者的存在
没有事件中心,订阅者和发布者之间有依赖关系
//发布者
class Dep{
constructor(){
// 记录所有的订阅者
this.subs = []
}
// 收集依赖
addSub(sub){
if(sub && sub.update){
this.subs.push(sub)
}
}
// 派发更新,调用所有订阅者的update方法
notify(){
this.subs.forEach((sub) => {
// 触发订阅者更新
sub.update()
})
}
}
//订阅者
class Watcher{
update(){
console.log('update')
}
}
let dep = new Dep()
let watcher = new Watcher()
dep.addSub(watcher)
dep.notify()
正式实现
一个简单的响应式vue2
总体来说,实现一个响应式主要需要借助数据劫持和观察者模式。实现过程中主要关注两个方面:
- 页面首次渲染的过程
- 数据改变更新视图的过程
响应式
这是官方vue
响应式的原理图,我们先简化一下它,抓住这几个几个核心,watcher
,dep
,defineReactive
,还有一个进行模版编译功能的compiler
。分别来看看这几个部分需要实现什么。
observer
- 把
data
选项中的属性转换成响应式数据 - 如果
data
中的某个属性也是对象,把子属性也变成响应式 get
时触发依赖收集,收集依赖于属性的watcher
set
时触发派发更新,数据变化时发送通知
compiler
- 负责编译模版
- 时机1:首次渲染
- 时机2:数据变化后重新渲染视图
Dep
- 收集的是依赖于属性的订阅者
- 当属性变换的时候,会去通知所有订阅者
watcher
用一张图来理解订阅者与发布者的关系。
- 当数据变化触发依赖,
dep
通知所有的watcher
更新视图 - 自身实例化的时候向
dep
对象中添加自己
实现步骤
- 将数据代理到
vue
实例上; - 通过一个数据监听器中的数据劫持将数据赋予
getter
和setter
,设置收集依赖和派发更新后使其变成响应式对象。在vue
中会把props
,data
等变成响应式对象,在创建过程中,发现子属性也为对象则递归把该对象变成响应式; - 编译
dom
节点,区分不同的节点处理方式,将数据能够展现在视图上; - 编译
dom
节点的时候,要定义一个watcher
对象,当数据改变时,会通知watcher
进行更新,设置订阅器中当前target
为该watcher
,访问属性时将订阅器会添加这个订阅者; - 当数据改变时,遍历属性的
subs
,也就是订阅器,去执行它们的update
方法。由此实现了数据驱动视图,也就是响应式。
关于实现源码这里不再详细分析,如果抓住这几个核心就没问题,万变不离其宗。
一个简单的响应式vue3
这里只实现reactive
,它能够把一个对象变成响应式对象。
实现核心
proxy
数据劫持- 观察者模式:收集依赖与派发更新,依赖是依赖于每个属性变化的
effect
函数,即收集订阅者,数据变化时通知订阅者进行更新
实现步骤
reactive
将一个对象变成响应式对象,类似于vue2
实现中的observer
类实现的数据劫持- 实现依赖收集
track
和 派发更新trigger
- 在
track
和trigger
的实现过程中使用到Set
和Map
数据结构存储依赖属性的操作函数
主要部分
reactive
const isObject = obj => { return obj != null && typeof obj == 'object'}
const convert = obj => { return isObject(obj) ? reactive(obj) : obj}
// 代理的是整个对象,不需要循环taregt,提升了性能
export function reactive(target){
// 判断target是否为对象,如果是继续,如果不是直接return
if(!isObject(target)) return
// 创建proxy实例
const proxy = new Proxy(target, {
get(target, key, receiver){
// 收集依赖
track(target, key)
const result = Reflect.get(target, key, receiver)
// 有子对象也要变成响应式
return convert(result)
},
set(target, key ,newValue, receiver){
let result = true
// 当新老不相等时才需要更新
if(Reflect.get(target, key, receiver) != newValue){
result = Reflect.set(target, key ,newValue, receiver)
// 派发更新
trigger(target,key)
}
return result
// 这里为什么要保存一个result值呢
// 因为需要要set之后需要派发更新(可以理解为额外的功能),而set本身必须返回一个布尔值
},
deleteProperty(target, key){
const hasOwn = target.hasOwnProperty(key)
const result = Reflect.deleteProperty(target, key)
if(hasOwn && result){
// 派发更新
trigger(target,key)
}
// deleteProperty本身也必须返回一个布尔值
return result
}
})
// 最后返回proxy实例
return proxy
}
effect
使用effect
来观测变化,相当于watcher
。
const product = reactive({
name:'iphone',
price:5000,
count:3
})
let total = 0
effect(() => {
total = product.price * product.count
})
console.log(total) //15000
product.price = 8000
console.log(total)//24000
track
和trigger
在proxy
的get
中需要track
收集依赖,set
和deleteProperty
中需要派发更新,那么需要考虑的是收集依赖的时候如何存放依赖呢?这里和vue2
中defineProperty
最大的区别是,vue2
中循环遍历对象每个key
时,在getter
中收集依赖;vue3
中对应的是整个对象,同样地,要收集对应属性的依赖。这里使用这样的结构来存储。
- 首先全局会创建一个
targetMap
,用来建立 数据 -> 依赖 的映射,它是一个WeakMap
数据结构。 targetMap
通过键target
,可以获取到depsMap
。depsMap
的键是属性名称,值dep
用来存放这个数据对应的所有响应式依赖。dep
是一个Set
数据结构,存放着对应key
的更新函数,也就是effect
的回调函数。track
时,传入target
和key
,举一个例子
target = {price: 190, count: 10}
// 此时targetMap.get(target)有值depsMap,是一个map结构
// 以两个属性为键,value是一个Set结构
depsMap = {'price' => Set(1), 'count' => Set(1)}
// 向Set添加依赖函数
// Set中是effect的回调函数,它依赖响应式属性的变化而变化
trigger
时,同样根据该结构一层层找到对应属性的Set
结构,执行变化
function trigger (target, key){
const depsMap = targetMap.get(target)
if(!depsMap) return
const dep = depsMap.get(key)
// 执行dep中的依赖函数
if(dep){
dep.forEach(effect => {
effect()
});
}
}
说完原理,让我们再回到表现上来。上文在对比vue2
和vue3
响应式时,提到了vue2
中不能监听数组索引和length
属性,那么现在vue3
使用proxy
后可以实现吗?大家可以自行试一试。
总结
vue2
响应式的实现核心:利用defineProperty
数据劫持+观察者模式vue3
响应式的实现核心:利用proxy
数据代理+观察者模式,WeakMap
,Set
,Map
数据结构作为辅助vue3
解决的问题:
proxy
对象实现属性监听,不需要去遍历每个属性,性能更好- 多层属性嵌套时只有访问某个属性时才会递归处理下一级属性,不再像
vue2
那样递归对所有的子数据进行响应式定义 - 默认监听动态添加的属性
- 默认监听属性的删除操作
- 默认监听数组索引和
length
属性