试着复刻一个vue的计算属性

之前对于 Vue 的响应式封装(这里主要思考的是 computed),主要是用用,其实是懒得思考背后的原理的。毕竟原理其实可以用一句话说明:计算属性对别的属性产生有向的依赖关系,这个依赖关系构成一个有向无环图(DAG),图上的某个节点更新后重新计算其所有后置节点即可。

但是仔细想来,内部其实有一些时间复杂度的问题。显然,我们不能暴力调用回调函数(会卡出指数级时间复杂度 O(2n)O(2^n) 下面会详细解释)。同时朴素的做法很难解决这样的问题:

const a = ref([]);
const b = computed(() => a.join(","));
for (let i = 1; i <= n; i++) {
  a.value = a.value.concat([i]);
}

注意到对于一个网页而言,我们实际上期望 b 能在整个宏任务跑完以后才更新。所以实际上a.value 的 setter 触发的更新必须要进行推迟。

这篇文章讲讲我怎么在思考这些的过程中试着复刻一个 vue 的计算属性 computed

关于 computed

Vue 的 computed 有多种,不过为了简化问题,这里我们只讨论最经典的那种:

function computed(fn: () => T): ComputedRef<T>;

这个 computed 函数接受一个 callback, 每当 callback 涉及到的其他响应式变量发生变化时,重新计算。具体请自行参考 vue 的文档。

如果我们希望构建一个高性能的computed,就必须进行复杂度分析。

复杂度分析

现在假设我们有 nn 个响应式状态,彼此之间总共有 mm 的依赖关系。它们可以构成一个 DAG G=(V,E)G = (V, E)。 其中, E={(u,v)u的计算依赖于v}E = \{(u, v) | u 的计算依赖于v \}。显然出度为 0 的点是可写的 Ref,其余点是 ComputedRef.

这里必须是 DAG,因为一旦计算属性出现环,那就会出现死循环。

我们把 ComputedRef 的平均计算时间记为 TT

朴素写法复杂度分析

考虑一个这样的朴素 computed 实现:

function computed(fn) {
  let cached = false;
  let val;
  let children = [];
  const res = {
    get value() {
      children.push(useContext());
      inContext(res, () => {
        if (!cached) val = fn();
        cached = true;
      });
      return val;
    },
    update() {
      val = fn();
      children.forEach((c) => c.update());
    },
  };
}

第一次尝试获得 .value 的时候,我们在 res 的 context 下跑一次 fn。 跑 fn 的过程中,每个响应式属性 .value 都会尝试把 context 设为自己的 children (也就是构建 u,v 边)

容易发现,跑完第一次的 fn 以后再调用 .valueO(1)O(1) 的。也就是说,计算全图的所有属性只需要挨个计算一次,即为 O(nT)O(nT)

但是考虑更新。最坏条件下,我们会发现有类似这样的依赖关系:

graph LR;
E --> D --> C --> B --> A
E --> C --> A
D --> B
E --> B
D --> A
E --> A

现在假设我们更新了 A 。A 的孩子是 [D, C, B],我们发现递归树是这样的:

graph TD;

A --> AB --> ABC --> ABCD --> ABCDE
ABC --> ABCE
AB --> ABD --> ABDE
AB --> ABE
A --> AC --> ACD --> ACDE
AC --> ACE
A --> AD --> ADE
A --> AE

这里的问题在于 A 可能先用错误的 B,C,D 数据触发了对 E 的更新;然后对 D 更新时 D 又引起了依赖 D 的 E 的更新。E 在这个递归树中整整被计算了 8 次!

显然树的叶子数即为 ABCDE 的全部以 A 开头 E 结尾的子串数。也就是说,如果我们有 nn 个计算属性依次依赖前面的 n1n-1 个计算属性 (第一个计算属性依赖于 A)。最后再有一个计算属性 E 依赖于这 nn 个计算属性,那么我们会得到 2n2^n 个不同的以 A 开头 E 结尾的子串。 更新 A 会导致整整 O(2n)O(2^n) 次 E 的计算!

优化

很容易想到一个优化算法。既然依赖关系是 DAG,那么我们可以在 DAG 上跑一个拓扑排序,按照拓扑序更新不就好了?

但问题来了,在这个隐形的有向图上优雅地跑拓扑排序呢?我们知道对于 nn 个简单节点我们可以这样跑拓扑排序:

const sorted = [];
function topo_sort(node) {
  if (n.vis) return;
  n.vis = true;
  node.parents.forEach((n) => topo_sort(n));
  sorted.push(node);
}
nodes.forEach((n) => topo_sort(n));

由于每个顶点只访问了一遍,复杂度显然为 O(n)O(n)

再考虑拓补排序回溯的过程本身已经求出了该点的前置,因此可以把 fn 放在这里计算。 computed 伪代码如下:

function computed(fn) {
  let cached_val;
  let cached = false;
  const calculate = () => {
    in_context(closure, () => {
      cached_val = fn();
    });
    cached = true;
  };
  const closure = {
    get value() {
      context.parents.add(closure);
      if (!cached) calculate();
      return cached_val;
    },
    uncache() {
      if (!cached) return;
      cached = false;
      recursiveUncache(closure.children);
    },
  };
  return ret;
}

这里的关键在于我们把整个得到的 ComputedRef 变成了静态的:上游更新某个节点以后,调用 recursiveUncache 递归地把其按拓扑序的所有子节点全部设为缓存失效状态,但不进行 fn 的计算。由于一个处于缓存失效状态的节点,其子节点必然已经先行被缓存失效,因此每个节点只会 uncache 一次。也就是说,更改的最坏时间复杂度为 O(n)O(n) 没有 TT

此时,某个元素可以进行多次更改;由于都是懒计算的,多次更改也不会触发计算属性的计算。这一点我们可以用如下的 vue 代码证实:

let b_count = 0;
const a = ref(1);
const b = computed(() => {
  b_count++;
  return a.value + 1;
});

a.value = 1;
a.value = 11;
a.value = 114;
a.value = 1145;
a.value = 11451;
a.value = 114514;

console.log(b_count); // 0,因为b没有被访问过value属性,根本无需被计算
console.log(b.value); // 114515

a.value = 114514;
a.value = 11451;
a.value = 1145;
a.value = 114;
a.value = 11;
a.value = 1;

console.log(b.value); // 1
console.log(b_count); // 2。b是懒计算的,因此实际上只计算了两次。

这样我们就初步写好了一个 computed。其时间复杂度是 O(nT)O(nT)

完整的,可运行的代码会类似这样:

function MyVue() {
  let _newId = 0;
  function newId() {
    return _newId++;
  }

  /** @template T */
  class Closure {
    /**
     * 依赖的其他响应式
     * @type {Set<Closure>}
     */
    parents = new Set();
    /**
     * 被这些东西依赖
     * @type {Set<Closure>}
     */
    children = new Set();
    /**
     * 闭包的其他状态
     * @type {Record<string, any>}
     */
    state = {};
    /** @param {T} ret  */
    constructor(ret) {
      this._ret = ret;
    }
    /** @returns {T} */
    get ret() {
      return this._ret;
    }
  }
  let context = null;
  const nextTickCalculates = new Set();

  /**
   * @param {Closure} closure
   */
  function triggerUncacheChain(closure) {
    for (const child of closure.children) {
      child.state.uncache?.();
    }
  }
  /**
   * 模拟nextTick触发的内容
   * @param {Closure} closure
   */
  function queueRecalculate(closure) {
    if (nextTickCalculates.size == 0) {
      nextTickCalculates.add(null);
      queueMicrotask(() => {
        console.log("// Microtask:");
        for (const closure of nextTickCalculates) {
          if (closure != null) {
            for (const child of Array.from(closure.children)) {
              console.log(
                "update = { id:",
                child.id,
                ", val: ",
                child.ret.value,
                "}"
              );
            }
          }
        }
        nextTickCalculates.clear();
      });
    }
    nextTickCalculates.add(closure);
  }
  /**
   * @param {Closure} closure
   */
  function cancelRecalculate(closure) {
    nextTickCalculates.delete(closure);
  }

  /**
   * 类似vue的shallowRef
   * @template T
   * @param {T} val
   * @returns {{value: T}}
   */
  function ref(val) {
    const closure = new Closure({
      get value() {
        context?.parents?.add(closure);
        return closure.state.val;
      },
      set value(v) {
        closure.state.val = v;
        triggerUncacheChain(closure);
        return true;
      },
    });
    closure.id = newId();
    closure.state.val = val;
    return closure.ret;
  }

  /**
   * @template {() => T} Fn
   * @param {Fn} fn
   * @returns {{value: ReturnType}}
   */
  function computed(fn) {
    const calculate = () => {
      for (const parent of Array.from(closure.parents)) {
        parent.children.delete(closure);
      }
      closure.parents.clear();

      old_context = context;
      context = closure;
      closure.state.cached_val = fn();
      context = old_context;

      for (const parent of closure.parents) {
        parent.children.add(closure);
      }

      cancelRecalculate(closure);
      closure.state.cached = true;
      return closure.state.cached_val;
    };
    const closure = new Closure({
      get value() {
        context?.parents?.add(closure);
        if (!closure.state.cached) calculate();
        return closure.state.cached_val;
      },
    });
    closure.id = newId();
    closure.state.cached = false;
    closure.state.uncache = () => {
      if (!closure.state.cached) return;
      closure.state.cached = false;
      queueRecalculate(closure);
      triggerUncacheChain(closure);
    };
    return closure.ret;
  }

  return { ref, computed };
}

const { ref, computed } = MyVue();

function test(desc, fn) {
  let prom = new Promise((res) => {
    console.log(`// =========================================`);
    console.log(`// =========================================`);
    console.log(`// =========================================`);
    console.log("// ", desc);
    console.log(`// =====TEST ${desc} START========`);
    fn();
    console.log(`// =====TEST ${desc} OVER=========`);
    res();
  });
  const res = {
    thenTest: (desc, fn) => {
      prom = prom.then(() => {
        test(desc, fn);
      });
      return res;
    },
  };
  return res;
}

test("链", () => {
  const a = ref(1); // id: 0
  const nums = [a];
  const TEST_SIZE = 8;
  for (let i = 1; i <= TEST_SIZE; i++) {
    nums[i] = computed(() =>
      Array.from({ length: i }, (_, index) => index)
        .sort(() => Math.random() - 0.5)
        .reduce((x, id) => x + nums[id].value, 0)
    );
  }

  let cnt = 0;

  const i = computed(() => {
    cnt++;
    return `last is: ${nums[TEST_SIZE].value}, computed ${cnt} times`;
  });

  console.log("/* NOW:  */  i.value =", JSON.stringify(i.value));
  console.log("/* STEP: */  a.value = 2");
  a.value = 2;
  console.log("/* STEP: */  a.value = 3");
  a.value = 3;
  console.log("/* STEP: */  a.value = 4");
  a.value = 4;
  console.log("/* STEP: */  a.value = 5");
  a.value = 5;
  console.log("/* STEP: */  a.value = 2");
  a.value = 2;
  console.log("/* NOW:  */  i.value =", JSON.stringify(i.value));

  console.log("/* STEP: */  a.value = 3");
  a.value = 3;
})
  .thenTest("测试2", () => {
    const a = ref(1);

    let cnt_c = 0;
    let cnt_d = 0;
    const c = computed(() => {
      a.value;
      return ++cnt_c;
    });
    const d = computed(() => {
      a.value;
      return ++cnt_d;
    });

    const b = computed(() => {
      if (a.value) {
        return c.value;
      } else {
        return d.value;
      }
    });

    const msg = [];

    msg.push(b.value + JSON.stringify({ cnt_c, cnt_d }));
    a.value = 0;
    msg.push(b.value + JSON.stringify({ cnt_c, cnt_d }));
    a.value = 1;
    msg.push(b.value + JSON.stringify({ cnt_c, cnt_d }));
    a.value = 0;
    msg.push(b.value + JSON.stringify({ cnt_c, cnt_d }));
    a.value = 1;
    msg.push(JSON.stringify({ cnt_c, cnt_d }));

    console.log(msg);
  })
  .thenTest("两个依赖", () => {
    const a = ref(1);
    const b = ref(1);
    const a_then = ref("a_then");
    const b_then = ref("b_then");
    const c = computed(() => {
      console.log("c发生了重新计算");
      if (a.value) {
        return a_then.value;
      }
      if (b.value) {
        return b_then.value;
      }
    });

    console.log(c.value);
    console.log(c.value);
    b.value = 0;
    console.log(c.value);
    a.value = 0;
    console.log(c.value);
    b.value = 1;
    console.log(c.value);
  });

Previous  Next

Loading...