深入Vue.js從原始碼開始(一)

codehunter發表於2019-02-16

本系列為慕課網《Vue.js 原始碼全方位深入解析》課程的學習筆記

認識 Flow

Flow 是 facebook 出品的 JavaScript 靜態型別檢查工具。Vue.js 的原始碼利用了 Flow 做了靜態型別檢查。

為什麼要靜態型別檢查

JavaScript 是動態型別語言,但由於是弱型別,往往在編譯階段很難發現這些隱患,在執行的時候出現各種各樣的BUG。
型別檢查是當前動態型別語言的發展趨勢,可以在編譯期儘早發現(由型別錯誤引起的)bug,越早在上游發現對專案的成本控制越有益。

Vue為什麼選擇Flow

Vue.js 在做 2.0 重構的時候,在 ES2015 的基礎上,除了 ESLint 保證程式碼風格之外,也引入了 Flow 做靜態型別檢查。之所以選擇 Flow,主要是因為 Babel 和 ESLint 都有對應的 Flow 外掛以支援語法,可以完全沿用現有的構建配置,非常小成本的改動就可以擁有靜態型別檢查的能力。

flow的安裝

npm install -g flow-bin

Flow 的工作方式

通常型別檢查分成 2 種方式:

  • 型別推斷:通過變數的使用上下文來推斷出變數型別,然後根據這些推斷來檢查型別。
  • 型別註釋:事先註釋好我們期待的型別,Flow 會基於這些註釋來判斷。

型別推斷

先上程式碼

/*@flow*/``

function split(str) {
  return str.split(` `)
}

split(11)


Flow 檢查上述程式碼後會報錯,因為函式 split 期待的引數是字串,而我們輸入了數字。

Error ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ index.js:4:14

Cannot call str.split because property split is missing in Number [1].

     1│ /*@flow*/``
     2│
     3│ function split(str) {
     4│   return str.split(` `)
     5│ }
     6│
 [1] 7│ split(11)
 
 Found 1 error

型別註釋

型別推斷的情況下以下程式碼並不會報錯

/*@flow*/

function add(x, y){
  return x + y
}

add(`Hello`, 11)


這往往就是隱患所在,flow的正確寫法應該是

/*@flow*/

function add(x: number, y: number): number {
  return x + y
}

add(`Hello`, 11)


關於flow的具體用法參見官網 flowg官網連結

Flow 在 Vue.js 原始碼中的應用

Flow 提出了一個 libdef 的概念,可以用來識別這些第三方庫或者是自定義型別,而 Vue.js 也利用了這一特性。
在 Vue.js 的主目錄下有 .flowconfig 檔案, 它是 Flow 的配置檔案,這其中的 [libs] 部分用來描述包含指定庫定義的目錄,預設是名為 flow-typed 的目錄。這裡 [libs] 配置的是 flow,表示指定的庫定義都在 flow 資料夾內。我們開啟這個目錄,會發現檔案如下:

flow
├── compiler.js        # 編譯相關
├── component.js       # 元件資料結構
├── global-api.js      # Global API 結構
├── modules.js         # 第三方庫定義
├── options.js         # 選項相關
├── ssr.js             # 服務端渲染相關
├── vnode.js           # 虛擬 node 相關


這裡的庫定義對我們在後面的原始碼閱讀時會有一定的幫助。

Vue.js 原始碼目錄設計

Vue.js 的原始碼都在 src 目錄下,其目錄結構如下

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


compiler

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

編譯的工作可以在構建時做(藉助 webpack、vue-loader 等輔助外掛);也可以在執行時做,使用包含構建功能的 Vue.js。我們往往在開發的時候使用執行時編譯,離線往往是用於釋出。

關於ast語法樹的介紹可見 一看就懂的JS抽象語法樹

core

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

platform

Vue.js 是一個跨平臺的 MVVM 框架,它可以跑在 web 上,也可以配合 weex 跑在 natvie 客戶端上。platform 是 Vue.js 的入口,2 個目錄代表 2 個主要入口,分別打包成執行在 web 上和 weex 上的 Vue.js。

server

Vue.js 2.0 支援了服務端渲染,所有服務端渲染相關的邏輯都在這個目錄下。

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

sfc

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

shared

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

Vue.js 原始碼構建

Vue.js 原始碼是基於 Rollup 構建的,它的構建相關配置都在 scripts 目錄下。

ps:roolup 往往用於純js的庫的構建,webpack功能更為強大一些,可以把css,圖片這些也一起打到包裡。這裡Roolup顯然更適合一點。

構建指令碼

通常一個基於 NPM 託管的專案都會有一個 package.json 檔案,它是對專案的描述檔案,它的內容實際上是一個標準的 JSON 物件。

我們通常會配置 script 欄位作為 NPM 的執行指令碼,Vue.js 原始碼構建的指令碼如下:

{
  "script": {
    "build": "node scripts/build.js",
    "build:ssr": "npm run build -- web-runtime-cjs,web-server-renderer",
    "build:weex": "npm run build --weex"
  }
}

當在命令列執行 npm run build 的時候,實際上就會執行 node scripts/build.js

構建過程

我們對於構建過程分析是基於原始碼的,先開啟構建的入口 JS 檔案,在 scripts/build.js 中:

let builds = require(`./config`).getAllBuilds()

// filter builds via command line arg
if (process.argv[2]) {
  const filters = process.argv[2].split(`,`)
  builds = builds.filter(b => {
    return filters.some(f => b.output.file.indexOf(f) > -1 || b._name.indexOf(f) > -1)
  })
} else {
  // filter out weex builds by default
  builds = builds.filter(b => {
    return b.output.file.indexOf(`weex`) === -1
  })
}

build(builds)

這段程式碼邏輯非常簡單,先從配置檔案讀取配置,再通過命令列引數對構建配置做過濾,這樣就可以構建出不同用途的 Vue.js 了。接下來我們看一下配置檔案,在 scripts/config.js 中

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 development build (Browser)
  `web-full-dev`: {
    entry: resolve(`web/entry-runtime-with-compiler.js`),
    dest: resolve(`dist/vue.js`),
    format: `umd`,
    env: `development`,
    alias: { he: `./entity-decoder` },
    banner
  },
  // Runtime+compiler production build  (Browser)
  `web-full-prod`: {
    entry: resolve(`web/entry-runtime-with-compiler.js`),
    dest: resolve(`dist/vue.min.js`),
    format: `umd`,
    env: `production`,
    alias: { he: `./entity-decoder` },
    banner
  },
  // ...
}

對於單個配置,它是遵循 Rollup 的構建規則的。其中 entry 屬性表示構建的入口 JS 檔案地址,dest 屬性表示構建後的 JS 檔案地址。format 屬性表示構建的格式,cjs 表示構建出來的檔案遵循 CommonJS 規範,es 表示構建出來的檔案遵循 ES Module 規範。 umd 表示構建出來的檔案遵循 UMD 規範。
ps: cjs,es,umd是什麼

以上面的web-runtime-cjs 配置為例,它的 entry 是
resolve(`web/entry-runtime.js`),先來看一下 resolve 函式的定義。
原始碼目錄:scripts/config.js

const aliases = require(`./alias`)
const resolve = p => {
  const base = p.split(`/`)[0]
  if (aliases[base]) {
    return path.resolve(aliases[base], p.slice(base.length + 1))
  } else {
    return path.resolve(__dirname, `../`, p)
  }
}

11

這裡的 resolve 函式實現非常簡單,它先把 resolve 函式傳入的引數 p 通過 / 做了分割成陣列,然後取陣列第一個元素設定為 base。在我們這個例子中,引數 p 是 web/entry-runtime.js,那麼 base 則為 web。base 並不是實際的路徑,它的真實路徑藉助了別名的配置,我們來看一下別名配置的程式碼,
在 scripts/alias 中:
const path = require(`path`)

module.exports = {
  vue: path.resolve(__dirname, `../src/platforms/web/entry-runtime-with-compiler`),
  compiler: path.resolve(__dirname, `../src/compiler`),
  core: path.resolve(__dirname, `../src/core`),
  shared: path.resolve(__dirname, `../src/shared`),
  web: path.resolve(__dirname, `../src/platforms/web`),
  weex: path.resolve(__dirname, `../src/platforms/weex`),
  server: path.resolve(__dirname, `../src/server`),
  entries: path.resolve(__dirname, `../src/entries`),
  sfc: path.resolve(__dirname, `../src/sfc`)
}

這裡 web 對應的真實的路徑是 path.resolve(__dirname, `../src/platforms/web`),這個路徑就找到了 Vue.js 原始碼的 web 目錄。然後 resolve 函式通過 path.resolve(aliases[base], p.slice(base.length + 1)) 找到了最終路徑,它就是 Vue.js 原始碼 web 目錄下的 entry-runtime.js。因此,web-runtime-cjs 配置對應的入口檔案就找到了。
它經過 Rollup 的構建打包後,最終會在 dist 目錄下生成 vue.runtime.common.js。

Runtime Only VS Runtime+Compiler

通常我們利用 vue-cli 去初始化我們的 Vue.js 專案的時候會詢問我們用 Runtime Only 版本的還是 Runtime+Compiler 版本。下面我們來對比這兩個版本。
• Runtime Only
我們在使用 Runtime Only 版本的 Vue.js 的時候,通常需要藉助如 webpack 的 vue-loader 工具把 .vue 檔案編譯成 JavaScript,因為是在編譯階段做的,所以它只包含執行時的 Vue.js 程式碼,因此程式碼體積也會更輕量。
• Runtime+Compiler
我們如果沒有對程式碼做預編譯,但又使用了 Vue 的 template 屬性並傳入一個字串,則需要在客戶端編譯模板,如下所示:
// 需要編譯器的版本

new Vue({
  template: `<div>{{ hi }}</div>`
})

// 這種情況不需要
new Vue({
  render (h) {
    return h(`div`, this.hi)
  }
})


因為在 Vue.js 2.0 中,最終渲染都是通過 render 函式,如果寫 template 屬性,則需要編譯成 render 函式,那麼這個編譯過程會發生執行時,所以需要帶有編譯器的版本。
很顯然,這個編譯過程對效能會有一定損耗,所以通常我們更推薦使用 Runtime-Only 的 Vue.js。

相關文章