手把手带你实现一个自己的简易版 Vue3(七)

👉 项目 Github 地址:github.com/XC0703/VueS...

(希望各位看官给本菜鸡的项目点个 star,不胜感激。)

11、组件的更新(一)

11-1 前言

在前面我们实现了组件的初始化(首次生成 vnode 并渲染成真实 dom 在页面上),这个章节我们主要讲组件的更新,借助 diff 算法实现高效更新。


首先将响应式模块从 runtime-core 模块导出,方便我们在 VueRuntimeDom 模块中使用:

typescript 复制代码
// weak-vue\packages\runtime-core\src\index.ts
export * from "@vue/reactivity";

此时去执行我们的测试用例,可以看到正确地渲染响应式数据的内容了:

html 复制代码
<!-- weak-vue\packages\examples\8.diff.html -->
<div id="app">111111111111</div>
<script src="../runtime-dom/dist/runtime-dom.global.js"></script>
<script>
  let { createApp, h, reactive } = VueRuntimeDom;

  let App = {
    setup(props, context) {
      let state = reactive({
        name: "张三",
        age: 10,
      });

      // return {
      //   state,
      // };

      return () => {
        return h(
          "div",
          { style: { color: "red" } },
          h("div", {}, [state.name, h("p", {}, "李四")]) // 正确渲染了state.name,setup函数一个函数返回的形式,这个函数就是render函数,与注释的代码等效
        );
      };
    },

    // render(proxy) {
    //   return h(
    //     "div",
    //     { style: { color: "red" } },
    //     h("div", {}, [proxy.state.name, h("p", {}, "李四")]) // 正确渲染了proxy.state.name
    //   );
    // },
  };
  createApp(App, { name: "模版里面的张三,TODO的部分", age: 10 }).mount(
    // 传入的参数对象还未处理,看weak-vue\packages\runtime-core\src\component.ts里面的finishComponentSetup函数
    "#app"
  );
</script>

此时若将测试用例代码改成下面这样:

html 复制代码
<!-- weak-vue\packages\examples\8.diff.html -->
<div id="app">111111111111</div>
<script src="../runtime-dom/dist/runtime-dom.global.js"></script>
<script>
  let { createApp, h, reactive } = VueRuntimeDom;

  let App = {
    setup(props, context) {
      let state = reactive({
        name: "张三",
        age: 10,
      });
      // 模拟更新
      let i = 0;
      const update = () => {
        state.name = "更新张三" + i++;
      };

      return () => {
        return h(
          "div",
          { style: { color: "red" }, onClick: update },
          h("div", {}, [state.name, h("p", {}, "李四")])
        );
      };
    },
  };
  createApp(App).mount("#app");
</script>

可以看到此时的渲染内容重复,老的内容并没有清除:

这是因为我们创建一个 effectrender 函数执行时,instance.isMounted 字段的值在首次渲染之后没有改变且还没有作更新处理。

typescript 复制代码
// weak-vue\packages\runtime-core\src\render.ts
// 创建一个effect让render函数执行(响应式)
const setupRenderEffect = (instance, container) => {
  // 创建effect(原理可以回前面的内容找)
  effect(function componentEffect() {
    // 判断是否是初次渲染
    if (!instance.isMounted) {
      // 获取到render返回值
      const proxy = instance.proxy; // 已经代理了组件,可以访问到组件的所有属性和所有方法
      // console.log("这是组件实例proxy:");
      // console.log(proxy);
      const subTree = instance.render.call(proxy, proxy); // render函数执行,即调用render函数,第一个参数表示render函数的this指向组件实例proxy,第二个参数表示执行render函数的参数也是proxy
      // console.log("h函数生成的vnode树:", subTree);
      patch(null, subTree, container); // 渲染vnode(此时是元素的vnode)
    } else {
      // TODO: 更新
    }
  });
};

此时加上了,便不会再渲染,直接走 TODO 里面的逻辑。因此要实现渲染更新,接下来的工作便是联系前面实现过的 effect 依赖更新。在 TODO 里面实现相应 diff 逻辑。

typescript 复制代码
// weak-vue\packages\runtime-core\src\render.ts
// console.log("更新");
// 对比新旧vnode--diff算法
let proxy = instance.proxy;
const prevVnode = instance.subTree; // 旧vnode,记得上面首次渲染在实例上挂载
const nextVnode = instance.render.call(proxy, proxy); // 新vnode
instance.subTree = nextVnode;
patch(prevVnode, nextVnode, container); // 此时在patch方法中会对比新旧vnode,然后更新

11-2 对比是否是相同元素

此时去到我们的 patch 方法,在更新渲染时传入的旧 vnode 不再是 null,因此要作出 diff 对比。

typescript 复制代码
// weak-vue\packages\runtime-core\src\render.ts
// 判断是否是同一个元素
const isSameVnode = (n1, n2) => {
  return n1.type === n2.type && n1.key === n2.key;
};
// 卸载老的元素
const unmount = (vnode) => {
  hostRemove(vnode.el);
};

// diff算法
// 1、判断是不是同一个元素
// console.log("n1:", n1, "n2:", n2);
if (n1 && n2 && !isSameVnode(n1, n2)) {
  // 卸载老的元素
  unmount(n1);
  n1 = null; // n1置空,可以重新走组件挂载了,即传给processElement的n1为null,走mountElement方法
}
// 2、如果是同一个元素,对比props、children,此时传给processElement的n1为老的vnode,走patchElement方法

11-3 对比 props

我们前面实现过处理元素的方法 processElement,不过前面只处理了首次挂载的情况。现在加上更新的情况。

typescript 复制代码
// weak-vue\packages\runtime-core\src\render.ts
/** ---------------处理元素--------------- */
const processElement = (n1, n2, container) => {
  if (n1 === null) {
    // 元素第一次挂载
    mountElement(n2, container);
  } else {
    // 更新
    console.log("同一个元素更新!!!");
    patchElement(n1, n2, container);
  }
};

第一步是对比 props,第二步是对比子节点:

typescript 复制代码
// weak-vue\packages\runtime-core\src\render.ts
// 元素的更新方法
const patchElement = (n1, n2, container) => {
  const oldProps = n1.props || {};
  const newProps = n2.props || {};
  // 1、对比属性
  let el = (n2.el = n1.el); // 获取真实dom
  patchProps(el, oldProps, newProps);
  // 2、对比子节点--与初次挂载一样,需要将可能的字符串也要转换成vnode
  n1.children = n1.children.map((item) => {
    return CVnode(item);
  });
  n2.children = n2.children.map((item) => {
    return CVnode(item);
  });
  patchChildren(n1, n2, el);
};

其中,props 的对比分为三种情况:

typescript 复制代码
// weak-vue\packages\runtime-core\src\render.ts
// 对比属性有三种情况:
// 1、新旧属性都有,但是值不一样
// 2、旧属性有,新属性没有
// 3、新属性有,旧属性没有
const patchProps = (el, oldProps, newProps) => {
  if (oldProps !== newProps) {
    // 1、新旧属性都有,但是值不一样
    for (const key in newProps) {
      const prev = oldProps[key];
      const next = newProps[key];
      if (prev !== next) {
        hostPatchProp(el, key, prev, next); // 替换属性
      }
    }
    // 2、新属性有,旧属性没有
    for (const key in oldProps) {
      if (!(key in newProps)) {
        hostPatchProp(el, key, oldProps[key], null); // 删除属性
      }
    }
    // 3、旧属性有,新属性没有
    for (const key in newProps) {
      if (!(key in oldProps)) {
        hostPatchProp(el, key, null, newProps[key]); // 新增属性
      }
    }
  }
};

此时去执行我们的测试用例:

html 复制代码
<!-- weak-vue\packages\examples\8.diff.html -->
<div id="app">111111111111</div>
<script src="../runtime-dom/dist/runtime-dom.global.js"></script>
<script>
  let { createApp, h, reactive } = VueRuntimeDom;

  let App = {
    setup(props, context) {
      let state = reactive({
        flag: false,
        name: "张三",
        age: 10,
      });
      // 模拟更新(非响应式的,手动更新)
      const update = () => {
        state.flag = !state.flag;
        if (state.flag) state.name = "更新的张三";
        else state.name = "张三";
      };
      return {
        state,
        update,
      };
    },

    render(proxy) {
      if (proxy.state.flag) {
        return h(
          "div",
          {
            style: { color: "red", background: "none" },
            onClick: proxy.update,
          },
          `${proxy.state.name}`
        );
      } else {
        return h(
          "div",
          {
            style: { color: "blue", background: "pink" },
            onClick: proxy.update,
          },
          `${proxy.state.name}`
        );
      }
    },
  };
  createApp(App, { name: "模版里面的张三,TODO的部分", age: 10 }).mount("#app");
</script>

可以看到点击样式变了:

但此时我们显示的文字却没有变,这是因为我们对子节点还未作处理。

11-4 对比 children

对比 children 的核心是分情况讨论,我们此时先去处理最简单的一种情况,即新的子节点为纯文本:

typescript 复制代码
// weak-vue\packages\runtime-core\src\render.ts
// 对比子节点有四种情况:
// 1、旧的有子节点,新的没有子节点
// 2、旧的没有子节点,新的有子节点
// 3、旧的有子节点,新的也有子节点,但是是文本节点(最简单的情况)
// 4、旧的有子节点,新的也有子节点,但是可能是数组
const patchChildren = (n1, n2, el) => {
  const c1 = n1.children;
  const c2 = n2.children;
  const prevShapeFlag = n1.shapeFlag;
  const newShapeFlag = n2.shapeFlag;
  // 处理情况3
  if (newShapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 新的是文本节点,直接替换
    if (c2 !== c1) {
      hostSetElementText(el, c2);
    } else {
    }
  }
};

此时去执行我们上面的测试用例,可以看到文字也被替换了。

另一种情况也比较简单,即旧的是文本节点,此时将文本节点清空,然后再将新的节点进行渲染即可,最复杂的是旧的也为数组,新的也为数组。

typescript 复制代码
// weak-vue\packages\runtime-core\src\render.ts
 else {
      // 新的是数组,此时要判断旧的
      if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // 旧的也是数组(较复杂)
        patchKeyChildren(c1, c2, el);
      } else {
        // 旧的是文本节点,将文本节点清空,然后再将新的节点进行渲染
        hostSetElementText(el, "");
        mountChildren(el, c2);
      }
    }

自此,关于组件的更新流程我们就讲得差不多了,处理了一些基本的更新情况,到这里的源码请看提交记录:11、组件的更新(一)

12、组件的更新(二)

前面我们处理了一些基本的更新情况,这里我们开始复杂更新的处理(比如 patchKeyChildren 方法,处理新旧子节点都是数组的情况)。

12-1 简单情况处理(双指针)

12-1-1 原理引入

  • vue2 中使用的是基于递归双指针 的 diff 算法,即双端 diff ,会对整个组件树进行完整的遍历和比较。详细讲解见:segmentfault.com/a/119000004...
  • 双端 diff 的目的是为了尽可能的复用节点,通过移动指针的方式来复用节点
  • vue3 中使用的是基于数组动态规划的 diff 算法。vue 3 的算法效率更高,因为它使用了一些优化技巧,例如按需更新、静态标记等,会跳过静态子树的比较减少比较次数。
  • 下面是 patchKeyChildren 方法处理新旧子节点都是数组且头部或者尾部节点可复用的的情况:
typescript 复制代码
// weak-vue\packages\runtime-core\src\render.ts
const patchKeyChildren = (c1, c2, el) => {
  // 下面是vue3的diff算法,处理简单情况时也用到了双端diff:

  let i = 0;
  let e1 = c1.length - 1;
  let e2 = c2.length - 1;
  // 1、diff from start,即从头开始对比--简单情况1:旧的排列和新的排列前面节点一致,这些节点是可以复用的
  while (i <= e1 && i <= e2) {
    const n1 = c1[i];
    const n2 = c2[i];
    if (isSameVnode(n1, n2)) {
      // 递归对比子节点,先渲染出来,相当于重新走一次流程
      patch(n1, n2, el);
    } else {
      break;
    }
    i++;
  }
  console.log(
    "diff from start停止时的i:",
    i,
    "当前旧节点停止的位置e1",
    e1,
    "当前新节点停止的位置e2",
    e2
  );
  // 2、diff from end,即从尾开始对比--简单情况2:旧的排列和新的排列后面节点一致,这些节点是可以复用的
  while (i <= e1 && i <= e2) {
    const n1 = c1[e1];
    const n2 = c2[e2];
    if (isSameVnode(n1, n2)) {
      // 递归对比子节点,先渲染出来,相当于重新走一次流程
      patch(n1, n2, el);
    } else {
      break;
    }
    e1--;
    e2--;
  }
  console.log(
    "diff from end停止时的i:",
    i,
    "当前旧节点停止的位置e1",
    e1,
    "当前新节点停止的位置e2",
    e2
  );
};

此时去执行测试用例:

html 复制代码
<!-- weak-vue\packages\examples\8.diff.html -->
<div id="app">111111111111</div>
<script src="../runtime-dom/dist/runtime-dom.global.js"></script>
<script>
  let { createApp, h, reactive } = VueRuntimeDom;

  let App = {
    setup(props, context) {
      let state = reactive({
        flag: false,
        name: "张三",
        age: 10,
      });
      // 模拟更新(非响应式的,手动更新)
      const update = () => {
        state.flag = !state.flag;
      };
      return {
        state,
        update,
      };
    },

    render(proxy) {
      if (proxy.state.flag) {
        return h(
          "div",
          {
            style: { color: "red", background: "none" },
            onClick: proxy.update,
          },
          [
            h("p", { key: "A" }, "A"),
            h("p", { key: "B" }, "B"),
            h("p", { key: "C" }, "C"),
            h("p", { key: "D" }, "D"), // diff from start 应该在这里停止(e1和e2还未变,分别为各自的长度),此时i=3
            h("p", { key: "O" }, "O"),
            h("p", { key: "P" }, "P"),
          ]
        );
      } else {
        return h(
          "div",
          {
            style: { color: "blue", background: "pink" },
            onClick: proxy.update,
          },
          [
            h("p", { key: "A" }, "A"),
            h("p", { key: "B" }, "B"),
            h("p", { key: "C" }, "C"),
            h("p", { key: "E" }, "E"),
            h("p", { key: "M" }, "M"), // diff from end 应该在这里停止(i不变),此时e1=4,e2=3
            h("p", { key: "O" }, "O"),
            h("p", { key: "P" }, "P"),
          ]
        );
      }
    },
  };
  createApp(App, { name: "模版里面的张三,TODO的部分", age: 10 }).mount("#app");
</script>

可以看到更新时的打印结果:

可以看到打印结果符合预期,体现了先复用前面相同节点、再复用后面相同节点这两种最简单情况的处理原理。


12-1-2 实际操作

上面我们讲解了简单情况下的处理原理,但是还没有实际地操作节点。

先分为两种情况:1、新的子节点数量多;2、新的子节点数量少。

首先看第一种情况:

typescript 复制代码
// 新:A B C D E F G
// 旧:A B C G

此时可以看出前面的 A、B、C 节点是可以复用的,后面的 G 节点是可以复用的,中间的 D、E、F 节点要新增插入进去,可以很快知道这些要新增的节点插入时又可以分为两种情况:1、新增的节点在旧的节点之前,2、新增的节点在旧的节点之后。

我们知道该种情况经过我们上面的原理提及的处理后(即跳过可复用节点 ),i 变成了 3,e1 变成了 2,e2 变成 5。i 为新旧子节点前面的部分开始不同的位置,e1 为旧子节点后面的部分开始不可复用的位置,e2 为新子节点后面的部分开始不可复用的位置。如果 i > e1,则说明此时新的子节点中间存在不可复用的节点,这些节点要插入处理。至于这些新子节点应该插入到哪个位置,则只需确认其后一个子节点即可 。为此,我们为 patch 渲染函数引入了第四个参数 ancher,用来表示其后一个子节点。

这是因为我们前面在实现对真实 dom 的操作中(具体见 weak-vue\packages\runtime-dom\src\nodeOps.ts 文件),实现了插入元素的方法,我们最终渲染元素会走到这个方法:

typescript 复制代码
// weak-vue\packages\runtime-dom\src\nodeOps.ts
  // 插入元素
  insert: (child, parent, ancher = null) => {
    parent.insertBefore(child, ancher); // ancher为空相当于appendchild
  },

那我们怎么确认 ancher 节点呢?

首先我们得确认需要新增的节点,即从 i 到 e2 的节点都应该插入(即上面的 D、E、F 节点)。从左到右遍历插入,插入 D 时,应该在 C、G 中间;插入 E 时,应该在 D、G 中间;插入 F 时,应该在 E、G 中间。这三个新节点的 ancher 节点都是 G,其实就是 e2+1 表示的节点,如果 e2+1 大于 c2 了,说明前面的节点为空。

typescript 复制代码
// weak-vue\packages\runtime-core\src\render.ts
if (i > e1) {
  const nextPos = e2 + 1; // e2+1要么表示后面部分可复用的节点的倒数最后一个,要么为null(即后面部分没有可复用的节点)
  const anchor = nextPos < c2.length ? c2[nextPos].el : null;
  while (i <= e2) {
    console.log(
      "要插入的节点:",
      c2[i].key,
      ",插入到:",
      anchor || "null",
      "节点之前"
    );
    patch(null, c2[i], el, anchor); // 记得给patch函数以及里面使用的相关方法传入anchor参数
    i++;
  }
}

此时去执行我们上面写的第一种情况例子,可以看到打印结果符合预期:


对于第二种即旧的子节点多的情况,便存在删除。先看例子:

typescript 复制代码
// 新:A B C G
// 旧:A B C D E F G

可以知道此时 D、E、F 节点是要删除的,即 i 到 e1 的节点。删除情况比较简单,直接删除即可:

typescript 复制代码
// // weak-vue\packages\runtime-core\src\render.ts
else if (i > e2) {
      // 2、旧的子节点数量多的情况--要删除
      while (i <= e1) {
        console.log("要删除的节点:", c1[i].key);
        unmount(c1[i]);
        i++;
      }
}

  // 卸载老的元素
  const unmount = (vnode) => {
    hostRemove(vnode.el);
  };

此时去执行我们的测试用例可以看到打印结果正确:

12-2 复杂情况处理(映射表)

12-2-1 操作实现

上面我们看到,对于中间的一些子节点,我们只考虑了顺序的情况,要么都要新增,要么都要删除。但实际有些节点是可以复用的只是顺序变了,有些要新增,有些则要删除。像下面这样:

typescript 复制代码
// 新:A B C D E F G
// 旧:A B C M F E Q G

上面这种复杂情况,新子节点序列中的 D、E、F 节点,旧子节点序列中的 M、F、E、Q 节点,D 节点是要新增的,E、F 节点是要复用的但顺序改变,M 节点是要删除的。如果此时去仅用我们上面实现的方法去执行我们的测试用例,可以看到无法更新,新旧替换也一样,因为并没有走我们上面顺序插入或者顺序删除的流程直接阻塞停止了。

对于这种复杂情况,Vue 的处理是通过映射表来处理。

typescript 复制代码
// weak-vue\packages\runtime-core\src\render.ts
// 3、乱序,并不是简单将中间乱序节点全部删除再全部新增,而是要尽可能的复用节点
// 解决思路:(1)以新的乱序个数创建一个映射表;(2)再用旧的乱序的数据去映射表中查找,如果有,说明是可以复用的,如果没有,说明是该旧节点需要删除的
const s1 = i; // 旧的乱序开始位置
const s2 = i; // 新的乱序开始位置
// 创建表
let keyToNewIndexMap = new Map();
// 用新的乱序数据去创建映射表
for (let i = s2; i <= e2; i++) {
  const nextChild = c2[i];
  keyToNewIndexMap.set(nextChild.key, i);
}
console.log("映射表:", keyToNewIndexMap);

此时执行测试用例打印结果:


继续处理:

typescript 复制代码
// weak-vue\packages\runtime-core\src\render.ts
// 新:A B C D E F G==>乱序映射表:D=>3,E=>4,F=>5。
// 旧:A B C M F E Q G==>乱序映射表:M=>3,F=>4,E=>5,Q=>6。
// 去旧的乱序数据中查找
for (let i = s1; i <= e1; i++) {
  const oldChildVnode = c1[i];
  const newIndex = keyToNewIndexMap.get(oldChildVnode.key);
  if (!newIndex) {
    // 说明旧的节点需要删除(即M和Q)
    console.log("要删除的节点:", oldChildVnode.key);
    unmount(oldChildVnode);
  } else {
    console.log("要复用的节点:", oldChildVnode.key);
    patch(oldChildVnode, c2[newIndex], el);
  }
}

此时执行测试用例打印结果:

此时页面渲染结果为:A B C F E G。


可以看出此时存在两个问题:1、复用的节点 E、F 渲染位置不对;2、要新增的节点 D 没有插入

因此要特殊处理这两个问题:

typescript 复制代码
// weak-vue\packages\runtime-core\src\render.ts
// 解决两个问题:1、复用的节点渲染位置不对;2、要新增的节点没有插入。
const toBePatched = e2 - s2 + 1; // 新的乱序的数量
const newIndexToOldIndexMap = new Array(toBePatched).fill(0); // 新的乱序的数量的数组,每个元素都是0

newIndexToOldIndexMap[newIndex - s2] = i + 1; // 现在将复用的节点的位置改为旧的乱序的位置+1
console.log("newIndexToOldIndexMap:", newIndexToOldIndexMap);

此时执行测试用例打印结果正确:


此时根据这个位置数组去判断是移动还是新增我们的节点:

typescript 复制代码
// weak-vue\packages\runtime-core\src\render.ts
// 此时根据这个位置数组去移动或者新增我们的节点(从后往前处理)
for (let i = toBePatched - 1; i >= 0; i--) {
  const currentIndex = s2 + i; // 当前要处理的新的乱序的节点的位置
  const anchor = currentIndex + 1 < c2.length ? c2[currentIndex + 1].el : null;
  if (newIndexToOldIndexMap[i] === 0) {
    // 说明是新增的节点
    console.log(
      "新增的节点:",
      c2[currentIndex].key,
      ",插入到:",
      anchor || "null",
      "节点之前"
    );
    patch(null, c2[currentIndex], el, anchor); // 比如从后往前遍历到D时,插入到E的前面。
  } else {
    // 说明是要移动的可复用节点
    console.log(
      "要移动的节点:",
      c2[currentIndex].key,
      ",移动到:",
      anchor || "null",
      "节点之前"
    );
    hostInsert(c2[currentIndex].el, el, anchor); // 比如从后往前遍历到F时,应该移动到G的前面;从后往前遍历到E时,应该移动到F的前面。此时已渲染序列为A B C E F G
  }
}

此时页面渲染正确,打印结果为:


12-2-2 性能优化

上面虽然渲染正确,但一个个节点重新编排在大量节点情况下会存在性能问题。如果已经有序,是可以一次性将有序的序列移动的。比如像下面这样:

typescript 复制代码
// 新:A B C D l o v e E F G
// 旧:A B C M l o v e F E Q G

此时若用我们上面的方法, l o v e 这四个节点也需要一个个地去倒序判断并复用,极端情况下会有性能问题。

优化措施是最长递增子序列 的思路,借助我们上面的 newIndexToOldIndexMap 数组,来找到符合可复用的最长子区段长度,然后一次性复用。


力扣 300. 最长递增子序列

  • 这个算法题比较容易想得到的思路就是动态规划,核心是两次循环遍历,每次循环会得到以 arr[i]为结尾的最长递增子序列长度。
  • 可以用贪心+二分查找思路优化。只有一次循环遍历,我们维护一个数组 dp 用来表示当前的最长递增子序列。若想让该序列尽可能地长,则需要每次末尾添加的数尽可能地小。我们遍历到一个数 arr[i],如果 arr[i]大于 dp[dp.length-1],则此时直接追加 arr[i]在 dp 末尾即可;如果不是,则为了让数组从左到右尽可能地小,则此时需要将 arr[i]插入到 dp 中,取代第一个比它大的数(改变了原始序列,但不影响长度,可以用二分来优化这个查找过程)。代码如下:
typescript 复制代码
/**
 * @param {number[]} nums
 * @return {number}
 */
var lengthOfLIS = function (nums) {
  const len = nums.length;
  if (len <= 1) return len;
  let dp = [nums[0]];
  for (let i = 0; i < len; i++) {
    if (nums[i] > dp[dp.length - 1]) {
      // 直接追加
      p[dp.length - 1] = i;
    } else {
      // 二分查找
      let left = 0;
      let right = dp.length - 1;
      while (left < right) {
        let mid = (left + right) >> 1;
        if (dp[mid] < nums[i]) {
          left = mid + 1;
        } else {
          right = mid;
        }
      }
      // 直接替换
      dp[left] = nums[i]; // 此时dp前面的元素都比nums[i]小
    }
    console.log(dp);
  }
  console.log("真正需要记住的索引数组:", p);
  return dp.length;
};

lengthOfLIS([2, 3, 1, 5, 6, 8, 7, 9, 4]);

打印结果如下:

可以看到最终 dp 数组长度是正确的(对应的索引是[2, 1, 8, 4, 6, 7]),但是元素不一定对,正确的 dp 应该是[2, 3, 5, 6, 7, 9](对应的索引是[0, 1, 3, 4, 6, 7])。这是因为二分查找时,nums[i]直接替换了原有的 dp[left]。

在快速 diff 算法中,求解最长递增子序列的目的是对子节点重新编号,所以肯定不只是求解出长度即可。那怎么样才能使得 dp 内容也是正确呢?我们维护一个数组 p,p[i]表示往 dp 插入某个下标 Q 时,dp 中此时 Q 前一个元素的值,该真实值要记住以便后面倒序处理 dp 时,能正确地放入真实值。

typescript 复制代码
/**
 * @param {number[]} nums
 * @return {number}
 */
var lengthOfLIS = function (nums) {
  const len = nums.length;
  if (len <= 1) return len;
  let dp = [0];
  let p = [0];
  for (let i = 0; i < len; i++) {
    if (nums[i] > nums[dp[dp.length - 1]]) {
      // 直接追加
      console.log(
        `dp插入${nums[i]}, 此时它前一个元素应该是${
          nums[dp[dp.length - 1]]
        },对应的下标为${dp[dp.length - 1]}`
      );
      p[i] = dp[dp.length - 1];
      dp.push(i);
    } else {
      // 二分查找
      let left = 0;
      let right = dp.length - 1;
      while (left < right) {
        let mid = (left + right) >> 1;
        if (nums[dp[mid]] < nums[i]) {
          left = mid + 1;
        } else {
          right = mid;
        }
      }
      // 直接替换
      if (left > 0) {
        console.log(
          `dp插入${nums[i]}, 此时它前一个元素应该是${
            nums[dp[left - 1]]
          },对应的下标为${dp[left - 1]}`
        );
        p[i] = dp[left - 1];
      }
      dp[left] = i; // 此时dp前面的元素都比nums[i]小
    }
  }
  console.log("处理前得到的索引数组dp:", dp); //  [2, 1, 8, 4, 6, 7]
  console.log("驱节点数组:", p); // [0, 0, 空, 1, 3, 4, 4, 6, 1]
  let u = dp.length;
  let v = dp[u - 1]; // dp的最后一个元素肯定是真实的,直接放入进去就可以了
  while (u-- > 0) {
    dp[u] = v;
    v = p[v]; // 去拿到前一个真实的元素来插入
  }
  console.log("处理后得到的索引数组dp:", dp); // [0, 1, 3, 4, 6, 7]
  return dp.length;
};

lengthOfLIS([2, 3, 1, 5, 6, 8, 7, 9, 4]);

按照这个算法思路,去优化我们的 diff 算法。

typescript 复制代码
// weak-vue\packages\runtime-core\src\render.ts
// 获取最长递增子序列的索引
console.log("乱序节点的索引数组newIndexToOldIndexMap:", newIndexToOldIndexMap);
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap);
console.log(
  "newIndexToOldIndexMap数组中最长递增子序列数组increasingNewIndexSequence:",
  increasingNewIndexSequence
);
let j = increasingNewIndexSequence.length - 1;
typescript 复制代码
// weak-vue\packages\runtime-core\src\render.ts
// 这个插入需要一个个的插入,大量情况下会可能导致性能问题。
// 用最长递增子序列去优化,如果在区间内,就不用移动,如果不在区间内,就移动。
if (i !== increasingNewIndexSequence[j]) {
  hostInsert(c2[currentIndex].el, el, anchor); // 比如从后往前遍历到F时,应该移动到G的前面;从后往前遍历到E时,应该移动到F的前面。此时已渲染序列为A B C E F G
} else {
  j--;
}

此时去执行我们的测试用例:

typescript 复制代码
// 新:A B C D l o v e E F G
// 旧:A B C M l o v e F E Q G

打印结果为:

更新渲染过程为:

此时可以看到优化成功。


自此,关于组件的更新我们已经处理完毕,到这里的源码请看提交记录:12、组件的更新(二)

12-3 diff 算法的总结

这个问题在面试中会经常被问到,我们上面处理了组件更新的全部过程,我们这里可以总结一下以便回答面试。

  1. 首先 vue 组件的渲染借助了虚拟 dom 的原理,组件更新时,因为响应式的存在,会去重新生成一个 vnode。
  2. 此时,进入新老两个 vnode 比较阶段,首先会对比是否是相同元素,如果不是相同元素,则之间卸载老的节点,重新走 mountElement 方法去挂载渲染节点。
  3. 如果是相同元素,则会去对比 props 参数, props 不同有三种情况(依据实际情况进行对属性进行处理即可):
    1. 属性在新旧中都有,但是值不一样
    2. 在旧的组件中有,在新的中没有
    3. 在新的组件中有,在旧的中没有
  4. props 对比介绍之后,便会去对比 children 子节点,这也是 diff 算法中最复杂最核心的内容。children 子节点本质上是一个可以嵌套的数组,对于嵌套子节点的处理,直接走递归即可。对于同一层级下的新旧对比,又分为简单情况和复杂情况的处理。
    • 简单情况是利用了双端 diff 算法,本质是利用双指针的原理,处理新旧子节点都是数组且头部或者尾部节点可复用的情况,其中新的子节点数量的时候要插入,旧的子节点数量多的时候要删除。处理这种简单情况时,我们只考虑了顺序的情况,要么都要新增,要么都要删除。
    • 但实际有些节点是可以复用的只是顺序变了,有些要新增,有些则要删除。对于这种复杂情况的处理,vue 引入了映射表,即以新的乱序节点为基准创建一个映射表;再遍历旧的乱序节点过程中去该映射表查找是否已存在,如果有,说明是可以复用的,如果没有,说明是该旧节点需要删除的。
    • 此时再用一个数组 newIndexToOldIndexMap 来表示新的节点在旧的节点数组中的索引+1,默认为 0 表示该节点不存在旧的节点序列中。
    • 之后倒序遍历该数组,如果值为 0 表示要新增该节点,如果不为 0 则要移动节点到正确的位置。移动时默认是一个个移动的,产生了一些没有必要的性能损耗,比如某个区段的子节点顺序都没有变,可以不用操作这部分节点。
    • 因此 vue 中引入了最长递增子序列的原理,在移动某个节点前,判断该节点是否在最长递增子序列中,在则不用移动跳过即可,继续操作前一个节点。
  5. vue 3 的算法效率更高,因为它使用了一些优化技巧,例如按需更新、静态标记等,会跳过静态子树的比较减少比较次数。

13、生命周期

13-1 前言

注意:

1、setup 相当于 vue2 中的 beforeCreatecreated

2、vue3 中的生命周期方法都是在 setup 中使用的

3、vue2 中的生命周期方法中的 this 指向当前组件实例,而 vue3 中的生命周期方法的 this 指向 window,但 vue3 提供了一个获取组件实例的 api:getCurrentInstance

问题: vue3.x 中的生命周期是怎么调用的?

  • 方法:每一个组件都有一个实例 instance,调用过程就是将组件的生命周期与这个组件实例产生关联

那什么时候产生关联?

  • 在调用 setup 之前将这个实例暴露到全局 instance 上,在 setup 调用之后,内部执行调用生命周期之后使得全局 instance 赋 null,再调用 getCurrentInstance 获取到当前最新的组件实例并更新挂载到全局。

13-2 生命周期的实现

创建组件实例的方法在 weak-vue\packages\runtime-core\src\component.ts 文件中,将全局的组件实例暴露出去:

typescript 复制代码
// weak-vue\packages\runtime-core\src\component.ts
export let currentInstance;

if (setup) {
  // setup执行之前,要创建全局的currentInstance
  currentInstance = instance;
  const setupContext = createContext(instance); // 返回一个上下文对象
  const setupResult = setup(instance.props, setupContext); // 实际执行的setup函数(实参)

  // setup执行完毕,currentInstance要赋空null
  currentInstance = null;
  // setup返回值有两种情况:1、对象;2、函数==>根据不同情况进行处理
  handlerSetupResult(instance, setupResult); // 如果是对象,则将值放在instance.setupState;如果是函数,则就是render函数
}

新建一个 weak-vue\packages\runtime-core\src\apilifecycle.ts 文件(因为生命周期钩子函数依然是在 VueRuntimeDom 模块导出):

typescript 复制代码
// weak-vue\packages\runtime-core\src\apilifecycle.ts
import { currentInstance } from "./component";

// 处理生命周期
const enum lifeCycle {
  BEFOREMOUNT = "bm",
  MOUNTED = "m",
  BEFOREUPDATE = "bu",
  UPDATED = "u",
}

// 常用的生命周期钩子------柯里化操作
export const onBeforeMount = createHook(lifeCycle.BEFOREMOUNT);
export const onMounted = createHook(lifeCycle.MOUNTED);
export const onBeforeUpdate = createHook(lifeCycle.BEFOREUPDATE);
export const onUpdated = createHook(lifeCycle.UPDATED);

// 创建生命周期钩子
function createHook(lifecycle: lifeCycle) {
  // 返回一个函数,这个函数接收两个参数,hook和target。hook是生命周期中的方法,target是当前组件实例
  return function (hook, target = currentInstance) {
    // 获取到当前组件的实例,然后和生命周期产生关联
    injectHook(lifecycle, hook, target);
  };
}

// 注入生命周期钩子
function injectHook(lifecycle: lifeCycle, hook, target = currentInstance) {
  console.log("当前组件实例:", target);
  // 注意:vue3.x中的生命周期都是在setup中使用的
  if (!target) {
    console.warn(`lifecycle: ${lifecycle} is used outside of setup`);
    return;
  }
  // 给这个实例添加生命周期
  const hooks = target[lifecycle] || (target[lifecycle] = []);

  hooks.push(hook);
}

注意,此时我们在当前组件示例中注入了生命周期钩子,即将其作为组件实例对象的一个属性(默认值为空数组)。

但是,为了可以在生命周期中获取到组件实例,vue3 通过切片 的手段实现(即函数劫持的思路,修改传入的 hook,使得 hook 执行前设置当前组件实例到全局)。

typescript 复制代码
// weak-vue\packages\runtime-core\src\apilifecycle.ts
// 注意:vue3.x中获取组件示例是通过getCurrentInstance()方法获取的
// 为了可以在生命周期中获取到组件实例,vue3.x通过切片的手段实现(即函数劫持的思路,修改传入的hook,使得hook执行前设置当前组件实例到全局)
const rap = (hook) => {
  setCurrentInstance(target);
  hook(); // 执行生命周期钩子前存放一下当前组件实例
  setCurrentInstance(null);
};

hooks.push(rap);

其中,获取到当前组件实例和设置当前组件实例的方法如下:

typescript 复制代码
// weak-vue\packages\runtime-core\src\component.ts
// 获取到当前组件实例
export const getCurrentInstance = () => {
  return currentInstance;
};

// 设置当前组件实例
export const setCurrentInstance = (target) => {
  currentInstance = target;
};

此时,去执行我们的测试用例:

html 复制代码
<!-- weak-vue\packages\examples\9.lifecycle.html -->
<div id="app">111111111111</div>
<script src="../runtime-dom/dist/runtime-dom.global.js"></script>
<script>
  let {
    createApp,
    reactive,
    h,
    onMounted,
    onBeforeMount,
    onUpdated,
    onBeforeUpdate,
    getCurrentInstance,
  } = VueRuntimeDom;

  let App = {
    setup() {
      let state = reactive({ count: 0 });
      onMounted(() => {
        console.log(this); // window
        console.log("mounted");
        const instance = getCurrentInstance();
        console.log("当前组件实例:", instance);
      });
      onBeforeMount(() => {
        console.log("beforeMount");
      });
      onUpdated(() => {
        console.log("updated");
      });
      onBeforeUpdate(() => {
        console.log("beforeUpdate");
      });
      const fn = () => {
        state.count++;
      };
      return () => {
        return h("div", {}, [
          h("div", {}, state.count),
          h(
            "button",
            {
              onClick: fn,
            },
            "+"
          ),
        ]);
      };
    },
  };
  createApp(App).mount("#app");
</script>

可以看到打印结果:

可以看到,此时四个生命周期都已经注入示例成功(但还没有执行对应的回调函数,打印结果是注入时的打印。

13-3 生命周期的调用

生命周期的调用即在组件渲染的不同阶段执行对应的回调函数。组件渲染的定义在 weak-vue\packages\runtime-core\src\render.ts 文件中,因为此时已经将生命周期放在实例上了,所以直接获取调用即可:

typescript 复制代码
// weak-vue\packages\runtime-core\src\render.ts
// 判断是否是初次渲染
if (!instance.isMounted) {
  // 渲染之前的阶段
  let { bm, m } = instance;
  if (bm) {
    invokeArrayFns(bm);
  }
} else {
  let { bu, u } = instance;
  if (bu) {
    invokeArrayFns(bu);
  }
}

其中,因为从上面打印的当前组件实例可以看到,instance 上面挂载的声明周期钩子方法是一个数组,每个数组元素都是一个要执行的回调方法。执行 hookinvokeArrayFns 方法定义如下:

typescript 复制代码
// weak-vue\packages\runtime-core\src\apilifecycle.ts
// 生命周期的执行
export function invokeArrayFns(fns) {
  fns.forEach((fn) => fn());
}

此时再去执行我们的测试用例:

可以看到,打印结果符合预期。


自此,关于生命周期我们已经处理完毕,到这里的源码请看提交记录:13、生命周期

相关推荐
梦境之冢几秒前
axios 常见的content-type、responseType有哪些?
前端·javascript·http
racerun3 分钟前
vue VueResource & axios
前端·javascript·vue.js
m0_5485147720 分钟前
前端Pako.js 压缩解压库 与 Java 的 zlib 压缩与解压 的互通实现
java·前端·javascript
AndrewPerfect21 分钟前
xss csrf怎么预防?
前端·xss·csrf
Calm55024 分钟前
Vue3:uv-upload图片上传
前端·vue.js
浮游本尊28 分钟前
Nginx配置:如何在一个域名下运行两个网站
前端·javascript
m0_7482398329 分钟前
前端bug调试
前端·bug
m0_7482329231 分钟前
[项目][boost搜索引擎#4] cpp-httplib使用 log.hpp 前端 测试及总结
前端·搜索引擎
新中地GIS开发老师36 分钟前
《Vue进阶教程》(12)ref的实现详细教程
前端·javascript·vue.js·arcgis·前端框架·地理信息科学·地信
漫天转悠37 分钟前
Vue3中404页面捕获(图文详情)
vue.js