一、计算属性的必要性
假设我们有如下模板:
{{ user.firstName + ' ' + user.lastName }}
当多处复用这个表达式时:
- 维护成本:表达式修改需全局搜索替换
- 性能浪费:每次渲染重复计算相同结果
- 逻辑隔离:业务逻辑侵入视图层
所以我们需要computed这个能自动追踪依赖,且最好能够缓存计算结果的东西
const name = computed(() => user.firstName + ' ' + user.lastName)
我们来看一下computed
的三个核心特性:
- 惰性执行:不访问时不计算,访问时执行
- 值缓存:依赖未变化时复用结果
- 依赖追踪:自动建立响应式关联
接下来,我们来分析一下computed
是如何写出来的
二: 从零实现计算属性
1. 幼崽形态
// 第一阶段:直接返回函数结果
function computed(fn) {
return fn()
}
let user = reactive({
firstName: '张',
lastName: '三'
})
let fullName = computed(() => user.firstName + user.lastName )
console.log(fullName); // 张三
现存问题:
正常的computed
是用.value
来访问值的,我们的用.value
访问不到
function computed(fn) {
return fn()
}
let user = reactive({
firstName: '张',
lastName: '三'
})
let fullName = computed(() => user.firstName + user.lastName )
console.log(fullName.value); // 报错,因为fullName没有value属性
2. 支持 .value
访问
我们要在获取.value
属性的时候进行拦截
// 第二阶段:value属性返回函数结果
function computed(fn) {
return {
get value() { return fn() }
}
}
let user = reactive({
firstName: '张',
lastName: '三'
})
let fullName = computed(() => user.firstName + user.lastName )
console.log(fullName.value); // 控制台:张三
现存问题:
在依赖的数据变化后,计算属性没有随之变化
// 第二阶段:value属性返回函数结果
function computed(fn) {
return {
get value() { return fn() }
}
}
let user = reactive({
firstName: '张',
lastName: '三'
})
let fullName = computed(() => user.firstName + user.lastName )
console.log(fullName.value); // 控制台:张三
user.lastName = '四'
console.log(fullName.value); // 控制台:张三(预期是变成了张四)
可以看到,在lastName属性更改后,我们的fullName依然是第一次计算的结果,并没有变成预想中的“张四”
3. ### 实现响应式更新
这里我们需要加入effect, 对effect不熟悉的小伙伴可以看下上篇文章 # Vue 3 响应式系统(二):Effect
// 接入effect
function computed(fn) {
const effectFn = effect(fn)
return {
get value() {
return effectFn() // 通过effect建立依赖关系
}
}
}
let user = reactive({
firstName: '张',
lastName: '三'
})
let fullName = computed(() => user.firstName + user.lastName )
console.log(fullName.value); // 控制台:张三
user.lastName = '四'
console.log(fullName.value); // 控制台:张四(因为当依赖的数据变化时,会重新执行传给computed的函数,也就重新计算了)
简单说下为什么加了effect
就可以,effect
会自动追踪函数内部访问的响应式数据(如 user.firstName
和 user.lastName
),并建立依赖关系。当这些响应式数据变化时,effect
会自动重新执行传入的函数,从而触发更新。
我们传给comptued
的fn
,computed
会传给effect
,这样当effect
中检测到我们computed
传给他的fn
中依赖数据变化的时候,就会重新执行我们计算属性传给他的fn
,也就重新计算了。
现存问题:
computed
在没有访问的时候应该是不执行的,现在还没有访问,就已经执行过一次fn
了
function computed(fn) {
// 省略 如上
}
let user = reactive({
// 省略 如上
})
let fullName = computed(() => (){
console.log('computed执行了')
return user.firstName + user.lastName
})
// 未访问计算属性fullName,控制台就已经打印'computed执行了'
4. 惰性执行优化
这里我们通过effect
的调度器,让effect
变为首次不执行。
// 加入lazy: true
function computed(fn) {
const effectFn = effect(fn, {
lazy: true, // 🚨 延迟执行
})
return {
get value() {
return effectFn() // 通过effect建立依赖关系
}
}
}
let user = reactive({
firstName: '张',
lastName: '三'
})
let fullName = computed(() => (){
console.log('computed执行了')
return user.firstName + user.lastName
})
// 未访问fullName,控制台不会打印
现存问题:
computed
应该是缓存的,但我们的computed
即使依赖的数据没变化,每次访问也都会重新计算。
// 加入lazy: true
function computed(fn) {
const effectFn = effect(fn, {
lazy: true, // 延迟执行
})
return {
get value() {
return effectFn() // 通过effect建立依赖关系
}
}
}
let user = reactive({
firstName: '张',
lastName: '三'
})
let fullName = computed(() => (){
console.log('computed执行了')
return user.firstName + user.lastName
})
// 测试访问 fullName
console.log(fullName.value); // 输出: "张三";打印: "computed执行了"
console.log(fullName.value); // 输出: "张三";打印: "computed执行了"
console.log(fullName.value); // 输出: "张三";打印: "computed执行了"
在例子中我们可以看到,每次访问fullName.value
都会执行这个log
语句,代表我们传给computed
的函数被执行,也就是说每次访问,都在重新计算,这当然是与我们对comptued
带缓存这个认知不符的,所以要怎么加入呢?
5. 值缓存机制
缓存如何实现,需要加入一个叫做dirty
的标志位,代表当前是否为脏数据,如果脏数据了,我们则重新计算。
scheduler(调度器):用于控制副作用函数的执行时机
function computed(fn) {
let value
let dirty = true // 🆕 脏检查标志
const effectFn = effect(fn, {
lazy: true,
scheduler() {
dirty = true
}
})
return {
get value() {
if (dirty) { // 如果为脏数据,才重新计算。
value = effectFn()
dirty = false
}
return value
}
}
}
只要重新运行effect
,我们就将dirty
变为true
,只要dirty
为true
,我们在获取的时候就重新获取数据。
反之,如果依赖的数据没有变化,也就不会重新运行effect
,dirty
也就不会变为true
,dirty
不为true
代表数据不需要重新请求,我们就直接返回老value
就好了。
看看效果:
function computed(fn) {
let value
let dirty = true // 🆕 脏检查标志
const effectFn = effect(fn, {
lazy: true,
scheduler() {
dirty = true
}
})
return {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
return value
}
}
}
let fullName = computed(() => (){
console.log('computed执行了')
return user.firstName + user.lastName
})
// 测试访问 fullName
console.log(fullName.value); // 输出: "张三";打印: "computed执行了"
console.log(fullName.value); // 输出: "张三";
console.log(fullName.value); // 输出: "张三";
user.lastName = '四'
console.log(fullName.value); // 输出: "张四";打印: "computed执行了"
console.log(fullName.value); // 输出: "张四"
现存问题:
当计算属性之间存在依赖链时,也就是computed A
引用computed B
时,我们的实现会出现computed B
变化后,computed A
不能及时更新的问题
function computed(fn) {
// ...如上
}
// 响应式数据
const data = reactive({
a: 1
})
// 创建计算属性
const double = computed(() => {
return data.a * 2
})
const quadruple = computed(() => {
return double.value * 2 // 依赖 double
})
// 初始访问
console.log(quadruple.value) // 期望输出: 4 ; 实际输出4
// 修改依赖的数据
data.a = 2
// 再次访问
console.log(quadruple.value) // 期望输出: 8 ; 实际输出4
在这个例子中:
double
依赖于data.a
。quadruple
依赖于double.value
。
当 data.a
发生变化时:
double
会被标记为dirty
(因为effect
的scheduler
会触发)。- 但是,由于
computed
中没有手动调用trigger
,quadruple
并不知道double
已经发生了变化。 - 因此,
quadruple
不会重新计算,即使double
的值已经更新。
所以,我们应该完善computed自身的响应式,如何做呢,就是加入track
和trigger
6. 支持嵌套计算属性
可能有同学要问了,我computed
中已经将传来的回调给effect
了,effect
中就会调用track
了,为什么还要在computed
中再次加入track
和trigger
呢?
那是因为,computed
既是观察者,也是被观察者。
我们现在仅仅做到了computed
当观察者的一面,他依赖的数据变化时他会重新计算,但并没有做好他当“被观察者”的一面,也就是说他自身变化的时候他不会去通知别人,所以我们要加入track
和trigger
来完善computed
自身的依赖触发
track 用于依赖收集,trigger 用于触发更新
// 完整版实现
function computed(fn) {
let value
let dirty = true
const deps = new Set() // 🆕 新增依赖收集器
const effectFn = effect(fn, {
lazy: true,
scheduler() {
if (!dirty) {
dirty = true
// 触发所有依赖该计算属性的副作用
trigger(thisObj, 'value')
}
}
})
const thisObj = {
get value() {
// 收集当前正在执行的副作用
track(thisObj, 'value')
if (dirty) {
value = effectFn()
dirty = false
}
return value
}
}
return thisObj
}
加入后我们重新测试:
function computed(fn) {
// ...如上
}
// 响应式数据
const data = reactive({
a: 1
})
// 创建计算属性
const double = computed(() => {
return data.a * 2
})
const quadruple = computed(() => {
return double.value * 2 // 依赖 double
})
// 初始访问
console.log(quadruple.value) // 期望输出: 4 ; 实际输出4
// 修改依赖的数据
data.a = 2
// 再次访问
console.log(quadruple.value) // 期望输出: 8 ; 实际输出8
加入了track
和 trigger
,computed
就可以既当好“观察者”又可以当好“被观察者”啦
总结
computed
实现的内部细节就已经完成啦~
附录:完整实现代码
// 生产级computed实现(简化版)
function computed(getter) {
let value, dirty = true
let effectFn = effect(getter, {
lazy: true,
scheduler: () => {
if (!dirty) {
dirty = true
trigger(obj, 'value')
}
}
})
const obj = {
_isRef: true,
get value() {
track(obj, 'value')
if (dirty) {
value = effectFn()
dirty = false
}
return value
}
}
return obj
}