1. 程式人生 > >vue的原始碼學習之五——3.資料驅動(Vue 例項掛載的實現)

vue的原始碼學習之五——3.資料驅動(Vue 例項掛載的實現)

  • 介紹

        版本:2.5.17。

       我們使用vue-vli建立基於Runtime+Compiler的vue腳手架。

       學習文件:https://ustbhuangyi.github.io/vue-analysis/data-driven/mounted.html

  • 掛載到DOM

        src/core/instance/init.js : 在初始化的最後,檢測到如果有 el 屬性,則呼叫 vm.$mount 方法掛載 vm,掛載的目標就是把模板渲染成最終的 DOM。

if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  • $mount

    Vue 中我們是通過 $mount 例項方法去掛載 vm 的,$mount 方法在多個檔案中都有定義,如 src/platform/web/entry-runtime-with-compiler.jssrc/platform/web/runtime/index.jssrc/platform/weex/runtime/index.js。因為 $mount 這個方法的實現是和平臺、構建方式都相關的。

    先來看一下 src/platform/web/entry-runtime-with-compiler.js 檔案中定義:

    /* @flow */
    
    import config from 'core/config'
    import { warn, cached } from 'core/util/index'
    import { mark, measure } from 'core/util/perf'
    
    import Vue from './runtime/index'
    import { query } from './util/index'
    import { compileToFunctions } from './compiler/index'
    import { shouldDecodeNewlines, shouldDecodeNewlinesForHref } from './util/compat'
    
    const idToTemplate = cached(id => {
      const el = query(id)
      return el && el.innerHTML
    })
    
    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)
    }
    
    /**
     * Get outerHTML of elements, taking care
     * of SVG elements in IE as well.
     */
    function getOuterHTML (el: Element): string {
      if (el.outerHTML) {
        return el.outerHTML
      } else {
        const container = document.createElement('div')
        container.appendChild(el.cloneNode(true))
        return container.innerHTML
      }
    }
    
    Vue.compile = compileToFunctions
    
    export default Vue
    

          這段程式碼首先快取了原型上的 $mount 方法,再重新定義該方法,我們先來分析這段程式碼。首先,它對 el 做了限制,Vue 不能掛載在 bodyhtml 這樣的根節點上。   

          我們會發現該js重新定義了Vue.prototype.$mount方法,而該方法來自於src/platform/web/runtime/index.js : 

    • 重新定義Vue.prototype.$mount方法

    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)
    }

    那是因為,src/platform/web/entry-runtime-with-compiler.js 中的是適用於 Runtime+Compiler 版本的。而src/platform/web/runtime/index.js 中的 Vue.prototype.$mount 方法是適用於 Runtime Only 版本的,

    我們可以看到對於Vue.prototype.$mount引數是可以傳遞 字串 和 DOM物件的。 
    我們來看一下 query 方法 ,src/platform/web/util/index.js

    • 將傳入的引數轉為DOM

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

             我們可以看到對於Vue.prototype.$mount引數是可以傳遞 字串 和 DOM物件的。 
               我們來看一下 query 方法 :src/platform/web/util/index.js

    export function query (el: string | Element): Element {
      if (typeof el === 'string') {
        const selected = document.querySelector(el)
        if (!selected) {
          process.env.NODE_ENV !== 'production' && warn(
            'Cannot find element: ' + el
          )
          return document.createElement('div')
        }
        return selected
      } else {
        return el
      }
    }

             這個方法是說如果說是字串,就是用 document.querySelector(el) 方法獲得字串代表的DOM物件,如果發現沒有,就會抱一個錯誤並且返回一個空div。所以 el = el && query(el) 代表的一定是一個DOM

    • 不得掛載在body和html上

      /* 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
        }
    • 判斷是否有render函式

      如果沒有定義 render 方法,則會把 el 或者 template 字串轉換成 render 方法。這裡我們要牢記,在 Vue 2.0 版本中,所有 Vue 的元件的渲染最終都需要 render 方法,無論我們是用單檔案 .vue 方式開發元件,還是寫了 el 或者 template 屬性,最終都會轉換成 render 方法,那麼這個過程是 Vue 的一個“線上編譯”的過程,它是呼叫 compileToFunctions 方法實現的 

      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')
            }
          }
        }

     上文說道el是DOM物件,如果el 是 body 或者 html元素的話就會報錯,那是因為掛載是覆蓋的,如果掛載在body或html上, 那麼整個HTML文件就不對了。 所以我們一般採用的都是掛載在div上的形式。 

              如果沒有render函式,則獲取template,template可以是#id、模板字串、dom元素,如果沒有template,則獲取el以及其子內容作為模板。 compileToFunctions是對我們最後生成的模板進行解析,生成render函式。

        compileToFunctions對生成的模板進行解析

           該方法來自於:.src/platform/compiler/index.js,如果我們的例子是:

    <div id="app">
      <p>{{message}}</p>
    </div>
    <script type="text/javascript">
      var vm = new Vue({
        el: '#app',
        data: {
          message: '第一個vue例項'
        }
      })
    </script>

        1.解析template,生成ast。

    {
      type: 1,
      tag: 'div',
      plain: false,
      parent: undefined,
      attrs: [{name:'id', value: '"app"'}],
      attrsList: [{name:'id', value: 'app'}],
      attrsMap: {id: 'app'},
      children: [{
        type: 1,
        tag: 'p',
        plain: true,
        parent: ast,
        attrs: [],
        attrsList: [],
        attrsMap: {},
        children: [{
          expression: "_s(message)",
          text: "{{message}}",
          type: 2
        }]
    }

    2.對ast進行優化,分析出靜態不變的內容部分,增加了部分屬性: 
    因為我們這裡只有一個動態的{{message}},所以static和staticRoot都是false。

    {
      type: 1,
      tag: 'div',
      plain: false,
      parent: undefined,
      attrs: [{name:'id', value: '"app"'}],
      attrsList: [{name:'id', value: 'app'}],
      attrsMap: {id: 'app'},
      static: false,
      staticRoot: false,
      children: [{
        type: 1,
        tag: 'p',
        plain: true,
        parent: ast,
        attrs: [],
        attrsList: [],
        attrsMap: {},
        static: false,
        staticRoot: false,
        children: [{
          expression: "_s(message)",
          text: "{{message}}",
          type: 2,
          static: false
        }]
      }
    

    3.ast生成render函式和staticRenderFns陣列。

    render = function () {
        with(this){return _c('div',{attrs:{"id":"app"}},[_c('p',[_v(_s(message))])])}
    }

     4.在src/core/instance/render.js中,我們曾經新增過如下多個函式,這裡和render內返回值呼叫一一對應。

    Vue.prototype._o = markOnce
    Vue.prototype._n = toNumber
    Vue.prototype._s = _toString
    Vue.prototype._l = renderList
    Vue.prototype._t = renderSlot
    Vue.prototype._q = looseEqual
    Vue.prototype._i = looseIndexOf
    Vue.prototype._m = renderStatic
    Vue.prototype._f = resolveFilter
    Vue.prototype._k = checkKeyCodes
    Vue.prototype._b = bindObjectProps
    Vue.prototype._v = createTextVNode
    Vue.prototype._e = createEmptyVNode
    Vue.prototype._u = resolveScopedSlots

    這裡的staticRenderFns目前是一個空陣列,其實它是用來儲存template中,靜態內容的render,比如我們把例子中的模板改為:

    <div id="app">
        <p>這是<span>靜態內容</span></p>
        <p>{{message}}</p>
    </div>

     staticRenderFns就會變為:

    staticRenderFns = function () {
        with(this){return _c('p',[_v("這是"),_c('span',[_v("靜態內容")])])}
    }
    • 呼叫原先原型上的 $mount 方法掛載

      mount.call(this, el, hydrating)

      我們知道該js儲存了mount = Vue.prototype.mount,然後又重新定義了Vue.prototype.上的方法 
      該js的最後又呼叫了mount方法。原先原型上的 $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)
      }

                   $mount 方法支援傳入 2 個引數,第一個是 el,它表示掛載的元素,可以是字串,也可以是 DOM 物件,如果是字串在瀏覽器環境下會呼叫 query 方法轉換成 DOM 物件的。第二個引數是和服務端渲染相關,在瀏覽器環境下我們不需要傳第二個引數。

      $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 核心就是先呼叫 vm._render 方法先生成虛擬 Node,再例項化一個渲染Watcher,在它的回撥函式中會呼叫 updateComponent 方法,最終呼叫 vm._update 更新 DOM。

  1.做DOM物件的快取

vm.$el = el

2.判斷是否有render函式 
如果使用者沒有寫render函式,並且template也沒有轉化為render函式,就會生成一個VNode節點,並在生成環境報警告。

if (!vm.$options.render) {
     vm.$options.render = createEmptyVNode
      if (process.env.NODE_ENV !== 'production') {...}
}

3.例項化一個渲染Watcher。 
Watcher 在這裡起到兩個作用, 一個是初始化的時候會執行回撥函式,另一個是當 vm 例項中的監測的資料發生變化的時候執行回撥函式,這塊兒我們會在之後的章節中介紹。.new Watcher傳的引數1.vue例項,2.updateComponent函式,3.空函式, 4.物件,5布林值。

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

 Watcher定義在來自檔案:src/core/observer/watcher.js

constructor ( 
    vm: Component,
    // 表示式
    expOrFn: string | Function,
    // 回撥
    cb: Function,
    // 配置物件
    options?: ?Object,
    // 是否渲染Watcher的標準位
    isRenderWatcher?: boolean
  ) {
    this.vm = vm

    // 如果渲染Watcher為true,則在 vm中新增_watcher
    if (isRenderWatcher) {
      vm._watcher = this
    }
    vm._watchers.push(this)

    // options
    if (options) {
     ...
    } else {
    ...
    }

    this.cb = cb
      ...
     // 如果是開發環境就將 expOrFn toString
    this.expression = process.env.NODE_ENV !== 'production'
      ? expOrFn.toString()
      : ''

    // 將expOrFn函式轉化為getter
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn
    } else {
      this.getter = parsePath(expOrFn)
      if (!this.getter) {
        this.getter = function () {}
        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
        )
      }
    }
    //計算屬性
    if (this.computed) {
      this.value = undefined
      this.dep = new Dep()
    } else {
        // 呼叫this.get()
      this.value = this.get()
    }
  }

  get () {
    pushTarget(this)
    let value
    const vm = this.vm
    try {
        // 呼叫this.getter,也就是呼叫expOrFn
      value = this.getter.call(vm, vm)
    } catch (e) {
    } finally {
  }

 我們會把expOrFn也就是updateComponent賦值給this.getter,並且在獲取this.value的值時會呼叫this.get(),進而呼叫了updateComponent。

4.通過watcher回撥函式中會呼叫 updateComponent 方法,最終呼叫 vm._update 更新 DOM。

updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }

5.函式最後判斷為根節點的時候設定 vm._isMounted 為 true, 表示這個例項已經掛載了,同時執行 mounted 鉤子函式。 這裡注意 vm.$vnode 表示 Vue 例項的父虛擬 Node,所以它為 Null 則表示當前是根 Vue 的例項。

if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm