在Vue
中,响应式是指数据与视图之间建立一种自动关联的关系,当数据发生变化时,视图会自动更新以反应数据的变化,无需开发者手动操作DOM元素更新视图。
响应式是实现数据驱动视图的基础,在Vue
中,数据可以理解为状态state
,视图就是用户直观看到的页面UI
。页面会随着状态的改变而动态变化,因此可以得到以下公式:
UI = render(state)
上述公式中:状态state
是输入,页面UI
输出,状态输入一旦变化了,页面输出也随之而变化。这种特性就叫数据驱动视图。
公式可以进一步拆成三部分:state
、render()
以及UI
。其中state
和UI
都是用户定的或开发者实现的,而不变的是render()
函数。所以Vue
就扮演了render()
这个渲染的角色,当Vue
检测到state
变化之后,经过一系列加工,最终将变化响应式地反应在UI
界面上。
实现数据的响应式需要解决以下三个问题:
state
的变化进行监听?state
改变之后要更新的视图?在Vue2
中,数据监听是借助了javaScript
提供的Object.defineProperty()
函数实现的。
Object.defineProperty()
静态方法用于在一个对象上定义一个新属性或修改该对象的现有属性。
Object.defineProperty(obj, prop, descriptor)
其中, descriptor
表示要定义的属性描述符object
,存在两种类型:数据描述符和访问器描述符。
注意: 描述符只能是这两种类型之一,且两者不能混合使用。
数据描述符是一个具有可写或不可写值的属性,对象支持四种键值:value
、enumerable
、writable
、configurable
const person = {}
Object.defineProperty(person, 'age', {
value: 18, // 定义属性值,默认undefined
enumerable: true, // 属性可枚举, 默认false
writable: true, // 属性值可修改, 默认false
configurable: true, // 属性可删除,默认false
})
console.log(person.age) // > 18
person.age = 19
console.log(person.age) // > 19
访问器描述符是由 getter/setter
函数对描述的属性,支持四种键值:enumerable
、configurable
、get
、set
const person = {}
let age = 18
Object.defineProperty(person, 'age', {
enumerable: true,
configurable: true,
get() {
console.log(`有人读取了person的年龄,当前年龄${age}`)
return age
},
set(value) {
console.log(`有人修改的person的年龄,新的年龄是${value}`)
age = value
},
})
person.age
person.age = 19
age
输出结果:
Vue2.0
就是利用了Object.defineProperty
方法中的访问器描述符来劫持数据的读写操作。在getter
中捕获数据的读取事件,在setter
中捕获数据的修改事件,进而对数据的变化进行监听。
对于Object
类型的数据,Vue
通过递归遍历的方式将数据中的每一个属性设置为getter/setter
的形式,使对象的每一个属性都变得可观测
接下来是对Object
类型数据监听的简单实现:
模拟一个更新视图的函数
/**
* @description: 更新视图函数
*/
function updateView() {
console.log('收到通知,我去更新视图了')
}
定义Observer
类
在vue
中,所有的响应式数据都是Observer
类的实例对象。
/**
* @description: 定义Observer类,把一个对象的所有属性转化成可观测对象
* @return {*}
*/
class Observer {
constructor(value) {
this.value = value
// 给value新增一个__ob__属性,值为该value的Observer实例
// 相当于为value打上标记,表示它已经被转化成响应式了,避免重复操作
Object.defineProperty(value, '__ob__', {
value: this,
enumerable: false,
writable: true,
configurable: true,
})
this.walk(value)
}
walk(value) {
const keys = Object.keys(value)
for (let i = 0; i < keys.length; i++) {
defineReactive(value, keys[i]) // 遍历所有的属性,将所有属性值转为getter/setter形式
}
}
}
定义响应式函数
// 源码位置:/src/core/observer/index.ts
/**
* @description: 给对象的属性递归定义响应式,设置getter/setter,使对象属性的读取事件可监听
*/
function defineReactive(obj, key, value) {
if (arguments.length === 2) {
value = obj[key]
}
if ( // 如果value值为object或array,递归调用observer函数实现value的响应式
Object.prototype.toString.call(value) === '[object Object]' ||
Array.isArray(value)
) {
let childObj = observer(value) // 递归调用监听函数对子属性进行监听
}
Object.defineProperty(obj, key, {
configurable: true,
enumerable: true,
get() {
console.log(`有人读取了${key}属性,属性值为${value}`) // 捕获数据读取事件
return value
},
set(newValue) {
if (newValue !== value) {
console.log(
`有人修改了${key}属性,修改后的值为${newValue},我要发更新视图的通知!`
) // 捕获数据修改事件
childObj = observer(newValue) // 更新的value值也要监听
value = newValue
updateView() // 通知更新视图
}
},
})
}
定义监听函数
/**
* @description: 监听函数
*/
function observer(value) {
if ( // 是对象或数组类型的数据才需要监听
Object.prototype.toString.call(value) === '[object Object]' ||
Array.isArray(value)
) {
if (typeof value.__ob__ !== 'undefined') { // value已经是响应式数据,直接返回observer实例
return value.__ob__
}
return new Observer(value) // 以value为参数构造observer实例并返回
}
}
验证实现:
const data = {
name: 'zhangsan',
age: 19,
address: {
city: 'beijing',
country: 'China',
},
}
const ob = observer(data)
console.log(ob)
输出结果:
之后对data
中的数据进行一些操作:
const data = {
name: 'zhangsan',
age: 19,
address: {
city: 'beijing',
country: 'China',
},
}
data.name
data.name = 'lisi'
data.address.city
data.address.city = 'shanghai'
输出结果:
可以看到,我们捕获了读取对象属性和修改对象属性值的事件。对于嵌套的对象,也可以实现被嵌套对象属性值的读取与修改监听。
⚠️值得注意的是,被嵌套对象内部属性值的修改,外层对象是感应不到的,这就是为什么在监听函数watch
中,想要实现深度监听必须使用deep
的原因了。
过程总结:
对于一个数据,首先调用observer
函数实现监听,如果传入的数据是未被监听过的对象,则new
一个可被监听的Observe
实例对象并返回,否则,返回该数据所对应的observer
实例对象。
在Observer
类的constructor
内,给传入的对象新增一个__ob__
属性,值为该对象的observer
实例对象,作用是给该对象打上标记,表明这个对象已经被转化成响应式了,避免重复的操作。
之后调用walk
函数将每一个属性转换成getter/setter
的形式来监听数据的变化。
最后,在defineReactive
函数中,当传入的属性值还是一个object
时,递归使用observer
函数来监听该对象的嵌套对象,这样就可以把data
中的所有属性(包括深层属性)都转换成getter/seter
的形式来深度监听对象数据的变化。
Array
本身也是一个对象,也支持使用defineProperty
设置getter/setter
对元素进行响应式监听,但使用defineProperty
会出现哪些问题呢?
举个栗子:
定义data
的属性hobbies
值为数组['swimming', 'football']
,当数组最前面插入一个元素running
,看看发生了什么?
const data = {
name: 'zhangsan',
age: 19,
address: {
city: 'beijing',
country: 'China',
},
hobbies: ['swimming', 'football'],
}
observer(data)
data.hobbies.unshift('running')
console.log(data.hobbies)
输出结果:
首先,当在数组的第一位插入一个元素后,触发了两次视图更新,这是因为在对hobbies
数组进行监听时,将数组的0
,1
两个key
分别进行了监听,所以当数组最前面插入了一个值以后,原来索引对应的值全部发生了改变,所以就触发了两次视图更新。尽管Vue
采用了异步更新策略,这两次更新会被合并,影响不大。但如果数组的数据量非常大或数组的元素是深层对象的话,性能损耗将会是巨大的。
其次,我们可以看到,更新后的hobbies
的第三个元素football
并非是响应式的,这是因为数组在调用observer
函数实现响应式监听的时候只有0
、1
两个索引,因此只把这两个key
转化成了getter/setter
形式进行监听,所以后续数组无论增加多少元素,都不再是响应式的了。
因此,如果使用defineProperty
对数组元素进行监听的话,是监听不到新增元素变化的,许多操作数组的API
也无从使用,而且还会触发多次视图更新,这并不是一个好的策略。
︎ 插一嘴,这个时候如果通过索引修改元素值的话,其实是可以监听到的。
const data = {
name: 'zhangsan',
age: 19,
address: {
city: 'beijing',
country: 'China',
},
hobbies: ['swimming', 'football'],
}
observer(data)
data.hobbies[0] = 'running'
console.log(data.hobbies)
输出结果:
在Vue2.0
中,作者采用了重写原地修改数组的七种方法实现数组数据的监听。
1. 数组方法拦截器
在Vue2.0
中,定义了一个数组方法拦截器,拦截在数组实例与Array.property
之间,并在拦截器内部重写了操作数组的一些方法,当数组实例使用操作数组方法时,使用的就是拦截器中重写的方法,而不再使用Array.prototype
上的原生方法。
// 源码位置:/src/core/observer/array.ts
const arrayProto = Array.prototype
// 创建一个对象作为拦截器
export const arrayMethods = Object.create(arrayProto)
// 改变数组自身内容的7个方法
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse',
]
/**
* @description: 在arrayMethods拦截器对象上定义并重写7种方法
* @return {*}
*/
methodsToPatch.forEach((method) => {
const original = arrayProto[method] // 缓存原生方法
Object.defineProperty(arrayMethods, method, {
enumerable: false,
writable: true,
configurable: true,
value: function mutator(...args) {
const result = original.apply(this, args)
const ob = this.__ob__
// 如果是插入或更新元素,则对新的元素进行响应式监听
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
if (inserted) ob.observeArray(inserted) // 调用observe函数将新增的元素转化成响应式
console.log(
`有人调用了重写的${method}方法改变数组数据,可以通知更新视图了`
)
updateView()
return result
},
})
})
const array = [1, 2]
Object.setPrototypeOf(array, arrayMethods)
array.push(3)
console.log(array)
输出结果:
在Observer
类中判断value
的类型,如果是数组,将value
的__proto__
指针指向拦截器,同时调用observeArray()
函数对value
进行监听
/**
* @description: 定义Observer类,把一个对象的所有属性转化成可观测对象
* @return {*}
*/
class Observer {
constructor(value) {
// ...
if (Array.isArray(value)) {
// setPrototypeOf是es6新增的语法
// 还可以判断浏览器是否支持__proto__语法,支持则value.__proto__ = arrayMethods
// 否则,遍历arrayMethods中的方法,逐一添加到value对象本身
Object.setPrototypeOf(value, arrayMethods)
this.observeArray(value) // 将数组中的所有元素都转化为可被侦测的响应式
} else {
this.walk(value)
}
}
observeArray(value) {
for (let i = 0, l = value.length; i < l; i++) {
observer(value[i])
}
}
}
const data = {
name: 'zhangsan',
age: 19,
address: {
city: 'beijing',
country: 'China',
},
hobbies: ['swimming', 'football'],
}
observer(data)
data.hobbies.unshift('running')
console.log('-----------------------------------------------------')
console.log(data.hobbies)
console.log('-----------------------------------------------------')
data.hobbies[0] = 'drawing'
console.log('-----------------------------------------------------')
console.log(data.hobbies)
输出结果:
observer
函数实现了对数组七种操作方法的监听。此时,通过索引修改数组元素就无法被监听到啦,可以使用splice
替代。
到此,我们已经实现了对象和数组的数据监听,解决了响应式的第一个问题:
state
的变化进行监听?那么监听到了状态的变化之后,vue
怎么知道要更新哪些视图呢?
vue
组件在渲染的过程中,会调用数据的getter
函数来读取数据并渲染在页面上,那么就可以在数据被读取的时候,将调用者收集起来,等到数据发生变化的时候,即setter
函数被触发的时候,通知所有的调用者去更新视图,就实现了数据的响应式更新。
我们称数据的调用者为数据的依赖项。
核心在于:在getter
中收集依赖,在setter
中触发依赖
在模板编译的过程中,会为每一个调用state
的元素实例化一个watcher
实例;
这个watcher
实例在构造的过程中首先把自己设置到全局唯一的指定位置window.target
,然后读取依赖的数据。读取数据就触发了数据的getter
,在getter
函数中会读取window.target
的值获取到当前数据的依赖项watcher
,之后调用dep.depend()
把这个watcher
收集到依赖收集器dep.subs
中去就完成了依赖的收集;
当数据发生变化时会调用数据的setter
函数,在setter
会调用dep.notify()
向收集器dep.subs
中的每个watcher
实例发送更新通知, 每个watcher
实例收到通知后就会调用自身的update
函数就行视图的更新,这样就完成了依赖的触发,进而实现了数据的响应式。
// 源码位置:src/core/observer/dep.js
/**
* @description: 定义Dep类
* @return {*}
*/
class Dep {
constructor() {
this.subs = [] //内部存放所有观测该依赖数据的watcher实例
}
/**
* @description: 通知更新函数
* @return {*}
*/
notify() {
this.subs.forEach((sub) => {
sub.update() //watcher实例身上的update方法
})
}
/**
* @description: 依赖收集函数
* @return {*}
*/
depend() {
if (window.target) {
//watcher在调用依赖项getter时会把自身放在window.target上,供依赖收集器获取,读取完之后删掉
this.addSub(window.target)
}
}
/**
* @description: subs数组中添加一个依赖
* @param {*} sub 一个watcher实例
* @return {*}
*/
addSub(sub) {
this.subs.push(sub)
}
/**
* @description: subs数组中移除一个依赖
* @param {*} sub 一个watcher实例
* @return {*}
*/
removeSub(sub) {
if (this.subs.length) {
const index = this.subs.indexOf(sub)
if (index > -1) {
return this.subs.splice(index, 1)
}
}
}
}
// 源码位置:src/core/observer/watcher.js
/**
* @description: 定义Watcher类
* @return {*}
*/
class Watcher {
constructor(vm, expOrFn, cb) {
This.vm = vm
this.cb = cb
this.getter = parsePath(expOrFn) // 根据表达式获取对象属性值的函数
this.value = this.get() // 构造的时候直接调用执行
}
/**
* @description: watcher实例的数据获取
* @return {*}
*/
get() {
window.target = this // 先将自身挂载到window.target上
let value = this.getter.call(this.vm) // 读取依赖数据,触发依赖收集
window.target = null // 依赖收集完毕之后window.target置空
return value // 获取到的数据的值,用于页面渲染
}
/**
* @description: watcher实例的视图更新
* @return {*}
*/
update() {
const oldValue = this.value
this.value = this.get()
this.cb.call(this.vm, this.value, oldValue) // 新旧虚拟dom对比或用户定义的回调函数等
}
}
/**
* Parse simple path.
* 把一个形如'data.a.b.c'的字符串路径所表示的值,从真实的data对象中取出来
* 例如:
* data = {a:{b:{c:2}}}
* parsePath('a.b.c')(data) // 2
*/
function parsePath(path) {
const segments = path.split('.')
return function (obj) {
for (let i = 0, len = segments.length; i < len; i++) {
if (!obj) return
obj = obj[segments[i]]
}
return obj
}
}
/**
* @description: 给对象的属性定义响应式,是对象属性的读取事件可监听
*/
function defineReactive(obj, key, value) {
// ...
let childOb = observer(value)
const dep = new Dep() // 实例化依赖收集器
Object.defineProperty(obj, key, {
get() {
dep.depend() // 收集依赖
if(childOb){ // 当前值的子元素如果是响应式的也需要收集依赖
childOb.dep.depend()
}
return value
},
set(newValue) {
if (newValue !== value) {
chileOb = observer(newValue) // 更新的value值也要监听
value = newValue
dep.notify() // 触发依赖,通知更新视图
if (childOb) { // 当前值的子元素如果是响应式的也需要通知更新
childOb.dep.notify()
}
}
},
})
return dep
}
到此,我们已经解决了响应式实现的第二个问题:
state
改变之后要更新的视图?Vue
在更新 DOM
时是异步执行的。只要侦听到数据变化,Vue
将开启一个队列,并缓冲在同一个事件循环中发生的所有数据变更。
如果在同一个eventLoop
中同一个 watcher
被多次触发,只会被推入到队列中一次。
然后,在下一个的事件循环tick
中,Vue
刷新队列并执行实际 (已去重的) 工作。
Vue
在内部对异步队列尝试使用原生的 Promise.then()
、MutationObserver()
和 setImmediate()
,如果执行环境不支持,则会采用 setTimeout()
代替。
如果想基于更新后的DOM
进行操作,则应该使用Vue.nextTick()
函数来进行。该函数的回调函数会在页面刷新后调用。
第三个问题也解决了:
以上就是数据驱动视图的全过程啦!
由于Vue2.0
采用的是javaScript
提供的Object.defineProperty
来实现数据的监听
存在无法监测以下变化的缺点:
key/value
键值对key/value
键值对解决办法:
对象
Vue.set(object, key, value)
或 vm.$set(object, key, value)
添加和修改属性Vue.delete(object, key)
或 vm.$delete(object, key)
删除属性object = Object.assign({}, newObject)
更新整个对象数组
array.splice(index, 1, value)
Vue.set(array, index, value)
或 vm.$set(array, index, value)
array = newArray
更新整个数组Vue3.0
采用了ES6
新增的Proxy
进行数据监听,不存在以上问题。
Vue2.0
采用了数据劫持和消息发布订阅模式实现了数据的响应式。
当一个Vue
实例创建时,Vue
会遍历data
中的数据,用 Object.defineProperty
将它们转为 getter/setter
,并且在内部追踪相关依赖(消息订阅),捕获数据的访问事件并收集依赖,捕获数据的修改事件并触发依赖。
每个组件实例都有相对应的 watcher
实例,它会在组件渲染的过程中把属性记录为依赖,当依赖数据的setter
被调用时,会通知watcher
更新页面(消息发布),进而实现“数据驱动视图”。