根据调试工具看Vue源码之虚拟dom(一)

1,657 阅读4分钟

初次探索

什么是虚拟dom

Vue 通过建立一个虚拟 DOM 对真实 DOM 发生的变化保持追踪。请仔细看这行代码:

return createElement('h1', this.blogTitle)

createElement 到底会返回什么呢?其实不是一个实际的 DOM 元素。它更准确的名字可能是 createNodeDescription,因为它所包含的信息会告诉 Vue 页面上需要渲染什么样的节点,及其子节点。我们把这样的节点描述为“虚拟节点 (Virtual Node)”,也常简写它为“VNode”。“虚拟 DOM”是我们对由 Vue 组件树建立起来的整个 VNode 树的称呼。

以上这段对虚拟Dom的简短介绍来自Vue官网

第一个断点

我们一开始的断点先打在app.vue的两个hook上:

export default {
    name: 'app',
    created () {
        debugger
    },
    mounted () {
        debugger
    }
}

刷新页面,此时调用栈中显示的函数跟预想中的不太一样:

avatar

created这个hook执行之前,多出了一些比较奇怪的函数:

  • createComponentInstanceForVnode
  • Vue._update
  • mountComponent

🤔看完以后我心中出现了一个疑问:

为什么在created钩子执行之前就出现了mountComponent这个方法,到底是文档出问题了,还是文档出问题了呢?带着这个疑惑我们接着往下看

mountComponent做了什么?

通过上面打第一个断点,其实不难看出这样的执行顺序(从上往下):

  • (annoymous)
  • Vue.$mount
  • mountComponent

(annoymous)这步其实就是在执行我们的main.js,代码很短:

...
new Vue({
    render: h => h(App)
}).$mount('#app')
Vue.$mount
Vue.prototype.$mount = function (
    el,
    hydrating
) {
    // 判断是否处于浏览器的环境
    el = el && inBrowser ? query(el) : undefined;
    // 执行mountComponent
    return mountComponent(this, el, hydrating)
};
mountComponent
function mountComponent (
  vm,
  el,
  hydrating
) {
  vm.$el = el;
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode;
    // 开发环境下给出警告提示
    if (process.env.NODE_ENV !== 'production') {
      /* istanbul ignore if */
      if ((vm.$options.template && vm.$options.template.charAt(0) !== '#') ||
        vm.$options.el || el) {
        warn(
          'You are using the runtime-only build of Vue where the template ' +
          'compiler is not available. Either pre-compile the templates into ' +
          'render functions, or use the compiler-included build.',
          vm
        );
      } else {
        warn(
          'Failed to mount component: template or render function not defined.',
          vm
        );
      }
    }
  }
  callHook(vm, 'beforeMount');

  var updateComponent;
  /* istanbul ignore if */
  // 这里对测试环境跟正式环境的updateComponent 做了实现上的一个区分
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    updateComponent = function () {
      var name = vm._name;
      var id = vm._uid;
      var startTag = "vue-perf-start:" + id;
      var endTag = "vue-perf-end:" + id;

      mark(startTag);
      var vnode = vm._render();
      mark(endTag);
      measure(("vue " + name + " render"), startTag, endTag);

      mark(startTag);
      vm._update(vnode, hydrating);
      mark(endTag);
      measure(("vue " + name + " patch"), startTag, endTag);
    };
  } else {
    updateComponent = function () {
      vm._update(vm._render(), hydrating);
    };
  }

  // we set this to vm._watcher inside the watcher's constructor
  // since the watcher's initial patch may call $forceUpdate (e.g. inside child
  // component's mounted hook), which relies on vm._watcher being already defined

  new Watcher(vm, updateComponent, noop, {
    before: function before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate');
      }
    }
  }, true /* isRenderWatcher */);
  hydrating = false;

  // manually mounted instance, call mounted on self
  // mounted is called for render-created child components in its inserted hook
  if (vm.$vnode == null) {
    vm._isMounted = true;
    callHook(vm, 'mounted');
  }
  return vm
}

简单罗列下上面这两段代码的逻辑👇:

  • 调用beforeMount钩子函数
  • 封装一个updateComponent函数
  • 执行new Watcher并将updateComponent当做参数传入
  • 调用vm._update方法

_update方法是如何被触发的?

Watcher
var Watcher = function Watcher (
  vm,
  expOrFn,
  cb,
  options,
  isRenderWatcher
) {
  ...
  // 将函数赋值给this.getter,这里是updateComponent函数
  if (typeof expOrFn === 'function') {
    this.getter = expOrFn;
  } else {
    this.getter = parsePath(expOrFn);
    if (!this.getter) {
      this.getter = noop;
      process.env.NODE_ENV !== 'production' && warn(
        "Failed watching path: \"" + expOrFn + "\" " +
        'Watcher only accepts simple dot-delimited paths. ' +
        'For full control, use a function instead.',
        vm
      );
    }
  }
  // 根据this.lazy决定是否触发get方法
  this.value = this.lazy
    ? undefined
    : this.get();
};
Watcher.prototype.get = function get () {
  pushTarget(this);
  var value;
  var vm = this.vm;
  try {
    // 这里调用getter方法,实际上也就是调用updateComponent方法并拿到返回值
    value = this.getter.call(vm, vm);
  } catch (e) {
    if (this.user) {
      handleError(e, vm, ("getter for watcher \"" + (this.expression) + "\""));
    } else {
      throw e
    }
  } finally {
    // "touch" every property so they are all tracked as
    // dependencies for deep watching
    if (this.deep) {
      traverse(value);
    }
    popTarget();
    this.cleanupDeps();
  }
  // 返回函数(updateComponent)执行结果
  return value
};

简单梳理下上面这段代码的逻辑:

  • 新建Watcher实例时,将updateComponent赋值给getter属性
  • 通过this.get方法,触发updateComponent函数
  • 最终拿到函数的执行结果

小结

通过上面的分析我们可以初步得出一个结论:

组件的渲染跟Watcher离不开关系,父组件在执行完created钩子函数之后,会调用updateComponent函数对子组件进行处理

深入研究

如果前面你动手跟着断点一直走,那么不难得知存在这样的调用关系(从上往下):

  • ...
  • mountComponent
  • Watcher
  • get
  • updateComponent
  • Vue._update
  • patch
  • createElm
  • createComponent
  • init
  • createComponentInstanceForVnode
  • VueComponent
  • Vue._init
  • callHook
  • invokeWithErrorHandling
  • created

Vue.prototype._update

Vue.prototype._update = function (vnode, hydrating) {
    var vm = this;
    var prevEl = vm.$el;
    var prevVnode = vm._vnode;
    // 重存储当前父实例
    var restoreActiveInstance = setActiveInstance(vm);
    vm._vnode = vnode;
    // Vue.prototype.__patch__ is injected in entry points
    // based on the rendering backend used.
    if (!prevVnode) {
      // initial render
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);
    } else {
      // 执行patch函数
      vm.$el = vm.__patch__(prevVnode, vnode);
    }
    restoreActiveInstance();
    ...
  };

当然,我们通过全局检索可以得知_patch函数相关的代码👇:

// 只在浏览器环境下patch函数有效
Vue.prototype.__patch__ = inBrowser ? patch : noop;
var patch = createPatchFunction({ nodeOps: nodeOps, modules: modules });
function createPatchFunction (backend) {
    ...
    return function patch (oldVnode, vnode, hydrating, removeOnly) {
        ...
    }
}

这里先不深究patch的实现,我们只要知道patch是使用createPatchFunction来生成的一个闭包函数即可。

子组件的渲染

我们注意到,在子组件created钩子执行之前存在一个init方法👇:

var componentVNodeHooks = {
  init: function init (vnode, hydrating) {
    if (
      vnode.componentInstance &&
      !vnode.componentInstance._isDestroyed &&
      vnode.data.keepAlive
    ) {
      // kept-alive components, treat as a patch
      var mountedNode = vnode; // work around flow
      componentVNodeHooks.prepatch(mountedNode, mountedNode);
    } else {
      // 创建子组件实例
      var child = vnode.componentInstance = createComponentInstanceForVnode(
        vnode,
        activeInstance
      );
      // 对子组件执行$mount方法
      child.$mount(hydrating ? vnode.elm : undefined, hydrating);
    }
  },
  ...

相关代码:

createComponentInstanceForVnode
function createComponentInstanceForVnode (
  vnode, // we know it's MountedComponentVNode but flow doesn't
  parent // activeInstance in lifecycle state
) {
  // 初始化一个子组件的vnode配置
  var options = {
    _isComponent: true,
    _parentVnode: vnode,
    parent: parent
  };
  // 检查render函数内是否有template模板
  var inlineTemplate = vnode.data.inlineTemplate;
  if (isDef(inlineTemplate)) {
    options.render = inlineTemplate.render;
    options.staticRenderFns = inlineTemplate.staticRenderFns;
  }
  // 返回子组件实例
  return new vnode.componentOptions.Ctor(options)
}

总结

  1. 存在子组件时,先初始化父组件,在created钩子执行之后,生成子组件的vnode实例
  2. 子组件的created钩子执行完,检查子组件是否也有子组件
  3. 子组件也存在子组件时,则重复1,否则直接执行$mount函数,渲染子组件

扫描下方的二维码或搜索「tony老师的前端补习班」关注我的微信公众号,那么就可以第一时间收到我的最新文章。