深入學習 vue

yhstsy發表於2024-08-11

---
highlight: a11y-dark
theme: smartblue
---
# vue 的生命週期
由於 vue2 和 vue3 的生命週期區別並沒有很大,更多隻是名字上的區分和使用上的微小差異,因此下文中,以 vue2 鉤子函式 為例子,講解 vue 的生命週期。
## 生命週期鉤子
- **beforeCreate**:在例項初始化之後,資料觀測 (data observer) 和 event/watcher 事件配置之前被呼叫。
- 在當前階段,data、methods、computed 以及 watch 上的資料和方法都不能被訪問。
- **created**:例項已經建立完成之後被呼叫。
- 在這一步,例項已經完成以下配置:資料觀測(data observer),屬性和方法的運算,event/watcher 事件回撥。
- 這裡沒有 $el,如果需要與 Dom 進行互動,可以透過 vm.$nextTick 來訪問 Dom
- **beforeMount**:在掛載開始之前被呼叫。
- render 函式首次被呼叫。
- **mounted**:在掛載完成後發生。
- 在當前階段,真實的 Dom 掛載完畢,資料完成雙向繫結,可以訪問到 Dom 節點
- **beforeUpdate**:資料更新時呼叫,發生在虛擬 Dom 重新渲染和打補丁(patch)之前。
- 可以在這個鉤子中進一步地更改狀態,這不會觸發附加的重渲染過程。
- **updated**:發生在更新完成之後,當前階段元件 Dom 已完成更新。
- 避免在此期間更改資料,因為可能會導致無限迴圈的更新。
- 該鉤子在伺服器端渲染期間不再呼叫。
- **beforeDestroy**:例項銷燬之前呼叫。
- 在這一步,例項仍然完全可用。可以在這時進行善後收尾工作,比如清除計時器。
- **destroyed**:例項銷燬後呼叫。
- 這個階段,例項指示的所有東西都會解繫結,所有的事件監聽器會被移除,所有的子例項也會被銷燬。
- 該鉤子在伺服器端渲染期間不再呼叫。
- **activated**:元件被啟用時呼叫,***是 keep-alive 專屬***。
- **deactivated**:元件被銷燬時呼叫,***是 keep-alive 專屬***。
## 非同步請求發起階段
可以在鉤子函式 **created、beforeMount、mounted** 中進行非同步請求,因為在這三個鉤子函式中,data 已經建立,可以將服務端端返回的資料進行賦值。

如果非同步請求不需要依賴 Dom,可以在 **created** 鉤子函式中呼叫非同步請求,因為在 **created** 鉤子函式中呼叫非同步請求有以下優點:
- 能更快獲取到服務端資料,減少頁面 loading 時間
- ssr 不支援 beforeMount 、mounted 鉤子函式,所以放在 created 中有助於一致性
## 原始碼分析
### --- 初始化流程
從 `new Vue(options)` 開始作為入口,`Vue` 只是一個簡單的建構函式,內部是這樣的:
```js
function Vue(options) {
this._init(options)
}
Vue.prototype._init = function(options?: Object) {
const vm: Component = this
// a uid
vm._uid = uid++

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

// 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)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')

/* istanbul ignore if */
if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
vm._name = formatComponentName(vm, false)
mark(endTag)
measure(`vue ${vm._name} init`, startTag, endTag)
}

if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
```
進入了 `_init` 函式之後,先初始化了一些屬性。

1. `initLifecycle`:初始化一些屬性如`$parent,$children`。根例項沒有 `$parent,$children` 開始是空陣列,直到它的 子元件 例項進入到 `initLifecycle` 時,才會往父元件的 `$children` 裡把自身放進去。所以 `$children` 裡的一定是元件的例項。
2. `initEvents`:初始化事件相關的屬性,如 `_events` 等。
3. `initRender`:初始化渲染相關如 `$createElement`,並且定義了 `$attrs` 和 `$listeners` 為淺層響應式屬性。具體可以檢視細節章節。並且還定義了`$slots、$scopedSlots`,其中 `$slots` 是立刻賦值的,但是 `$scopedSlots` 初始化的時候是一個 `emptyObject`,直到元件的 `vm._render` 過程中才會透過 `normalizeScopedSlots` 去把真正的 `$scopedSlots` 整合後掛到 `vm` 上。

然後開始第一個生命週期:
```js
callHook(vm, 'beforeCreate')
```

#### 一、beforeCreate 之後:
1. 初始化 `inject`
2. 初始化 `state`
- 初始化 `props`
- 初始化 `methods`
- 初始化 `data`
- 初始化 `computed`
- 初始化 `watch`
3. 初始化 `provide`

所以在 `data` 中可以使用 `props` 上的值,反過來則不行。

然後進入 `created` 階段:
```js
callHook(vm, 'created')
```

#### 二、created 被呼叫完成

呼叫 `$mount` 方法,開始掛載元件到 `Dom` 上。

如果使用了 `runtime-with-compile` 版本,則會把你傳入的 `template` 選項,或者 `html` 文字,透過一系列的編譯生成 `render` 函式。

- 編譯這個 template,生成 ast 抽象語法樹。
- 最佳化這個 ast,標記靜態節點。(渲染過程中不會變的那些節點,最佳化效能)。
- 根據 ast,生成 render 函式。

對應具體的程式碼就是:
```js
const ast = parse(template.trim(), options)
if (options.optimize !== false) {
optimize(ast, options)
}
const code = generate(ast, options)
```
#### 三、beforeMount 被呼叫完成

把 渲染元件的函式 定義好,具體程式碼是:
```js
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
```
`vm._render` 其實就是呼叫上一步拿到的 `render` 函式生成一個 `vnode`,而 `vm._update` 方法則會對這個 `vnode` 進行 `patch` 操作, 把 `vnode` 透過 `createElm` 函式建立新節點並且渲染到 `dom節點` 中。

接下來是由 `響應式原理` 的一個核心類 `Watcher` 負責執行這個函式。
> 為什麼要 Watcher 來代理執行呢?
>
> 因為我們需要在這段過程中去 觀察 這個函式讀取了哪些響應式資料,將來這些響應式資料更新的時候,我們需要重新執行 `updateComponent` 函式。
>

如果是更新後呼叫 `updateComponent` 函式的話,`updateComponent` 內部的 `patch` 就不再是初始化時候的建立節點,而是對新舊 `vnode` 進行 `diff`,最小化的更新到 `dom節點` 上去,這個過程使用的是 diff 演算法。
這一切交給 Watcher 完成:
```js
new Watcher(
vm,
updateComponent,
noop,
{
before() {
if (vm._isMounted) {
callHook(vm, 'beforeUpdate')
}
},
},
true /* isRenderWatcher */
)
```
這裡在 `before` 屬性上定義了 `beforeUpdate` 函式,也即,在 `Watcher` 被響應式屬性的更新觸發之後,重新渲染新檢視之前,會先呼叫 `beforeUpdate` 生命週期。

> 注意,在 `render` 的過程中,如果遇到了 子元件,則會呼叫 `createComponent` 函式。

`createComponent` 函式內部,會為子元件生成一個屬於自己的建構函式,可以理解為子元件自己的 `Vue` 函式:
```js
Ctor = baseCtor.extend(Ctor)
```
在普通的場景下,其實這就是 `Vue.extend` 生成的建構函式,它繼承自 `Vue` 函式,擁有它的很多全域性屬性。

> 除了元件有自己的生命週期外,其實 `vnode` 也有自己的 `生命週期`。

那麼子元件的 `vnode` 會有自己的 `init` 週期,這個週期內部會做這樣的事情:
```js
// 建立子元件
const child = createComponentInstanceForVnode(vnode)
// 掛載到 dom 上
child.$mount(vnode.elm)
```
深入察看,`createComponentInstanceForVnode` 內部會呼叫 `子元件` 的建構函式。
```js
new vnode.componentOptions.Ctor(options)
```
繼續深入,建構函式的內部:
```js
const Sub = function VueComponent(options) {
this._init(options)
}
```
這個 `_init` 其實就是本文開頭的那個函式。

如果遇到 子元件,那麼就會優先開始子元件的構建過程,也就是說,從 `beforeCreated` 重新開始。這是一個遞迴的構建過程。

也即,如果我們有 父 -> 子 -> 孫 這三個元件,那麼它們的初始化生命週期順序是這樣的:
```js
父 beforeCreate
父 created
父 beforeMount
子 beforeCreate
子 created
子 beforeMount
孫 beforeCreate
孫 created
孫 beforeMount
孫 mounted
子 mounted
父 mounted
```
然後,`mounted` 生命週期被觸發。
#### 四、mounted 被呼叫完成
至此,元件的掛載就完成了,初始化的生命週期結束。
### --- 更新流程
當一個響應式屬性被更新後,觸發了 `Watcher` 的回撥函式,也就是 `vm._update(vm._render())`,在更新之前,會先呼叫剛才在 `before` 屬性上定義的函式,也即:
```js
callHook(vm, 'beforeUpdate')
```
> 注意,由於 `Vue` 的非同步更新機制,`beforeUpdate` 的呼叫已經是在 `nextTick` 中了。具體程式碼如下:
```js
nextTick(flushSchedulerQueue)

function flushSchedulerQueue {
for (index = 0; index < queue.length; index++) {
watcher = queue[index]
if (watcher.before) {
// callHook(vm, 'beforeUpdate')
watcher.before()
}
}
}
```
#### 五、beforeUpdate 被呼叫完成

然後經歷了一系列的 `patch、diff` 流程後,元件重新渲染完畢,呼叫 `updated` 鉤子。

> 注意,這裡是對 `watcher` 倒序 `updated` 呼叫的。
```js
function callUpdatedHooks(queue) {
let i = queue.length
while (i--) {
const watcher = queue[i]
const vm = watcher.vm
if (vm._watcher === watcher && vm._isMounted) {
callHook(vm, 'updated')
}
}
}
```
也即,假如同一個屬性透過 `props` 分別流向 `父 -> 子 -> 孫` 這個路徑,那麼收集到依賴的先後也是這個順序,但是觸發 `updated` 鉤子確是 `孫 -> 子 -> 父` 這個順序去觸發的。
#### 六、updated 被呼叫完成
至此,渲染更新流程完畢。
### --- 銷燬流程
在上文提及的更新後的 `patch` 過程中,如果發現有元件在下一輪渲染中消失了,比如 `v-for` 對應的陣列中少了一個資料。那麼就會呼叫 `removeVnodes` 進入元件的銷燬流程。

`removeVnodes` 會呼叫 `vnode` 的 `destroy` 生命週期,而 `destroy` 內部則會呼叫我們相對比較熟悉的 `vm.$destroy()`。(`keep-alive` 包裹的子元件除外) 這時,就會呼叫 `callHook(vm, 'beforeDestroy')`。

#### 七、beforeDestroy 被呼叫完成

beforeDestroy 呼叫完成,之後就會經歷一系列的清理:比如清除父子關係、watcher 關閉等邏輯。但是注意,$destroy 並不會把元件從檢視上移除,如果想要手動銷燬一個元件,則需要我們自己去完成這個邏輯。

然後,呼叫最後的 `callHook(vm, 'destroyed')`

#### destroyed 被呼叫完成
至此,銷燬流程完畢。
# 元件的通訊
vue 元件間通訊主要有以下七種方式:
## (一)props / `$emit`
常用的**父子**間通訊方式。父元件向子元件**傳值**,透過繫結屬性來向子元件傳入資料,子元件透過 **Props 屬性**獲取對應資料。

- 適用場景:父元件傳遞資料給子元件
- 子元件設定 props 屬性,定義接收父元件傳遞過來的引數
- 父元件在使用子元件標籤中透過字面量來傳遞值
- 適用場景:子元件傳遞資料給父元件
- 子元件透過 `$emit` 觸發自定義事件,`$emit` 第二個引數為傳遞的數值
- 父元件繫結監聽器獲取到子元件傳遞過來的引數
## (二)`$emit` / `$on`
**eventBus** 中央事件匯流排

這個方法是透過建立了一個空的 **vue 例項**,當做 $emit 事件的處理中心(事件匯流排),透過他來觸發以及監聽事件,方便的實現了**任意元件**間的通訊,包含父子,兄弟,隔代元件。

- 使用場景:兄弟元件傳值
- 建立一箇中央時間匯流排 EventBus
- 兄弟元件透過 `$emit` 觸發自定義事件,`$emit` 第二個引數為傳遞的數值
- 另一個兄弟元件透過 `$on` 監聽自定義事件
## (三)`$attrs` / `$listeners`
適用於建立高階別的元件或者封裝第三方元件。

- `$attrs`:包含了父作用域中不作為 Prop 被識別 (且獲取) 的特性繫結(Class 和 Style 除外)。當一個元件沒有宣告任何 Prop 時,這裡會包含所有父作用域的繫結 (Class 和 Style 除外),並且可以透過 `v-bind="$attrs"` 傳入內部元件。

- `$listeners`:包含了父作用域中的 (不含 `.native`修飾器的) `v-on` 事件監聽器。它可以透過 `v-on="$listeners"` 傳入內部元件。

- 適用場景:**祖先**傳遞資料給**子孫**

- 設定批次向下傳屬性 `$attrs` 和 `$listeners`

- 包含了父級作用域中不作為 prop 被識別 (且獲取) 的特性繫結 ( class 和 style 除外)。

- 可以透過 `v-bind="$attrs"` 傳⼊內部組
## (四)Provider / Inject
這對選項需要一起使用,以允許一個**祖先**元件向其**所有子孫後代**注入一個**依賴**,不論元件層次有多深,並在其上下游關係成立的時間裡始終生效。

簡單來說,就是父元件透過 Provider 傳入變數,**任意**子孫元件透過 Inject 來拿到變數。

Provide 和 Inject 繫結並不是可響應的。這是刻意為之的。然而,如果你傳入了一個可監聽的物件,那麼其物件的**屬性**還是**可響應**的。

Provider / Inject 在專案中需要有較多公共傳參時使用還是頗為方便的。傳輸資料父級一次注入,子孫元件一起共享的方式。

- 在**祖先**元件定義 provide 屬性,返回傳遞的值
- 在**後代**元件透過 inject 接收元件傳遞過來的值
## (五)`$parent` / `$children`
指定已建立的例項之**父例項**,在兩者之間建立父子關係。子例項可以用 `this.$parent` 訪問父例項,子例項被推入父例項的 `$children` 陣列中。**不能跨級以及兄弟間通訊**。
## (六)`$refs`
一個物件,持有註冊過 ref 的所有 DOM 元素和元件例項。

ref 被用來給元素或子元件註冊引用資訊。引用資訊將會註冊在父元件的 `$refs` 物件上。如果在普通的 DOM 元素上使用,引用指向的就是 DOM 元素;如果用在子元件上,引用就指向元件。**不能跨級以及兄弟間通訊**。

- 父元件在使用子元件的時候設定 ref
- 父元件透過設定**子元件 ref** 來獲取資料
## (七)Vuex
vue 狀態管理模式。它採用集中式儲存管理應用的所有元件的狀態,並以相應的規則保證狀態以一種可預測的方式發生變化。

Vuex 實現了一個**單項資料流**,透過建立一個全域性的 State 資料,元件想要修改 State 資料只能透過 Mutation 來進行,例如頁面上的操作想要修改 State 資料時,需要透過 Dispatch (觸發 Action ),而 Action 也不能直接運算元據,還需要透過 Mutation 來修改 State 中資料,最後根據 State 中資料的變化,來渲染頁面。

1. Mutation: 是修改 State 資料的唯一推薦方法,且只能進行**同步**操作。
2. Getter: Vuex 允許在 Store 中定義 “ Getter ”(類似於 Store 的計算屬性)。Getter 的返回值會根據他的依賴進行快取,只有依賴值發生了變化,才會重新計算。統一的維護了一份共同的 State 資料,方便元件間共同呼叫。

- state 用來存放共享變數的地方
- getter 可以增加一個 getter 派生狀態,(相當於store中的計算屬性),用來獲得共享變數的值
- mutations 用來存放修改 state 的方法。
- actions 也是用來存放修改 state 的方法,不過 action 是在 mutations 的基礎上進行。常用來做一些**非同步**操作。

## Vue 元件通訊總結
- 父子通訊:`Props/$emit`,`$emit/$on`,`Vuex`,`$attrs/$listeners`,`provide/inject`,`$parent/$children`,`$refs`
- 兄弟通訊:`$emit/$on`,`Vuex`
- 隔代(跨級)通訊:`$emit/$on`,`Vuex`,`provide/inject`,`$attrs/$listeners`

# Vue 路由原理
## hash 模式
vue-router 預設是 **hash** 模式,即使用 URL 的 hash 來模擬一個完整的 URL,於是當 URL 改變時,頁面不會重新載入。hash(#)雖然在 url 中,但不會被包括在http 請求中,對後端完全沒有影響,因此**改變 hash 不會重新載入頁面**。

## history 模式
路由的 **history** 模式充分利用 `history.pushState` API 來完成 URL 跳轉而無須重新載入頁面。若想配置這種模式,還需要**後臺配置支援**。因為當前 vue 應用是個單頁客戶端應用(SPA),如果後臺沒有正確的配置,當使用者在瀏覽器直接訪問 `http://oursite.com/user/id` 就會返回 404。

為了避免這種情況,開發者應該在 Vue 應用裡面覆蓋所有的路由情況,然後再配置一個 404 頁面。或者,使用 Node.js 伺服器,透過使用服務端路由匹配到來的 URL,並在沒有匹配到路由的時候返回 404,以實現回退。

history 模式,利用了 `html5 History Interface` 中新增的 pushState() 和 replaceState() 方法。這兩個方法應用於瀏覽器的**歷史記錄棧**,在當前已有的 back、forward、go 的基礎之上,它們提供了對歷史記錄進行修改的功能。只是當它們執行修改時,雖然改變了當前的 url,但瀏覽器不會立即向後端傳送請求。

hash 模式和 history 模式都屬於瀏覽器自身的特性,vue-router 只是利用了這兩個特性(透過瀏覽器提供的介面)來實現前端路由。pushState 方法不會觸發頁面重新整理,只是導致了 history 物件發生變化,位址列會有反應。如果 pushState 的 url 引數,設定了一個新的**錨點值**(即hash),並不會觸發 hashChange 事件,如果設定了一個跨域網址,則會報錯。

每當同一個文件的瀏覽歷史(即history)出現變化時,就會觸發 popState 事件。需要注意:僅僅呼叫 pushState 方法或 replaceState 方法,並不會觸發該事件,只有使用者點選瀏覽器後退和前進按鈕時,或者使用 js 呼叫 back、forward、go 方法時才會觸發。

另外該事件只針對**同一個文件**,如果瀏覽歷史的切換,導致載入不同的文件,該事件不會被觸發。使用的時候,可以為 popState 事件指定回撥函式。
> 注意:頁面第一次載入的時候,瀏覽器不會觸發popState事件。

相關文章