vue2原始碼解析(重構版)(1)

Charon發表於2021-10-26

vue設計模式

之前寫過三篇vue的解析,那個按原始碼順序走的,看著有點亂,這裡看了很多資料之後又整理一版。

首先說下原始碼結構,原始碼都在src下

src
├── compiler        # 編譯相關 
├── core            # 核心程式碼 
├── platforms       # 不同平臺的支援
├── server          # 服務端渲染
├── sfc             # .vue 檔案解析
├── shared          # 共享程式碼

compiler

compiler 目錄包含 Vue.js 所有編譯相關的程式碼。它包括把模板解析成 ast 語法樹,ast 語法樹優化,程式碼生成等功能。

編譯的工作可以在構建時做(藉助 webpack、vue-loader 等輔助外掛);也可以在執行時做,使用包含構建功能的 Vue.js。顯然,編譯是一項耗效能的工作,所以更推薦前者——離線編譯。

core

core 目錄包含了 Vue.js 的核心程式碼,包括內建元件、全域性 API 封裝,Vue 例項化、觀察者、虛擬 DOM、工具函式等等。

這裡的程式碼可謂是 Vue.js 的靈魂,也是我們之後需要重點分析的地方。

platform

Vue.js 是一個跨平臺的 MVVM 框架,它可以跑在 web 上,也可以配合 weex 跑在 native 客戶端上。platform 是 Vue.js 的入口,2 個目錄代表 2 個主要入口,分別打包成執行在 web 上和 weex 上的 Vue.js。
我們主要目標是web端的,weex 有興趣可以自己看下

server

Vue.js 2.0 支援了服務端渲染,所有服務端渲染相關的邏輯都在這個目錄下。注意:這部分程式碼是跑在服務端的 Node.js,不要和跑在瀏覽器端的 Vue.js 混為一談。

服務端渲染主要的工作是把元件渲染為伺服器端的 HTML 字串,將它們直接傳送到瀏覽器,最後將靜態標記"混合"為客戶端上完全互動的應用程式。

sfc

通常我們開發 Vue.js 都會藉助 webpack 構建, 然後通過 .vue 單檔案來編寫元件。
這個目錄下的程式碼邏輯會把 .vue 檔案內容解析成一個 JavaScript 的物件。

shared

Vue.js 會定義一些工具方法,這裡定義的工具方法都是會被瀏覽器端的 Vue.js 和服務端的 Vue.js 所共享的。

從 Vue.js 的目錄設計可以看到,作者把功能模組拆分的非常清楚,相關的邏輯放在一個獨立的目錄下維護,並且把複用的程式碼也抽成一個獨立目錄。
這樣的目錄設計讓程式碼的閱讀性和可維護性都變強,是非常值得學習的。


除錯準備

首先這裡註釋了一份vue2.6的原始碼,大家可以下載下來跟著走

vue2.6 原始碼載地址

  1. vscode中開啟設定把javascript.validate.enable暫時設定為false,不檢查javascript的語法問題,防止flow報錯,flow和typescript類似,用來做型別檢測。
  2. 這會原始碼裡部分程式碼是沒有高亮顯示的,vscode下載一個外掛,Babel javascript開其它就有高亮顯示了
  3. 然後大家可以安裝一個全域性包,serve,然後在examples目錄下執行 serve .,把該目錄當成靜態伺服器啟動,然後該檔案裡放的都是vue測試例子,然後把src的vue地址改為../../dist/vue.js",這個是我們下面命令改後,構建出的vue,這樣就可以自由打斷點除錯了。

我們改下 vue 原始碼 package.json 的scripts的配置方便後續除錯
dev命令改為

"dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:web-full-dev"

開啟熱載入 -w -c 設定配置檔案 開啟sourcemap 設定環境變數

找到 scripts/configs.js 搜尋對應的環境變數值 web-full-dev 找到對應的入口 src/platforms/web/entry-runtime-with-compiler.js
我們這裡除錯的是全量的vue,vue-cli裡是runtime執行時版本,全量版本的可以更好的除錯vue所有程式碼。

因為vue的原始碼,裡面跳來跳去的,首次看邏輯很難串起來,這裡畫了一個大體的思維導圖。
vue渲染流程 (1).png
上圖有3個watcher 建立的關鍵地方,這裡也有一個對應的思維導圖
watcher (4).png
watcher示意圖
image.png
注意:從watcher被dep.notify()釋出通知,然後執行watcher.update() 我們可以看到,計算的watcher.update只是dirty=true 標記快取失效,依賴的值有更新. 而不會執行queueWatcher(this)把當前計算watcher 放入queue更新 watcher佇列中。
而 使用者watcher和渲染watcher 執行update時,基本都會執行queueWatcher(this),把當前watcher放到queue 佇列中,因為計算watcher是有快取的。
這裡需要注意一下。

未縮減版的圖太大了,步驟太多,這裡寫了一個縮減版的思維導圖。

![上傳中...]()首次渲染過程 (1).png


從入口開始

我們根據我的思維導圖找到vue最終定義的地方,發現vue它是一個建構函式,它實際上就是一個用 Function 實現的類,我們只能通過 new Vue 去例項化它。

有些同學看到這不禁想問,為何 Vue 不用 ES6 的 Class 去實現呢?我們往後看這裡有很多 xxxMixin 的函式呼叫,並把 Vue 當引數傳入,它們的功能都是給 Vue 的 prototype 上擴充套件一些方法(這裡具體的細節會在之後的文章介紹,這裡不展開),Vue 按功能把這些擴充套件分散到多個模組中去實現,而不是在一個模組裡實現所有,這種方式是用 Class 難以實現的。這麼做的好處是非常方便程式碼的維護和管理,這種程式設計技巧也非常值得我們去學習。

資料驅動

那實際我們用vue時,會new Vue()這樣呼叫,這會實際上我們根據原始碼,看到實際就是呼叫了this._init()初始化函式,大家按我思維導圖結合原始碼會發現,vue初始化 init主要就幹了幾件事情,合併配置,初始化生命週期,初始化事件中心,初始化渲染,初始化 data、props、computed、watcher 等等。

Vue 的初始化邏輯寫的非常清楚,把不同的功能邏輯拆成一些單獨的函式執行,讓主線邏輯一目瞭然,這樣的程式設計思想是非常值得借鑑和學習的。

vue例項掛載

Vue 中我們是通過 $mount 例項方法去掛載 vm 的,$mount 方法在多個檔案中都有定義,如 src/platform/web/entry-runtime-with-compiler.js、src/platform/web/runtime/index.js、src/platform/weex/runtime/index.js。因為 $mount 這個方法的實現是和平臺、構建方式都相關的。接下來我們重點分析帶 compiler 版本的 $mount 實現,因為拋開 webpack 的 vue-loader,我們在純前端瀏覽器環境分析 Vue 的工作原理,有助於我們對原理理解的深入。

compiler 版本的 $mount 實現非常有意思,先來看一下 src/platform/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) {
    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)
}

這段程式碼首先快取了原型上的 $mount 方法,再重新定義該方法,我們先來分析這段程式碼。首先,它對 el 做了限制,Vue 不能掛載在 body、html 這樣的根節點上。接下來的是很關鍵的邏輯 —— 如果沒有定義 render 方法,則會把 el 或者 template 字串轉換成 render 方法。這裡我們要牢記,在 Vue 2.0 版本中,所有 Vue 的元件的渲染最終都需要 render 方法,無論我們是用單檔案 .vue 方式開發元件,還是寫了 el 或者 template 屬性,最終都會轉換成 render 方法,那麼這個過程是 Vue 的一個“線上編譯”的過程,它是呼叫 compileToFunctions 方法實現的,編譯過程我們之後會介紹。最後,呼叫原先原型上的 $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)
}

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

Watcher 在這裡起到兩個作用,一個是初始化的時候會執行回撥函式,另一個是當 vm 例項中的監測的資料發生變化的時候執行回撥函式。

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

mountComponent 方法的邏輯也是非常清晰的,它會完成整個渲染工作,接下來我們要重點分析其中的細節,也就是最核心的 2 個方法:vm._render 和 vm._update。

相關文章