实现mini-vue -- runtime-core模块(十九)更新组件的实现

1,562 阅读10分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第27天,点击查看活动详情

前面我们实现了元素的更新,也是整个runtime-core模块中最困难的部分,这节也是实现更新逻辑,但是不再是元素的更新了,而是组件的更新,组件的更新就相对而言简单很多,首先我们通过一个例子来看看组件更新的场景吧

1. 组件更新场景

首先有一个子组件Child,它会接收一个msg属性,并将其渲染出来

export const Child = {
  name: 'Child',
  setup() {},
  render() {
    return h(
      'p',
      {},
      `here is child, I receive a message from App: ${this.$props.msg}`
    );
  },
};

然后父组件中会调用该子组件并传递props给它,以及会有按钮,点击之后会修改传入的msg,我们的目的就是要触发子组件的更新

export const App = {
  name: 'App',
  setup() {
    const msg = ref('hello plasticine');
    const count = ref(0);

    const changeMsg = () => {
      msg.value = 'hello plasticine' ? 'hello again' : 'hello plasticine';
    };

    const addCount = () => {
      count.value++;
    };

    return { msg, changeMsg, count, addCount };
  },
  render() {
    return h('div', {}, [
      h(Child, { msg: this.msg }),
      h('button', { onClick: this.changeMsg }, 'change msg'),
      h('p', {}, `count: ${this.count}`),
      h('button', { onClick: this.addCount }, 'add count'),
    ]);
  },
};

但是现在有一个问题,子组件中我们访问了this.$props,这个是我们前面还没有实现的,所以先来把这个小功能实现一下


2. render 函数中访问 this.$props

this.$xxx的实现逻辑是放在componentPublicInstance.ts中的,由于我们之前已经实现了在组件实例上挂载了proxy代理对象,而代理对象的handler会根据publicPropertiesMap去获取$xxx属性,所以我们在publicPropertiesMap中添加$props的获取实现即可

const publicPropertiesMap = {
  $el: (i) => i.vnode.el,
  $slots: (i) => i.slots,
+  $props: (i) => i.props,
};

现在我们的案例场景就可以渲染出来了 image.png


3. 未实现组件更新逻辑时的 bug

现在我们还没有实现组件更新的逻辑,那么点击change msg会怎样呢? 未实现组件更新时的bug.gif 可以看到,当点击change msg修改msg时,居然会重复渲染出子组件的内容,这是为什么呢? 可以回顾一下我们之前的patch逻辑中对组件类型的处理

function patch(n1, n2, container, parentComponent = null, anchor) {
  const { type, shapeFlag } = n2;

  switch (type) {
    case Fragment:
      processFragment(n1, n2, container, parentComponent, anchor);
      break;
    case Text:
      processText(n1, n2, container);
      break;

    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        // 真实 DOM
        processElement(n1, n2, container, parentComponent, anchor);
      } else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
        // 处理 component 类型
        processComponent(n1, n2, container, parentComponent, anchor);
      }
      break;
  }
}

只有一个地方 -- processComponent用于处理组件

function processComponent(
  n1,
  n2: any,
  container: any,
  parentComponent,
  anchor
) {
  mountComponent(n2, container, parentComponent, anchor);
}

而这个函数中只调用了mountComponent函数去处理组件,也就是无论如何,只要遇到组件,就会将它挂载,所以也就会看到bug中无论怎么修改msg,都会触发依赖,调用子组件的render函数重新将子组件渲染一遍,而没有进行修改

但是有一个问题,为什么点击add count也会导致子组件重新渲染呢?明明子组件都没有用到count这个响应式变量呀

别忘了,count在父组件的render函数中有用到,所以当修改count的时候,父组件的render函数会被执行,由于render函数中用到了子组件Child,所以每次执行render函数都会把子组件的render函数也执行一遍


4. 实现 updateComponent 更新组件

4.1 修改组件处理入口

首先我们要修改一下processComponent入口,将updateComponent更新组件的调用逻辑添加上

function processComponent(
  n1,
  n2: any,
  container: any,
  parentComponent,
  anchor
) {
  if (!n1) {
    // 没有旧组件 -- 挂载组件
    mountComponent(n2, container, parentComponent, anchor);
  } else {
    // 有旧组件 -- 更新组件
    updateComponent(n1, n2);
  }
}

判断逻辑也很简单,只要n1vnode不存在就意味着要挂载组件,存在则进行组件更新


4.2 组件更新的思路

组件更新的本质起始就是重新调用组件的render函数,对已有的组件元素进行更新,所以我们肯定需要在updateComponent函数中获取到组件实例对象,这样才能获取到它的render函数并进行调用

但是其实并不只是调用render函数那么简单,我们还要处理组件的子组件呢,但是别忘了之前我们实现了一个交setupRenderEffect的函数

在这个函数里面实现了对组件render函数的调用,以及进行组件实例上subTree属性的更新,将render函数的结果作为当前组件实例的子树,好在这一切都已经在setupRenderEffect中实现了,所以最终我们的目的是希望调用下面这段代码:

function setupRenderEffect(instance, container, anchor) {
  effect(() => {
    if (!instance.isMounted) {
      // ...
    } else {
      // ================ 调用这段代码进行组件更新 ================
      const { proxy, vnode } = instance;
      const subTree = instance.render.call(proxy); // 新 vnode
      const prevSubTree = instance.subTree; // 旧 vnode
      instance.subTree = subTree; // 新的 vnode 要更新到组件实例的 subTree 属性 作为下一更新的旧 vnode

      patch(prevSubTree, subTree, container, instance, anchor);
      // ================ 调用这段代码进行组件更新 ================
    }
  });
}

这里由于我们需要做数据响应式,所以将整个setupRenderEffect的代码包装到effect函数中了,而effect函数执行完毕后会返回一个runner,也就是被包裹的这个函数

我们可以把这个runner挂载到组件实例上,给组件实例添加一个update属性,这个属性指向返回的runner,这样就能够通过调用组件实例的update方法实现对组件的更新了!

但是我们能够在updateComponent中获取到的只有n1n2,它们是vnode,并不是组件实例,所以还需要给vnode添加一个指向组件实例的引用,方便我们直接通过n1/n2虚拟节点获取到它们对应的组件实例,从而调用update方法


4.3 给组件实例挂载 update 方法

通过前面的思路分析,可以很清楚地知道我们应当将effect调用后返回的runner挂载到组件实例上

function setupRenderEffect(instance, container, anchor) {
  instance.update = effect(() => {
    // ...
  }
}

4.4 给组件实例添加 update 属性

const component = {
  vnode,
  type: vnode.type,
  setupState: {},
  props: {},
  emit: () => {},
  slots: {},
  provides: parent ? parent.provides : {},
  parent,
  isMounted: false,
  subTree: {},
+  update: null,
};

4.5 给 vnode 添加指向组件实例的属性

export function createVNode(type, props?, children?) {
  const vnode = {
    type,
    props,
    children,
    shapeFlag: getShapeFlag(type),
    el: null,
    key: props?.key,
+    component: null,
  };
}

并且我们还需要在首次挂载组件的时候,将创建的组件实例挂载到vnode上,这样后续才能在vnode上访问到组件实例

function mountComponent(
  initialVNode: any,
  container,
  parentComponent,
  anchor
) {
  // 根据 vnode 创建组件实例
-  const instance = createComponentInstance(initialVNode, parentComponent);
+  const instance = (initialVNode.component = createComponentInstance(
+    initialVNode,
+    parentComponent
+  ));

  // setup 组件实例
  setupComponent(instance);
  setupRenderEffect(instance, container, anchor);
}

现在就可以通过n1/n2访问到组件实例了

function updateComponent(n1, n2) {
  const instance = (n2.component = n1.component);
  instance.update();
}

这里注意一定要把组件实例赋值给n2,因为n2是新的vnode,它是没有被挂载的,不存在组件实例,而由于它和n1都是同一个组件,所以我们需要把n1.component赋值给n2.component,这样也保证了更新前后都是同一个组件

最后调用组件实例的update方法即可


5. 组件更新预处理

在真正调用组件实例的update之前,我们需要进行一些预处理,因为vnodeprops是会变化的,所以我们需要保证组件获取到的也是最新的props,同时每次组件更新之后,都要修改组件实例的vnode属性,让其指向最新的vnode

5.1 将新 vnode 挂载到组件实例中

为了能够在setupRenderEffect中获取到更新后的vnode.props,我们需要把新的vnode挂载到组件实例上,可以给组件实例添加一个next属性,让其指向新的vnode,也就是n2

所以现在组件实例有两个引用会指向vnode,一个是vnode属性,指向旧的vnode,另一个是next属性,会指向updateComponent中新的vnode

export function createComponentInstance(vnode, parent) {
  console.log('createComponentInstance -- parent: ', parent);

  const component = {
    vnode,
    type: vnode.type,
    setupState: {},
    props: {},
    emit: () => {},
    slots: {},
    provides: parent ? parent.provides : {},
    parent,
    isMounted: false,
    subTree: {},
    update: null,
+    next: null
  };
}

然后在updateComponent中将n2赋值给next属性

function updateComponent(n1, n2) {
  const instance = (n2.component = n1.component);

+  instance.next = n2;
  instance.update();
}

5.2 修改 setupRenderEffect 对组件进行更新

由于调用组件实例的update方法实际上就是调用setupRenderEffecteffect函数中的runner,所以我们真正的组件更新逻辑其实要在这里进行处理

刚才已经给组件实例添加了next属性了,现在就可以在这里获取到

function setupRenderEffect(instance, container, anchor) {
  instance.update = effect(() => {
    if (!instance.isMounted) {
      // 首次挂载组件
      // ...
    } else {
      // 组件更新
-      const { proxy, vnode } = instance;
+      const { proxy, vnode, next } = instance;

+      if (next) {
+        // 让新 vnode.el 指向旧 vnode.el,因为它们仍然是同一个 vnode
+        next.el = vnode.el;
+        updateComponentPreRender(instance, next);
+      }

      const subTree = instance.render.call(proxy); // 新 vnode
      const prevSubTree = instance.subTree; // 旧 vnode
      instance.subTree = subTree; // 新的 vnode 要更新到组件实例的 subTree 属性 作为下一更新的旧 vnode

      patch(prevSubTree, subTree, container, instance, anchor);
    }
  });
}

这里我们从组件实例中解构出next属性,如果next存在则说明有新的vnode,那么我们就要做以下几件事:

  1. 让新的vnode.el指向旧vnode.el,因为它们仍然是同一个vnode,只是属性发生了变化
  2. 调用updateComponentPreRender函数更新组件实例

没错,这里我们又多了一个updateComponentPreRender函数去处理真正的组件实例的更新

function updateComponentPreRender(instance, nextVNode) {
  instance.vnode = nextVNode;
  instance.next = null;
  instance.props = nextVNode.props;
}

主要就是让vnode指向新的vnode,让next指向null,这样的一来新的vnode在下一次更新组件的时候就会成为老的vnode

其次,还要更新组件的props


6. 测试组件更新

至此我们的组件更新逻辑已经算是实现了,那么我们来看一下是否真的可以更新呢,还是打开开头准备的案例 组件更新.gif 可以看到确实是可以更新组件了,但其实还有一个小问题,如果当我们修改和子组件无关的父组件数据,触发父组件的视图更新,执行父组件的render函数的话,是否会导致子组件的更新逻辑又被执行呢?


7. 修复子组件更新逻辑的不必要调用 bug

可以进入调试模式看看,我们在processComponent中的更新组件调用入口添加一个断点

function processComponent(
  n1,
  n2: any,
  container: any,
  parentComponent,
  anchor
) {
  if (!n1) {
    // 没有旧组件 -- 挂载组件
    mountComponent(n2, container, parentComponent, anchor);
  } else {
+    debugger;
    // 有旧组件 -- 更新组件
    updateComponent(n1, n2);
  }
}

当我们点击add count的时候,会在断点处停下,并且查看n1n2,正是子组件中的内容 image.png image.png 可以看到,n1n2中的props是一样的,因为本来我们就没有修改子组件的数据,但是现在却触发了更新逻辑,很明显是有问题的

要解决这个问题很简单,不难发现子组件更新的前提是它的props发生了变化,才导致需要更新子组件的视图,所以我们只需要在调用update方法之前判断以下props是否发生改变即可

考虑到后续这个子组件更新的前提条件还会变,可能不仅仅是通过props来约束,所以我们可以将这个判断逻辑封装到一个shouldUpdateComponent函数中

创建src/runtime-core/componentUpdateUtils.ts

export function shouldUpdateComponent(prevVNode, nextVNode) {
  const { props: prevProps } = prevVNode;
  const { props: nextProps } = nextVNode;

  for (const key in nextVNode) {
    if (prevProps[key] !== nextProps[key]) {
      return true;
    }
  }

  return false;
}

然后修改updateComponent函数,只在需要更新的时候才更新组件

function updateComponent(n1, n2) {
  const instance = (n2.component = n1.component);

  debugger;
  if (shouldUpdateComponent(n1, n2)) {
    instance.next = n2;
    instance.update();
  } else {
    // 即使不需要更新 也要修改 n2.el = n1.el,因为它们仍然是同一个 vnode
    n2.el = n1.el;
    // 让 n2 成为下一次组件更新时的旧 vnode
    instance.vnode = n2;
  }
}

这次我们把断点打在updateComponent中,观察以下点击add count是否会进入else分支,会的话就说明修复完毕了

function updateComponent(n1, n2) {
  const instance = (n2.component = n1.component);

+  debugger;
  if (shouldUpdateComponent(n1, n2)) {
    instance.next = n2;
    instance.update();
  } else {
    // 即使不需要更新 也要修改 n2.el = n1.el,因为它们仍然是同一个 vnode
    n2.el = n1.el;
    // 让 n2 成为下一次组件更新时的旧 vnode
    instance.vnode = n2;
  }
}

image.png 可以看到,确实进入了else分支,修复完毕