手写Vue源码(一)-数据劫持
source link: https://zhuanlan.zhihu.com/p/341606710
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
手写Vue源码(一)-数据劫持
源代码: 传送门
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
的数据初始化和响应式有更深的认识。
在工作中,我们可能总是会疑惑,为什么我更新了值,但是页面没有发生变化?现在我们可以从源码的角度进行理解,从而更清楚的知道代码中存在的问题以及如何解决和避免这些问题。
代码的目录结构是参考了源码的,所以看完文章的小伙伴,也可以从源码中找出对应的代码进行阅读,相信你会有不一样的理解!
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK