根據除錯工具看原始碼之虛擬dom(一)

tonychen發表於2019-04-01

初次探索

什麼是虛擬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函式,渲染子元件

相關文章