【面試題解析✨】Vue 的數據是如何渲染到頁面的?

面試的時候,面試官常常會問 Vue 雙向綁定的原理是什麼?我猜大部分人會跟我同樣,不假思索的回答利用 Object.defineProperty 實現的。html

其實這個回答很籠統,並且也沒回答完整?Vue 中 Object.defineProperty 只是對數據作了劫持,具體的如何渲染到頁面上,並無考慮到。接下來從初始化開始,看看 Vue 都作了什麼事情。前端

前提知識

在讀源碼前,須要瞭解 Object.defineProperty 的使用,以及 Vue Dep 的用法。這裏就簡單帶過,各位大佬能夠直接跳過,進行源碼分析。vue

Object.defineProperty

當使用 Object.defineProperty 對對象的屬性進行攔截時,調用該對象的屬性,則會調用 get 函數,屬性值則是 get 函數的返回值。當修改屬性值時,則會調用 set 函數。node

固然也能夠經過 Object.defineProperty 給對象添加屬性值,Vue 中就是經過這個方法將 datacomputed 等屬性添加到 vm 上。react

Object.defineProperty(obj, key, {
  enumerable: true,
  configurable: true,
  get: function reactiveGetter () {
    const value = getter ? getter.call(obj) : val
    // 用於依賴收集,Dep 中講到
    if (Dep.target) {
      dep.depend()
      if (childOb) {
        childOb.dep.depend()
        if (Array.isArray(value)) {
          dependArray(value)
        }
      }
    }
    return value
  },
  set: function reactiveSetter (newVal) {
    val = newVal
    // val 發生變化時,發出通知,Dep 中講到
    dep.notify()
  }
})
複製代碼

Dep

這裏不講什麼設計模式了,直接看代碼。web

let uid = 0

export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor () {
    this.id = uid++
    this.subs = []
  }

  addSub (sub: Watcher) {
    // 添加 Watcher
    this.subs.push(sub)
  }

  removeSub (sub: Watcher) {
    // 從列表中移除某個 Watcher
    remove(this.subs, sub)
  }

  depend () {
    // 當 target 存在時,也就是目標 Watcher 存在的時候,
    // 就能夠爲這個目標 Watcher 收集依賴
    // Watcher 的 addDep 方法在下文中
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  notify () {
    // 對 Watcher 進行排序
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      subs.sort((a, b) => a.id - b.id)
    }
    // 當該依賴發生變化時, 調用添加到列表中的 Watcher 的 update 方法進行更新
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

// target 爲某個 Watcher 實例,一次只能爲一個 Watcher 收集依賴
Dep.target = null
// 經過堆棧存放 Watcher 實例,
// 當某個 Watcher 的實例未收集完,又有新的 Watcher 實例須要收集依賴,
// 那麼舊的 Watcher 就先存放到 targetStack,
// 等待新的 Watcher 收集完後再爲舊的 Watcher 收集
// 配合下面的 pushTarget 和 popTarget 實現
const targetStack = []

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

export function popTarget () {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}
複製代碼

當某個 Watcher 須要依賴某個 dep 時,那麼調用 dep.addSub(Watcher) 便可,當 dep 發生變化時,調用 dep.notify() 就能夠觸發 Watcher 的 update 方法。接下來看看 Vue 中 Watcher 的實現。面試

class Watcher {
  // 不少屬性,這裏省略
  ...
  // 構造函數
  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) { ... }
  
  get () {
    // 當執行 Watcher 的 get 函數時,會將當前的 Watcher 做爲 Dep 的 target
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      // 在執行 getter 時,當遇到響應式數據,會觸發上面講到的 Object.defineProperty 中的 get 函數
      // Vue 就是在 Object.defineProperty 的 get 中調用 dep.depend() 進行依賴收集。
      value = this.getter.call(vm, vm)
    } catch (e) {
      ...
    } finally {
      ...
      // 當前 Watcher 的依賴收集完後,調用 popTarget 更換 Watcher
      popTarget()
      this.cleanupDeps()
    }
    return value
  }
  
  // dep.depend() 收集依賴時,會通過 Watcher 的 addDep 方法
  // addDep 作了判斷,避免重複收集,而後調用 dep.addSub 將該 Watcher 添加到 dep 的 subs 中
  addDep (dep: Dep) {
    const id = dep.id
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        dep.addSub(this)
      }
    }
  }
}
複製代碼

經過 Object.defineProperty 中的 getDepdepend 以及 WatcheraddDep 這三個函數的配合,完成了依賴的收集,就是將 Watcher 添加到 depsubs 列表中。設計模式

當依賴發生變化時,就會調用 Object.defineProperty 中的 set,在 set 中調用 depnotify,使得 subs 中的每一個 Watcher 都執行 update 函數。數組

Watcher 中的 update 最終會從新調用 get 函數,從新求值並從新收集依賴。瀏覽器

源碼分析

先看看 new Vue 都作了什麼?

// vue/src/core/instance/index.js
function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    // 只能使用 new Vue 調用該方法,不然輸入警告
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  // 開始初始化
  this._init(options)
}
複製代碼

_init 方法經過原型掛載在 Vue 上

// vue/src/core/instance/init.js
export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    // a uid
    vm._uid = uid++

    let startTag, endTag
    // 初始化前打點,用於記錄 Vue 實例初始化所消耗的時間
    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
    // 合併參數到 $options
    if (options && options._isComponent) {
      initInternalComponent(vm, options)
    } else {
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }

    if (process.env.NODE_ENV !== 'production') {
      // 非生產環境以及支持 Proxy 的瀏覽器中,對 vm 的屬性進行劫持,並將代理後的 vm 賦值給 _renderProxy
      // 當調用 vm 不存在的屬性時,進行錯誤提示。
      // 在不支持 Proxy 的瀏覽器中,_renderProxy = vm; 爲了簡單理解,就當作等同於 vm

      // 代碼在 src/core/instance/proxy.js
      initProxy(vm)
    } else {
      vm._renderProxy = vm
    }
    // expose real self
    vm._self = vm
    // 初始化聲明周期函數
    initLifecycle(vm)
    // 初始化事件
    initEvents(vm)
    // 初始化 render 函數
    initRender(vm)
    // 觸發 beforeCreate 鉤子
    callHook(vm, 'beforeCreate')
    // 初始化 inject
    initInjections(vm) // resolve injections before data/props
    // 初始化 data/props 等
    // 經過 Object.defineProperty 對數據進行劫持
    initState(vm)
    // 初始化 provide
    initProvide(vm) // resolve provide after data/props
    // 數據處理完後,觸發 created 鉤子
    callHook(vm, 'created')

    // 從 new Vue 到 created 所消耗的時間
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
      vm._name = formatComponentName(vm, false)
      mark(endTag)
      measure(`vue ${vm._name} init`, startTag, endTag)
    }

    // 若是 options 有 el 參數則進行 mount
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}
複製代碼

接下來進入 $mount,由於用的是完整版的 Vue,直接看 vue/src/platforms/web/entry-runtime-with-compiler.js 這個文件。

// vue/src/platforms/web/entry-runtime-with-compiler.js
// 首先將 runtime 中的 $mount 方法賦值給 mount 進行保存
const mount = Vue.prototype.$mount
// 重寫 $mount,對 template 編譯爲 render 函數後再調用 runtime 的 $mount
Vue.prototype.$mount = function ( el?: string | Element, hydrating?: boolean ): Component {
  el = el && query(el)

  // 掛載元素不容許爲 body 或 html
  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
  if (!options.render) {
    let template = options.template
    // render 函數不存在時,將 template 轉化爲 render 函數
    // 具體就不展開了
    ...
    if (template) {
        ...
    } else if (el) {
      // template 不存在,則將 el 轉成 template
      // 從這裏能夠看出 Vue 支持 render、template、el 進行渲染
      template = getOuterHTML(el)
    }
    if (template) {
      const { render, staticRenderFns } = compileToFunctions(template, {
        outputSourceRange: process.env.NODE_ENV !== 'production',
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns
    }
  }
  // 調用 runtime 中 $mount
  return mount.call(this, el, hydrating)
}
複製代碼

查看 runtime 中的 $mount

// vue/src/platforms/web/runtime/index.js
Vue.prototype.$mount = function ( el?: string | Element, hydrating?: boolean ): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}
複製代碼

mountComponent 定義在 vue/src/core/instance/lifecycle.js

// vue/src/core/instance/lifecycle.js
export function mountComponent ( vm: Component, el: ?Element, hydrating?: boolean ): Component {
  vm.$el = el
  if (!vm.$options.render) {
    // 未定義 render 函數時,將 render 賦值爲 createEmptyVNode 函數
    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) {
        // 用了 Vue 的 runtime 版本,而沒有 render 函數時,報錯處理
        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 {
        // template 和 render 都未定義時,報錯處理
        warn(
          'Failed to mount component: template or render function not defined.',
          vm
        )
      }
    }
  }
  // 調用 beforeMount 鉤子
  callHook(vm, 'beforeMount')
  // 定義 updateComponent 函數
  let updateComponent
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    // 須要作監控性能時,在 updateComponent 內加入打點的操做
    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 = () => {
      // updateComponent 主要調用 _update 進行瀏覽器渲染
      // _render 返回 VNode
      // 先繼續往下看,等會再回來看這兩個函數
      vm._update(vm._render(), hydrating)
    }
  }

  // new 一個渲染 Watcher
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  // 掛載完成,觸發 mounted
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}
複製代碼

先繼續往下看,看看 new Watcher 作了什麼,再回過頭看 updateComponent 中的 _update_render

Watcher 的構造函數以下

// vue/src/core/observer/watcher.js
constructor (
  vm: Component,
  expOrFn: string | Function,
  cb: Function,
  options?: ?Object,
  isRenderWatcher?: boolean
) {
  this.vm = vm
  if (isRenderWatcher) {
    vm._watcher = this
  }
  vm._watchers.push(this)
  // options
  if (options) {
    this.deep = !!options.deep
    this.user = !!options.user
    this.lazy = !!options.lazy
    this.sync = !!options.sync
    this.before = options.before
  } else {
    this.deep = this.user = this.lazy = this.sync = false
  }
  this.cb = cb
  this.id = ++uid // uid for batching
  this.active = true
  this.dirty = this.lazy // for lazy watchers
  ...
  // expOrFn 爲上文的 updateComponent 函數,賦值給 getter
  if (typeof expOrFn === 'function') {
    this.getter = expOrFn
  } else {
    this.getter = parsePath(expOrFn)
    if (!this.getter) {
      this.getter = noop
      ...
    }
  }
  // lazy 爲 false,調用 get 方法
  this.value = this.lazy
    ? undefined
    : this.get()
}

// 執行 getter 函數,getter 函數爲 updateComponent,並收集依賴
get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    ...
  } finally {
    if (this.deep) {
      traverse(value)
    }
    popTarget()
    this.cleanupDeps()
  }
  return value
}
複製代碼

new Watcher 後會調用 updateComponent 函數,上文中 updateComponent 內執行了 vm._update_update 執行前會經過 _render 得到 vnode,接下里看看 _update 作了什麼。_update 定義在 vue/src/core/instance/lifecycle.js

// vue/src/core/instance/lifecycle.js
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  const vm: Component = this
  const prevVnode = vm._vnode
  vm._vnode = vnode
  ...
  
  if (!prevVnode) {
    // 初始渲染
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // 更新 vnode
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
  ...
}
複製代碼

接下來到了 __patch__ 函數進行頁面渲染。

// vue/src/platforms/web/runtime/index.js
import { patch } from './patch'
Vue.prototype.__patch__ = inBrowser ? patch : noop
複製代碼
// vue/src/platforms/web/runtime/patch.js
import { createPatchFunction } from 'core/vdom/patch'
export const patch: Function = createPatchFunction({ nodeOps, modules })
複製代碼

createPatchFunction 提供了不少操做 virtual dom 的方法,最終會返回一個 path 函數。

export function createPatchFunction (backend) {
  ...
  // oldVnode 表明舊的節點,vnode 表明新的節點
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // vnode 爲 undefined, oldVnode 不爲 undefined 則須要執行 destroy
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    let isInitialPatch = false
    const insertedVnodeQueue = []

    if (isUndef(oldVnode)) {
      // oldVnode 不存在,表示初始渲染,則根據 vnode 建立元素
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue)
    } else {
      
      const isRealElement = isDef(oldVnode.nodeType)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // oldVnode 與 vnode 爲相同節點,調用 patchVnode 更新子節點
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } else {
        if (isRealElement) {
          // 服務端渲染的處理
          ...
        }
        // 其餘操做
        ...
      }
    }

    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    // 最終渲染到頁面上
    return vnode.elm
  }
}
複製代碼

當渲染 Watcher 的依賴的數據發生變化時,會觸發 Object.defineProperty 中的 set 函數。

從而調用 dep.notify() 通知該 Watcher 進行 update 操做。最終達到數據改變時,自動更新頁面。 Watcherupdate 函數就再也不展開了,有興趣的小夥伴能夠自行查看。

最後再回過頭看看前面遺留的 _render 函數。

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}
複製代碼

以前說了 _render 函數會返回 vnode,看看具體作了什麼吧。

// vue/src/core/instance/render.js
Vue.prototype._render = function (): VNode {
  const vm: Component = this
  // 從 $options 取出 render 函數以及 _parentVnode
  // 這裏的 render 函數能夠是 template 或者 el 編譯的
  const { render, _parentVnode } = vm.$options

  if (_parentVnode) {
    vm.$scopedSlots = normalizeScopedSlots(
      _parentVnode.data.scopedSlots,
      vm.$slots,
      vm.$scopedSlots
    )
  }

  vm.$vnode = _parentVnode
  let vnode
  try {
    currentRenderingInstance = vm
    // 最終會執行 $options 中的 render 函數
    // _renderProxy 能夠看作 vm
    // 將 vm.$createElement 函數傳遞給 render,也就是常常看到的 h 函數
    // 最終生成 vnode
    vnode = render.call(vm._renderProxy, vm.$createElement)
  } catch (e) {
    // 異常處理
    ...
  } finally {
    currentRenderingInstance = null
  }

  // 若是返回的數組只包含一個節點,則取第一個值
  if (Array.isArray(vnode) && vnode.length === 1) {
    vnode = vnode[0]
  }
  
  // vnode 若是不是 VNode 實例,報錯並返回空的 vnode
  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()
  }
  // 設置父節點
  vnode.parent = _parentVnode
  // 最終返回 vnode
  return vnode
}
複製代碼

接下來就是看 vm.$createElement 也就是 render 函數中的 h

// vue/src/core/instance/render.js
import { createElement } from '../vdom/create-element'
export function initRender (vm: Component) {
  ...
  vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
  ...
}
複製代碼
// vue/src/core/vdom/create-element.js
export function createElement ( context: Component, tag: any, data: any, children: any, normalizationType: any, alwaysNormalize: boolean ): VNode | Array<VNode> {
  // data 是數組或簡單數據類型,表明 data 沒傳,將參數值賦值給正確的變量
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children
    children = data
    data = undefined
  }
  if (isTrue(alwaysNormalize)) {
    normalizationType = ALWAYS_NORMALIZE
  }
  // 將正確的參數傳遞給 _createElement
  return _createElement(context, tag, data, children, normalizationType)
}

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__)) {
    // render 函數中的 data 不能爲響應式數據
    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
    )
    // 返回空的 vnode 節點
    return createEmptyVNode()
  }
  // 用 is 指定標籤
  if (isDef(data) && isDef(data.is)) {
    tag = data.is
  }
  if (!tag) {
    // in case of component :is set to falsy value
    return createEmptyVNode()
  }
  // key 值不是簡單數據類型時,警告提示
  if (process.env.NODE_ENV !== 'production' &&
    isDef(data) && isDef(data.key) && !isPrimitive(data.key)
  ) { ... }

  if (Array.isArray(children) &&
    typeof children[0] === 'function'
  ) {
    data = data || {}
    data.scopedSlots = { default: children[0] }
    children.length = 0
  }
  // 處理子節點
  if (normalizationType === ALWAYS_NORMALIZE) {
    // VNode 數組
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }
  
  // 生成 vnode
  let vnode, ns
  if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    if (config.isReservedTag(tag)) {
      ...
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
    vnode = createComponent(tag, data, context, children)
  }
  
  // 返回 vnode
  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()
  }
}
複製代碼

總結

代碼看起來不少,其實主要流程能夠分爲如下 4 點:

一、 new Vue 初始化數據等

二、$mount 將 render、template 或 el 轉爲 render 函數

三、生成一個渲染 Watcher 收集依賴,並將執行 render 函數生成 vnode 傳遞給 patch 函數執行,渲染頁面。

四、當渲染 Watcher 依賴發生變化時,執行 Watcher 的 getter 函數,從新依賴收集。而且從新執行 render 函數生成 vnode 傳遞給 patch 函數進行頁面的更新。


以上內容均是我的理解,若是有講的不對的地方,還請各位大佬指點。

若是以爲內容還不錯的話,但願小夥伴能夠幫忙點贊轉發,給更多的小夥伴看到,感謝感謝!

若是你喜歡個人文章,還能夠關注個人公衆號【前端develop】

前端develop
相關文章
相關標籤/搜索