重学Vue源码,根据黄轶大佬的vue技术揭秘,逐个过一遍,巩固一下vue源码知识点,毕竟嚼碎了才是自己的,所有文章都同步在 公众号(道道里的前端栈) 和 github 上。
正文
在以往的开发过程中,可能会遇到操作了数据,但是视图没有更新的情况,然后使用官网提供的 Vue.set
方法就可以生效了,那为什么没有生效呢?下面我们用一个例子来把这种情况过一遍:
<template> <div class="xx"> <p>{{obj}}</p> <button @click="change">change</button> </div> </template> <script> export default { data(){ return { obj: { a: 1 } } }, methods:{ change(){ this.obj.b = 2; } } }; </script>
点击按钮之后,obj
是变化了的,但是视图上显示的还是 {a: 1}
,从前面分析响应式对象的逻辑来看,如果一个对象被定义成响应式的话,在它走 setter
的时候,会走派发更新的逻辑,视图就会改变,就算试图更新是在下一个 tick 才执行,它也应该改变才对呀?这种情况其实新增的属性 b
不是一个响应式对象的属性,也就是没有走响应式对象的 setter
,此时官网告诉我们:
Vue.set
方法可以向响应式对象上添加一个属性,并且属性也是响应式的,这样就解决了视图更新的问题,修改数据就会触发 渲染 watcher 的重新渲染,那我们来看下它是怎么实现的。
对象
Vue.set
的代码在 src/core/global-api/index.js
中:
// ... Vue.set = set // ...
引用了一个名为 set
的方法,那它真正的逻辑是在 src/core/observer/index.js
中:
/** * Set a property on an object. Adds the new property and * triggers change notification if the property doesn't * already exist. */ export function set (target: Array<any> | Object, key: any, val: any): any { if (process.env.NODE_ENV !== 'production' && (isUndef(target) || isPrimitive(target)) ) { warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`) } if (Array.isArray(target) && isValidArrayIndex(key)) { target.length = Math.max(target.length, key) target.splice(key, 1, val) return val } if (key in target && !(key in Object.prototype)) { target[key] = val return val } const ob = (target: any).__ob__ if (target._isVue || (ob && ob.vmCount)) { process.env.NODE_ENV !== 'production' && warn( 'Avoid adding reactive properties to a Vue instance or its root $data ' + 'at runtime - declare it upfront in the data option.' ) return val } if (!ob) { target[key] = val return val } defineReactive(ob.value, key, val) ob.dep.notify() return val }
可以看到 set
方法接收三个参数,target
可以是数组或者对象,key
代表数组的下标或者对象的值,val
就是新增的值。首先判断如果 target
是一个数组并且 key
是一个合法的下标,就通过 splice
添加进新数组中然后返回,这里的 splice
注意不是原生的 splice
方法了,后面会说到。
接着又判断如果 key
已经在 target
里,就直接赋值返回,因为这样的变化是可以监测到的。接着再获取到 target.__ob__
属性,并赋值给 __ob__
,这个之前分析响应式对象的时候提到过,在 Observer
的构造函数执行的时候会初始化一个 __ob__
,然后会实例化一个 Observer
对象,如果它不存在,就说明 target
不是一个响应式对象,就直接赋值返回,最后通过 defineReactive
把新加的属性变成响应式对象,然后通过 ob.dep.notify()
去手动触发依赖的更新,这里回顾一下响应式对象里的 getter
:
export function defineReactive ( obj: Object, key: string, val: any, customSetter?: ?Function, shallow?: boolean ) { // ... let childOb = !shallow && observe(val) Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function reactiveGetter () { const value = getter ? getter.call(obj) : val if (Dep.target) { dep.depend() if (childOb) { childOb.dep.depend() if (Array.isArray(value)) { dependArray(value) } } } return value }, // ... }) }
在 getter
过程中判断了一个 childOb
,并调用了 childOb.dep.notify()
去收集依赖,这也就是为什么通过 Vue.set
的方式可以拿到改变后的值,因为 ob.dep.notify
手动去通知了 watcher
,从而将新的属性的对象也可以检测到它的变化,并且后面也加了数组的判断,如果 value
是一个数组,就执行 dependArray
去做依赖收集。那这里解释了上面例子的 obj
对象,如果是数组呢?
数组
数组的变动,Vue也是不会检测到的,和对象类似,如果想利用下标去改变一个数组的话,可以:vm.items[index] = newValue
,那如果要修改数组的长度的话,可以:vm.items.splice(newLength)
,splice
上面说到是不是原生的了,它是被重写过的。
这里再回顾一下 Observer
构造函数:
export class Observer { constructor (value: any) { this.value = value this.dep = new Dep() this.vmCount = 0 def(value, '__ob__', this) if (Array.isArray(value)) { const augment = hasProto ? protoAugment : copyAugment augment(value, arrayMethods, arrayKeys) this.observeArray(value) } else { // ... } } }
如果 value
是一个数组的话,就获取 argument
,这里的 hasProto
其实就是判断对象中有没有 __proto__
属性,如果存在就把 argumet
指向 protoAugment
,否则就指向 copyAugment
,来看下这两个函数的定义:
/** * Augment an target Object or Array by intercepting * the prototype chain using __proto__ */ function protoAugment (target, src: Object, keys: any) { /* eslint-disable no-proto */ target.__proto__ = src /* eslint-enable no-proto */ } /** * Augment an target Object or Array by defining * hidden properties. */ /* istanbul ignore next */ function copyAugment (target: Object, src: Object, keys: Array<string>) { for (let i = 0, l = keys.length; i < l; i++) { const key = keys[i] def(target, key, src[key]) } }
protoAugment
函数直接把 target.__proto__
原型改成了 src
,而 copyAugment
函数遍历了 keys
,然后循环调用 def
,也就是 Object.defineProperty
来定义自己的属性,一般浏览器都有 __proto__
,所以都会走到 protoAugment
,这样就把 value
的原型指向了 arrayMethods
,它的定义在 src/core/observer/array.js
:
import { def } from '../util/index' const arrayProto = Array.prototype export const arrayMethods = Object.create(arrayProto) const methodsToPatch = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ] /** * Intercept mutating methods and emit events */ methodsToPatch.forEach(function (method) { // cache original method const original = arrayProto[method] def(arrayMethods, method, 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) // notify change ob.dep.notify() return result }) })
arrayMethods
继承了 Array
,然后把数组中可以改变自身的方法都重写了一遍,重写之后会先执行原有方法的逻辑,然后针对 push
,unshift
和 splice
作了其他判断,目的是获取到插入的值,然后把新值变成了一个响应式对象,再次手动调用 ob.dep.notify()
来触发依赖的通知,所以上面的 vm.items.splice(newLength)
就可以检测到数组的变化了。
那对于最上面的例子,如果想监听到 obj
的变化,得改成这样:
<template> <div class="xx"> <p>{{obj}}</p> <button @click="change">change</button> </div> </template> <script> import Vue from "vue"; export default { data(){ return { obj: { a: 1 } } }, methods:{ Vue.set(this.obj, "b", 2); } } }; </script>