一、認識 Flow
-
Flow 是
facebook
出品的JavaScript
靜態類型檢查工具,和Typescript
功能類型。Vue.js
的源碼利用了Flow
做了靜態類型檢查,所以瞭解Flow
有助於我們閱讀源碼。 -
使用
Flow
的原因,如下所示:
-
JavaScript
是動態類型語言,它的靈活性有目共睹,但是過於靈活的副作用是很容易就寫出非常隱蔽的隱患代碼,在編譯期甚至看上去都不會報錯,但在運行階段就可能出現各種奇怪的bug
。 -
類型檢查是當前動態類型語言的發展趨勢,所謂類型檢查,就是在編譯期儘早發現(由類型錯誤引起的)
bug
,又不影響代碼運行(不需要運行時動態檢查類型),使編寫JavaScript
具有和編寫Java
等強類型語言相近的體驗。 -
項目越複雜就越需要通過工具的手段來保證項目的維護性和增強代碼的可讀性。
Vue.js
在做2.0
重構的時候,在ES2015
的基礎上,除了ESLint
保證代碼風格之外,也引入了Flow
做靜態類型檢查。之所以選擇Flow
,主要是因爲Babel
和ESLint
都有對應的Flow
插件以支持語法,可以完全沿用現有的構建配置,非常小成本的改動就可以擁有靜態類型檢查的能力。
Flow
的工作方式,通常類型檢查分成兩種方式,如下所示:
-
類型推斷:通過變量的使用上下文來推斷出變量類型,然後根據這些推斷來檢查類型。
-
類型註釋:事先註釋好我們期待的類型,
Flow
會基於這些註釋來判斷。
-
類型推斷:它不需要任何代碼修改即可進行類型檢查,最小化開發者的工作量。它不會強制你改變開發習慣,因爲它會自動推斷出變量的類型。這就是所謂的類型推斷,
Flow
最重要的特性之一。可以通過一個簡單例子說明一下:/*@flow*/ function split(str) { return str.split(' ') } split(11)
Flow 檢查上述代碼後會報錯,因爲函數
split
期待的參數是字符串,而我們輸入了數字。 -
類型註釋:類型推斷是
Flow
最有用的特性之一,不需要編寫類型註釋就能獲取有用的反饋。但在某些特定的場景下,添加類型註釋可以提供更好更明確的檢查依據,考慮如下代碼:/*@flow*/ function add(x, y){ return x + y } add('Hello', 11)
Flow 檢查上述代碼時檢查不出任何錯誤,因爲從語法層面考慮,
+
既可以用在字符串上,也可以用在數字上,我們並沒有明確指出add()
的參數必須爲數字。在這種情況下,我們可以藉助類型註釋來指明期望的類型。類型註釋是以冒號
:
開頭,可以在函數參數,返回值,變量聲明中使用。如果我們在上段代碼中添加類型註釋,就會變成如下:
/*@flow*/ function add(x: number, y: number): number { return x + y } add('Hello', 11)
現在
Flow
就能檢查出錯誤,因爲函數參數的期待類型爲數字,而我們提供了字符串。
上面的例子是針對函數的類型註釋。接下來我們來看看 Flow
能支持的一些常見的類型註釋,如下所示:
- 數組,如下所示:
/*@flow*/
var arr: Array<number> = [1, 2, 3]
arr.push('Hello')
數組類型註釋的格式是
Array<T>
,T
表示數組中每項的數據類型。在上述代碼中,arr
是每項均爲數字的數組。如果我們給這個數組添加了一個字符串,Flow
能檢查出錯誤。
- 類和對象,如下所示:
/*@flow*/
class Bar {
x: string; // x 是字符串
y: string | number; // y 可以是字符串或者數字
z: boolean;
constructor(x: string, y: string | number) {
this.x = x
this.y = y
this.z = false
}
}
var bar: Bar = new Bar('hello', 4)
var obj: { a: string, b: number, c: Array<string>, d: Bar } = {
a: 'hello',
b: 11,
c: ['hello', 'world'],
d: new Bar('hello', 3)
}
類的類型註釋格式如上,可以對類自身的屬性做類型檢查,也可以對構造函數的參數做類型檢查。這裏需要注意的是,屬性
y
的類型中間用|
做間隔,表示y
的類型即可以是字符串也可以是數字。對象的註釋類型類似於類,需要指定對象屬性的類型。
- Null,如下所示:
若想任意類型
T
可以爲null
或者undefined
,只需類似如下寫成?T
的格式即可。
/*@flow*/
var foo: ?string = null
此時,
foo
可以爲字符串,也可以爲null
。
Flow
在Vue.js
源碼中的應用,如下所示:
-
有時候我們想引用第三方庫,或者自定義一些類型,但
Flow
並不認識,因此檢查的時候會報錯。爲了解決這類問題,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
有很多自定義類型的定義,在閱讀源碼的時候,如果遇到某個類型並想了解它完整的數據結構的時候,可以回來翻閱這些數據結構的定義。
- 總結:通過對
Flow
的認識,有助於我們閱讀Vue
的源碼,並且這種靜態類型檢查的方式非常有利於大型項目源碼的開發和維護。
二、Vue.js 源碼目錄設計
Vue.js
的源碼都在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
入口打包後的Vue.js
- 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
的目錄設計可以看到,把功能模塊拆分的非常清楚,相關的邏輯放在一個獨立的目錄下維護,並且把複用的代碼也抽成一個獨立目錄。這樣的目錄設計讓代碼的閱讀性和可維護性都變強,是非常值得學習和推敲的。
三、Vue.js 源碼構建
-
Vue.js
源碼是基於 Rollup 構建的,它的構建相關配置都在scripts
目錄下。 -
構建腳本,如下所示:
- 通常一個基於
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"
}
}
這裏總共有 3 條命令,作用都是構建 Vue.js,後面 2 條是在第一條命令的基礎上,添加一些環境參數。當在命令行運行
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 CommonJS build (CommonJS)
'web-full-cjs': {
entry: resolve('web/entry-runtime-with-compiler.js'),
dest: resolve('dist/vue.common.js'),
format: 'cjs',
alias: { he: './entity-decoder' },
banner
},
// Runtime only (ES Modules). Used by bundlers that support ES Modules,
// e.g. Rollup & Webpack 2
'web-runtime-esm': {
entry: resolve('web/entry-runtime.js'),
dest: resolve('dist/vue.runtime.esm.js'),
format: 'es',
banner
},
// Runtime+compiler CommonJS build (ES Modules)
'web-full-esm': {
entry: resolve('web/entry-runtime-with-compiler.js'),
dest: resolve('dist/vue.esm.js'),
format: 'es',
alias: { he: './entity-decoder' },
banner
},
// runtime-only build (Browser)
'web-runtime-dev': {
entry: resolve('web/entry-runtime.js'),
dest: resolve('dist/vue.runtime.js'),
format: 'umd',
env: 'development',
banner
},
// runtime-only production build (Browser)
'web-runtime-prod': {
entry: resolve('web/entry-runtime.js'),
dest: resolve('dist/vue.runtime.min.js'),
format: 'umd',
env: 'production',
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
},
// ...
}
-
這裏列舉了一些
Vue.js
構建的配置,關於還有一些服務端渲染webpack
插件以及weex
的打包配置就不列舉了。 -
對於單個配置,它是遵循
Rollup
的構建規則的。其中entry
屬性表示構建的入口JS
文件地址,dest
屬性表示構建後的JS
文件地址。format
屬性表示構建的格式,cjs
表示構建出來的文件遵循 CommonJS 規範,es
表示構建出來的文件遵循 ES Module 規範。umd
表示構建出來的文件遵循 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)
}
}
這裏的
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。 -
- 總結:可以瞭解到
Vue.js
的構建打包過程,也知道了不同作用和功能的Vue.js
它們對應的入口以及最終編譯生成的JS
文件。儘管在實際開發過程中我們會用Runtime Only
版本開發比較多,但爲了分析Vue
的編譯過程,重點分析的源碼是Runtime + Compiler
的Vue.js
。
四、入口開始
- 我們之前提到過
Vue.js
構建過程,在web
應用下,我們來分析Runtime + Compiler
構建出來的Vue.js
,它的入口是src/platforms/web/entry-runtime-with-compiler.js
:
/* @flow */
import config from 'core/config'
import { warn, cached } from 'core/util/index'
import { mark, measure } from 'core/util/perf'
import Vue from './runtime/index'
import { query } from './util/index'
import { compileToFunctions } from './compiler/index'
import { shouldDecodeNewlines, shouldDecodeNewlinesForHref } from './util/compat'
const idToTemplate = cached(id => {
const el = query(id)
return el && el.innerHTML
})
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)
}
/**
* Get outerHTML of elements, taking care
* of SVG elements in IE as well.
*/
function getOuterHTML (el: Element): string {
if (el.outerHTML) {
return el.outerHTML
} else {
const container = document.createElement('div')
container.appendChild(el.cloneNode(true))
return container.innerHTML
}
}
Vue.compile = compileToFunctions
export default Vue
那麼,當我們的代碼執行
import Vue from 'vue'
的時候,就是從這個入口執行代碼來初始化 Vue,
那麼 Vue 到底是什麼,它是怎麼初始化的。
Vue
的入口,如下所示:
- 在這個入口
JS
的上方我們可以找到Vue
的來源:import Vue from './runtime/index'
,我們先來看一下這塊兒的實現,它定義在src/platforms/web/runtime/index.js
中:
import Vue from 'core/index'
import config from 'core/config'
import { extend, noop } from 'shared/util'
import { mountComponent } from 'core/instance/lifecycle'
import { devtools, inBrowser, isChrome } from 'core/util/index'
import {
query,
mustUseProp,
isReservedTag,
isReservedAttr,
getTagNamespace,
isUnknownElement
} from 'web/util/index'
import { patch } from './patch'
import platformDirectives from './directives/index'
import platformComponents from './components/index'
// install platform specific utils
Vue.config.mustUseProp = mustUseProp
Vue.config.isReservedTag = isReservedTag
Vue.config.isReservedAttr = isReservedAttr
Vue.config.getTagNamespace = getTagNamespace
Vue.config.isUnknownElement = isUnknownElement
// install platform runtime directives & components
extend(Vue.options.directives, platformDirectives)
extend(Vue.options.components, platformComponents)
// install platform patch function
Vue.prototype.__patch__ = inBrowser ? patch : noop
// public mount method
Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && inBrowser ? query(el) : undefined
return mountComponent(this, el, hydrating)
}
// ...
export default Vue
這裏關鍵的代碼是
import Vue from 'core/index'
,之後的邏輯都是對 Vue 這個對象做一些擴展,可以先不用看,我們來看一下真正初始化Vue
的地方,在src/core/index.js
中:
import Vue from './instance/index'
import { initGlobalAPI } from './global-api/index'
import { isServerRendering } from 'core/util/env'
import { FunctionalRenderContext } from 'core/vdom/create-functional-component'
initGlobalAPI(Vue)
Object.defineProperty(Vue.prototype, '$isServer', {
get: isServerRendering
})
Object.defineProperty(Vue.prototype, '$ssrContext', {
get () {
/* istanbul ignore next */
return this.$vnode && this.$vnode.ssrContext
}
})
// expose FunctionalRenderContext for ssr runtime helper installation
Object.defineProperty(Vue, 'FunctionalRenderContext', {
value: FunctionalRenderContext
})
Vue.version = '__VERSION__'
export default Vue
這裏有 2 處關鍵的代碼,
import Vue from './instance/index'
和initGlobalAPI(Vue)
,初始化全局 Vue API(我們稍後介紹),我們先來看第一部分,在src/core/instance/index.js
中。
Vue
的定義,如下所示:
import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'
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)
}
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)
export default Vue
-
在這裏,我們終於看到了
Vue
它實際上就是一個用Function
實現的類,我們只能通過new Vue
去實例化它 -
爲何
Vue
不用ES6
的Class
去實現呢?我們往後看這裏有很多xxxMixin
的函數調用,並把Vue
當參數傳入,它們的功能都是給Vue
的prototype
上擴展一些方法,Vue
按功能把這些擴展分散到多個模塊中去實現,而不是在一個模塊裏實現所有,這種方式是用Class
難以實現的。這麼做的好處是非常方便代碼的維護和管理,這種編程技巧也非常值得我們去學習
initGlobalAPI
,如下所示:
Vue.js
在整個初始化過程中,除了給它的原型prototype
上擴展方法,還會給Vue
這個對象本身擴展全局的靜態方法,它的定義在src/core/global-api/index.js
中:
export function initGlobalAPI (Vue: GlobalAPI) {
// config
const configDef = {}
configDef.get = () => config
if (process.env.NODE_ENV !== 'production') {
configDef.set = () => {
warn(
'Do not replace the Vue.config object, set individual fields instead.'
)
}
}
Object.defineProperty(Vue, 'config', configDef)
// exposed util methods.
// NOTE: these are not considered part of the public API - avoid relying on
// them unless you are aware of the risk.
Vue.util = {
warn,
extend,
mergeOptions,
defineReactive
}
Vue.set = set
Vue.delete = del
Vue.nextTick = nextTick
Vue.options = Object.create(null)
ASSET_TYPES.forEach(type => {
Vue.options[type + 's'] = Object.create(null)
})
// this is used to identify the "base" constructor to extend all plain-object
// components with in Weex's multi-instance scenarios.
Vue.options._base = Vue
extend(Vue.options.components, builtInComponents)
initUse(Vue)
initMixin(Vue)
initExtend(Vue)
initAssetRegisters(Vue)
}
- 這裏就是在
Vue
上擴展的一些全局方法的定義,Vue
官網中關於全局API
都可以在這裏找到。有一點要注意的是,Vue.util
暴露的方法最好不要依賴,因爲它可能經常會發生變化,是不穩定的
- 總結:那麼
Vue
的初始化過程基本介紹完畢。Vue
它本質上就是一個用Function
實現的Class
,然後它的原型prototype
以及它本身都擴展了一系列的方法和屬性。