『手写Vue3』组件更新、nextTick

113 阅读4分钟

组件更新

虽然已经实现了diff,但是更新还没做完。

image.png

根组件中的setup暴露了ref属性msg和count,把msg传给子组件,并且提供修改msg的方法changeChildProps和修改count的方法changeCount。

当点击按钮1,由于msg是在根组件内声明的,将从根组件开始更新。传入子组件的props改变,因此子组件也需要更新。

点击按钮2,同样从根组件开始更新,但是props没有改变,因此子组件不用更新。

image.png

export const App = {
  name: 'App',
  setup() {
    const msg = ref('123');
    const count = ref(1);

    window.msg = msg;

    const changeChildProps = () => {
      msg.value = '456';
    };

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

    return { msg, changeChildProps, changeCount, count };
  },

  render() {
    return h('div', {}, [
      h('div', {}, '你好'),
      h(
        'button',
        {
          onClick: this.changeChildProps
        },
        'change child props'
      ),
      h(Child, {
        msg: this.msg
      }),
      h(
        'button',
        {
          onClick: this.changeCount
        },
        'change self count'
      ),
      h('p', {}, 'count: ' + this.count)
    ]);
  }
};


// 子组件
export default {
  name: 'Child',
  setup(props, { emit }) {},
  render(proxy) {
    return h('div', {}, [
      h('div', {}, 'child - props - msg: ' + this.$props.msg)
    ]);
  }
};

子组件使用了this.$props,需要完善组件代理,从instance上获取props。

复习:props本身是vnode的属性,在patch -> processComponent -> mountComponent -> setupComponent -> initProps中,设置了instance的props属性。

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

来串一下修改msg之后的流程:从根结点开始,调用render生成新的element vnode,然后patch -> patchElement -> patchChildren -> patchedKeyedChildren -> patch相同的结点。

在patch相同的结点时,patch传入old Child组件和new Child组件,这俩都是component vnode,于是patch -> processComponent,而之前的processComponent中只有mount的逻辑,没有update的逻辑。所以点击按钮1,就会mount新的Child组件。

增加更新:

  function processComponent(n1, n2, container, parentComponent) {
    if (!n1) {
      mountComponent(n2, container, parentComponent);
    } else {
      updateComponent(n1, n2);
    }
  }

在updateComponent中要更新Child组件,那么需要调用render生成新的element vnode,再把它们传入patch。这个流程是不是似曾相识?它就是之前在effect中传入的回调函数。

    // 把runner作为update属性保存到instance上
    instance.update = effect(() => {
      const { proxy, isMounted } = instance;

      if (!isMounted) {
        // ...
      } else {
        // 就是这段
        const subTree = instance.render.call(proxy);
        patch(instance.subTree, subTree, container, instance, null);
        instance.subTree = subTree;
      }
    });

那么现在要如何重新运行这个回调呢,答案是runner,把runner作为update属性保存到instance上。

为了在updateComponent中拿到instance,可以在vnode上保存component属性,它就是vnode的instance。

在instance上保存next,表示用于更新的vnode。

  function mountComponent(initialVNode, container, parentComponent) {
    // 为了实现组件更新,在vnode上保存instance
    const instance = (initialVNode.component = createComponentInstance(
      initialVNode,
      parentComponent
    ));

    setupComponent(instance);
    setupRenderEffect(instance, initialVNode, container);
  }
  
  function updateComponent(n1, n2) {
    // 拿到instance,同时别忘了更新n2.component
    const instance = (n2.component = n1.component);
    // 只有当props变化时,子组件才需要更新
    if (shouldComponentUpdate(n1, n2)) {
      instance.next = n2;
      instance.update(); // runner
    } else {
      // 无需更新,因此el也不会再改变,直接赋值给n2
      n2.el = n1.el;
      instance.vnode = n2;
    }
  }

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

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

最后再修改一下更新回调函数,取出instance.next。

  function setupRenderEffect(instance: any, initialVNode, container) {
    instance.update = effect(() => {
      const { proxy, isMounted } = instance;

      if (!isMounted) {
        // ...
      } else {
        const { next, vnode } = instance;

        if (next) {
          // patchElement中有 n2.el = n1.el,这一步应当可有可无 
          next.el = vnode.el;
          updateComponentPreRender(instance, next);
        }

        const subTree = instance.render.call(proxy);
        patch(instance.subTree, subTree, container, instance, null);
        instance.subTree = subTree;
      }
    });
  }
  
function updateComponentPreRender(instance, nextVNode) {
  // 子组件访问的this.$props是instance.props
  // 此处修改后,视图就能渲染新的props.msg
  instance.props = nextVNode.props;
  // 更新完毕,instance.next设置为null
  instance.next = null;
  // 更新instance.vnode
  instance.vnode = nextVNode;
}

异步更新、nextTick

提供demo app:

import {
  h,
  ref,
  getCurrentInstance,
  nextTick
} from '../../lib/my-mini-vue.esm.js';

export default {
  name: 'App',
  setup() {
    const count = ref(1);
    const instance = getCurrentInstance();

    async function onClick() {
      for (let i = 0; i < 100; i++) {
        console.log('update');
        count.value = i;
      }

      console.log(instance.vnode.el.querySelector('p').innerText);

      nextTick(() => {
        console.log(instance.vnode.el.querySelector('p').innerText);
      });

      await nextTick();
      console.log(instance.vnode.el.querySelector('p').innerText);
    }

    return {
      onClick,
      count
    };
  },
  render() {
    const button = h('button', { onClick: this.onClick }, 'update');
    const p = h('p', {}, 'count:' + this.count);

    return h('div', {}, [button, p]);
  }
};

暂时注释掉下方的nextTick,可见setup中有一个for循环,每轮循环都会修改ref.value,按照目前的实现,每次值改变都会触发页面更新,在patchElement函数中加一行log,当我点击页面上的按钮时,如图所示,每次都会触发更新:

image.png

显然这样的性能比较低,理想情况应该是在i自增到99以后,页面直接更新成99,即只更新一次。为了做到这样,要提供一个队列,把每次刷新操作视为一个“job”,当全部job都加入数组后,找时机flush这个队列。

修改ref.value后,每次都会执行effect中的回调函数,然后进行更新操作,不便于我们控制。因此用到Scheduler,又因为之前用instance.update保存了回调函数,我们便可以这样:

function setupRenderEffect(instance: any, initialVNode, container) {
    instance.update = effect(
      () => {
        const { proxy, isMounted } = instance;

        if (!isMounted) {
          const subTree = (instance.subTree = instance.render.call(proxy));
          patch(null, subTree, container, instance, null);

          initialVNode.el = subTree.el;
          instance.isMounted = true;
        } else {
          const { next } = instance;

          if (next) {
            updateComponentPreRender(instance, next);
          }

          const subTree = instance.render.call(proxy); 
          patch(instance.subTree, subTree, container, instance, null);
          instance.subTree = subTree;
        }
      },
      { 
        // 添加Scheduler
        scheduler: () => {
          console.log('update - scheduler');
          queueJobs(instance.update);
        }
      }
    );
  }

queueJobs将任务添加到队列,并flush。

刚才说,我们要“找时机flush”。由事件循环可知,flush是微任务比较合适。app组件中执行的for循环在某轮宏任务之中,每次修改value都会把回调加入队列,等到flush执行的时候,队列已经初始化完毕,依次执行其中任务并且出队。

宏任务(同步) -> 微任务 -> 渲染(可选) -> 宏任务 -> 微任务...

const queue: any[] = [];
const p = Promise.resolve(); // 用于复用promise
let isFlushPending = false;

// nextTick把回调变为微任务
export function nextTick(fn) {
  return fn ? p.then(fn) : p;
}

export function queueJobs(job) {
  // 不会添加相同的任务
  if (!queue.includes(job)) {
    console.log('push');
    queue.push(job);
  }

  queueFlush();
}

function queueFlush() {
  // 只开启一次flush,在本轮微任务时执行
  // 起初没有复用promise,本函数末尾写的是 return Promise.resolve()
  // 此时如果没有isFlushPending的话,就会运行多次queueFlush,产生多个Promise
  
  if (isFlushPending) return;
  isFlushPending = true;
  
  // flush操作是微任务
  return nextTick(() => {
    flushJobs();
  });
}

function flushJobs() {
  let job;
  // 开始执行flush,脱离pending状态
  isFlushPending = false;
  while ((job = queue.shift())) {
    job && job();
  }
}

再来分析之前的组件,每次添加到队列的任务都是instance.update,属于同一个任务,所以只有第一个会进入队列。到for循环结束时,队列中只有一个回调,flush时,也就只会更新一次了。

再说说nextTick,上面已经给出了实现。由于更新操作变成了微任务,for循环刚结束时,还没来得及更新页面,因此此时获得的dom还是更新前的。给nextTick提供回调,或是使用await,可以使得回调在微任务阶段执行,根据调用nextTick的位置在修改ref的value之前或之后,可以拿到修改前/后的dom

    async function onClick() {
      // 第一个nextTick
      nextTick(() => {
        console.log(instance.vnode.el.querySelector('p').innerText);
      });

      for (let i = 0; i < 100; i++) {
        console.log('update');
        count.value = i;
      }
      // 没用nextTick
      console.log(instance.vnode.el.querySelector('p').innerText);

      // 后两个nextTick
      nextTick(() => {
        console.log(instance.vnode.el.querySelector('p').innerText);
      });

      await nextTick();
      console.log(instance.vnode.el.querySelector('p').innerText);
    }

点击按钮,输出如下:

image.png