Vue2.x 响应式原理
众所周知,Vue2.x 是基于 Object.defineProperty()来实现响应式的。
那么,defineProperty 是啥呢。
先附上 MDN 的连接Object.defineProperty()
Object.defineProperty
引用 MDN 的原话:
Object.defineProperty()方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
那么重点有两个:
- 对象
- 定义新属性或修改现有属性
那么什么叫定义新属性或修改现有属性呢?
我们来看看另外一个概念属性描述符
属性描述符
描述符的介绍
对象里目前存在的属性描述符有两种主要形式:
- 数据描述符:具有值的属性,该值可以是可写的,也可以是不可写的。
- 存取描述符:由 getter 和 setter 函数描述的属性。
一个描述符只可能是二者之一,不可能同时是两者。即二选一。
上述两种描述符都是对象,共享以下可选键值。(默认值是指在使用 Object.defineProperty() 定义属性时的默认值)
| 键值 | 描述 | 默认值 |
|---|---|---|
| configurable | 当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。 | false |
| enumerable | 当且仅当该属性的 enumerable 键值为 true 时,该属性才会出现在对象的枚举属性中。 | false |
| value | 该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。 | undefined |
| writable | 当且仅当该属性的 writable 键值为 true 时,属性的值,也就是上面的 value,才能被赋值。 | false |
因为
enumerable属性默认是 false,所以通过Object.defineProperty()定义的属性都是不会被for...in和Object.keys访问到。
以下是存取描述符独有的:
| 键值 | 描述 | 默认值 |
|---|---|---|
| get | 属性的 getter 函数,如果没有 getter,则为 undefined。当访问该属性时,会调用此函数。执行时不传入任何参数,但是会传入 this 对象(由于继承关系,这里的this并不一定是定义该属性的对象)。该函数的返回值会被用作属性的值。 | undefined |
| get | 属性的 setter 函数,如果没有 setter,则为 undefined。当属性值被修改时,会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的 this 对象。 | undefined |
可以看到,跟值相关的,默认值都是 undefined。跟able相关的,默认值都是 false。
怎么区分描述符呢?
| configurable | enumerable | value | writable | get | set | |
|---|---|---|---|---|---|---|
| 数据描述符 | √ | √ | √ | √ | × | × |
| 存取描述符 | √ | √ | × | × | √ | √ |
如果一个描述符不具有 value、writable、get 和 set 中的任意一个键,那么它将被认为是一个数据描述符。如果一个描述符同时拥有 value 或 writable
和 get 或 set 键,则会产生一个异常。
了解到这里已经够我们去理解 Vue2.x 的响应式原理了。
我们再填一下上面的坑:
如果对象中不存在指定的属性,Object.defineProperty() 会创建这个属性。当描述符中省略某些字段时,这些字段将使用它们的默认值。 搬一下 MDN 的代码:
var o = {}; // 创建一个新对象
// 在对象中添加一个属性与数据描述符的示例
Object.defineProperty(o, "a", {
value: 37,
writable: true,
enumerable: true,
configurable: true,
});
// 对象 o 拥有了属性 a,值为 37
// 在对象中添加一个设置了存取描述符属性的示例
var bValue = 38;
Object.defineProperty(o, "b", {
// 使用了方法名称缩写(ES2015 特性)
// 下面两个缩写等价于:
// get : function() { return bValue; },
// set : function(newValue) { bValue = newValue; },
get() {
return bValue;
},
set(newValue) {
bValue = newValue;
},
enumerable: true,
configurable: true,
});
o.b; // 38
// 对象 o 拥有了属性 b,值为 38
// 现在,除非重新定义 o.b,o.b 的值总是与 bValue 相同
// 数据描述符和存取描述符不能混合使用
Object.defineProperty(o, "conflict", {
value: 0x9f91102,
get() {
return 0xdeadbeef;
},
});
// 抛出错误 TypeError: value appears only in data descriptors, get appears only in accessor descriptors
响应式原理
所谓响应式,即,一个值变化的时候要根据这个变化,产生相应的行为。
在浏览器上,dom 渲染完成之后,那么如果你不主动通过 dom 修改操作来重新渲染,那么这个 dom 就永远不变了。 那如果 dom 上渲染的是 X 的值,X 变了怎么办呢。难不成你每次给 X 赋值的时候,都手动 document.balabala 吗?
看过上面的 set 属性之后你可能就想明白了,重点再复习一下:
当属性值被修改时,会调用此函数。
锵锵锵,简单来说就是用get和set来实现的啦。
简单实现
话不多说,直接上代码 。
let x;
let f = function (v) {
return v * 100;
};
// 变化之后的处理函数
let active;
const onXChange = (cb) => {
active = cb;
// 初始化的时候就需要执行一次
active();
};
// 工厂
const ref = (value) => {
let initValue = value;
return Object.defineProperty({}, "value", {
get() {
return initValue;
},
set(newVal) {
initValue = newVal;
active();
},
});
};
// 初始化
x = ref(1);
// 添加回调
onXChange(() => {
console.log(f(x.value));
});
x.value = 2;
x.value = 3;
上述的写法有点问题。如果,不光是一个 callback,有多个 callback 如何处理?
一个 active 不够用。所以我们需要一个地方进行依赖收集。(即,多个组件依赖这个数据,每个组件都需要进行变化。)
带依赖收集
变化后的代码如下:
/* eslint-disable no-debugger */
let x;
let f = function (v) {
return v * 100;
};
let active;
const onXChange = (cb) => {
active = cb;
active();
active = null;
};
class Dep {
deps = new Set();
depend() {
if (active) {
this.deps.add(active);
}
}
notify() {
this.deps.forEach((dep) => dep());
}
}
const ref = (value) => {
let initValue = value;
let deps = new Dep();
return Object.defineProperty({}, "value", {
get() {
deps.depend();
return initValue;
},
set(newVal) {
initValue = newVal;
deps.notify();
},
});
};
x = ref(1);
onXChange(() => {
console.log(f(x.value));
});
onXChange(() => {
console.log(x.value + 1);
});
x.value = 2;
x.value = 3;