综述
Vue的响应式利用变化侦测实现。
所谓的变化侦测,就是在数据被访问的时候收集依赖,在改变的时候通知依赖更新。
总的来说就是Object是getter里收集依赖,setter触发依赖更新;Array是getter收集依赖,拦截器触发依赖更新。
其中数组和对象的实现思路略有区别,后面再详细分析。
上述是思路,具体实现我们还要解决以下问题
Object的变化侦测
怎么监听数据被访问和修改
前面说过所谓的变化侦测,就是在数据被访问的时候收集依赖,在改变的时候通知依赖更新,那么如何知道数据被访问或被修改了呢?
我们知道,对于Object.defineProperty
可以声明对象属性,并且可以配置属性修饰符。而属性修饰符中的setter和getter访问器函数分别会在属性被修改和被访问时触发,因为我们可以利用Object.defineProperty
来实现数据的监听。
let obj = {}
Object.defineProperty(obj,'key',{
value: "123",
set(){
console.log('数据被修改')
},
get(){
console.log('数据被访问')
}
})
obj.key
// 数据被访问
// 123
obj.key = 321
// 数据被修改
什么是依赖
什么是依赖?我们可以这样理解:某个数据的值需要访问另一个数据,我们就可以说这个数据依赖了另一个数据,例如计算属性 a(){return b + '123'}
,我们可以说a依赖b。
解决了什么是依赖,那么依赖应该存放在哪呢?我们很容易想到可以用一个数组存着,但是这样很耦合,我们抽象成一个类Dep,为这个类定义一些方法。
// vue-src\core\observer\dep.js
export default class Dep {
static target: ?Watcher;
id: number;
subs: Array<Watcher>;
constructor () {
this.id = uid++
this.subs = [] // 存放依赖
}
/*添加一个观察者对象*/
addSub (sub: Watcher) {
}
/*移除一个观察者对象*/
removeSub (sub: Watcher) {
}
/*依赖收集,当存在window.target的时候添加观察者对象*/
depend () {
}
/*通知所有订阅者*/
notify () {
}
}
还有一个问题,前面我们说过,一个数据依赖另一个数据,那么就将这个依赖存起来,如果将来数据改变就通知依赖更新,那么所有依赖都要被收集吗?我们知道在methods中,如果一个方法里的响应式数据发生改变,这个方法却不会发送任何改变,而computed却会更新,这也就说明,Vue只会选择性的收集依赖,我们将这种会被当做依赖收集的数据抽象为类Watcher,收集只收集Watcher的实例。
现在可以回答前面的问题: 什么是依赖?依赖是Watcher。依赖收集后放哪里?放到Dep里。
如何收集依赖
前面说到依赖收集到Dep,那么具体如何实现呢?
我们可以利用一个Dep和数据都能访问到的唯一变量。当创造Watcher实例时,将这个唯一变量指向实例自身,然后获取一下数据,数据的getter会将这个唯一变量收集到Dep中。
注意:上述的唯一变量在本例中是window.target
,而实际上是静态变量Dep.target
,两者效果相同,后者的优点是不会污染全局变量。
// watcher将window.target指向自己,然后get一下数据
export default class Watcher{
constructor (){
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
}
this.get()
}
get(){
const vm = this.vm
// 将实例添加到全局唯一变量中
window.target = this;
// 调用数据的getter方法,一是获取数据的值,而是调用getter将watcher实例添加Dep中
let value = this.getter.call(vm, vm)
// 清空
window.target = undefined;
}
}
// 数据的getter调用后会将window.target添加到Dep之中
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter() {
/*如果原本对象拥有getter方法则执行*/
const value = (property && property.get) ? getter.call(obj) : val;
if (window.target) {
/*进行依赖收集*/
let dep = new Dep()
dep.addSub(window.target)
}
return value;
},
}
实际代码加上了很多处理和判断,更复杂,这里进行了简化,理解核心思路即可。
如何通知依赖更新
在setter中通知Dep,Dep遍历存储依赖的数组,依次调用更新方法。
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
set: function reactiveSetter(newVal) {
if (setter) {
/*如果原本对象拥有setter方法则执行setter*/
setter.call(obj, newVal);
} else {
val = newVal;
}
/*新的值需要重新进行observe,保证数据响应式*/
childOb = observe(newVal);
/*dep对象通知所有的观察者*/
dep.notify();
},
});
export default class Dep {
static target: ?Watcher;
id: number;
subs: Array<Watcher>;
constructor () {
this.id = uid++ // 设置Dep编号
this.subs = []
}
/*通知所有订阅者*/
notify () {
// stabilize the subscriber list first
const subs = this.subs.slice()
for (let i = 0, l = subs.length; i < l; i++) {
subs[i].update() // 调用watcher的更新方法
}
}
}
export default class Watcher {
update () {
// 调用对应的更新方法
}
}
总结
Object的变化侦测在getter收集依赖,存到Dep中,setter里通知Dep中的依赖进行更新操作,依赖就是watcher,Vue在需要依赖响应式数据的地方创造watcher实例,比如计算属性、模板语法或用户的watch。
Array的变化侦测
我们知道Array其实本质上也是对象,因此也可以使用之前说的Object.defineProperty
方法,但是实际上Vue并没有采用这种方法,请看下面代码。
var obj = []
Object.defineProperty(obj,0,{
set(){
console.log('数据被修改')
}
}
obj[0] = 1
// 数据被修改
// 1
obj.push(2)
// 2
可以看到,如果调用Object.defineProperty
声明数组元素,固然使用中括号方法改变属性时可以执行setter,但是如果使用push
这类数组方法,却无法触发setter。
事实上,这样写同样也会遇到性能问题,数组有些情况是需要存放大量数据的,如果每个元素都设置setter,对性能会有一定影响,因此Vue采用了另一种巧妙的方式实现数组的响应式。
拦截器
什么是拦截器?举个例子。
class A {
toString() {
return "我是A";
}
}
class B extends A {}
let a = new A();
let b = new B();
console.log(a.toString(), b.toString());
// 我是A 我是A
类A里声明了toString
方法,而类B继承于A,自然也有toString
方法,并且A和B的toString
方法相同。
这个时候如果我们在类B中再声明一个toString
方法,那么实例b调用的就是类B自身的toString
方法,而不是继承过来的类A的toString
,我们可以理解类B的toString
被拦截了。
class A {
toString() {
return "我是A";
}
}
class B extends A {
// 新增
toString() {
return "我是B";
}
}
let a = new A();
let b = new B();
console.log(a.toString(), b.toString());
// 我是A 我是B
同理,我们声明的数组是Array的实例,调用的push
等方法其实是调用数组原型对象上的push
方法,因此我们只需要在我们声明的数组里添加拦截器,那么调用push
方法就是调用我们自定义的push
方法,而不是Array.prototype.push
。
那么什么方法才需要使用拦截器?很明显,能够改变数组自身数据的方法都需要拦截,整理后有以下几种:
明白这些之后,我们就可以开始写拦截器了。
// vue-src\core\observer\array.js
/*取得原生数组的原型*/
const arrayProto = Array.prototype
/*创建一个新的数组对象,修改该对象上的数组的七个方法,防止污染原生数组方法*/
export const arrayMethods = Object.create(arrayProto);
[
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
.forEach(function (method) {
/*将数组的原生方法缓存起来,实际操作数组的时候还是需要调用原生方法*/
const original = arrayProto[method]
// def函数的对Object.defineProperty的封装
def(arrayMethods, method, function mutator () {
let i = arguments.length
const args = new Array(i)
while (i--) {
args[i] = arguments[i]
}
/*调用原生的数组方法*/
const result = original.apply(this, args)
/*ob指向数据的Observer实例,后面再讲*/
const ob = this.__ob__
/* 以下方法会新增元素,将新增的元素存起来,然后执行observeArray,
这是为了把新增元素也变成响应式的
*/
let inserted
switch (method) {
case 'push':
inserted = args
break
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
if (inserted) ob.observeArray(inserted)
// notify change
/*dep通知所有注册的观察者进行响应式处理*/
ob.dep.notify()
return result
})
})
export function def (obj: Object, key: string, val: any, enumerable?: boolean) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true
})
}
现在我们得到了一个数组方法拦截器。
挂载拦截器
写好拦截器后下一步就是要将拦截器挂载到响应式数组上。
拦截器本质上是个包含七个特殊方法的对象,并且继承了Array的原型,因此拦截器的挂载可以直接将拦截器作为响应式数组的prototype
,在浏览器环境下对象的原型被暴露为__proto__
属性,因此我们可以:
// 判断浏览器是否支持__proto__属性
const hasProto = '__proto__' in {}
if(hasProto){
target.__proto__ = arrayMethods;
}else {
// 如果不支持,则调用Object.defineProperty将方法覆盖到目标上
for (let i = 0, l = arrayMethods.length; i < l; i++) {
const key = arrayMethods[i];
def(target, key, src[key]);
}
}
收集和通知依赖
拦截器解决了数组触发依赖更新的问题,那么我们如何收集依赖呢?
答案是getter。请看以下代码:
let obj = {
arr: [1, 2, 3],
};
Object.defineProperty(obj, "_arr", {
enumerable: true,
configurable: true,
get() {
console.log("数据被访问");
// 收集依赖
let dep = new Dep()
dep.depend();
return obj.arr;
},
});
obj._arr[0]; // 数据被访问
obj._arr.pop(); // 数据被访问
以上代码,我们首先声明一个包含数组arr的对象(obj),这是模拟Vue中data的写法,同时配置好修饰符,在getter中收集依赖后返回真实的数据,最后测试使用数组方法和中括号访问是否能触发,很明显这种方法效果很好。
Vue中的watcher
前面说到vue会将响应式状态的依赖封装成Watcher,那么我们现在就来看看Vue中有哪些Watcher。
// lifecycle
export function mountComponent(){
// ...
new Watcher(
vm,
updateComponent,
noop,
{
before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, "beforeUpdate");
}
},
},
true /* isRenderWatcher */
);
}
// state.js
Vue.prototype.$watch = function(){
// ...
const watcher = new Watcher(vm, expOrFn, cb, options);
}
function initComputed(){
// ...
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
computedWatcherOptions
);
}
可以看到,vue使用响应式的地方主要有
组件
事实上,组件本身其实也是响应式的,例如你使用了一个文本插值(即{{}}
),然后更新响应式状态,此时UI跟着改变。
<template>
<div>
{{a}}
</div>
</template>
<script>
export default{
data(){
return {
a: 1
}
},
mounted() {
setInterval(() => {
this.a++
}, 1000)
}
}
</script>
我们来分析一下这个过程。
当文本插值的响应式状态改变时,通知依赖(组件)更新(即重新渲染)。
而要达到当响应式状态改变通知组件更新这一目的,就需要将组件转成watcher,作为状态的依赖。
首先将组件封装成Watcher,将渲染函数作为更新时的回调传入
组件第一次渲染时会获取响应式状态的值,这个时候就会将组件作为依赖加入到状态的dep中。
当更改响应式状态时,会触发dep.notify(),通知状态的所有依赖更新,而组件的更新回调就是组件的渲染函数,因此就能达到重新渲染UI的目的。
在代码中的流程是这样的(省略了不重要的代码)
// 首先将组件 new Watcher,并且标记这是一个RenderWatcher
// updateComponent是组件渲染函数
// 在watcher更新执行,会执行所有beforeUpdate函数(生命周期函数)
new Watcher(
vm,
updateComponent,
noop,
{
before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, "beforeUpdate");
}
},
},
true /* isRenderWatcher */
);
接着会将组件会执行渲染后函数updateComponent
export default class Watcher {
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: ?Object,
isRenderWatcher?: boolean)
{
this.vm = vm
if (isRenderWatcher) {
vm._watcher = this
}
vm._watchers.push(this)
if (typeof expOrFn === 'function') {
// getter指向了组件的渲染函数
this.getter = expOrFn
}
// 执行get方法
this.value = this.lazy
? undefined
: this.get()
}
get(){
pushTarget(this)
let value
const vm = this.vm
// 这里首次执行了渲染函数,vue将模板渲染成UI
value = this.getter.call(vm, vm)
return value
}
}
// pushTarget方法在dep.js里
export function pushTarget(target: ?Watcher) {
targetStack.push(target);
Dep.target = target;
}
先将Dep.target执行组件的watcher,然后执行updateComponent
来触发状态的getter,状态的getter函数会将Dep.target
加入到状态的deps中,这样就成功的将组件作为依赖传入到所有用到的状态的deps中。如果有某一个状态改变,就会触发setter,然后触发deps.notify来通知所有依赖执行回调,对于组件的wather来说,回调就是渲染函数,因此就会重新执行渲染。
computed
computed可以看做是一个特殊的响应式状态,它的值取决于对其他的响应式状态的计算,computed可以是一个函数,也可以是一个包含set或get的对象,如果是函数,那么等同于包含get函数的对象。computed真正强大之处在于它的值会被缓存,只有当其他响应式状态改变时才会重新计算,这对性能很有好处。
vue首先会在initComputed
中为每个computed创建watcher
function initComputed(vm: Component, computed: Object) {
const watchers = (vm._computedWatchers = Object.create(null));
for (const key in computed) {
const userDef = computed[key];
const getter = typeof userDef === "function" ? userDef : userDef.get;
if (!isSSR) {
// 为每个computed创建watcher
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
{lazy: true} // 开启缓存
);
}
defineComputed(vm, key, userDef);
}
}
然后会使用一个中间层函数createComputedGetter
,这个函数会判断是否需要重新计算,如果需要才会调用computed真实的函数。createGetterInvoker
则是在无法使用缓存的情况下使用。
const sharedPropertyDefinition = {
enumerable: true,
configurable: true,
get: noop,
set: noop,
};
export function defineComputed(
target: any,
key: string,
userDef: Object | Function
) {
// SSR
const shouldCache = !isServerRendering();
if (typeof userDef === "function") {
sharedPropertyDefinition.get = shouldCache
? createComputedGetter(key)
: createGetterInvoker(userDef);
sharedPropertyDefinition.set = noop;
} else {
// 根据shouldCache && userDef.cache !== false条件
// 来判断是否要使用缓存
sharedPropertyDefinition.get = userDef.get
? (shouldCache && userDef.cache !== false
? createComputedGetter(key)
: createGetterInvoker(userDef.get))
: noop;
sharedPropertyDefinition.set = userDef.set || noop;
}
Object.defineProperty(target, key, sharedPropertyDefinition);
}
function createComputedGetter(key) {
return function computedGetter() {
const watcher = this._computedWatchers && this._computedWatchers[key];
if (watcher) {
// watcher.dirty是一个用于判断数据是否发生了变化的标志
if (watcher.dirty) {
// 执行watcher.get()方法,并设置dirty为false
watcher.evaluate();
}
// 收集依赖
if (Dep.target) {
watcher.depend();
}
return watcher.value;
}
};
}
function createGetterInvoker(fn) {
return function computedGetter() {
return fn.call(this, this);
};
}
vue对computed的处理和对state的处理很类似,具体流程是:首先vue将每个computed创建watcher(传递lazy来表明要使用缓存),以便作为依赖被使用到的响应式状态添加到deps中,接着通过Object.defineProperty创建属性,但是get函数加了一个中间层来判断是否要重新计算(即createComputedGetter函数)。
computed每次获取时都会触发getter,也就是 createComputedGetter
函数,这里会收集依赖,并且会通过watcher.dirty
标记来判断是否要重新计算。如果computed的依赖更新,那就会通知依赖(也就是computed的watcher)进行更新,此时就会将watcher.dirty
标记设为true,那么就会重新计算。
$watch()
$watch()
方法实际上就是将Watcher主动暴露给用户使用,用户指定监听的响应式状态和回调,然后该状态会将watcher作为依赖收集到deps,更新时就会触发执行回调函数。
响应式API
Vue2.0中有三个常用响应式API,分别是vm.#watch
、vm.$set
、vm.$delete
Vue.$watch
vm.$watch
能监听一个表达式或computed函数,触发时执行特定函数。本质上也是利用Watcher。在使用vm.$watch时会为目标数据创建一个watcher,watcher的构造函数里会读取一下数据,这样就将这个watcher添加到dep中了,如果数据更新了,就会触发对应的回调函数。
// vue-src\core\instance\state.js
Vue.prototype.$watch = function (
expOrFn: string | Function,
cb: Function,
options?: Object
): Function {
const vm: Component = this
options = options || {}
options.user = true
const watcher = new Watcher(vm, expOrFn, cb, options)
/*有immediate参数的时候会立即执行*/
if (options.immediate) {
cb.call(vm, watcher.value)
}
/*返回一个取消观察函数,用来停止触发回调*/
return function unwatchFn () {
/*将自身从所有依赖收集订阅列表删除*/
watcher.teardown()
}
}
vm.$watch
第一个参数能接收表达式或computed函数
如果是函数,直接作为getter,如果是keypath,则需要处理
if (typeof expOrFn === 'function') {
this.getter = expOrFn
} else {
this.getter = parsePath(expOrFn)
}
// 下面是处理keypath的函数,其实就是遍历一层一层的查找
const bailRE = /[^\w.$]/
export function parsePath (path: string): any {
if (bailRE.test(path)) {
return
}
const segments = path.split('.')
return function (obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) return
obj = obj[segments[i]]
}
return obj
}
}
vm.$watch
会返回一个函数,执行函数会取消监听。我们看到前面代码里返回了一个函数,函数里就执行了watcher.teardown()
语句,因此取消监听重点是在watcher.teardown()
上,它的作用是将watcher自身从所有依赖收集订阅列表删除。
要实现将watcher自身从所有依赖收集订阅列表删除,首先需要在Watcher中记录自己都订阅了谁,也就是watcher实例被收集进了哪些Dep里。然后当Watcher不想继续订阅这些Dep时,循环自己记录的订阅列表来通知它们(Dep)将自己从它们(Dep)的依赖列表中移除掉。
这个简单,我们首先为每个dep定义一个编号,每次将watcher添加到dep时就将这个dep的编号存到一个数组里。
class Watcher{
/*添加一个依赖关系到Deps集合中*/
addDep (dep: Dep) {
const id = dep.id
// 如果当前Watcher已经订阅了这个Dep则跳过
if (!this.depIds.has(id)) {
// 记录当前Watcher已经订阅了这个Dep
this.depIds.add(id)
// 记录自己都订阅了哪些Dep
this.deps.push(dep)
// 将自己订阅到Dep
dep.addSub(this)
}
}
}
然后再看watcher.teardown()
class Watcher{
/*将自身从所有依赖收集订阅列表删除*/
teardown () {
// this.active表示当前watcehr实例是否正在被使用
if (this.active) {
/*从vm实例的观察者列表中将自身移除,由于该操作比较耗费资源,所以如果vm实例正在被销毁则跳过该步骤。*/
if (!this.vm._isBeingDestroyed) {
remove(this.vm._watchers, this)
}
let i = this.deps.length
while (i--) {
this.deps[i].removeSub(this)
}
this.active = false
}
}
}
class Dep{
/*移除一个观察者对象*/
removeSub (sub: Watcher) {
if (this.subs) {
const index = this.subs.indexOf(sub)
if (index > -1) {
return this.subs.splice(index, 1)
}
}
}
watcher.teardown()
会遍历this.deps
依次执行removeSub
方法,将自身从dep上删除。
Vue.$set
Vue.prototype.$set = set
export function set(target: Array<any> | Object, key: any, val: any): any {
/*如果传入数组则在指定位置插入val*/
if (Array.isArray(target) && typeof key === "number") {
target.length = Math.max(target.length, key);
target.splice(key, 1, val);
/*因为数组不需要进行响应式处理,数组会修改七个Array原型上的方法来进行响应式处理*/
return val;
}
/*如果是一个对象,并且已经存在了这个key则直接返回*/
if (hasOwn(target, key)) {
target[key] = val;
return val;
}
/*获得target的Oberver实例*/
const ob = (target: any).__ob__;
/*
_isVue 一个防止vm实例自身被观察的标志位 ,_isVue为true则代表vm实例,也就是this
vmCount判断是否为根节点,存在则代表是data的根节点,Vue 不允许在已经创建的实例上动态添加新的根级响应式属性(root-level reactive property)
*/
if (target._isVue || (ob && ob.vmCount)) {
/*
Vue 不允许在已经创建的实例上动态添加新的根级响应式属性(root-level reactive property)。
https://cn.vuejs.org/v2/guide/reactivity.html#变化检测问题
*/
return val;
}
if (!ob) {
target[key] = val;
return val;
}
/*为对象defineProperty上在变化时通知的属性*/
defineReactive(ob.value, key, val);
ob.dep.notify();
return val;
}
Vue.$delete
Vue.prototype.$delete = del
export function del(target: Array<any> | Object, key: any) {
if (Array.isArray(target) && typeof key === "number") {
target.splice(key, 1);
return;
}
const ob = (target: any).__ob__;
// Vue 不允许在已经创建的实例上动态添加新的根级响应式属性(root-level reactive property)。
if (target._isVue || (ob && ob.vmCount)) {
return;
}
// 如果没有这个属性则跳过
if (!hasOwn(target, key)) {
return;
}
delete target[key];
// 如果数据不是响应式的则结束,否则还要通知依赖
if (!ob) {
return;
}
ob.dep.notify();
}