【讀vue源碼】探究模版和數(shù)據(jù)是如何被渲染成DOM的隘弊?

過程圖

閱讀資源推薦

vue.js源碼托管地址

flow 靜態(tài)檢查工具地址

rollup 源碼構(gòu)建

虛擬DOM開源庫

【讀vue 源碼】溯源 import Vue from 'vue' 到底做了什么?

前言

Vue.js 一個(gè)核心思想是數(shù)據(jù)驅(qū)動(dòng)濒持。也就是說視圖是由數(shù)據(jù)驅(qū)動(dòng)生成的哼御,我們對(duì)視圖的修改腊状,不會(huì)直接操作 DOM纳本,而是通過修改數(shù)據(jù)。當(dāng)交互復(fù)雜的時(shí)候土思,只關(guān)心數(shù)據(jù)的修改會(huì)讓代碼的邏輯變的非常清晰务热,因?yàn)?DOM 變成了數(shù)據(jù)的映射,我們所有的邏輯都是對(duì)數(shù)據(jù)的修改己儒,而不用碰觸 DOM崎岂,這樣的代碼非常利于維護(hù)。

在 Vue.js 中我們可以采用簡(jiǎn)潔的模板語法來聲明式的將數(shù)據(jù)渲染為 DOM:

<div id="app">
  {{ msg }}
</div>
var app = new Vue({
  el: '#app',
  data: {
    msg: 'Hello world!'
  }
})

結(jié)果頁面上會(huì)展示出Hello world!闪湾。這是入門vue.js的時(shí)候就知道的知識(shí)冲甘。那么現(xiàn)在要問vue.js的源碼到底做了什么,才能讓模版和數(shù)據(jù)最終被渲染成了DOM?江醇?濒憋?

new Vue() 開始

在寫vue 項(xiàng)目的時(shí)候,會(huì)在項(xiàng)目的入口文件 main.js文件里實(shí)例化一個(gè)vue 陶夜。
如下:

var app = new Vue({
  el: '#app',
  data: {
    msg: 'Hello world!'
  },
})

由上一篇文章最后的結(jié)論可知凛驮,Vue 就是一個(gè)用 Function 實(shí)現(xiàn)的類。源碼如下:在src/core/instance/index.js

// _init 方法所在的位置
import { initMixin } from './init' 
// Vue就是一個(gè)用 Function 實(shí)現(xiàn)的類,所以才通過 new Vue 去實(shí)例化它条辟。
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)
}

當(dāng)我們?cè)陧?xiàng)目中 new Vue({})傳入一個(gè)對(duì)象的時(shí)候黔夭,其實(shí)就是執(zhí)行的上面的方法,并傳入?yún)?shù)為 options 羽嫡,然后調(diào)用了this._init(options)方法本姥。該方法在src/core/instance/init.js文件中。代碼如下:

import { initState } from './state'
Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    // 定義了uid
    vm._uid = uid++

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

    vm._isVue = true
    // 合并options 
    if (options && options._isComponent) {
      initInternalComponent(vm, options)
    } else {
      // 這里將傳入的options全部合并在$options上杭棵。
      // 因此我們可以通過$el訪問到 vue 項(xiàng)目中new Vue 中的el
      // 通過$options.data 訪問到 vue 項(xiàng)目中new Vue 中的data
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }
    if (process.env.NODE_ENV !== 'production') {
      initProxy(vm)
    } else {
      vm._renderProxy = vm
    }
    // 初始化函數(shù)
    vm._self = vm
    initLifecycle(vm) // 生命周期函數(shù)
    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')

    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
      vm._name = formatComponentName(vm, false)
      mark(endTag)
      measure(`vue ${vm._name} init`, startTag, endTag)
    }
    // 判斷當(dāng)前的$options.el是否有el 也就是說是否傳入掛載的DOM對(duì)象
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }

由以上代碼可知 this._init(options)主要是合并配置婚惫,初始化生命周期,初始化事件中心魂爪,初始化渲染辰妙,初始化 data、props甫窟、computed密浑、watcher 等等。重要的部分在代碼里做里注釋粗井。

那么接下來依然從其中一個(gè)功能為例進(jìn)行分析:以initState(vm)為例:

為什么在鉤子函數(shù)里可以訪問到 data 里定義的數(shù)據(jù)尔破?

vue 項(xiàng)目中,當(dāng)定義了 data 就可以在組件的鉤子函數(shù) 或者 在 methods 函數(shù)里都可以訪問到data 里定義的屬性浇衬。這是為什么懒构??

var app = new Vue({
  el: '#app',
  data:(){
      return{
          msg: 'Hello world!'
      }
  },
  mounted(){
    console.log(this.msg) // logs 'Hello world!'
  },

分析源碼:可以看到this._init(options)方法耘擂,在初始化函數(shù)部分有一個(gè) initState(vm)函數(shù)胆剧。該方法實(shí)在./state.js中:具體代碼如下:

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  // 如果定義了 props 就初始化props;
  if (opts.props) initProps(vm, opts.props)
  // 如果定義了methods 就初始化methods醉冤;
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    // 如果定義了data,就初始化data;(要分析的內(nèi)容從這里開始)
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

initState方法中判斷:如果定義了data,就初始化data;繼續(xù)看初始化data 的函數(shù):initData(vm)秩霍。代碼如下:

function initData (vm: Component) {
 /* 
  這個(gè)data 就是 我們vue 項(xiàng)目中定義的data。也就是上面例子中的 
  data(){
    return {
      msg: 'Hello world!'
    }
  }
  */
  let data = vm.$options.data
  // 拿到data 后蚁阳,做了判斷铃绒,判斷它是不是一個(gè)function
  data = vm._data = typeof data === 'function'
    ? getData(data, vm) // 如果是 執(zhí)行了getData()方法 ,這個(gè)方法就是返回data
    : data || {}
  // 如果不是一個(gè)對(duì)象則在開發(fā)環(huán)境報(bào)出一個(gè)警告
  if (!isPlainObject(data)) {
    data = {}
    process.env.NODE_ENV !== 'production' && warn(
      'data functions should return an object:\n' +
      'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
      vm
    )
  }
  // 拿到data 定義的屬性
  const keys = Object.keys(data) 
  // 拿到props
  const props = vm.$options.props
  // 拿到 methods
  const methods = vm.$options.methods
  let i = keys.length
  // 做了一個(gè)循環(huán)對(duì)比螺捐,如果在data 上定義的屬性颠悬,就不能在props與methods在定義該屬性矮燎。因?yàn)椴还苁莇ata里定義的,在props里定義的赔癌,還是在medthods里定義的诞外,最終都掛載在vm實(shí)例上了。見proxy(vm, `_data`, key)
  while (i--) {
    const key = keys[i]
    if (process.env.NODE_ENV !== 'production') {
      if (methods && hasOwn(methods, key)) {
        warn(
          `Method "${key}" has already been defined as a data property.`,
          vm
        )
      }
    }
    if (props && hasOwn(props, key)) {
      process.env.NODE_ENV !== 'production' && warn(
        `The data property "${key}" is already declared as a prop. ` +
        `Use prop default value instead.`,
        vm
      )
    } else if (!isReserved(key)) {
      proxy(vm, `_data`, key) // 代理 定義了Getter 和 Setter
    }
  }
  // observe data
  observe(data, true /* asRootData */)
}
// proxy 代理
const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}
export function proxy (target: Object, sourceKey: string, key: string) {
  // 通過對(duì)象 sharedPropertyDefinition  定義了Getter 和 Setter
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
    // 當(dāng)訪問vm.key 的時(shí)候其實(shí)訪問的是 vm[sourceKey][key]
    // 以上述開始的問題灾票,當(dāng)訪問this.msg 實(shí)際是訪問 this._data.msg
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  // 對(duì)vm 的 key 做了一次Getter 和 Setter
  Object.defineProperty(target, key, sharedPropertyDefinition)
  
}

綜上:初始化 data 實(shí)在./state.js文件里浅乔。執(zhí)行initState() 方法,該方法判斷如果定義了data,就初始化data铝条。

如果data 是一個(gè)function,就執(zhí)行了getData()方法return data.call(vm, vm)席噩。然后對(duì) vm 上的 data 里定義的屬性班缰、vm上的 props 、vm上的methods里的屬性進(jìn)行循環(huán)比對(duì)悼枢,如果在data 上定義的屬性埠忘,就不能在props與methods在定義該屬性。因?yàn)椴还苁莇ata里定義的馒索,在props里定義的莹妒,還是在medthods里定義的,最終都掛載在vm實(shí)例上了绰上。見proxy(vm, _data, key)旨怠。

然后通過proxy 方法給vm 上的屬性做了Getter 和 Setter 方法的綁定◎诳椋回到上述的問題鉴腻,當(dāng)訪問this.msg 實(shí)際是訪問 vm._data.msg。因此在鉤子函數(shù)里確實(shí)可以訪問到 data 里定義的數(shù)據(jù)了百揭。

不得不在說一遍爽哎,Vue 的初始化邏輯寫的非常清楚,把不同的功能邏輯拆成一些單獨(dú)的函數(shù)執(zhí)行器一,讓主線邏輯一目了然课锌,這樣的編程思想是非常值得借鑒和學(xué)習(xí)的。

其它初始化的內(nèi)容大家可以自己補(bǔ)充祈秕,接下來看掛載vm渺贤。在初始化的最后,檢測(cè)到如果有 el 屬性请毛,則調(diào)用 vm.$mount 方法掛載 vm癣亚,掛載的目標(biāo)就是把模板渲染成最終的 DOM,那么接下來探究 Vue 的掛載過程吧

Vue 實(shí)例掛載的實(shí)現(xiàn)

Vue 中我們是通過 $mount 實(shí)例方法去掛載 vm 的获印。接下來要探究執(zhí)行$mount('#app')的時(shí)候述雾,源碼都干了什么街州??玻孟?

new Vue({
  render: h => h(App),
}).$mount('#app')

$mount 方法在多個(gè)文件中都有定義唆缴,如 src/platform/web/entry-runtime-with-compiler.jssrc/platform/web/runtime/index.js黍翎、src/platform/weex/runtime/index.js面徽。因?yàn)?$mount 這個(gè)方法的實(shí)現(xiàn)是和平臺(tái)、構(gòu)建方式都有關(guān)系匣掸。

就選取 compiler 版本的 $mount 分析吧趟紊,文件地址在src/platform/web/entry-runtime-with-compiler.js,代碼如下:

// 獲取vue 原型上的 $mount 方法, 存在變量 mount 上。
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  // query 定義在 './util/index'文件中
  // 調(diào)用原生的DOM api querySelector() 方法碰酝。最后將el轉(zhuǎn)化為一個(gè)DOM 對(duì)象霎匈。
  el = el && query(el)
  ...
  return mount.call(this, el, hydrating)
}

讀代碼可知,代碼首先獲取了 vue 原型上的 $mount 方法送爸,將其存在變量mount中铛嘱,然后重新定義了該方法。該方法對(duì)傳入的el做了處理袭厂,el 可以是個(gè)字符串墨吓,也可以是DOM 對(duì)象。然后調(diào)用了 query()方法纹磺,該方法在./util/index文件中帖烘。主要是調(diào)用原生的DOM api querySelector() 方法。最后將el轉(zhuǎn)化為一個(gè)DOM 對(duì)象返回橄杨。上述只貼出了主要的代碼部分蚓让。

源碼了還對(duì)el進(jìn)行了判斷,判斷傳入的el 是否為body 或者 html ,如果是讥珍,就會(huì)在開發(fā)環(huán)境報(bào)一個(gè)警告历极。vue 不可以直接掛載到body 和html上 ,因?yàn)闀?huì)被覆蓋,當(dāng)覆蓋了 html 或 body 整個(gè)文檔就會(huì)報(bào)錯(cuò)衷佃。

源碼還獲取到 $options 判斷是否定義render方法趟卸。如果沒有定義 render 方法,則會(huì)把 el 或者 template 字符串最終將編譯為render()函數(shù)氏义。

最后 return mount.call(this, el, hydrating)锄列。此處的mount是vue 原型上的 $mount 方法。在文件./runtime/index惯悠。代碼如下:

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

其中參數(shù) el 表示掛載的元素邻邮,它可以是字符串,也可以是一個(gè)DOM 對(duì)象克婶。如果是字符串在瀏覽器環(huán)境下會(huì)調(diào)用 query() 方法轉(zhuǎn)換成 DOM 對(duì)象筒严。第二個(gè)參數(shù)是和服務(wù)端渲染相關(guān)丹泉,在瀏覽器環(huán)境下我們不需要傳第二個(gè)參數(shù)。最后return 的時(shí)候調(diào)用了mountComponent()方法鸭蛙。該方法定義在src/core/instance/lifecycle.js,代碼如下:

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
    vm.$el = el
    ...
    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)
      }
    }
  
    new Watcher(vm, updateComponent, noop, {
    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
}

讀代碼可知摹恨,該方法首先實(shí)例化一個(gè)渲染Watcher,在它的回調(diào)函數(shù)中會(huì)調(diào)用 updateComponent 方法娶视,在此方法中調(diào)用 vm._render() 方法先生成虛擬DOM節(jié)點(diǎn)晒哄,最終調(diào)用 vm._update 更新 DOM。

最后判斷為根節(jié)點(diǎn)的時(shí)候設(shè)置 vm._isMountedtrue肪获, 表示這個(gè)實(shí)例已經(jīng)掛載了寝凌,同時(shí)執(zhí)行 mounted 鉤子函數(shù)。 vm.$vnode 表示 Vue 實(shí)例的父虛擬節(jié)點(diǎn)孝赫,所以它為 Null 則表示當(dāng)前是根 Vue 的實(shí)例较木。

那么vm._render()是怎樣生成虛擬DOM節(jié)點(diǎn)的呢?

_render()渲染虛擬DOM 節(jié)點(diǎn)

在 Vue 2.0 版本中寒锚,所有 Vue 的組件的渲染最終都需要 render()。Vue 的 _render() 是實(shí)例的一個(gè)私有方法违孝,它用來把實(shí)例渲染成一個(gè)虛擬DOM節(jié)點(diǎn)刹前。它的定義在 src/core/instance/render.js 文件中,代碼如下:

  Vue.prototype._render = function (): VNode {
    const vm: Component = this
    const { render, _parentVnode } = vm.$options
    
    ...
    
    let vnode
    try {
      currentRenderingInstance = vm
      vnode = render.call(vm._renderProxy, vm.$createElement)
    }
  }

上述代碼 從vue實(shí)例的 $options 上獲取到 render 函數(shù)。通過call()調(diào)用了_renderProxycreateElement()方法雌桑,先來探索createElement()方法喇喉。

createElement()

createElement()是在initRender()中。如下:

// 該函數(shù)是在 _init() 過程中執(zhí)行 initRender()
// 見 './init.js' 文件中的 initRender(vm) 傳入vm校坑。就執(zhí)行到下面的方法拣技。
export function initRender (vm: Component) {
    // 被編譯后生成的render函數(shù)
    vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false) 
    // 手寫render函數(shù) 創(chuàng)建 vnode 的方法。
    vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true) 
}

initRender()是在 _init過程中執(zhí)行了initRender()./init.js 文件中的 initRender(vm)傳入vm耍目。

在 vue 項(xiàng)目實(shí)際開發(fā)中膏斤,手寫 render 函數(shù) 案例如下:

new Vue({
  render(createElement){
    return createElement('div',{
      style:{color:'red'}
    },this.msg)
  },
  data(){
    return{
      msg:"hello world"
    }
  }
}).$mount('#app')

因?yàn)槭鞘謱懙膔ender函數(shù)省去了將 template 編譯為 render函數(shù)的過程,因此性能更好邪驮。

接下來看_renderProxy方法:

_renderProxy

_renderProxy方法莫辨,也是在 init 過程中執(zhí)行的。見文件./init.js中毅访,代碼如下:

import { initProxy } from './proxy'

if (process.env.NODE_ENV !== 'production') {
    initProxy(vm)
} else {
    vm._renderProxy = vm
}

如果當(dāng)前環(huán)境為生產(chǎn)環(huán)境 就將 vm 直接賦值給 vm._renderProxy;

如果當(dāng)前環(huán)境為開發(fā)環(huán)境,則執(zhí)行initProxy()沮榜。

該函數(shù)在./proxy.js文件中,代碼如下:

initProxy = function initProxy (vm) {
    // 判斷瀏覽器是否支持 proxy 。
    if (hasProxy) {
      // determine which proxy handler to use
      const options = vm.$options
      const handlers = options.render && options.render._withStripped
        ? getHandler
        : hasHandler
      vm._renderProxy = new Proxy(vm, handlers)
    } else {
      vm._renderProxy = vm
    }
  }

首先判斷瀏覽器是否支持 proxy喻粹。它是ES6 新增的蟆融,用于給目標(biāo)對(duì)象之前架設(shè)一層“攔截”,外界對(duì)該對(duì)象的訪問守呜,都必須先通過這層攔截型酥,因此提供了一種機(jī)制山憨,可以對(duì)外界的訪問進(jìn)行過濾和改寫。

如果瀏覽器不支持 proxy冕末, 就將 vm 直接賦值給 vm._renderProxy;

如果瀏覽器支持 proxy萍歉,就執(zhí)行new Proxy()

綜上所述:vm._render 是通過執(zhí)行 createElement 方法并返回虛擬的DOM 節(jié)點(diǎn)档桃。那么什么是虛擬的DOM呢枪孩??藻肄?

虛擬的DOM

在探究vue 的虛擬DOM 之前蔑舞,先推薦一個(gè)虛擬DOM開源庫。有時(shí)間嘹屯,有興趣的朋友可以去深入了解腐缤。它是用一個(gè)函數(shù)去表示一個(gè)應(yīng)用程序的視圖層。view.js 是借鑒它實(shí)現(xiàn)了虛擬DOM殿雪。從而大大的提升了程序的性能嘿悬。接下來我們就來看vue.js是怎么做的。

vnode 的定義在 src/core/vdom/vnode.js文件中婆翔,如下:

export default class VNode {
 tag: string | void;
 data: VNodeData | void;
 children: ?Array<VNode>;
 text: string | void;
 elm: Node | void;
 ...
}

虛擬DOM 是個(gè)js對(duì)象拯杠,是對(duì)真實(shí)DOM 的一種抽象描述,比如標(biāo)簽名啃奴、數(shù)據(jù)潭陪、子節(jié)點(diǎn)名等。因?yàn)樘摂MDOM只是用來映射真實(shí)DOM的渲染最蕾,所以不包含操作DOM的方法操作DOM的方法依溯。因此更加的輕量,更加的簡(jiǎn)單瘟则。因?yàn)樘摂MDOM 的創(chuàng)建是通過createElement方法黎炉,那這個(gè)環(huán)節(jié)又是如何實(shí)現(xiàn)的呢?醋拧?拜隧?

createElement

Vue.js 利用 createElement 方法創(chuàng)建 DOM節(jié)點(diǎn),它定義在 src/core/vdom/create-elemenet.js文件中趁仙,代碼如下:

export function createElement (
 context: Component, // vm 實(shí)例
 tag: any, // 標(biāo)簽
 data: any, // 數(shù)據(jù)
 children: any,// 子節(jié)點(diǎn) 可以構(gòu)造DOM 樹
 normalizationType: any,
 alwaysNormalize: boolean
): VNode | Array<VNode> {
 // 對(duì)參數(shù)不一致的處理
 if (Array.isArray(data) || isPrimitive(data)) {
   normalizationType = children
   children = data
   data = undefined
 }
 if (isTrue(alwaysNormalize)) {
   normalizationType = ALWAYS_NORMALIZE
 }
 // 處理好參數(shù)洪添,則調(diào)用 _createElement() 去真正的創(chuàng)建節(jié)點(diǎn)。
 return _createElement(context, tag, data, children, normalizationType)
}

createElement 方法是對(duì) _createElement 方法的封裝雀费,它允許傳入的參數(shù)更加靈活干奢,在處理這些參數(shù)后,調(diào)用真正創(chuàng)建 DOM 節(jié)點(diǎn)的函數(shù)_createElement,代碼如下:

export function _createElement (
 context: Component,
 tag?: string | Class<Component> | Function | Object,
 data?: VNodeData,
 children?: any,
 normalizationType?: number
): VNode | Array<VNode> {
   ...
   if (normalizationType === ALWAYS_NORMALIZE) {
       children = normalizeChildren(children)
   } else if (normalizationType === SIMPLE_NORMALIZE) {
       children = simpleNormalizeChildren(children)
   }
   ...
}

_createElement 方法提供 5 個(gè)參數(shù)如下:

  • context 表示DOM節(jié)點(diǎn)的上下文環(huán)境盏袄,它是 Component 類型忿峻;
  • tag 表示標(biāo)簽薄啥,它可以是一個(gè)字符串,也可以是一個(gè) Component逛尚;
  • data 表示 DOM節(jié)點(diǎn)上的數(shù)據(jù)垄惧,它是一個(gè) VNodeData 類型,可以在 flow/vnode.js 中找到它的定義绰寞;
  • children 表示當(dāng)前DOM節(jié)點(diǎn)的子節(jié)點(diǎn)到逊,它是任意類型的,它接下來需要被規(guī)范為標(biāo)準(zhǔn)的 VNode 數(shù)組滤钱;
  • normalizationType 表示子節(jié)點(diǎn)規(guī)范的類型觉壶,類型不同規(guī)范的方法也就不一樣,它主要是參考 render 函數(shù)是編譯生成的還是手寫的 render 函數(shù)件缸。

createElement 函數(shù)的流程略微有點(diǎn)多铜靶,本文將重點(diǎn)探究 children 的規(guī)范化以及 VNode 的創(chuàng)建。

children 的規(guī)范化

虛擬DOM(Virtual DOM)實(shí)際上是一個(gè)樹狀結(jié)構(gòu)他炊,每一個(gè)DOM 節(jié)點(diǎn)都可能會(huì)有若干個(gè)子節(jié)點(diǎn)争剿,這些子節(jié)點(diǎn)應(yīng)該也是 VNode 的類型。

_createElement 接收的第 4 個(gè)參數(shù) children 是任意類型的痊末,因此我們需要把它們規(guī)范成 VNode 類型蚕苇。

它是根據(jù) normalizationType 的不同,調(diào)用了 normalizeChildren(children)simpleNormalizeChildren(children) 方法舌胶,它們的定義都在 src/core/vdom/helpers/normalzie-children.js文件 中捆蜀,代碼如下:

// render 函數(shù)是編譯生成的時(shí)候調(diào)用
// 拍平數(shù)組為一維數(shù)組
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
}
// 返回一維數(shù)組
export function normalizeChildren (children: any): ?Array<VNode> {
  return isPrimitive(children)
    ? [createTextVNode(children)]
    : Array.isArray(children)
      ? normalizeArrayChildren(children)
      : undefined
}

simpleNormalizeChildren 方法調(diào)用場(chǎng)景是 render 函數(shù)是編譯生成的疮丛。但是當(dāng)子節(jié)點(diǎn)為一個(gè)組件的時(shí)候幔嫂,函數(shù)式組件返回的是一個(gè)數(shù)組而不是一個(gè)根節(jié)點(diǎn),所以會(huì)通過 Array.prototype.concat 方法把整個(gè) children 數(shù)組拍平誊薄,讓它的深度只有一層履恩。

normalizeChildren 方法的調(diào)用場(chǎng)景有 2 種,一個(gè)場(chǎng)景是手寫 render 函數(shù)呢蔫,當(dāng) children 只有一個(gè)節(jié)點(diǎn)的時(shí)候切心,Vue.js 從接口層面允許用戶把 children 寫成基礎(chǔ)類型用來創(chuàng)建單個(gè)簡(jiǎn)單的文本節(jié)點(diǎn),這種情況會(huì)調(diào)用 createTextVNode 創(chuàng)建一個(gè)文本節(jié)點(diǎn)的DOM 節(jié)點(diǎn)片吊;另一個(gè)場(chǎng)景是當(dāng)編譯 slot绽昏、v-for 的時(shí)候會(huì)產(chǎn)生嵌套數(shù)組的情況,會(huì)調(diào)用 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)) {
        res[lastIndex] = createTextVNode(last.text + c)
      } else if (c !== '') {
        res.push(createTextVNode(c))
      }
    } else {
      // 如果兩個(gè)節(jié)點(diǎn)都為文本節(jié)點(diǎn)全谤,則合并他們。
      if (isTextNode(c) && isTextNode(last)) {
        res[lastIndex] = createTextVNode(last.text + c.text)
      } else {
        if (isTrue(children._isVList) &&
          isDef(c.tag) &&
          isUndef(c.key) &&
          isDef(nestedIndex)) {
          c.key = `__vlist${nestedIndex}_${i}__`
        }
        res.push(c)
      }
    }
  }
  return res
}

normalizeArrayChildren 接收 2 個(gè)參數(shù)爷贫。

  • children 表示要規(guī)范的子節(jié)點(diǎn)认然;
  • nestedIndex 表示嵌套的索引;
    因?yàn)閱蝹€(gè) child可能是一個(gè)數(shù)組類型补憾。 normalizeArrayChildren 主要是遍歷 children,獲得單個(gè)節(jié)點(diǎn) c卷员,然后對(duì) c 的類型判斷盈匾,如果是一個(gè)數(shù)組類型,則遞歸調(diào)用 normalizeArrayChildren; 如果是基礎(chǔ)類型毕骡,則通過 createTextVNode 方法轉(zhuǎn)換成 VNode 類型削饵;否則就已經(jīng)是 VNode 類型了,如果 children 是一個(gè)列表并且列表還存在嵌套的情況挺峡,則根據(jù) nestedIndex 去更新它的 key葵孤。

在遍歷的過程中,對(duì)這 3 種情況都做了如下處理:如果存在兩個(gè)連續(xù)的 text 節(jié)點(diǎn)橱赠,會(huì)把它們合并成一個(gè) text 節(jié)點(diǎn)尤仍。

到此,children 變成了一個(gè)類型為 VNode 的 Array狭姨。這就是children 的規(guī)范化宰啦。

虛擬的DOM節(jié)點(diǎn)的創(chuàng)建

回到 createElement 函數(shù),規(guī)范化 children 后饼拍,接下來就要?jiǎng)?chuàng)建一個(gè)DOM實(shí)例赡模,代碼如下:

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 {
    // 不認(rèn)識(shí)的節(jié)點(diǎn)的處理
    vnode = new VNode(
      tag, data, children,
      undefined, undefined, context
    )
  }
} else {
  // direct component options / constructor
  vnode = createComponent(tag, data, context, children)
}

這里先對(duì) tag 做判斷,如果是 string 類型师抄,則接著判斷如果是內(nèi)置的一些節(jié)點(diǎn)漓柑,則直接創(chuàng)建一個(gè)普通 VNode,如果是為已注冊(cè)的組件名叨吮,則通過 createComponent 創(chuàng)建一個(gè)組件類型的 VNode辆布,否則創(chuàng)建一個(gè)未知的標(biāo)簽的 VNode。 如果 tag是一個(gè) Component 類型茶鉴,則直接調(diào)用 createComponent 創(chuàng)建一個(gè)組件類型的 VNode 節(jié)點(diǎn)锋玲。

到這一步,createElement方法就創(chuàng)建好了一個(gè)虛擬DOM樹的實(shí)例涵叮,它用來描述了真實(shí)DOM 樹惭蹂,那么如何渲染為真實(shí)的DOM 樹呢?割粮?盾碗?其實(shí)它是由 vm._update 完成的。

update把虛擬DOM 渲染為真實(shí)DOM

_update 方法是如何把虛擬DOM 渲染為真實(shí)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 restoreActiveInstance = setActiveInstance(vm)
    vm._vnode = vnode
    if (!prevVnode) {
      // 數(shù)據(jù)的首次渲染時(shí)候執(zhí)行
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    }
   ...
  }

讀代碼可知,當(dāng)數(shù)據(jù)首次渲染的時(shí)候,調(diào)用了vm.__patch__()的方法榜轿,他接收了四個(gè)參數(shù)幽歼,結(jié)合我們實(shí)際vue項(xiàng)目的開發(fā)過程。vm.$el就是 id 為 app 的 DOM 對(duì)象谬盐,即:<div id="app"></div>甸私;vnode 對(duì)應(yīng)的是調(diào)用 render 函數(shù)的返回值;hydrating 在非服務(wù)端渲染情況下為 falseremoveOnly 為 false飞傀。

vm.__patch__ 方法在不同的平臺(tái)的定義是不一樣的皇型,對(duì) web 平臺(tái)的定義在 src/platforms/web/runtime/index.js 中,代碼如下:

// 是否在瀏覽器環(huán)境
Vue.prototype.__patch__ = inBrowser ? patch : noop

在 web 平臺(tái)上,是否是服務(wù)端渲染也會(huì)對(duì)這個(gè)方法產(chǎn)生影響砸烦。因?yàn)樵诜?wù)端渲染中弃鸦,沒有真實(shí)的瀏覽器 DOM 環(huán)境,所以不需要把 VNode 最終轉(zhuǎn)換成 DOM幢痘,因此是一個(gè)空函數(shù)唬格,而在瀏覽器端渲染中,它指向了 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'

const modules = platformModules.concat(baseModules)

export const patch: Function = createPatchFunction({ nodeOps, modules })

讀代碼可知 createPatchFunction 方法的返回值被傳入了一個(gè)對(duì)象,其中门粪,

  • nodeOps 封裝了一系列 DOM 操作的方法;
  • modules 定義了模塊的鉤子函數(shù)的實(shí)現(xiàn);
    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]])
      }
    }
  }
    
  // ...
  // 定義了一些輔助函數(shù)
  
  
  // 當(dāng)調(diào)用 vm.__dispatch__時(shí),其實(shí)就是調(diào)用下面的 patch 方法
  // 這塊應(yīng)用了函數(shù)柯理化的技巧
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // ...
    return vnode.elm
  }
}

createPatchFunction 內(nèi)部定義了一系列的輔助方法玄妈,最終返回了一個(gè) patch 方法乾吻,這個(gè)方法就賦值給了 vm._update函數(shù)里調(diào)用的 vm.__patch__。也就是說當(dāng)調(diào)用 vm.__dispatch__時(shí)拟蜻,其實(shí)就是調(diào)用patch (oldVnode, vnode, hydrating, removeOnly) 方法绎签,這塊其實(shí)是應(yīng)用了函數(shù)柯理化的技巧。

patch 方法接收 4個(gè)參數(shù),如下:

  • oldVnode 表示舊的 VNode 節(jié)點(diǎn)瞭郑,它也可以不存在或者是一個(gè) DOM 對(duì)象辜御;
  • vnode 表示執(zhí)行 _render 后返回的 VNode 的節(jié)點(diǎn)鸭你;
  • hydrating 表示是否是服務(wù)端渲染屈张;
  • removeOnly 是給 transition-group 用的。

分析patch方法袱巨,因?yàn)閭魅氲?code>oldVnode實(shí)際上是一個(gè) DOM container阁谆,所以 isRealElement 為 true,然后調(diào)用 emptyNodeAt 方法把 oldVnode 轉(zhuǎn)換成 虛擬DOM節(jié)點(diǎn)(一個(gè)js對(duì)象),然后再調(diào)用 createElm 方法愉老。代碼如下:

if (isRealElement) {
    oldVnode = emptyNodeAt(oldVnode)
}
function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  if (isDef(vnode.elm) && isDef(ownerArray)) {
    vnode = ownerArray[index] = cloneVNode(vnode)
  }

  vnode.isRootInsert = !nested // for transition enter check

  const data = vnode.data
  const children = vnode.children
  const tag = vnode.tag
  // 接下來判斷 vnode 是否包含 tag场绿,
  // 如果包含,先對(duì)tag的合法性在非生產(chǎn)環(huán)境下做校驗(yàn)嫉入,看是否是一個(gè)合法標(biāo)簽焰盗;
  // 然后再去調(diào)用平臺(tái) DOM 的操作去創(chuàng)建一個(gè)占位符元素璧尸。
  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
        )
      }
    }
     // 調(diào)用 createChildren 方法去創(chuàng)建子元素:
    vnode.elm = vnode.ns
      ? nodeOps.createElementNS(vnode.ns, tag)
      : nodeOps.createElement(tag, vnode)
    setScope(vnode)

    /* istanbul ignore if */
    if (__WEEX__) {
      // ...
    } else {
      // 調(diào)用 createChildren 方法去創(chuàng)建子元素
      // 用 createChildren 方法遍歷子虛擬節(jié)點(diǎn),遞歸調(diào)用 createElm
      // 在遍歷過程中會(huì)把 vnode.elm 作為父容器的 DOM 節(jié)點(diǎn)占位符傳入熬拒。
      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)
  }
}

createElm方法的作用是通過虛擬節(jié)點(diǎn)創(chuàng)建真實(shí)的 DOM 并插入到它的父節(jié)點(diǎn)中爷光。判斷 vnode 是否包含 tag,如果包含澎粟,先對(duì) tag 的合法性在非生產(chǎn)環(huán)境下做驗(yàn)證蛀序,看是否是一個(gè)合法標(biāo)簽;然后再去調(diào)用平臺(tái) DOM 的操作去創(chuàng)建一個(gè)占位符元素活烙。然后調(diào)用 createChildren 方法去創(chuàng)建子元素,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方法遍歷子虛擬節(jié)點(diǎn)徐裸,遞歸調(diào)用 createElm,在遍歷過程中會(huì)把 vnode.elm 作為父容器的 DOM 節(jié)點(diǎn)占位符傳入啸盏。然后調(diào)用 invokeCreateHooks方法執(zhí)行所有的 create 的鉤子并把 vnode push 到 insertedVnodeQueue 中重贺。最后調(diào)用 insert 方法把 DOM 插入到父節(jié)點(diǎn)中,因?yàn)槭沁f歸調(diào)用回懦,子元素會(huì)優(yōu)先調(diào)用 insert檬姥,所以整個(gè) vnode 樹節(jié)點(diǎn)的插入順序是先子后父。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方法調(diào)用一些輔助方法把子節(jié)點(diǎn)插入到父節(jié)點(diǎn)中(其實(shí)就是調(diào)用原生 DOM 的 API 進(jìn)行 DOM 操作),這些輔助方法定義在 src/platforms/web/runtime/node-ops.js 文件中贫贝。到此秉犹,Vue 動(dòng)態(tài)創(chuàng)建的 DOM節(jié)點(diǎn)就完成了。emm~~ 回頭在看看這個(gè)圖稚晚。

過程圖

結(jié)束

最近一段時(shí)間都會(huì)認(rèn)真的去看vue.js的源碼崇堵。【讀vue 源碼】會(huì)按照一個(gè)系列去更新客燕。分享自己學(xué)習(xí)的同時(shí)鸳劳,也希望與更多的同行交流所得,如此而已也搓。

第一篇:【讀vue 源碼】溯源 import Vue from 'vue' 到底做了什么?

第二篇:【讀vue源碼】探究模版和數(shù)據(jù)是如何被渲染成DOM的赏廓? 【當(dāng)前在讀】

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市傍妒,隨后出現(xiàn)的幾起案子幔摸,更是在濱河造成了極大的恐慌,老刑警劉巖颤练,帶你破解...
    沈念sama閱讀 210,835評(píng)論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件既忆,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)患雇,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 89,900評(píng)論 2 383
  • 文/潘曉璐 我一進(jìn)店門跃脊,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人苛吱,你說我怎么就攤上這事匾乓。” “怎么了又谋?”我有些...
    開封第一講書人閱讀 156,481評(píng)論 0 345
  • 文/不壞的土叔 我叫張陵拼缝,是天一觀的道長。 經(jīng)常有香客問我彰亥,道長咧七,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 56,303評(píng)論 1 282
  • 正文 為了忘掉前任任斋,我火速辦了婚禮继阻,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘废酷。我一直安慰自己瘟檩,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,375評(píng)論 5 384
  • 文/花漫 我一把揭開白布澈蟆。 她就那樣靜靜地躺著墨辛,像睡著了一般。 火紅的嫁衣襯著肌膚如雪趴俘。 梳的紋絲不亂的頭發(fā)上睹簇,一...
    開封第一講書人閱讀 49,729評(píng)論 1 289
  • 那天,我揣著相機(jī)與錄音寥闪,去河邊找鬼太惠。 笑死,一個(gè)胖子當(dāng)著我的面吹牛疲憋,可吹牛的內(nèi)容都是我干的凿渊。 我是一名探鬼主播,決...
    沈念sama閱讀 38,877評(píng)論 3 404
  • 文/蒼蘭香墨 我猛地睜開眼缚柳,長吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼埃脏!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起喂击,我...
    開封第一講書人閱讀 37,633評(píng)論 0 266
  • 序言:老撾萬榮一對(duì)情侶失蹤剂癌,失蹤者是張志新(化名)和其女友劉穎淤翔,沒想到半個(gè)月后翰绊,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 44,088評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,443評(píng)論 2 326
  • 正文 我和宋清朗相戀三年监嗜,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了谐檀。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,563評(píng)論 1 339
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡裁奇,死狀恐怖桐猬,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情刽肠,我是刑警寧澤溃肪,帶...
    沈念sama閱讀 34,251評(píng)論 4 328
  • 正文 年R本政府宣布,位于F島的核電站音五,受9級(jí)特大地震影響惫撰,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜躺涝,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,827評(píng)論 3 312
  • 文/蒙蒙 一厨钻、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧坚嗜,春花似錦夯膀、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,712評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至碟绑,卻和暖如春涂佃,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背蜈敢。 一陣腳步聲響...
    開封第一講書人閱讀 31,943評(píng)論 1 264
  • 我被黑心中介騙來泰國打工辜荠, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人抓狭。 一個(gè)月前我還...
    沈念sama閱讀 46,240評(píng)論 2 360
  • 正文 我出身青樓伯病,卻偏偏與公主長得像,于是被迫代替她去往敵國和親否过。 傳聞我的和親對(duì)象是個(gè)殘疾皇子午笛,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,435評(píng)論 2 348