源代码:
Vue会对我们在data中传入的数据进行拦截:
- 对象:递归的为对象的每个属性都设置get/set方法
- 数组:修改数组的原型方法,对于会修改原数组的方法进行了重写
在用户为data中的对象设置值、修改值以及调用修改原数组的方法时,都可以添加一些逻辑来进行处理,实现数据更新页面也同时更新。
Vue中的响应式(reactive): 对对象属性或数组方法进行了拦截,在属性或数组更新时可以同时自动地更新视图。在代码中被观测过的数据具有响应性
创建Vue实例
我们先让代码实现下面的功能:
<body> <script> const vm = new Vue({ el: '#app', data () { return { age: 18 }; } }); // 会触发age属性对应的set方法 vm.age = 20; // 会触发age属性对应的get方法 console.log(vm.age); </script> </body>
在src/index.js中,定义Vue的构造函数。用户用到的Vue就是在这里导出的Vue:
import initMixin from './init'; function Vue (options) { this._init(options); } // 进行原型方法扩展 initMixin(Vue); export default Vue;
在init中,会定义原型上的_init方法,并进行状态的初始化:
import initState from './state'; function initMixin (Vue) { Vue.prototype._init = function (options) { const vm = this; // 将用户传入的选项放到vm.$options上,之后可以很方便的通过实例vm来访问所有实例化时传入的选项 vm.$options = options; initState(vm); }; } export default initMixin;
在_init方法中,所有的options被放到了vm.$options中,这不仅让之后代码中可以更方便的来获取用户传入的配置项,也可以让用户通过这个api来获取实例化时传入的一些自定义选选项。比如在Vuex 和Vue-Router中,实例化时传入的router和store属性便可以通过$options获取到。
除了设置vm.$options,_init中还执行了initState方法。该方法中会判断选项中传入的属性,来分别进行props、methods、data、watch、computed 等配置项的初始化操作,这里我们主要处理data选项:
import { observe } from './observer'; import { proxy } from './shared/utils'; function initState (vm) { const options = vm.$options; if (options.props) { initProps(vm); } if (options.methods) { initMethods(vm); } if (options.data) { initData(vm); } if (options.computed) { initComputed(vm) } if (options.watch) { initWatch(vm) } } function initData (vm) { let data = vm.$options.data; vm._data = data = typeof data === 'function' ? data.call(vm) : data; // 对data中的数据进行拦截 observe(data); // 将data中的属性代理到vm上 for (const key in data) { if (data.hasOwnProperty(key)) { // 为vm代理所有data中的属性,可以直接通过vm.xxx来进行获取 proxy(vm, key, data); } } } export default initState;
在initData中进行了如下操作:
- data可能是对象或函数,这里将data统一处理为对象
- 观测data中的数据,为所有对象属性添加set/get方法,重写数组的原型链方法
- 将data中的属性代理到vm上,方便用户直接通过实例vm来访问对应的值,而不是通过vm._data来访问
新建src/observer/index.js,在这里书写observe函数的逻辑:
function observe (data) { // 如果是对象,会遍历对象中的每一个元素 if (typeof data === 'object' && data !== null) { // 已经观测过的值不再处理 if (data.__ob__) { return; } new Observer(data); } } export { observe };
observe函数中会过滤data中的数据,只对对象和数组进行处理,真正的处理逻辑在Observer中:
/** * 为data中的所有对象设置`set/get`方法 */ class Observer { constructor (value) { this.value = value; // 为data中的每一个对象和数组都添加__ob__属性,方便直接可以通过data中的属性来直接调用Observer实例上的属性和方法 defineProperty(this.value, '__ob__', this); // 这里会对数组和对象进行单独处理,因为为数组中的每一个索引都设置get/set方法性能消耗比较大 if (Array.isArray(value)) { Object.setPrototypeOf(value, arrayProtoCopy); this.observeArray(value); } else { this.walk(); } } walk () { for (const key in this.value) { if (this.value.hasOwnProperty(key)) { defineReactive(this.value, key); } } } observeArray (value) { for (let i = 0; i < value.length; i++) { observe(value[i]); } } }
需要注意的是,__ob__属性要设置为不可枚举,否则之后在对象遍历时可能会引发死循环
Observer类中会为对象和数组都添加__ob__属性,之后便可以直接通过data中的对象和数组vm.value.__ob__来获取到Observer实例。
当传入的value为数组时,由于观测数组的每一个索引会耗费比较大的性能,并且在实际使用中,我们可能只会操作数组的第一项和最后一项,即arr[0],arr[arr.length-1],很少会写出arr[23] = xxx的代码。
所以我们选择对数组的方法进行重写,将数组的原型指向继承Array.prototype新创建的对象arrayProtoCopy,对数组中的每一项继续进行观测。
创建data中数组原型的逻辑在src/observer/array.js中:
// if (Array.isArray(value)) { // Object.setPrototypeOf(value, arrayProtoCopy); // this.observeArray(); // } const arrayProto = Array.prototype; export const arrayProtoCopy = Object.create(arrayProto); const methods = ['push', 'pop', 'unshift', 'shift', 'splice', 'reverse', 'sort']; methods.forEach(method => { arrayProtoCopy[method] = function (...args) { const result = arrayProto[method].apply(this, args); console.log('change array value'); // data中的数组会调用这里定义的方法,this指向该数组 const ob = this.__ob__; let inserted; switch (method) { case 'push': case 'unshift': inserted = args; break; case 'splice': // splice(index,deleteCount,item1,item2) inserted = args.slice(2); break; } if (inserted) {ob.observeArray(inserted);} return result; }; });
通过Object.create方法,可以创建一个原型为Array.prototype的新对象arrayProtoCopy。修改原数组的7个方法会设置为新对象的私有属性,并且在执行时会调用arrayProto 上对应的方法。
在这样处理之后,便可以在arrayProto中的方法执行前后添加自己的逻辑,而除了这7个方法外的其它方法,会根据原型链,使用arrayProto上的对应方法,并不会有任何额外的处理。
在修改原数组的方法中,添加了如下的额外逻辑:
const ob = this.__ob__; let inserted; switch (method) { case 'push': case 'unshift': inserted = args; break; case 'splice': // splice(index,deleteCount,item1,item2) inserted = args.slice(2); break; } if (inserted) {ob.observeArray(inserted);}
push、unshift、splice会为数组新增元素,对于新增的元素,也要对其进行观测。这里利用到了Observer中为数组添加的__ob__属性,来直接调用ob.observeArray ,对数组中新增的元素继续进行观测。
对于对象,要遍历对象的每一个属性,来为其添加set/get方法。如果对象的属性依旧是对象,会对其进行递归处理
function defineReactive (target, key) { let value = target[key]; // 继续对value进行监听,如果value还是对象的话,会继续new Observer,执行defineProperty来为其设置get/set方法 // 否则会在observe方法中什么都不做 observe(value); Object.defineProperty(target, key, { get () { console.log('get value'); return value; }, set (newValue) { if (newValue !== value) { // 新加的元素也可能是对象,继续为新加对象的属性设置get/set方法 observe(newValue); // 这样写会新将value指向一个新的值,而不会影响target[key] console.log('set value'); value = newValue; } } }); } class Observer { constructor (value) { // some code ... if (Array.isArray(value)) { // some code ... } else { this.walk(); } } walk () { for (const key in this.value) { if (this.value.hasOwnProperty(key)) { defineReactive(this.value, key); } } } // some code ... }
数据观测存在的问题
我们先创建一个简单的例子:
const mv = new Vue({ data () { return { arr: [1, 2, 3], person: { name: 'zs', age: 20 } } } })
对于对象,我们只是拦截了它的取值和赋值操作,添加值和删除值并不会进行拦截:
vm.person.school = '北大' delete vm.person.age
而对于数组,用索引修改值以及修改数组长度不会被观测到:
vm.arr[0] = 0 vm.arr.length--
为了能处理上述的情况,Vue为用户提供了$set和$delete方法:
- $set: 为响应式对象添加一个属性,确保新属性也是响应式的,因此会触发视图更新
- $delete: 删除对象上的一个属性。如果对象是响应式的,确保删除触发视图更新。
结语
通过实现Vue的数据劫持,将会对Vue的数据初始化和响应式有更深的认识。
在工作中,我们可能总是会疑惑,为什么我更新了值,但是页面没有发生变化?现在我们可以从源码的角度进行理解,从而更清楚的知道代码中存在的问题以及如何解决和避免这些问题。
代码的目录结构是参考了源码的,所以看完文章的小伙伴,也可以从源码中找出对应的代码进行阅读,相信你会有不一样的理解!