1. 前置知识
Vue.js
是通过数据劫持 + 发布者-订阅者模式来实现响应式功能的。
1.1. Object.defineProperty
Object.defineProperty()
方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。
通过Object.defineProperty()
给某个属性设置get和set就可以劫持该属性的读写过程,具体的api使用我们不展开(MDN传送门),现在使用Object.defineProperty
写个简单的小demo,来劫持一个对象的属性赋值和取值过程👇
function Store() {
let data = null;
Object.defineProperty(this, 'data', {
get: function() {
console.log('拦截到了,你在读取数据'); // 拦截到了,你在读取数据
return data;
},
set: function(val) {
console.log('拦截到了,你在设置数据'); // 拦截到了,你在设置数据
data = val;
}
});
}
let store = new Store();
store.data = 'Hi';
console.log(store.data) // Hi
1.1.1. Object.defineProperty的局限
Object.defineProperty
只能劫持对象的某一个属性,当一个对象有嵌套的对象时,只能通过通过递归去给每一个属性设置拦截Object.defineProperty
并不能劫持Array.prototype
上的方法。比如还是上面那个例子,当data
是个数组的时候,对data
调用push
、pop
等数组原型上的方法的时候,并不会触发setter
:
function Store() {
let list = [];
Object.defineProperty(this, 'list', {
get: function() {
console.log('拦截到了,你在读取数据'); // 拦截到了,你在读取数据
return list;
},
set: function(val) {
console.log('⚠️push操作并没有被拦截到');
list = val;
}
});
}
let store = new Store();
store.list.push(1);
console.log(store.list); // [1]
1.1.2. 如何劫持数组上的方法
既然setter里面拦截不到原型链上的方法,那就针对数组原型链上的需要被拦截的方法进行二次封装。
function newArrMethod() {
// 先把数组上的方法复制一遍
let arrExtend = Object.create(Array.prototype);
// 然后把需要改写的方法重新封装,比如下面对push方法进行重新封装
let newPushMethod = function(...args) {
console.log('在这里做拦截');
Array.prototype.push.apply(this, args);
}
arrExtend.push = newPushMethod;
return arrExtend;
}
let arr = [];
arr.__proto__ = newArrMethod();
arr.push(1); // arr的push方法被劫持到了
1.2. Proxy
Proxy
对象用于定义基本操作的自定义行为(如属性查找,赋值,枚举,函数调用等)
Proxy
(MDN传送门)字面意思是代理,可以理解成,在目标对象之前架设一层 "拦截",当外界对该对象访问的时候,都必须经过这层拦截,因此直接使用这个api就能够达到对象劫持的效果,vue2.*
之所以不用Proxy
而使用Object.defineProperty
是因为Proxy
是一个比较新的特性,考虑到浏览器的兼容性问题,所以没有被尤大使用。在vue3.0
已经把Object.defineProperty
替换为proxy
,下面使用proxy
来写一个劫持👇
let data = {};
data = new Proxy(data, {
get: function(target, key) {
console.log('拦截到了,你在读取数据');
return target[key];
},
set: function(target, key, val) {
console.log('拦截到了,你在设置数据');
target[key] = val;
}
})
data.name = '小明';
console.log(data.name);
1.3. 发布-订阅者模式
Vue.js里面的依赖收集是通过发布-订阅模式来实现的,这种设计模式在前端经常使用,比如document.addEventListener
就是很典型发布-订阅者应用。一般来说发布-订阅模式有这几个要素:
- 有一个指定的发布者
- 发布者有一个缓存列表,里面存有所有订阅者的回调函数
- 发布消息的时候,发布者遍历缓存列表,依次触发订阅者的回调
👇下面实现一个典型的发布订阅者模式
class Event {
constructor() {
this.subscriberList = [];
}
listen(key, fn) {
if (!this.subscriberList[key]) {
this.subscriberList[key] = [];
}
this.subscriberList[key].push(fn);
}
notify() {
let key = Array.prototype.shift.call(arguments);
let fns = this.subscriberList[key];
fns && fns.forEach(fn => fn.apply(this, arguments));
}
remove(key, fn) {
let fns = this.subscriberList[key];
if (fns) {
for (let i = fns.length - 1; i >= 0; i--) {
let _fn = fns[i];
if (_fn == fn) {
fns.splice(i, 1);
}
}
}
}
}
let event = new Event();
event.listen("sayHi", function(name) {
console.log("Hi," + name);
});
event.listen("sayHallo", function(name) {
console.log("Hallo," + name);
});
event.notify("sayHi", "小明");
event.notify("sayHallo", "小红");
1.4. 创建一个纯净的对象
let obj1 = {};
let obj2 = Object.create(null);
console.log(obj1 instanceof Object) // true
console.log(obj1.__proto__) // {}
console.log(obj2 instanceof Object) // false
console.log(obj2.__proto__) // undefined
Object.create()
方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__
。在上面的例子中创建了两个对象,其中obj1
有原型链,obj2
则是一个原型为null
的空对象,它的原型链是纯净的。其实在很多框架源码里面我们都能找到Object.create(null)
这种初始对象的写法,究竟是用{}
还是Object.create(null)
可以这样考量:
- 当你需要一个非常干净的字典对象的时候
- 当你需要自定义对象原型链上的方法的时候,不用担心覆盖其他对象的原型
上面两种情况都应该使用
Object.create(null)
,其他情况还是使用{}