实现 computed
约 1967 字大约 7 分钟
2026-03-29
懒执行
Vue 官方的
computed是一个 懒执行 的副作用函数。只有在真正读取计算属性的值时,才会去执行它内部的副作用函数下面这段代码中,传入的函数本质上更像一个
getter。如果还是沿用之前的实现,在调用effect的那一刻就直接执行,那就不是计算属性想要的行为了const effectFn = effect( () => { return proxy.age + 10; }, { lazy: true, }, );实现核心
与调度实行类似,可以通过在
effect里增加一个lazy选项来实现这个功能。默认情况还是立即执行,只有当lazy: true时才先不执行,并把包装后的effectFn返回出去,方便后面手动调用代码
effect.tslet activeEffect; const effectStack = []; function effect(fn, options = {}) { const effectFn = () => { cleanup(effectFn); activeEffect = effectFn; effectStack.push(effectFn); fn(); effectStack.pop(); activeEffect = effectStack[effectStack.length - 1]; }; effectFn.options = options; effectFn.deps = []; // 只有非 lazy 的情况才立即执行 if (!options.lazy) { effectFn(); } // 把包装后的副作用函数返回出去 return effectFn; }effect还需要返回执行结果仅仅做到懒执行还不够,因为
computed最终关心的是一个值,而不是 "某个副作用函数有没有被执行过"。所以这里还要再往前走一步:让包装后的effectFn在执行时,把用户传入函数的返回值再原样返回出来关键点
computed的底层依然是effect,只不过这个effect不再主要为了 "产生副作用",而是为了 "在依赖变化后重新拿到getter的计算结果"代码
effect.tsfunction effect(fn, options = {}) { const effectFn = () => { cleanup(effectFn); activeEffect = effectFn; effectStack.push(effectFn); // 保存 getter 的返回值 const res = fn(); effectStack.pop(); activeEffect = effectStack[effectStack.length - 1]; // 把结果再返回出去 return res; }; effectFn.options = options; effectFn.deps = []; if (!options.lazy) { effectFn(); } return effectFn; }index.tsconst effectFn = effect( () => { return proxy.age + 10; }, { lazy: true, }, ); const value = effectFn(); console.log(value);实现
computed有了上面的改造以后,实现最简单的
computed:把effect(getter, { lazy: true })放到computed函数中,然后对外暴露一个带有value访问器属性的对象。这样在调用computed时,就会得到一个对象,读取它的value属性时就会执行getter,并返回结果代码
computed.tsfunction computed(getter) { const effectFn = effect(getter, { lazy: true }); const obj = { get value() { return effectFn(); }, }; return obj; }index.tsconst res = computed(() => proxy.age + 10); console.log(res.value);但这个版本显然还不够,因为每次读取
res.value,都会重新执行一遍getterconst res = computed(() => proxy.age + 10); console.log(res.value); console.log(res.value); console.log(res.value);这里虽然依赖的数据根本没变,但 getter 还是会被反复执行。这和
computed的预期不一致缓存上一次的计算结果
computed的一个核心特点就是:依赖没有变化时,重复读取应该直接返回缓存值,而不是重新求值关键点
这个逻辑其实很适合用一个
dirty标记来表示:dirty = true说明缓存已经失效,需要重新计算dirty = false说明缓存仍然可用,直接返回上一次的值即可
代码
computed.tsfunction computed(getter) { let value; let dirty = true; const effectFn = effect(getter, { lazy: true }); const obj = { get value() { if (dirty) { value = effectFn(); dirty = false; } return value; }, }; return obj; }index.tsconst res = computed(() => proxy.age + 10); console.log(res.value); console.log(res.value); console.log(res.value);这样以后,多次读取
res.value时,getter 就只会在第一次真正执行一次不过这里又会暴露出新的问题:当依赖的响应式数据变化以后,
dirty并不会自动重新变成trueconst res = computed(() => proxy.age + 10); console.log(res.value); proxy.age++; console.log(res.value);这里第二次读取按理说应该拿到新值,但现在还是旧值。原因很简单:缓存虽然该失效了,但我们还没有在依赖变化时主动把
dirty重置掉借助调度器让缓存失效
关键点
到这里,前一节 "实现调度执行" 的作用就体现出来了。
computed内部本身就是一个懒执行的effect,那么只要这个effect依赖的响应式数据发生变化,就会触发它的scheduler。那么在scheduler里,就可以把dirty标记重置掉,这样下一次读取value时,就会重新计算了代码
computed.tsfunction computed(getter) { let value; let dirty = true; const effectFn = effect(getter, { lazy: true, // scheduler 在 trigger 中的逻辑就是如果存在 scheduler 那么执行 scheduler 并传入 effectFn scheduler() { dirty = true; }, }); const obj = { get value() { if (dirty) { value = effectFn(); dirty = false; } return value; }, }; return obj; }这样以后:
- 初次读取
value时会执行 getter,并缓存结果 - 当底层依赖发生变化时,
scheduler只负责把dirty改回true - 下一次真正读取
value时,才重新计算
到这里,单独读取
computed.value其实已经没问题了。但只要把它放进外层effect中,又会出现新的情况- 初次读取
computed.value自己也需要参与依赖收集看下面这段代码:
const res = computed(() => proxy.age + 10); effect(() => { console.log(res.value); }); proxy.age++;按直觉来说,
proxy.age变化以后,外层effect应该重新执行,但现在实际上不会有任何反应问题的根源在于,当外层副作用函数读取
res.value时,真正发生的流程是:- 进入
computed的get value() - 在里面执行了
effectFn() effectFn()内部的 getter 读取了proxy.age- 所以
proxy.age收集到的其实是 computed 内部的 effect
这就意味着,底层依赖变化时,只会让 computed 内部这个 effect 知道自己该失效了,但外层那个读取了
res.value的 effect 根本没有和value建立联系
计算属性自身的 value 也需要参与依赖收集 所以这里必须补上两件事:
- 在读取
obj.value时,对obj的value手动做一次track - 当底层依赖变化导致计算属性失效时,再对
obj的value手动做一次trigger
这样一来,外层副作用函数才会真正和
computed.value建立依赖关系代码
computed.tsfunction computed(getter) { let value; let dirty = true; const obj = { get value() { if (dirty) { value = effectFn(); dirty = false; } // 手动把外层读取 value 的副作用函数收集起来 track(obj, 'value'); return value; }, }; const effectFn = effect(getter, { lazy: true, scheduler() { if (!dirty) { dirty = true; // 当底层依赖变化时,手动触发与 value 相关的副作用函数 trigger(obj, 'value'); } }, }); return obj; }index.tsconst res = computed(() => proxy.age + 10); effect(() => { console.log(res.value); }); proxy.age++;这里调度器里多加了一个
if (!dirty)判断,也是为了避免重复触发。因为如果当前本来就已经是脏的了,就没必要再重复trigger(obj, 'value')- 进入
重新封装成完整的
computed到这里其实已经能得到一个可用的计算属性了,不过它还只支持这种写法:
computed(() => proxy.age + 10);但在 Vue 里,
computed还支持另一种形式:computed({ get() {}, set() {}, });所以最后可以再往前走一步,把
computed重构成一个更完整的封装:- 如果传入的是函数,就把它当成
getter - 如果传入的是对象,就分别取出
get和set - 再把缓存、脏标记、依赖收集和触发更新这些逻辑统一收进一个
ComputedRefImpl类里
代码
computed.tsconst isFunction = (val) => typeof val === 'function'; const NOOP = () => {}; function computed(getterOrOptions) { let getter; let setter; if (isFunction(getterOrOptions)) { getter = getterOrOptions; setter = NOOP; } else { getter = getterOrOptions.get; setter = getterOrOptions.set; } return new ComputedRefImpl(getter, setter); } class ComputedRefImpl { _value; _dirty = true; effect; _setter; constructor(getter, setter) { this._setter = setter; this.effect = effect(getter, { lazy: true, scheduler: () => { if (!this._dirty) { this._dirty = true; trigger(this, 'value'); } }, }); } get value() { if (this._dirty) { this._value = this.effect(); this._dirty = false; } track(this, 'value'); return this._value; } set value(newValue) { this._setter(newValue); } }index.tsconst res = computed({ get() { return proxy.firstName + proxy.lastName; }, set(val) { const names = val.split(''); proxy.firstName = names[0]; proxy.lastName = names[1]; }, }); effect(() => { layer1.innerHTML = res.value; layer2.innerHTML = proxy.firstName + '---' + proxy.lastName; }); btn1.addEventListener('click', () => { proxy.firstName = '李'; proxy.lastName = '四'; }); btn2.addEventListener('click', () => { res.value = '王五'; });- 如果传入的是函数,就把它当成
