Vue 源碼深入解析之 數據驅動、new Vue 發生的過程、Vue 實例掛載的實現、render 、Virtual DOM 、createElement 和 update

一、數據驅動

  1. Vue.js 一個核心思想是數據驅動。所謂數據驅動,是指視圖是由數據驅動生成的,我們對視圖的修改,不會直接操作 DOM,而是通過修改數據。它相比我們傳統的前端開發,如使用 jQuery 等前端庫直接修改 DOM,大大簡化了代碼量。特別是當交互複雜的時候,只關心數據的修改會讓代碼的邏輯變的非常清晰,因爲 DOM 變成了數據的映射,我們所有的邏輯都是對數據的修改,而不用碰觸 DOM,這樣的代碼非常利於維護。

  2. Vue.js 中我們可以採用簡潔的模板語法來聲明式的將數據渲染爲 DOM,代碼如下所示:

<div id="app">
  {{ message }}
</div>
var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!'
  }
})
  1. 最終它會在頁面上渲染出 Hello Vue。接下來,我們會從源碼角度來分析 Vue 是如何實現的,分析過程會以主線代碼爲主,重要的分支邏輯會放在之後單獨分析。數據驅動還有一部分是數據更新驅動視圖變化,會在之後分析,目標是弄清楚模板和數據如何渲染成最終的 DOM

二、new Vue 發生的過程

  1. 從入口代碼開始分析,我們先來分析 new Vue 背後發生了哪些事情。我們都知道,new 關鍵字在 Javascript 語言中代表實例化是一個對象,而 Vue 實際上是一個類,類在 Javascript 中是用 Function 來實現的,來看一下源碼,在src/core/instance/index.js 中:
function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}
  1. 由上可以看到 Vue 只能通過 new 關鍵字初始化,然後會調用 this._init 方法, 該方法在 src/core/instance/init.js 中定義,如下所示:
Vue.prototype._init = function (options?: Object) {
  const vm: Component = this
  // a uid
  vm._uid = uid++

  let startTag, endTag
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    startTag = `vue-perf-start:${vm._uid}`
    endTag = `vue-perf-end:${vm._uid}`
    mark(startTag)
  }

  // a flag to avoid this being observed
  vm._isVue = true
  // merge options
  if (options && options._isComponent) {
    // optimize internal component instantiation
    // since dynamic options merging is pretty slow, and none of the
    // internal component options needs special treatment.
    initInternalComponent(vm, options)
  } else {
    vm.$options = mergeOptions(
      resolveConstructorOptions(vm.constructor),
      options || {},
      vm
    )
  }
  /* istanbul ignore else */
  if (process.env.NODE_ENV !== 'production') {
    initProxy(vm)
  } else {
    vm._renderProxy = vm
  }
  // expose real self
  vm._self = vm
  initLifecycle(vm)
  initEvents(vm)
  initRender(vm)
  callHook(vm, 'beforeCreate')
  initInjections(vm) // resolve injections before data/props
  initState(vm)
  initProvide(vm) // resolve provide after data/props
  callHook(vm, 'created')

  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    vm._name = formatComponentName(vm, false)
    mark(endTag)
    measure(`vue ${vm._name} init`, startTag, endTag)
  }

  if (vm.$options.el) {
    vm.$mount(vm.$options.el)
  }
}
  1. Vue 初始化主要就幹了幾件事情,合併配置,初始化生命週期,初始化事件中心,初始化渲染,初始化 datapropscomputedwatcher 等等。

  2. 總結:Vue 的初始化邏輯寫的非常清楚,把不同的功能邏輯拆成一些單獨的函數執行,讓主線邏輯一目瞭然,這樣的編程思想是非常值得借鑑和學習的。在初始化的最後,檢測到如果有 el 屬性,則調用 vm.$mount 方法掛載 vm,掛載的目標就是把模板渲染成最終的 DOM,那麼接下來我們來分析 Vue 的掛載過程。

三、Vue 實例掛載的實現

  1. Vue 中我們是通過 $mount 實例方法去掛載 vm 的,$mount方法在多個文件中都有定義,如 src/platform/web/entry-runtime-with-compiler.jssrc/platform/web/runtime/index.jssrc/platform/weex/runtime/index.js。因爲 $mount 這個方法的實現是和平臺、構建方式都相關的。接下來我們重點分析帶 compiler 版本的 $mount 實現,因爲拋開 webpackvue-loader,我們在純前端瀏覽器環境分析 Vue 的工作原理,有助於我們對原理理解的深入。

  2. compiler 版本的 $mount 實現非常有意思,先來看一下 src/platform/web/entry-runtime-with-compiler.js 文件中定義:

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el)

  /* istanbul ignore if */
  if (el === document.body || el === document.documentElement) {
    process.env.NODE_ENV !== 'production' && warn(
      `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
    )
    return this
  }

  const options = this.$options
  // resolve template/el and convert to render function
  if (!options.render) {
    let template = options.template
    if (template) {
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !template) {
            warn(
              `Template element not found or is empty: ${options.template}`,
              this
            )
          }
        }
      } else if (template.nodeType) {
        template = template.innerHTML
      } else {
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) {
      template = getOuterHTML(el)
    }
    if (template) {
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile')
      }

      const { render, staticRenderFns } = compileToFunctions(template, {
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns

      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile end')
        measure(`vue ${this._name} compile`, 'compile', 'compile end')
      }
    }
  }
  return mount.call(this, el, hydrating)
}
  1. 這段代碼首先緩存了原型上的 $mount 方法,再重新定義該方法,我們先來分析這段代碼。首先,它對 el 做了限制,Vue 不能掛載在 bodyhtml 這樣的根節點上。接下來的是很關鍵的邏輯 —— 如果沒有定義 render 方法,則會把 el 或者 template 字符串轉換成 render 方法。這裏我們要牢記,在 Vue 2.0 版本中,所有 Vue 的組件的渲染最終都需要 render 方法,無論我們是用單文件 .vue 方式開發組件,還是寫了 el 或者 template 屬性,最終都會轉換成 render 方法,那麼這個過程是 Vue 的一個“在線編譯”的過程,它是調用 compileToFunctions 方法實現的,編譯過程我們之後會介紹。最後,調用原先原型上的 $mount 方法掛載。

  2. 原先原型上的 $mount 方法在 src/platform/web/runtime/index.js 中定義,之所以這麼設計完全是爲了複用,因爲它是可以被 runtime only 版本的 Vue 直接使用的,如下所示:

// public mount method
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}
  1. $mount 方法支持傳入 2 個參數,第一個是 el,它表示掛載的元素,可以是字符串,也可以是 DOM 對象,如果是字符串在瀏覽器環境下會調用 query 方法轉換成 DOM 對象的。第二個參數是和服務端渲染相關,在瀏覽器環境下我們不需要傳第二個參數。

  2. $mount 方法實際上會去調用 mountComponent 方法,這個方法定義在 src/core/instance/lifecycle.js 文件中,如下所示:

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  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')

  let updateComponent
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    updateComponent = () => {
      const name = vm._name
      const id = vm._uid
      const startTag = `vue-perf-start:${id}`
      const endTag = `vue-perf-end:${id}`

      mark(startTag)
      const 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 = () => {
      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 () {
      if (vm._isMounted) {
        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
}

從上面的代碼可以看到,mountComponent 核心就是先實例化一個渲染Watcher,在它的回調函數中會調用 updateComponent 方法,在此方法中調用 vm._render 方法先生成虛擬 Node,最終調用 vm._update 更新 DOM。

  1. Watcher 在這裏起到兩個作用,一個是初始化的時候會執行回調函數,另一個是當 vm 實例中的監測的數據發生變化的時候執行回調函數,這塊兒我們會在之後介紹。

  2. 函數最後判斷爲根節點的時候設置 vm._isMountedtrue, 表示這個實例已經掛載了,同時執行 mounted 鉤子函數。 這裏注意 vm.$vnode 表示 Vue 實例的父虛擬 Node,所以它爲 Null 則表示當前是根 Vue 的實例。

  3. 總結:mountComponent 方法的邏輯也是非常清晰的,它會完成整個渲染工作,接下來我們要重點分析其中的細節,也就是最核心的 2 個方法:vm._rendervm._update

四、render 的理解

  1. Vue_render 方法是實例的一個私有方法,它用來把實例渲染成一個虛擬 Node。它的定義在 src/core/instance/render.js 文件中,如下所示:
Vue.prototype._render = function (): VNode {
  const vm: Component = this
  const { render, _parentVnode } = vm.$options

  // reset _rendered flag on slots for duplicate slot check
  if (process.env.NODE_ENV !== 'production') {
    for (const key in vm.$slots) {
      // $flow-disable-line
      vm.$slots[key]._rendered = false
    }
  }

  if (_parentVnode) {
    vm.$scopedSlots = _parentVnode.data.scopedSlots || emptyObject
  }

  // set parent vnode. this allows render functions to have access
  // to the data on the placeholder node.
  vm.$vnode = _parentVnode
  // render self
  let vnode
  try {
    vnode = render.call(vm._renderProxy, vm.$createElement)
  } catch (e) {
    handleError(e, vm, `render`)
    // return error render result,
    // or previous vnode to prevent render error causing blank component
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== 'production') {
      if (vm.$options.renderError) {
        try {
          vnode = vm.$options.renderError.call(vm._renderProxy, vm.$createElement, e)
        } catch (e) {
          handleError(e, vm, `renderError`)
          vnode = vm._vnode
        }
      } else {
        vnode = vm._vnode
      }
    } else {
      vnode = vm._vnode
    }
  }
  // return empty vnode in case the render function errored out
  if (!(vnode instanceof VNode)) {
    if (process.env.NODE_ENV !== 'production' && Array.isArray(vnode)) {
      warn(
        'Multiple root nodes returned from render function. Render function ' +
        'should return a single root node.',
        vm
      )
    }
    vnode = createEmptyVNode()
  }
  // set parent
  vnode.parent = _parentVnode
  return vnode
}
  1. 這段代碼最關鍵的是 render 方法的調用,我們在平時的開發工作中手寫 render 方法的場景比較少,而寫的比較多的是 template 模板,在之前的 mounted 方法的實現中,會把 template 編譯成 render 方法,但這個編譯過程是非常複雜的,之後會分析 Vue 的編譯過程。

  2. Vue 的官方文檔中介紹了 render 函數的第一個參數是 createElement,那麼結合之前的例子:

<div id="app">
  {{ message }}
</div>

相當於我們編寫如下 render 函數:

render: function (createElement) {
  return createElement('div', {
     attrs: {
        id: 'app'
      },
  }, this.message)
}

再回到 _render 函數中的 render 方法的調用:

vnode = render.call(vm._renderProxy, vm.$createElement)

可以看到,render 函數中的 createElement 方法就是 vm.$createElement 方法:

export function initRender (vm: Component) {
  // ...
  // bind the createElement fn to this instance
  // so that we get proper render context inside it.
  // args order: tag, data, children, normalizationType, alwaysNormalize
  // internal version is used by render functions compiled from templates
  vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
  // normalization is always applied for the public version, used in
  // user-written render functions.
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
}

  1. 實際上,vm.$createElement 方法定義是在執行 initRender 方法的時候,可以看到除了 vm.$createElement 方法,還有一個 vm._c 方法,它是被模板編譯成的 render 函數使用,而 vm.$createElement 是用戶手寫 render 方法使用的, 這倆個方法支持的參數相同,並且內部都調用了 createElement 方法。

  2. 總結:vm._render 最終是通過執行 createElement 方法並返回的是 vnode,它是一個虛擬 NodeVue 2.0 相比 Vue 1.0 最大的升級就是利用了 Virtual DOM。因此在分析 createElement 的實現前,我們先了解一下 Virtual DOM 的概念。

五、Virtual DOM 的理解

  1. Virtual DOM 這個概念相信大部分人都不會陌生,它產生的前提是瀏覽器中的 DOM 是很“昂貴"的,爲了更直觀的感受,我們可以簡單的把一個簡單的 div 元素的屬性都打印出來。可以看到,真正的 DOM 元素是非常龐大的,因爲瀏覽器的標準就把 DOM 設計的非常複雜。當我們頻繁的去做 DOM 更新,會產生一定的性能問題。

  2. Virtual DOM 就是用一個原生的 JS 對象去描述一個 DOM 節點,所以它比創建一個 DOM 的代價要小很多。在 Vue.js 中,Virtual DOM 是用 VNode 這麼一個 Class 去描述,它是定義在 src/core/vdom/vnode.js 中的:

export default class VNode {
  tag: string | void;
  data: VNodeData | void;
  children: ?Array<VNode>;
  text: string | void;
  elm: Node | void;
  ns: string | void;
  context: Component | void; // rendered in this component's scope
  key: string | number | void;
  componentOptions: VNodeComponentOptions | void;
  componentInstance: Component | void; // component instance
  parent: VNode | void; // component placeholder node

  // strictly internal
  raw: boolean; // contains raw HTML? (server only)
  isStatic: boolean; // hoisted static node
  isRootInsert: boolean; // necessary for enter transition check
  isComment: boolean; // empty comment placeholder?
  isCloned: boolean; // is a cloned node?
  isOnce: boolean; // is a v-once node?
  asyncFactory: Function | void; // async component factory function
  asyncMeta: Object | void;
  isAsyncPlaceholder: boolean;
  ssrContext: Object | void;
  fnContext: Component | void; // real context vm for functional nodes
  fnOptions: ?ComponentOptions; // for SSR caching
  fnScopeId: ?string; // functional scope id support

  constructor (
    tag?: string,
    data?: VNodeData,
    children?: ?Array<VNode>,
    text?: string,
    elm?: Node,
    context?: Component,
    componentOptions?: VNodeComponentOptions,
    asyncFactory?: Function
  ) {
    this.tag = tag
    this.data = data
    this.children = children
    this.text = text
    this.elm = elm
    this.ns = undefined
    this.context = context
    this.fnContext = undefined
    this.fnOptions = undefined
    this.fnScopeId = undefined
    this.key = data && data.key
    this.componentOptions = componentOptions
    this.componentInstance = undefined
    this.parent = undefined
    this.raw = false
    this.isStatic = false
    this.isRootInsert = true
    this.isComment = false
    this.isCloned = false
    this.isOnce = false
    this.asyncFactory = asyncFactory
    this.asyncMeta = undefined
    this.isAsyncPlaceholder = false
  }

  // DEPRECATED: alias for componentInstance for backwards compat.
  /* istanbul ignore next */
  get child (): Component | void {
    return this.componentInstance
  }
}
  1. 可以看到 Vue.js 中的 Virtual DOM 的定義還是略微複雜一些的,因爲它這裏包含了很多 Vue.js 的特性。這裏千萬不要被這些茫茫多的屬性嚇到,實際上 Vue.jsVirtual DOM 是借鑑了一個開源庫 snabbdom 的實現,然後加入了一些 Vue.js 特色的東西。

  2. 總結:其實 VNode 是對真實 DOM 的一種抽象描述,它的核心定義無非就幾個關鍵屬性,標籤名、數據、子節點、鍵值等,其它屬性都是用來擴展 VNode 的靈活性以及實現一些特殊 feature 的。由於 VNode 只是用來映射到真實 DOM 的渲染,不需要包含操作 DOM 的方法,因此它是非常輕量和簡單的。Virtual DOM 除了它的數據結構的定義,映射到真實的 DOM 實際上要經歷 VNodecreate、diff、patch 等過程。那麼在 Vue.js 中,VNodecreate 是通過之前提到的 createElement 方法創建的,我們接下來分析這部分的實現。

六、createElement 的理解

  1. Vue.js 利用 createElement 方法創建 VNode,它定義在 src/core/vdom/create-elemenet.js 中,如下所示:
// wrapper function for providing a more flexible interface
// without getting yelled at by flow
export function createElement (
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode> {
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children
    children = data
    data = undefined
  }
  if (isTrue(alwaysNormalize)) {
    normalizationType = ALWAYS_NORMALIZE
  }
  return _createElement(context, tag, data, children, normalizationType)
}
  1. createElement 方法實際上是對 _createElement 方法的封裝,它允許傳入的參數更加靈活,在處理這些參數後,調用真正創建 VNode 的函數 _createElement,如下所示:
export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  if (isDef(data) && isDef((data: any).__ob__)) {
    process.env.NODE_ENV !== 'production' && warn(
      `Avoid using observed data object as vnode data: ${JSON.stringify(data)}\n` +
      'Always create fresh vnode data objects in each render!',
      context
    )
    return createEmptyVNode()
  }
  // object syntax in v-bind
  if (isDef(data) && isDef(data.is)) {
    tag = data.is
  }
  if (!tag) {
    // in case of component :is set to falsy value
    return createEmptyVNode()
  }
  // warn against non-primitive key
  if (process.env.NODE_ENV !== 'production' &&
    isDef(data) && isDef(data.key) && !isPrimitive(data.key)
  ) {
    if (!__WEEX__ || !('@binding' in data.key)) {
      warn(
        'Avoid using non-primitive value as key, ' +
        'use string/number value instead.',
        context
      )
    }
  }
  // support single function children as default scoped slot
  if (Array.isArray(children) &&
    typeof children[0] === 'function'
  ) {
    data = data || {}
    data.scopedSlots = { default: children[0] }
    children.length = 0
  }
  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }
  let vnode, ns
  if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    if (config.isReservedTag(tag)) {
      // platform built-in elements
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    } else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // unknown or unlisted namespaced elements
      // check at runtime because it may get assigned a namespace when its
      // parent normalizes children
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }
  if (Array.isArray(vnode)) {
    return vnode
  } else if (isDef(vnode)) {
    if (isDef(ns)) applyNS(vnode, ns)
    if (isDef(data)) registerDeepBindings(data)
    return vnode
  } else {
    return createEmptyVNode()
  }
}
  1. _createElement 方法有 5 個參數,context 表示 VNode 的上下文環境,它是 Component 類型;tag 表示標籤,它可以是一個字符串,也可以是一個 Componentdata 表示 VNode 的數據,它是一個 VNodeData 類型,可以在 flow/vnode.js 中找到它的定義;children 表示當前 VNode 的子節點,它是任意類型的,它接下來需要被規範爲標準的 VNode 數組;normalizationType 表示子節點規範的類型,類型不同規範的方法也就不一樣,它主要是參考 render 函數是編譯生成的還是用戶手寫的。

  2. createElement 函數的流程略微有點多,我們接下來主要分析兩個重點的流程 —— children 的規範化以及 VNode 的創建。

  3. children 的規範化,如下所示:

  • 由於 Virtual DOM 實際上是一個樹狀結構,每一個 VNode 可能會有若干個子節點,這些子節點應該也是 VNode 的類型。_createElement 接收的第 4 個參數 children 是任意類型的,因此我們需要把它們規範成 VNode 類型。

  • 這裏根據 normalizationType 的不同,調用了 normalizeChildren(children)simpleNormalizeChildren(children) 方法,它們的定義都在 src/core/vdom/helpers/normalzie-children.js 中:

// The template compiler attempts to minimize the need for normalization by
// statically analyzing the template at compile time.
//
// For plain HTML markup, normalization can be completely skipped because the
// generated render function is guaranteed to return Array<VNode>. There are
// two cases where extra normalization is needed:

// 1. When the children contains components - because a functional component
// may return an Array instead of a single root. In this case, just a simple
// normalization is needed - if any child is an Array, we flatten the whole
// thing with Array.prototype.concat. It is guaranteed to be only 1-level deep
// because functional components already normalize their own children.
export function simpleNormalizeChildren (children: any) {
  for (let i = 0; i < children.length; i++) {
    if (Array.isArray(children[i])) {
      return Array.prototype.concat.apply([], children)
    }
  }
  return children
}

// 2. When the children contains constructs that always generated nested Arrays,
// e.g. <template>, <slot>, v-for, or when the children is provided by user
// with hand-written render functions / JSX. In such cases a full normalization
// is needed to cater to all possible types of children values.
export function normalizeChildren (children: any): ?Array<VNode> {
  return isPrimitive(children)
    ? [createTextVNode(children)]
    : Array.isArray(children)
      ? normalizeArrayChildren(children)
      : undefined
}
  1. simpleNormalizeChildren 方法調用場景是 render 函數是編譯生成的。理論上編譯生成的 children 都已經是 VNode 類型的,但這裏有一個例外,就是 functional component 函數式組件返回的是一個數組而不是一個根節點,所以會通過 Array.prototype.concat 方法把整個 children 數組打平,讓它的深度只有一層。

  2. normalizeChildren 方法的調用場景有 2 種,一個場景是 render 函數是用戶手寫的,當 children 只有一個節點的時候,Vue.js 從接口層面允許用戶把 children 寫成基礎類型用來創建單個簡單的文本節點,這種情況會調用 createTextVNode 創建一個文本節點的 VNode;另一個場景是當編譯 slotv-for 的時候會產生嵌套數組的情況,會調用 normalizeArrayChildren 方法,接下來看一下它的實現:

function normalizeArrayChildren (children: any, nestedIndex?: string): Array<VNode> {
  const res = []
  let i, c, lastIndex, last
  for (i = 0; i < children.length; i++) {
    c = children[i]
    if (isUndef(c) || typeof c === 'boolean') continue
    lastIndex = res.length - 1
    last = res[lastIndex]
    //  nested
    if (Array.isArray(c)) {
      if (c.length > 0) {
        c = normalizeArrayChildren(c, `${nestedIndex || ''}_${i}`)
        // merge adjacent text nodes
        if (isTextNode(c[0]) && isTextNode(last)) {
          res[lastIndex] = createTextVNode(last.text + (c[0]: any).text)
          c.shift()
        }
        res.push.apply(res, c)
      }
    } else if (isPrimitive(c)) {
      if (isTextNode(last)) {
        // merge adjacent text nodes
        // this is necessary for SSR hydration because text nodes are
        // essentially merged when rendered to HTML strings
        res[lastIndex] = createTextVNode(last.text + c)
      } else if (c !== '') {
        // convert primitive to vnode
        res.push(createTextVNode(c))
      }
    } else {
      if (isTextNode(c) && isTextNode(last)) {
        // merge adjacent text nodes
        res[lastIndex] = createTextVNode(last.text + c.text)
      } else {
        // default key for nested array children (likely generated by v-for)
        if (isTrue(children._isVList) &&
          isDef(c.tag) &&
          isUndef(c.key) &&
          isDef(nestedIndex)) {
          c.key = `__vlist${nestedIndex}_${i}__`
        }
        res.push(c)
      }
    }
  }
  return res
}

  1. normalizeArrayChildren 接收 2 個參數,children 表示要規範的子節點,nestedIndex 表示嵌套的索引,因爲單個 child 可能是一個數組類型。 normalizeArrayChildren 主要的邏輯就是遍歷 children,獲得單個節點 c,然後對 c 的類型判斷,如果是一個數組類型,則遞歸調用 normalizeArrayChildren; 如果是基礎類型,則通過 createTextVNode 方法轉換成 VNode 類型;否則就已經是 VNode 類型了,如果 children 是一個列表並且列表還存在嵌套的情況,則根據 nestedIndex 去更新它的 key。這裏需要注意一點,在遍歷的過程中,對這 3 種情況都做了如下處理:如果存在兩個連續的 text 節點,會把它們合併成一個 text 節點。

  2. 經過對 children 的規範化,children 變成了一個類型爲 VNodeArray

  3. VNode 的創建,回到 createElement 函數,規範化 children 後,接下來會去創建一個 VNode 的實例,如下所示:

let vnode, ns
if (typeof tag === 'string') {
  let Ctor
  ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
  if (config.isReservedTag(tag)) {
    // platform built-in elements
    vnode = new VNode(
      config.parsePlatformTagName(tag), data, children,
      undefined, undefined, context
    )
  } else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
    // component
    vnode = createComponent(Ctor, data, context, children, tag)
  } else {
    // unknown or unlisted namespaced elements
    // check at runtime because it may get assigned a namespace when its
    // parent normalizes children
    vnode = new VNode(
      tag, data, children,
      undefined, undefined, context
    )
  }
} else {
  // direct component options / constructor
  vnode = createComponent(tag, data, context, children)
}
  1. 這裏先對 tag 做判斷,如果是 string 類型,則接着判斷如果是內置的一些節點,則直接創建一個普通 VNode,如果是爲已註冊的組件名,則通過 createComponent 創建一個組件類型的 VNode,否則創建一個未知的標籤的 VNode。 如果是 tag 一個 Component 類型,則直接調用 createComponent 創建一個組件類型的 VNode 節點。對於 createComponent 創建組件類型的 VNode 的過程,之後會去介紹,本質上它還是返回了一個 VNode

  2. 總結:那麼至此,我們大致瞭解了 createElement 創建 VNode 的過程,每個 VNodechildrenchildren 每個元素也是一個 VNode,這樣就形成了一個 VNode Tree,它很好的描述了我們的 DOM Tree。回到 mountComponent 函數的過程,我們已經知道 vm._render 是如何創建了一個 VNode,接下來就是要把這個 VNode 渲染成一個真實的 DOM 並渲染出來,這個過程是通過 vm._update 完成的,接下來分析一下這個過程。

七、update 的理解

  1. Vue_update 是實例的一個私有方法,它被調用的時機有兩個,一個是首次渲染,一個是數據更新的時候;在這隻分析首次渲染部分,數據更新部分會在之後分析響應式原理的時候涉及。_update 方法的作用是把 VNode 渲染成真實的 DOM,它的定義在 src/core/instance/lifecycle.js 中:
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  const vm: Component = this
  const prevEl = vm.$el
  const prevVnode = vm._vnode
  const prevActiveInstance = activeInstance
  activeInstance = 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 {
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  activeInstance = prevActiveInstance
  // update __vue__ reference
  if (prevEl) {
    prevEl.__vue__ = null
  }
  if (vm.$el) {
    vm.$el.__vue__ = vm
  }
  // if parent is an HOC, update its $el as well
  if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
    vm.$parent.$el = vm.$el
  }
  // updated hook is called by the scheduler to ensure that children are
  // updated in a parent's updated hook.
}
  1. _update 的核心就是調用 vm.__patch__ 方法,這個方法實際上在不同的平臺,比如 webweex 上的定義是不一樣的,因此在 web 平臺中它的定義在 src/platforms/web/runtime/index.js 中:
Vue.prototype.__patch__ = inBrowser ? patch : noop
  1. 由上可以看到,甚至在 web 平臺上,是否是服務端渲染也會對這個方法產生影響。因爲在服務端渲染中,沒有真實的瀏覽器 DOM 環境,所以不需要把 VNode 最終轉換成 DOM,因此是一個空函數,而在瀏覽器端渲染中,它指向了 patch 方法,它的定義在 src/platforms/web/runtime/patch.js中:
import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'

// the directive module should be applied last, after all
// built-in modules have been applied.
const modules = platformModules.concat(baseModules)

export const patch: Function = createPatchFunction({ nodeOps, modules })
  1. 該方法的定義是調用 createPatchFunction 方法的返回值,這裏傳入了一個對象,包含 nodeOps 參數和 modules 參數。其中,nodeOps 封裝了一系列 DOM 操作的方法,modules 定義了一些模塊的鉤子函數的實現,我們這裏先不詳細介紹,來看一下 createPatchFunction 的實現,它定義在 src/core/vdom/patch.js 中:
const hooks = ['create', 'activate', 'update', 'remove', 'destroy']

export function createPatchFunction (backend) {
  let i, j
  const cbs = {}

  const { modules, nodeOps } = backend

  for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      if (isDef(modules[j][hooks[i]])) {
        cbs[hooks[i]].push(modules[j][hooks[i]])
      }
    }
  }

  // ...

  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    let isInitialPatch = false
    const insertedVnodeQueue = []

    if (isUndef(oldVnode)) {
      // empty mount (likely as component), create new root element
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)
    } else {
      const isRealElement = isDef(oldVnode.nodeType)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // patch existing root node
        patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
      } else {
        if (isRealElement) {
          // mounting to a real element
          // check if this is server-rendered content and if we can perform
          // a successful hydration.
          if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
            oldVnode.removeAttribute(SSR_ATTR)
            hydrating = true
          }
          if (isTrue(hydrating)) {
            if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
              invokeInsertHook(vnode, insertedVnodeQueue, true)
              return oldVnode
            } else if (process.env.NODE_ENV !== 'production') {
              warn(
                'The client-side rendered virtual DOM tree is not matching ' +
                'server-rendered content. This is likely caused by incorrect ' +
                'HTML markup, for example nesting block-level elements inside ' +
                '<p>, or missing <tbody>. Bailing hydration and performing ' +
                'full client-side render.'
              )
            }
          }
          // either not server-rendered, or hydration failed.
          // create an empty node and replace it
          oldVnode = emptyNodeAt(oldVnode)
        }

        // replacing existing element
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)

        // create new node
        createElm(
          vnode,
          insertedVnodeQueue,
          // extremely rare edge case: do not insert if old element is in a
          // leaving transition. Only happens when combining transition +
          // keep-alive + HOCs. (#4590)
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        )

        // update parent placeholder node element, recursively
        if (isDef(vnode.parent)) {
          let ancestor = vnode.parent
          const patchable = isPatchable(vnode)
          while (ancestor) {
            for (let i = 0; i < cbs.destroy.length; ++i) {
              cbs.destroy[i](ancestor)
            }
            ancestor.elm = vnode.elm
            if (patchable) {
              for (let i = 0; i < cbs.create.length; ++i) {
                cbs.create[i](emptyNode, ancestor)
              }
              // #6513
              // invoke insert hooks that may have been merged by create hooks.
              // e.g. for directives that uses the "inserted" hook.
              const insert = ancestor.data.hook.insert
              if (insert.merged) {
                // start at index 1 to avoid re-invoking component mounted hook
                for (let i = 1; i < insert.fns.length; i++) {
                  insert.fns[i]()
                }
              }
            } else {
              registerRef(ancestor)
            }
            ancestor = ancestor.parent
          }
        }

        // destroy old node
        if (isDef(parentElm)) {
          removeVnodes(parentElm, [oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          invokeDestroyHook(oldVnode)
        }
      }
    }

    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }
}
  1. createPatchFunction 內部定義了一系列的輔助方法,最終返回了一個 patch 方法,這個方法就賦值給了 vm._update 函數裏調用的 vm.__patch__

  2. 在前面介紹過,patch 是平臺相關的,在 WebWeex 環境,它們把虛擬 DOM 映射到 “平臺 DOM” 的方法是不同的,並且對 “DOM” 包括的屬性模塊創建和更新也不盡相同。因此每個平臺都有各自的 nodeOpsmodules,它們的代碼需要託管在 src/platforms 這個大目錄下。

  3. 在不同平臺的 patch 的主要邏輯部分是相同的,所以這部分公共的部分託管在 core 這個大目錄下。差異化部分只需要通過參數來區別,這裏用到了一個函數柯里化的技巧,通過 createPatchFunction 把差異化參數提前固化,這樣不用每次調用 patch 的時候都傳遞 nodeOpsmodules 了,這種編程技巧也非常值得學習。在這裏,nodeOps 表示對 “平臺 DOM” 的一些操作方法,modules 表示平臺的一些模塊,它們會在整個 patch 過程的不同階段執行相應的鉤子函數。

  4. patch 方法本身,它接收 四個參數,oldVnode 表示舊的 VNode 節點,它也可以不存在或者是一個 DOM 對象;vnode 表示執行 _render 後返回的 VNode 的節點;hydrating 表示是否是服務端渲染;removeOnly 是給 transition-group 用的,之後會介紹。

  5. patch 的邏輯看上去相對複雜,因爲它有着非常多的分支邏輯,爲了方便理解,我們並不會在這裏介紹所有的邏輯,僅會針對我們之前的例子分析它的執行邏輯。之後我們對其它場景做源碼分析的時候會再次回顧 patch 方法,先來回顧我們的例子,如下所示:

var app = new Vue({
  el: '#app',
  render: function (createElement) {
    return createElement('div', {
      attrs: {
        id: 'app'
      },
    }, this.message)
  },
  data: {
    message: 'Hello Vue!'
  }
})

然後我們在 vm._update 的方法裏是這麼調用 patch 方法的,如下所示:

// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  1. 結合我們的例子,我們的場景是首次渲染,所以在執行 patch 函數的時候,傳入的 vm.$el 對應的是例子中 idappDOM 對象,這個也就是我們在 index.html 模板中寫的 <div id="app">vm.$el 的賦值是在之前 mountComponent 函數做的,vnode 對應的是調用 render 函數的返回值,hydrating 在非服務端渲染情況下爲 falseremoveOnlyfalse

  2. 在確定了這些入參後,我們回到 patch 函數的執行過程,看幾個關鍵步驟,如下所示:

const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
  // patch existing root node
  patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
} else {
  if (isRealElement) {
    // mounting to a real element
    // check if this is server-rendered content and if we can perform
    // a successful hydration.
    if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
      oldVnode.removeAttribute(SSR_ATTR)
      hydrating = true
    }
    if (isTrue(hydrating)) {
      if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
        invokeInsertHook(vnode, insertedVnodeQueue, true)
        return oldVnode
      } else if (process.env.NODE_ENV !== 'production') {
        warn(
          'The client-side rendered virtual DOM tree is not matching ' +
          'server-rendered content. This is likely caused by incorrect ' +
          'HTML markup, for example nesting block-level elements inside ' +
          '<p>, or missing <tbody>. Bailing hydration and performing ' +
          'full client-side render.'
        )
      }
    }      
    // either not server-rendered, or hydration failed.
    // create an empty node and replace it
    oldVnode = emptyNodeAt(oldVnode)
  }

  // replacing existing element
  const oldElm = oldVnode.elm
  const parentElm = nodeOps.parentNode(oldElm)

  // create new node
  createElm(
    vnode,
    insertedVnodeQueue,
    // extremely rare edge case: do not insert if old element is in a
    // leaving transition. Only happens when combining transition +
    // keep-alive + HOCs. (#4590)
    oldElm._leaveCb ? null : parentElm,
    nodeOps.nextSibling(oldElm)
  )
}

由於我們傳入的 oldVnode 實際上是一個 DOM container,所以 isRealElement 爲 true,接下來又通過 emptyNodeAt 方法把 oldVnode 轉換成 VNode 對象,然後再調用 createElm 方法,這個方法在這裏非常重要,來看一下它的實現:

function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  if (isDef(vnode.elm) && isDef(ownerArray)) {
    // This vnode was used in a previous render!
    // now it's used as a new node, overwriting its elm would cause
    // potential patch errors down the road when it's used as an insertion
    // reference node. Instead, we clone the node on-demand before creating
    // associated DOM element for it.
    vnode = ownerArray[index] = cloneVNode(vnode)
  }

  vnode.isRootInsert = !nested // for transition enter check
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }

  const data = vnode.data
  const children = vnode.children
  const tag = vnode.tag
  if (isDef(tag)) {
    if (process.env.NODE_ENV !== 'production') {
      if (data && data.pre) {
        creatingElmInVPre++
      }
      if (isUnknownElement(vnode, creatingElmInVPre)) {
        warn(
          'Unknown custom element: <' + tag + '> - did you ' +
          'register the component correctly? For recursive components, ' +
          'make sure to provide the "name" option.',
          vnode.context
        )
      }
    }

    vnode.elm = vnode.ns
      ? nodeOps.createElementNS(vnode.ns, tag)
      : nodeOps.createElement(tag, vnode)
    setScope(vnode)

    /* istanbul ignore if */
    if (__WEEX__) {
      // ...
    } else {
      createChildren(vnode, children, insertedVnodeQueue)
      if (isDef(data)) {
        invokeCreateHooks(vnode, insertedVnodeQueue)
      }
      insert(parentElm, vnode.elm, refElm)
    }

    if (process.env.NODE_ENV !== 'production' && data && data.pre) {
      creatingElmInVPre--
    }
  } else if (isTrue(vnode.isComment)) {
    vnode.elm = nodeOps.createComment(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  } else {
    vnode.elm = nodeOps.createTextNode(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  }
}
  1. createElm 的作用是通過虛擬節點創建真實的 DOM 並插入到它的父節點中。 我們來看一下它的一些關鍵邏輯,createComponent 方法目的是嘗試創建子組件,在當前這個 case 下它的返回值爲 false;接下來判斷 vnode 是否包含 tag,如果包含,先簡單對 tag 的合法性在非生產環境下做校驗,看是否是一個合法標籤;然後再去調用平臺 DOM 的操作去創建一個佔位符元素,如下所示:
vnode.elm = vnode.ns
  ? nodeOps.createElementNS(vnode.ns, tag)
  : nodeOps.createElement(tag, vnode)
  1. 接下來調用 createChildren 方法去創建子元素,如下所示:
createChildren(vnode, children, insertedVnodeQueue)

function createChildren (vnode, children, insertedVnodeQueue) {
  if (Array.isArray(children)) {
    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(children)
    }
    for (let i = 0; i < children.length; ++i) {
      createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
    }
  } else if (isPrimitive(vnode.text)) {
    nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
  }
}

createChildren 的邏輯很簡單,實際上是遍歷子虛擬節點,遞歸調用 createElm,這是一種常用的深度優先的遍歷算法,這裏要注意的一點是在遍歷過程中會把 vnode.elm 作爲父容器的 DOM 節點佔位符傳入。

  1. 接着再調用 invokeCreateHooks 方法執行所有的 create 的鉤子並把 vnode pushinsertedVnodeQueue 中,如下所示:
 if (isDef(data)) {
  invokeCreateHooks(vnode, insertedVnodeQueue)
}

function invokeCreateHooks (vnode, insertedVnodeQueue) {
  for (let i = 0; i < cbs.create.length; ++i) {
    cbs.create[i](emptyNode, vnode)
  }
  i = vnode.data.hook // Reuse variable
  if (isDef(i)) {
    if (isDef(i.create)) i.create(emptyNode, vnode)
    if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
  }
}
  1. 最後調用 insert 方法把 DOM 插入到父節點中,因爲是遞歸調用,子元素會優先調用 insert,所以整個 vnode 樹節點的插入順序是先子後父。來看一下 insert 方法,它的定義在 src/core/vdom/patch.js 上,如下所示:
insert(parentElm, vnode.elm, refElm)

function insert (parent, elm, ref) {
  if (isDef(parent)) {
    if (isDef(ref)) {
      if (ref.parentNode === parent) {
        nodeOps.insertBefore(parent, elm, ref)
      }
    } else {
      nodeOps.appendChild(parent, elm)
    }
  }
}

insert 邏輯很簡單,調用一些 nodeOps 把子節點插入到父節點中,這些輔助方法定義在 src/platforms/web/runtime/node-ops.js 中:

export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) {
  parentNode.insertBefore(newNode, referenceNode)
}

export function appendChild (node: Node, child: Node) {
  node.appendChild(child)
}

其實就是調用原生 DOM 的 API 進行 DOM 操作,看到這裏,很多同學恍然大悟,原來 Vue 是這樣動態創建的 DOM。

  1. createElm 過程中,如果 vnode 節點不包含 tag,則它有可能是一個註釋或者純文本節點,可以直接插入到父元素中。在我們這個例子中,最內層就是一個文本 vnode,它的 text 值取的就是之前的 this.message 的值 Hello Vue!

  2. patch 方法,首次渲染我們調用了 createElm 方法,這裏傳入的 parentElmoldVnode.elm 的父元素,在我們的例子是 id#app div 的父元素,也就是 Body;實際上整個過程就是遞歸創建了一個完整的 DOM 樹並插入到 Body 上。最後,我們根據之前遞歸 createElm 生成的 vnode 插入順序隊列,執行相關的 insert 鉤子函數。

  3. 總結:那麼至此我們從主線上把模板和數據如何渲染成最終的 DOM 的過程分析完畢了。我們這裏只是分析了最簡單和最基礎的場景,在實際項目中,我們是把頁面拆成很多組件的,Vue 另一個核心思想就是組件化,那麼接下來我們就來分析 Vue 的組件化過程。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章