vue原始碼之資料控制檢視

小胖發表於2018-03-27

分析vue是如何實現資料改變更新檢視的.

前記

三個月前看了vue原始碼來分析如何做到響應式資料的, 文章名字叫vue原始碼之響應式資料, 最後分析到, 資料變化後會呼叫Watcherupdate()方法. 那麼時隔三月讓我們繼續看看update()做了什麼. (這三個月用react-native做了個專案, 也無心總結了, 因為好像太簡單了).

本文敘事方式為樹藤摸瓜, 順著看原始碼的邏輯走一遍, 檢視的vue的版本為2.5.2. 我fork了一份原始碼用來記錄註釋.

目的

明確調查方向才能直至目標, 先說一下目標行為: 資料變化以後執行了什麼方法來更新檢視的. 那麼準備開始以這個方向為目標從vue原始碼的入口開始找答案.

從之前的結論開始

先來複習一下之前的結論:

  • vue構造的時候會在data(和一些別的欄位)上建立Observer物件, getter和setter被做了攔截, getter觸發依賴收集, setter觸發notify.
  • 另一個物件是Watcher, 註冊watch的時候會呼叫一次watch的物件, 這樣觸發了watch物件的getter, 把依賴收集到當前Watcher的deps裡, 當任何dep的setter被觸發就會notify當前Watcher來呼叫Watcher的update()方法.

那麼這裡就從註冊渲染相關的Watcher開始.

找到了檔案在src/core/instance/lifecycle.js中.

new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)

mountComponent

渲染相關的Watcher是在mountComponent()這個方法中呼叫的, 那麼我們搜一下這個方法是在哪裡呼叫的. 只有2處, 分別是src/platforms/web/runtime/index.jssrc/platforms/weex/runtime/index.js, 以web為例:

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

原來如此, 是$mount()方法呼叫了mountComponent(), (或者在vue構造時指定el欄位也會自動呼叫$mount()方法), 因為web和weex(什麼是weex?之前別的文章介紹過)渲染的標的物不同, 所以在釋出的時候應該引入了不同的檔案最後發不成不同的dist(這個問題留給之後來研究vue的整個流程).

下面是mountComponent方法:

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el // 放一份el到自己的屬性裡
  if (!vm.$options.render) { // render應該經過處理了, 因為我們經常都是用template或者vue檔案
    // 判斷是否存在render函式, 如果沒有就把render函式寫成空VNode來避免紅錯, 並報出黃錯
    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) {
    // 不看這裡的程式碼了, 直接看else裡的, 行為是一樣的
    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
  // 註冊一個Watcher
  new Watcher(vm, updateComponent, noop, null, 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
}

這段程式碼其實只做了3件事:

  • 呼叫beforeMount鉤子
  • 建立Watcher
  • 呼叫mounted鉤子

(哈哈哈)那麼其實核心就是建立Watcher了.

看一下Watcher的引數: vm是this, updateComponent是一個函式, noop是空, null是空, true代表是RenderWatcher.

在Watcher裡看了isRenderWatcher:

if (isRenderWatcher) {
      vm._watcher = this
    }

是的, 只是複製了一份用來在watcher第一次patch的時候判斷一些東西(從註釋裡看的, 我現在還不知道是幹嘛的).

那麼只有一個問題沒解決就是updateComponent是個什麼東西.

updateComponent

在Watcher的建構函式的第二個引數傳了function, 那麼這個函式就成了watcher的getter. 聰明的你應該已經猜到, 在這個updateComponent裡一定呼叫了檢視中所有的資料的getter, 才能在watcher中建立依賴從而讓檢視響應資料的變化.

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

那麼就去找vm._update()vm._render().

src/core/instance/render.js找到了._render()方法.

Vue.prototype._render = function (): VNode {
    const vm: Component = this
    const { render, _parentVnode } = vm.$options // todo: render和_parentVnode的由來

    // 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) {
      // catch其實不需要看了, 都是做異常處理, _vnode是在vm._update的時候儲存的, 也就是上次的狀態或是null(init的時候給的)
      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
  }
}

這個方法做了:

  • 根據當前vm的render方法來生成VNode. (render方法可能是根據template或vue檔案編譯而來, 所以推論直接寫render方法效率最高)
  • 如果render方法有問題, 那麼首先呼叫renderError方法, 再不行就讀取上次的vnode或是null.
  • 如果有父節點就放到自己的.parent屬性裡.
  • 最後返回VNode

所以核心是這句:

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

其中的render(), vm._renderProxy, vm.$createElement都不知道是什麼.

先看vm._renderProxy: 是initMixin()的時候設定的, 在生產環境返回vm, 開發環境返回代理, 那麼我們認為他是一個可以debug的vm(就是vm), 細節之後再看.

vm.$createElement的程式碼在vdom資料夾下, 看了下是一個方法, 返回值一個VNode.

render有點複雜, 能不能以後研究, 總之就是把template或者vue單檔案和mount目標parse成render函式.

小總結: vm._render()的返回值是VNode, 根據當前vm的render函式

接下來看vm._update()

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    if (vm._isMounted) {
      callHook(vm, `beforeUpdate`)
    }
    // 記錄update之前的狀態
    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) { // 初次載入, 只有_update方法更新vm._vnode, 初始化是null
      // initial render
      vm.$el = vm.__patch__( // patch建立新dom
        vm.$el, vnode, hydrating, false /* removeOnly */,
        vm.$options._parentElm,
        vm.$options._refElm
      )
      // no need for the ref nodes after initial patch
      // this prevents keeping a detached DOM tree in memory (#5851)
      vm.$options._parentElm = vm.$options._refElm = null
    } else {
      // updates
      vm.$el = vm.__patch__(prevVnode, vnode) // patch更新dom
    }
    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.
  }

我們關心的部分其實就是__patch()的部分, __patch()做了對dom的操作, 在_update()裡判斷了是否是初次呼叫, 如果是的話建立新dom, 不是的話傳入新舊node進行比較再操作.

vue的入口檔案

現在render()方法和__patch__()方法都不在core資料夾中被定義, 那麼現在來一起看看我們最終引用的vue物件的整體.

以webpack的vue專案為例, 用的是vue.esm.js, package.json的main欄位不是他, 於是看build命令:

node scripts/build.js

是用rollup把配置中的所有欄位都對應地編譯, 配置如下:

const builds = {
  // Runtime only (CommonJS). Used by bundlers e.g. Webpack & Browserify
  `web-runtime-cjs`: {
    entry: resolve(`web/entry-runtime.js`),
    dest: resolve(`dist/vue.runtime.common.js`),
    format: `cjs`,
    banner
  },
  // Runtime+compiler CommonJS build (CommonJS)
  `web-full-cjs`: {
    entry: resolve(`web/entry-runtime-with-compiler.js`),
    dest: resolve(`dist/vue.common.js`),
    format: `cjs`,
    alias: { he: `./entity-decoder` },
    banner
  },
  // Runtime only (ES Modules). Used by bundlers that support ES Modules,
  // e.g. Rollup & Webpack 2
  `web-runtime-esm`: {
    entry: resolve(`web/entry-runtime.js`),
    dest: resolve(`dist/vue.runtime.esm.js`),
    format: `es`,
    banner
  },
  // Runtime+compiler CommonJS build (ES Modules)
  `web-full-esm`: {
    entry: resolve(`web/entry-runtime-with-compiler.js`),
    dest: resolve(`dist/vue.esm.js`),
    format: `es`,
    alias: { he: `./entity-decoder` },
    banner
  },
    ... // 以下省略, 還有很多...
}

我們找的檔案vue.esm.js的入口檔案找到啦, 是web/entry-runtime-with-compiler.js.

而在web/entry-runtime-with-compiler.js中, 又從./runtime/index引入了Vue, 最後才從core/index中引入Vue.

所以Vue的平臺無關的內容放在core中, 最後打成dist的時候根據不同的釋出平臺(web, weex), 釋出模式(browser, es-module)來給核心Vue物件掛載更多的方法和屬性, 那麼我們現在來看看web/es-module這條路新增了些什麼~

runtime/index開始:

// runtime/index.js 部分程式碼
// install platform specific utils
Vue.config.mustUseProp = mustUseProp
Vue.config.isReservedTag = isReservedTag
Vue.config.isReservedAttr = isReservedAttr
Vue.config.getTagNamespace = getTagNamespace
Vue.config.isUnknownElement = isUnknownElement

// install platform runtime directives & components
extend(Vue.options.directives, platformDirectives)
extend(Vue.options.components, platformComponents)

// install platform patch function
Vue.prototype.__patch__ = inBrowser ? patch : noop

// public mount method
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

掛載了一些常量和平臺專屬directive和component. 我們關心的__patch__()方法是在這裡被掛上的, $mount()方法也是這個時候掛上的, 正是呼叫了mountComponent().

然後看web/entry-runtime-with-compiler.js:

// 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) { // 如果沒有render方法就嘗試把別的欄位編譯成render方法
    let template = options.template
    if (template) { // 嘗試template欄位, 沒有的話就獲取el欄位並編譯成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`)
      }

      // 把template編譯成render函式

      const { render, staticRenderFns } = compileToFunctions(template, {
        shouldDecodeNewlines, // 檢測瀏覽器的行為, 是否會把一些東西url-encode
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters, // 預設是雙花括號 `{{` `}}`, 用來編譯模板的
        comments: options.comments // 預設是false, 如果true就不丟棄註釋
      }, 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`)
      }
    }
    // 如果所有if都沒走到, 那麼就沒有render方法, 異常將在$mount的時候丟擲. 這裡沒有做處理
  }
  return mount.call(this, el, hydrating)
}

註釋都貼在上面的程式碼裡了, 在這個檔案裡在$mount()方法裡插入render()方法的註冊, 總結為:

  • 如果有render()函式, 就用render()函式.
  • 如果沒有, 就用template屬性編譯成render()函式.
  • 如果沒有template屬性, 就用找el屬性所指的dom, 並把他編譯成template.
  • 最後用template(原來的template或是el編譯成的)編譯出render()函式.
  • 如果是三無產品(render(), template, el都沒有). 那麼什麼都不做, 這個Vue例項就沒有render()函式, 但沒有報錯, 因為在mountComponent()的時候會報錯.

結論

  • vue的檢視渲染是一種特殊的Watcher, watch的內容是一個函式, 函式執行的過程呼叫了render函式, render又是由template或者el的dom編譯成的(template中含有一些被observe的資料). 所以template中被observe的資料有變化觸發Watcher的update()方法就會重新渲染檢視.
  • Vue的平臺無關的內容在core中, 最後打成dist的時候根據不同的釋出平臺(web, weex), 釋出模式(browser, es-module)來給核心Vue物件掛載更多的方法和屬性(程式碼在platforms中). render()__patch__()是在platforms裡掛上的.

遺留

  • template編譯成render的實現
  • __patch__和VNode的分析

相關文章