vue原始碼剖析(一)

Cuke發表於2021-06-05

0.獲取原始碼

github.com/vuejs/vue

從github地址,直接download下來就行了。在新建專案的時候也可以node_modelus裡的vue搭配著看。

1.資料的掛載

首先先引入vue,然後新建他的例項。

import Vue from 'vue'
var app = new Vue({
  el:'#app',
  data:{
    return {
          message:"hello world!"  
      }
    }
})

首先我們得知道我們引入 的是個什麼東西。所以我們找到原始碼./src/core/instance/index.js裡,找到了vue的廬山真面目了,其實vue就是一個類。

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

首先process.env.NODE_ENV是判斷你啟動時候的引數的,如果符合的話,就警告,否則執行_init方法。值得一提的是一般屬性名前面加_預設代表是私有屬性,不對外展示。當然如果你列印vue例項的話還是能看見,因為只是_是私有屬性人們約定俗成的,沒有js語言層面的私有。

那麼這個_init是哪來的呢?往下看:

initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

可以看到下面有一大串Mixin,_init的方法就在第一個initMixin裡。vscode可以直接右鍵選擇跳轉到定義或者command加左鍵點選,可以跳過去看到定義這個方法的地方。

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

   //..

    // 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)
      // ..

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

​ init就在最開頭

 Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    // a uid
    vm._uid = uid++

   //..

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

​ init具體包括啥呢,首先將this上下文傳給vm這個物件,然後設定_uid然後再機型一系列的初始化的工作。然後再合併options,最後掛載到vm上。

​ 可能有人會好奇,在形參部分,Vue: Class<Component>是什麼意思,因為JavaScript是一個動態型別語言,也就是說,宣告變數的時候不會指派他是任何一種型別的語言,像java就是典型的靜態型別語言。例如:boolean result = true就是宣告result是一個布林型別,而相對的,JavaScript中可以宣告var result =true。這樣雖然方便很多,但是因為靜態型別在編譯過程中就會查出錯誤並提示開發者改正錯誤,但是像Js這樣的動態語言在編譯的時候既是存在錯誤也不會提出,只有在真正執行時才會出錯。所以就會有不必要的麻煩,那麼如何對Js進行靜態型別檢查呢?就是外掛唄。vue用了flow的外掛,讓js有了靜態型別檢查,:後面代表了限定vue這個形參的屬性。具體就不展開了,可以去看flow的文件。

Flow:flow.org/

​ 接下來接著說正文,const vm: Component = this可以看到把當前的執行前後文給了vm。然後之後就是一些陸陸續續的掛載,值得注意的就是vm.$options就是填寫在vue例項裡的引數,例如el,mounted,data都被儲存在$options裡。

但是平常使用的時候我們沒有用到this.$options.data1裡,反而是直接用this.data1來呼叫,這其實vue也在其中進行了操作。

我們會發現在上面的程式碼段裡有一行initState(vm),我們找到initState的定義。

export function initState (vm: Component) {
    // ..
  const opts = vm.$options
  if (opts.data) {
    initData(vm)
  } 
  // ..
}

然後我們可以接著轉到initData這個方法的定義

function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  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
    )
  }
  // proxy data on instance
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  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)
    }
  }
  // observe data
  observe(data, true /* asRootData */)
}

把上面的程式碼拆分來看

 let data = vm.$options.data
 data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}

上面程式碼先通過$options獲取到data,然後判斷data是不是通過返回物件的方式建立的,如果是,那麼則執行getData方法。getData的方法主要操作就是 data.call(vm, vm) 這步通過給data呼叫了vm這個上下文環境,然後直接返回這個包括data的vm物件。

那麼現在vm上已經有data了是嗎?確實,但是這個data是vm._data也就是說如果你想訪問message這個屬性你現在只能通過vue._data.message這樣來訪問。所以我們接著往下看。

  // proxy data on instance
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  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)
    }
  }

這一大段上面聚焦的是prop data methods 們如果相同之後就會提出相應的警示。為什麼要他們不一樣呢,因為他們都是通過this.XX來呼叫的,如果重名,vue分不清他們是誰。如果都沒問題了,我們就把_datas上的值直接賦給vm,然後轉到最後一步proxy(vm, _data, key) ,然後我們轉移到proxy這個方法中:

const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}

export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}

就是通過sharedPropertyDefinition.getsharedPropertyDefinition.set的設定的get和set方法,然後在通過Object.defineProperty來定義訪問target.key的時候呼叫sharedPropertyDefinition的set和get。

也就是相當於,我要求vm.message,就會觸發sharedPropertyDefinition的get,然後返回vm._data.message

至此資料就可以通過vm.message的方式訪問了。

2.例項掛載

在plantforms/entry-runtime-with-compiler.js上看到

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

先從Vue中的原型鏈子找到$mount然後賦值給mount(),然後再定義一遍,是因為前面的適用於compilers版本,如果是runtime with compiler版本則上面的娶不到,所以只能自己在下面重新定義一遍。

Ps:最開始的runtime with compiler版本的$mount在./util/index.js裡有定義。

現在看定義的Vue.prototype.$mount裡面都有啥

el = el && query(el)

先獲取到el的dom物件。

接下來的判斷是不讓你掛載到html和body標籤裡

 /* 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
  }

需要知道的就是你的template就是需要進行compiler的,編譯過後他就轉化成了render。所以我們下一步看他的options是否有render

    const options = this.$options
  // resolve template/el and convert to render function
  if (!options.render) {
    // ...
  }

如果沒有render,那麼就想盡辦法把template弄成一個字串返回出來,下面是具體辦法

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

拿著這個字串,然後進行一系列的編譯,把template渲染成render,然後掛載到options上。

if (template) {
           // ...
      options.render = render
      options.staticRenderFns = staticRenderFns
            // ... 
}

然後我們拿到這個render函式,我們就開始執行mounte方法

return mount.call(this, el, hydrating)

這個mount是開頭說的在./platforms/runtime/index.js裡的那個函式,而不是當前作用域的這個mount函式。所以我們直接跳轉到mount方法上去

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

我們可以看到開頭又是校驗了一波el,然後執行mountComponent函式,那接下來我們開始分析mountComponent。

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
   vm.$el = el
  // ...

  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
   //...
  } else {
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  }
  // ...
}

重點是vm._update(vm._render(), hydrating)這句,vm._render()生成一個虛擬節點,然後vm._update用來將這個虛擬節點上傳。

然後我們定義完了這個updateComponent方法,我們在下面建立一個渲染watcher

new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)

保證我們每次在更新的時候都能重新的渲染資料。

4.vm._render()

/src/core/instance/render.js

 Vue.prototype._render = function (): VNode{
    const vm: Component = this
    const { render, _parentVnode } = vm.$options
    //。。。
     try {
      // There's no need to maintain a stack because all render fns are called
      // separately from one another. Nested component's render fns are called
      // when parent component is patched.
      currentRenderingInstance = vm
      vnode = render.call(vm._renderProxy, vm.$createElement)
    }catch(e){
      // ...
    }
 }

然後vnode = render.call(vm._renderProxy, vm.$createElement)是重點.vm.$createElement是建立建立元素用的,和他一起的還有一個vm._c_c使用來處理編譯過的render,$creatElement用來處理使用者手寫的render。

  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)

然後vm._renderProxy呢,在/core/instance/init.js裡

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

如果是在開發環境,則執行InitProxy如果是生產環境,則直接把this賦給他。

轉過頭來看看initProxy



 initProxy = function initProxy (vm) {
    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
    }
  }

  const hasHandler = {
    has (target, key) {
      const has = key in target
      const isAllowed = allowedGlobals(key) ||
        (typeof key === 'string' && key.charAt(0) === '_' && !(key in target.$data))
      if (!has && !isAllowed) {
        if (key in target.$data) warnReservedPrefix(target, key)
        else warnNonPresent(target, key)
      }
      return has || !isAllowed
    }
  }

這裡用到了proxy語法,proxy是es6新新增的代理器,可以稱為攔截器,詳情可看阮一峰的《es6標準入門》或者參考MDN文件:

es6.ruanyifeng.com/#docs/proxy

developer.mozilla.org/zh-CN/docs/W...

總之,這裡initProxy就是通過攔截vm例項,然後判斷你所要求的屬性到底有沒有在vm上,如果沒有就發出警告。

5.creatElement建立虛擬DOM

上面說了,最後就是這個createElement,建立一個虛擬DOM

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)

首先我們找到createElement方法

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

可以看到開始是對引數做一個位置的調整,然後返回的是另一個方法的執行,那麼我們接著看_createElement的方法。

export function _createElement(
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  // ...
}

然後經過一系列處理,返回一個VNode。

6.update渲染虛擬節點

當建立好vnode的時候,就應該去執行update了,注意我們現在從456講的都是講

  vm._update(vm._render(), hydrating)

這一句話.當vm._render()生成了虛擬dom之後,我們要通過_update來進行dom的更新操作。

_update 的核心就是呼叫 vm.__patch__ 方法,而且dom的更新操作一般有兩種原因,一種是因為初次渲染,另一種是資料更新,所以在我們執行_update這個方法的時候,我們需要判斷我們現在是否是初次渲染,然後根據是否初次渲染來選擇相應的引數

if (!prevVnode) {
    // initial render
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode)
  }

另外因為vue在weex和web平臺上是不一樣的,所以相應的方法和屬性也有變化,例如上面的這個vm.__patch__,在src/platforms/web/runtime/index.js裡我們可以看到它是根據是否在瀏覽器裡做出的判斷。

Vue.prototype.__patch__ = inBrowser ? patch : noop

noop就相當於一個空函式了,而如果在瀏覽器裡,__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 })

該方法的定義是呼叫 createPatchFunction 方法的返回值,這裡傳入了一個物件,包含 nodeOps 引數和 modules 引數。其中,nodeOps 封裝了一系列 DOM 操作的方法,modules 定義了一些模組的鉤子函式的實現,我們這裡先不詳細介紹,來看一下 createPatchFunction 的實現,它定義在 src/core/vdom/patch.js 中:

export function createPatchFunction (backend) {
  // ...
  const { modules, nodeOps } = backend
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // ...
  }
}

createPatchFunction 內部定義了一系列的輔助方法,最終返回了一個 patch 方法,這個方法就賦值給了 vm._update 函式裡呼叫的 vm.__patch__

那麼我們最後見到了patch這個方法,那麼為什麼我們要翻來覆去折騰這麼半天呢?

是因為我們要選擇不同的平臺,然後選擇合適的nodeOps來操作dom。

先來回顧我們的例子:

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 */)

結合我們的例子,我們的場景是首次渲染,所以在執行 patch 函式的時候,傳入的 vm.$el 對應的是例子中 id 為 app 的 DOM 物件,這個也就是我們在 index.html 模板中寫的 ``, vm.$el 的賦值是在之前 mountComponent 函式做的,vnode 對應的是呼叫 render 函式的返回值,hydrating 在非服務端渲染情況下為 false,removeOnly 為 false。

確定了這些入參後,我們回到 patch 函式的執行過程,看幾個關鍵步驟。然後建立完成。

1.createcomponent

在建立vnode的時候,也就是_render的時候,我們會去選擇,如果是正常的節點的話就會觸發CreatElement,如果是一個元件的話就會觸發createcomponent.

createcomponent在src/core/vdom/create-component.js裡。

  // context.$options._base is Vue
  const baseCtor = context.$options._base

  // plain options object: turn it into a constructor
  if (isObject(Ctor)) {
    // Ctor Inherit a lot of vue capabilities
    // extend inherits some of the vue's methods through a prototype
    Ctor = baseCtor.extend(Ctor)
  }

然後再和後面加上相應的鉤子:

installComponentHooks(data)

然後最後建立vnode:

const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  )

返回。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章