一、前言
上面是官网给出的解释,意思就是计算属性会观察依赖属性是否发生变化,一旦发生变化就会重新去计算出新的值,否则将会继续使用之前的缓存,当然本篇文章不会仅仅局限介绍计算属性的使用,不然也太low了,下面我们将会从以下几个方面来进行探讨。
- class 中为什么要存在 getter 和 setter 两个 API? 使用 getter 和 setter 描述的属性被定义到哪里了?他们和 Object.defineProperty 有什么区别?
- 假如我在 getter 中使用了 this,那这个 this 到底指向的是谁?是当前的实例还是实例的原型?
- mobx 中是怎么将 getter 定义的属性变成计算属性的?
二、getter 和 setter
在定义 class 时有时候需要允许访问动态计算值的属性或者可能需要反映内部变量的状态,而不需要使用显示的方式去调用,此时我们就可以使用 getter 来实现,但是使用的时候需要注意不能同时将一个 getter 绑定在一个属性的并且该属性实际上具有一个值,比如下面这样:
class Person{}
const person = new Person()
Object.defineProperty(person, 'name', {
value: 'xl',
get: () => 'xxx'
})
那使用 getter 和 Object.defineProperty 定义的属性有什么区别呢?当使用 getter 方法时,属性将会被定义到实例的原型上,但是使用 Object.defineProperty 定义的属性会直接绑定到实例自身上(详细讲解点击这里),我们通过具体的例子来说明,首先我们使用 Object.defineProperty 在实例上去绑定属性:
class Person{}
const person = new Person()
Object.defineProperty(person, 'name', {
value: 'xl',
get: () => 'xxx'
})
console.log(person.hasOwnProperty('name')) // true
下面使用 getter 在 class 中去定义属性:
class Person{
get name() {
return 'xl'
}
}
const person = new Person()
console.log(person.hasOwnProperty('name')) // false
const proto = Object.getPrototypeOf(person)
console.log(proto.hasOwnProperty('name')) // true
上面这个例子我们在 getter 方法中直接返回了一个固定的字符串,假如需要我们去返回一个计算表达式会发生什么情况呢?准备好了。
class Person{
count = 1;
get doubleCount() {
return this.count * 2
}
}
const person = new Person()
console.log(person.hasOwnProperty('doubleCount')) // false
console.log(person.doubleCount) // 2
const proto = Object.getPrototypeOf(person)
console.log(proto.hasOwnProperty('doubleCount')) // true
console.log(proto.doubleCount) // NaN
什么情况?为什 proto.doubleCount 是 NaN,我们来回顾一下属性的查找流程,我们通过 person.doubleCount 去访问 doubleCount 首先会从实例本身去查找有没有,当前肯定没有,因为这个值被绑定到原型上,好那我们去原型上去找,此时确实可以访问到 doubleCount,而且也正常给我们返回了 2,但是这就很奇怪了,为什么直接通过原型去访问这个属性拿到的却是 NaN 呢?没关系我们来跟着 person 的原型链来找找。
途中我框选了两个地方,其中第二个就是 person 真正的原型,他里面的 doubleCount = NaN,其实原因也很简单,就是原型中的 this 指向的是原型本身,但是原型本身是没有 count 的属性的,所以最终的值就是 NaN 了,那第一个框选的原型是个什么鬼?作者暂时也没找到比较官方的解释,暂时叫这个原型为伪原型,因为这个原型上具有正实原型上的所有方法和属性,同时他只会存在于当前的实例上,所以此时 getter 方法中的 this 指向的就是当前的实例了。
三、mobx 计算属性原理
在看这里之前需要有前两章的基础:
下面会通过这个例子来进行讲解:
class Store {
count = 1;
age = 2;
get sum() {
return this.age + this.count;
}
get doubleSum() {
return this.sum * 2
}
change(){
this.count = 2;
}
constructor() {
makeObservable(this, {
count: observable,
age: observable,
sum: computed,
doubleSum: computed,
change: action
})
autorun(() => {
console.log("sum: ", this.sum)
})
autorun(() => {
console.log("doubleSum: ", this.doubleSum)
})
}
}
const store = new Store();
console.log(store);
我们先看一下 mobx 转换后 store 实例中的计算属性:
图中框选的两个地方是我们要关注的点,从第二个框中的内容可以知道,mobx 也是对计算属性的 set 和 get 进行了代理,这与普通的属性没什么区别,在第一个框中我们看到每一个计算属性都有一个与之对应的 ComputedValue 值,这其实是 ComputedValue 类的一个实例,里面保存了计算属性的一些信息,如下所示:
export class ComputedValue<T> implements IObservable, IComputedValue<T>, IDerivation {
value_: T; // 计算属性的值
observing_: IObservable[] = []; // 计算属性依赖的可观察属性
observers_ = new Set<IDerivation>(); // 依赖该计算属性的副作用/计算属性
derivation: () => T // 计算属性在原class中 get 方法,用来计算计算属性的值
private equals_: IEqualsComparer<any> // 用来比较更新前后的值是否相等
get: () => T // 获取计算属性的方法
set: (value: T) => void; // 设置计算属性的 value
// ...
}
上面列举了一些关键属性,下面我们来看一下 sum 的信息:
将所有的依赖当前计算属性的副作用和计算属性收集起来便于当值发生变化时通知他们重新执行,下面我们来走一遍计算属性的更新流程。
当我们触发 change 事件后会更新 count 的值,此时 count 就会通知计算属性 sum,紧接着就会执行 sum 对应的 ComputedValue 实例的 onBecomeStale 方法,该方法最终会调用 propagateMaybeChanged 方法通知所有的副作用和计算属性,我们来看下实现:
onBecomeStale_ = function onBecomeStale() {
// 通知sum的所有的副作用和计算属性,我可能更新了哦,我先告诉你们一声
propagateMaybeChanged(this);
};
// propagateMaybeChanged 函数
function propagateMaybeChanged(observable) {
// 通知所有的副作用和计算属性,也就是调用他们的onBecomeStale_方法
observable.observers_.forEach(function (d) {
if (d.dependenciesState_ === IDerivationState_.UP_TO_DATE_) {
d.dependenciesState_ = IDerivationState_.POSSIBLY_STALE_;
d.onBecomeStale_();
}
});
}
其实这个流程也非常简洁,因为计算属性所依赖的可观察属性发生变化但是计算属性最终的值却不一定会变化,所以这里只是先通知依赖于计算属性的副作用和计算属性,我可能会变,接着就回调用他们的 onBecomeStale_ 方法,对于计算属性其实就是重新走一遍上面的流程,比如sum或通知doubleSum,此时doubleSum就会去调用他对应的ComputedValue实例上的onBecomeStale_方法,接着又会通知依赖doubleSum的副作用和计算属性,如此循环下去。
那么对于副作用来说,我们调用他的onBecomeStale_方法其实就是将当前副作用push到 globalState.pendingReactions队列中,等批量更新(inBatch === 0)结束再统一执行,所以所有的计算属性完成通知之后, globalState.pendingReactions上就会保存所有需要重新执行的副作用,拿我们的例子来说:
globalState.pendingReactions = [Reaction1,Reaction2];
// Reaction1 对应第一个autorun
// Reaction2 对应第二个autorun
下面看总结了一张流程图:
收集完副作用接下来就是执行了,执行副作用就是调用副作用的 runReaction_ 函数,如下所示:
_proto.runReaction_ = function runReaction_() {
// 判断当前副作用是否应该被执行
if (!this.isDisposed_) {
//...
if (shouldCompute(this)) {
// ... 执行副作用的逻辑
}
};
};
我们看到在执行副作用的逻辑之前会判断该副作用是否应该执行,在 shouldCompute 函数中会遍历当前副作用依赖的所有的可观察属性和计算属性,只要有一个符合更新的条件就需要执行当前的副作用,如下所示:
放到我们这个例子中来说就是需要判断计算属性 sum 的值在更新前后有没有发生变化,如果有变化则执行当前的副作用,上图中 obj.get() 其实就是去获取计算属性最新的值。
总结
看到这里我们的计算属性就已经学完了,我们再来捋一遍,当计算属性依赖的可观察属性变化时,计算属性需要通知依赖他的所有的副作用和计算属性,这里要注意的是只是通知副作用并不是执行,通知完之后副作用会被收集到全局的队列中去,等所有的副作用收集完成之后再去统一执行,在执行之前需要去更新计算属性的值,然后判断计算属性更新前后的值是否一样,不一样则执行当前的副作用。
四、immer和mobx坑点
在处理不可变数据结构的时候,为了不改变原来的对象、数组或映射的任何属性,我们始终需要创建一个更改后的副本,通常情况下就需要使用大量的 ... 操作符来实现浅拷贝,这可能会导致代码编写起来非常麻烦,而使用 Immer 时,它会对 draft 对象进行更改,该对象会记录更改并负责创建必要的副本,而不会影响原始对象,官网也给出了一个具体的例子,这里就不展开讲了,下面我们主要介绍 immer 和 mobx 结合使用的一些坑。
1、失效的计算属性
class Store {
[immerable] = true;
count = 1;
age = 2;
get sum() {
return this.age + this.count;
}
get doubleSum() {
return this.sum * 2
}
change(){
this.count = 2;
}
constructor() {
makeObservable(this, {
count: observable,
age: observable,
sum: computed,
doubleSum: computed,
change: action
})
autorun(() => {
console.log("count: ", this.count)
})
autorun(() => {
console.log("sum: ", this.sum)
})
autorun(() => {
console.log("doubleSum: ", this.doubleSum)
})
}
}
const store = new Store();
const store1 = produce(store, (dart) => {
dart.count = 2
})
console.log("store1-count: ", store1.count) // 2
console.log("store1-sum: ", store1.sum) //3
console.log("store1-doubleSum: ", store1.doubleSum) //6
console.log(store)
console.log(store1)
在这个例子中我们通过 produce 对 dart 的 count 属性进行了修改同时生成了一个新的副本 store1,下面我们输出了副本中的 count、sum、doubleSum 属性,发现除了 count 属性其他都没改变,同时我们定义的三个副作用也都没有执行,这是为啥?是我们的计算属性失效了吗?
我们先来看下第二个问题,为什么副作用没有执行,前面我们讲过 mobx 对我们定义的所有的属性的 get 和 set 方法进行了代理,一但属性发生变化就需要通知他们的副作用,我们来看下答应出来的 store 和 store1:
我们看到 store1 中所有的 get 和 set 方法都不见了,这其实是 immer 内部将我们的属性进行了冻结,不允许用户进行更改,所以这就解释了为什么副作用没有生效。
那为什么计算属性也失效了呢?就算响应式系统失效了,但是计算属性应该是可以获取到最新的值呀,不急我们先看下 store1 的结构:
这里我框选了三个部分,这三个部分的计算属性的值都是不一样的,下面来一一解释下他们的含义:
1、第一个部分的计算属性其实是 immer 帮我们从原型上拷贝下来的值,也就是说 sum 和 doubleSum 此时就是这个实例上的普通属性,之后就不会再发生变化了,我们通过 store1.sum 去访问的时候访问的就是这里的值。
2、第二部分其实就是 伪原型上的值,此时 sum = 4,因为此时通过 this.count 访问到的是实例上的值,也就是 2,对于 doubleSum 来说因为通过 this.sum 访问时优先是从实例上读取的,所以 doubleSum = 3 * 2。
3、第三部分就是原型上的值,这个上面也介绍过此时 this 就是原型所以找不到 count 和 sum。
2、值得注意的箭头函数
我们将上面的例子改一下:
class Store {
[immerable] = true;
count = 1;
age = 2;
get sum() {
return this.age + this.count;
}
get doubleSum() {
return this.sum * 2
}
change = () => {
this.count = 3;
}
constructor() {
makeObservable(this, {
count: observable,
age: observable,
sum: computed,
doubleSum: computed,
change: action
})
}
}
const store = new Store();
const store1 = produce(store, (dart) => {
dart.count = 2
})
store1.change();
console.log(store.count) // 3
console.log(store1.count) // 2
在这个例子中我们将 change 方法改为箭头函数然后通过 store1 调用它,最终 store.count 变成了 3,而 store1.count 却没变,这里其实就涉及到 this 的绑定问题了,我们在 class 中定义的箭头函数其实是会绑定到当前的实例的(详情),虽然我们是通过 store1 去调用的 change 方法,但是此时 this 还是指向的是 store。