Vue父组件更新时子组件接收新属性过程分析

465 阅读4分钟

为说明方便,我们采用如下案例:

    <div id="app">
        <my :data="abc"></my>
    </div>
    <script>
        const vm = new Vue({
            el: '#app',
            data: {
                abc: '123'
            },
            components: {
                my: {
                    props: {
                        data: {type:String}
                    },
                    template: '<div>my-component {{data}}</div>'
                }
            }
        });
        setTimeout(() => {
            vm.abc = 'world'
        }, 1000)
    </script>

可以看到在定时器里面,我们更新了根组件上的abc这个data数据

每一个组件中data数据的更新,都会触发这个data数据所在的组件的渲染watcher重新执行,我们这里的这个动作首先就会触发根组件的渲染watcher的get方法执行,其次会触发my组件的更新流程:

根组件watcher.run() -> 根组件watcher.get() -> vm._render() -> vm._update -> vm.patch(prevVnode, vnode)即patch

往后的流程就和创建节点不同了,这种组件更新的情况,在patch方法中,会走到if (!isRealElement && sameVnode(oldVnode, vnode))这个分支:

    return function patch (oldVnode, vnode, hydrating, removeOnly) { 
      if (isUndef(vnode)) { // 要做删除的时候 patch(oldVnode,null)  destroy方法
        if (isDef(oldVnode)) { invokeDestroyHook(oldVnode); }
        return
      }

      var isInitialPatch = false;
      var insertedVnodeQueue = [];

      if (isUndef(oldVnode)) {  // new Ctor.$mount();
        // empty mount (likely as component), create new root element
        isInitialPatch = true;
        createElm(vnode, insertedVnodeQueue);
      } else {
        var isRealElement = isDef(oldVnode.nodeType); // patch(el,vnode) 
        if (!isRealElement && sameVnode(oldVnode, vnode)) {  // patch(oldVnode,newVnode)
          // patch existing root node
          patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);

从而进入patchVnode方法,patchVnode方法里思路也非常清晰,先对当前入参传进来的节点进行对比更新,此处就是oldVnode, vnode,之后再对children进行对比更新:

    function patchVnode (
      oldVnode,
      vnode,
      insertedVnodeQueue,
      ownerArray,
      index,
      removeOnly
    ) {
      // ...
      var i;
      var data = vnode.data;
      if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
        i(oldVnode, vnode);
      }

      var oldCh = oldVnode.children; // 老的儿子 
      var ch = vnode.children; // 新儿子
      if (isDef(data) && isPatchable(vnode)) { // 调用一系列的更新方法
        for (i = 0; i < cbs.update.length; ++i) { cbs.update[i](oldVnode, vnode); }
        if (isDef(i = data.hook) && isDef(i = i.update)) { i(oldVnode, vnode); }
      }
      if (isUndef(vnode.text)) {// vnode不是文本
        if (isDef(oldCh) && isDef(ch)) { //  两方都有儿子
          if (oldCh !== ch) { updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly); }
        } else if (isDef(ch)) {
          {
            checkDuplicateKeys(ch);
          }
          if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, ''); } // 新的有 老的没有则添加节点
          addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
        } else if (isDef(oldCh)) { // 如果老的有新的没有 则删除
          removeVnodes(oldCh, 0, oldCh.length - 1);
        } else if (isDef(oldVnode.text)) {
          nodeOps.setTextContent(elm, ''); // 如果老的是文本则清空文本
        }
      } else if (oldVnode.text !== vnode.text) { // 文本不一致 则设置文本
        nodeOps.setTextContent(elm, vnode.text);
      }
      if (isDef(data)) {
        if (isDef(i = data.hook) && isDef(i = i.postpatch)) { i(oldVnode, vnode); }
      }
    }

在本案例中,对children进行对比就是对新旧两个my节点进行对比,会进入updateChildren方法,该方法里面就会通过diff算法对子组件调用patchVnode挨个进行更新

由于上面分析的是根节点的更新过程,根节点是个普通的div,所以它没有prepatch、update这些hook,而对于组件节点来说,它在VNode初始化阶段就会把这些hook注册上去,组件就是在这个时机更新的属性,然后属性更新进一步触发它所关联的渲染Watcher,这里渲染Watcher就是my组件了,然后完成DOM更新过程,因此我们重点关注一下my节点进入patchVnode的过程

首先会执行prepatch这个hook:

  var componentVNodeHooks = {
    init: function init (vnode, hydrating) {
      ...
    },

    prepatch: function prepatch (oldVnode, vnode) {
      var options = vnode.componentOptions;
      var child = vnode.componentInstance = oldVnode.componentInstance;
      updateChildComponent(
        child,
        options.propsData, // updated props
        options.listeners, // updated listeners
        vnode, // new parent vnode
        options.children // new children
      );
    },

可以看到prepatch里面又执行了updateChildComponent

function updateChildComponent (
    vm,
    propsData,
    listeners,
    parentVnode,
    renderChildren
  ) {
    
    if (propsData && vm.$options.props) {
      toggleObserving(false); // 不要在进行观测了
      var props = vm._props;
      var propKeys = vm.$options._propKeys || [];
      for (var i = 0; i < propKeys.length; i++) {
        var key = propKeys[i];
        var propOptions = vm.$options.props; // wtf flow?
        props[key] = validateProp(key, propOptions, propsData, vm);
      }
      toggleObserving(true);
      // keep a copy of raw propsData
      vm.$options.propsData = propsData;
    }

    // update listeners
    listeners = listeners || emptyObject;
    var oldListeners = vm.$options._parentListeners;
    vm.$options._parentListeners = listeners;
    updateComponentListeners(vm, listeners, oldListeners);

    // resolve slots + force update if has children
    if (needsForceUpdate) {
      vm.$slots = resolveSlots(renderChildren, parentVnode.context);
      vm.$forceUpdate();
    }

    {
      isUpdatingChildComponent = false;
    }
  }

在该方法最前面有很多和插槽、forceUpdate相关的代码,并不是我们分析的重点,直接去掉了,我们重点关注属性更新的部分

可以看到,这个方法主要就是取到新的属性值,然后将其放到Vue实例对象的propsData属性上:vm.$options.propsData,并在放的过程中暂时关闭了组件的observe响应式注册(这里为什么要关掉,目前我能想到的是如果赋值对象类型的属性时不会触发更新,但还是搞不太明白更具体的原因是什么)

这其中需要关注的是这行代码:

props[key] = validateProp(key, propOptions, propsData, vm);

这行代码的作用是对属性进行类型校验,然后再给属性赋值,需要注意的是,Vue官网明确说明,在开发过程中禁止修改props属性,我个人认为事实上Vue是希望把对props的修改收敛到框架内部,从而更便于管理,才有了这个规定

修改属性的这行代码会触发my组件的data属性的set钩子,在set钩子里面,我们会调用该属性对应的依赖对象dep的notify,进而触发my组件的渲染watcher.run从而执行更新

      set: function reactiveSetter (newVal) {
        var value = getter ? getter.call(obj) : val;
        /* eslint-disable no-self-compare */
        if (newVal === value || (newVal !== newVal && value !== value)) {
          return
        }
        /* eslint-enable no-self-compare */
        if ( customSetter) {
          customSetter();
        }
        // #7981: for accessor properties without setter
        if (getter && !setter) { return }
        if (setter) {
          setter.call(obj, newVal);
        } else {
          val = newVal;
        }
        childOb = !shallow && observe(newVal);
        dep.notify();
      }

但这次更新操作可不是立即进行的,而是将更新操作放到了queueWatcher更新队列中去了,等本轮代码执行结束后,才会轮到更新操作:

  Dep.prototype.notify = function notify () {
    // stabilize the subscriber list first
    var subs = this.subs.slice();
    if ( !config.async) {
      // subs aren't sorted in scheduler if not running async
      // we need to sort them now to make sure they fire in correct
      // order
      subs.sort(function (a, b) { return a.id - b.id; });
    }
    for (var i = 0, l = subs.length; i < l; i++) {
      subs[i].update();
    }
  };

上面notify方法里subs[i].update()执行时就是将更新操作放到了Watcher更新队列中去:

  Watcher.prototype.update = function update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true;
    } else if (this.sync) {
      this.run();
    } else {
      queueWatcher(this); // 每次组件数据更新了 都会执行queueWatcher
    }
  };
  
  function queueWatcher (watcher) { // 过滤同名的watcher
    var id = watcher.id;
    if (has[id] == null) {
      has[id] = true;
      if (!flushing) {
        queue.push(watcher); // 将多个渲染watcher去重后放到队列中
      } else {
        // if already flushing, splice the watcher based on its id
        // if already past its id, it will be run next immediately.
        var i = queue.length - 1;
        while (i > index && queue[i].id > watcher.id) {
          i--;
        }
        queue.splice(i + 1, 0, watcher);
      }
      // queue the flush
      if (!waiting) {
        waiting = true;

        if ( !config.async) {
          flushSchedulerQueue();
          return
        }
        nextTick(flushSchedulerQueue); // 这里会产生一个nextTick
      }
    }
  }

执行到这里时,上面queueWatcher里flushing是true,代表之前已经注册过一个watcher队列,所以此时新产生的watcher加到该队列中即可,那之前什么时候新注册过一个watcher队列呢?

其实在文章开头的时候我们提到过,vm.abc = 'world'这行代码执行时,就触发了abc对应的渲染watcher的执行,也就是根组件的渲染watcher的执行,我们当时是直接从watcher.run()开始分析的,事实上在此之前,从定时器里变更abc属性到watcher.run()就包含了注册watcher队列这个工作: vm.abc = 'world' ->
abc的dep.notify(); ->
queueWatcher(this); // 这里放入队列的watcher是根组件的watcher ->
nextTick(flushSchedulerQueue) ->
timerFunc(); ->
p.then(flushCallbacks);

接下来这个宏任务就结束了,就进入到微任务遍历flushCallbacks执行的过程了,flushCallbacks里有一个方法就是flushSchedulerQueue,即遍历此时watcher队列里的各项执行run,这个时候就会进入根组件的watcher.run里面去了,此时队列里也只有一个根组件的渲染watcher

但通过之前的分析,根组件渲染watcher在执行过程中又加入了my组件的渲染watcher,也就是在这行代码时:

props[key] = validateProp(key, propOptions, propsData, vm);

给子组件赋值时触发它的notify,再将my组件的渲染watcher放进队列里去的

updateChildComponent里面在执行了notify之后,又对listeners事件进行了更新,对$forceUpdate做了一些处理

随着updateChildComponent方法执行完毕,子组件的prepatch这个hook执行完毕,再返回patchVnode继续执行一系列update方法(cbs.update),用于更新一些class、attrs、style、listeners等等,再返回根节点的patch方法中,根节点子节点更新操作updateChildren执行完毕,patch方法随即执行完毕,根节点_update方法执行完毕

接下来会再回到flushSchedulerQueue方法里,继续取到下一个watcher执行,这里的下一个watcher,就是我们刚刚加进去的my组件的渲染watcher,该渲染watcher执行完后,使用了新的属性的DOM就被创建出来,并随之更新到页面上了