總覽 Vue3 的單向數據流 盡信官網,不如那啥。 vue的版本一直在不斷更新,內部實現方式也是不斷的優化,官網也在不斷更新。 既然一切皆在不停地發展,那麼我們呢?等著官網更新還是有自己的思考? 我覺得我們要走在官網的前面,而不是等官網更新後,才知道原來可以這麼實現。。。 我習慣先給大家一個整體的概 ...
總覽 Vue3 的單向數據流
盡信官網,不如那啥。
vue的版本一直在不斷更新,內部實現方式也是不斷的優化,官網也在不斷更新。
既然一切皆在不停地發展,那麼我們呢?等著官網更新還是有自己的思考?
我覺得我們要走在官網的前面,而不是等官網更新後,才知道原來可以這麼實現。。。
我習慣先給大家一個整體的概念,然後再介紹各個細節。
腦圖版
先整理一下和單向數據流有關的信息,做個腦圖:
大綱版
列個大綱看看:
- 自動版
- v-model、emit(defineModel):組成無障礙通道,實現父子組件之間的值類型的響應性。
- pinia.$state、pinia.$patch:狀態管理提供的方法。
- props + reactive:直接改 reactive,爭議比較大
- 註入 + reactive:直接改 reactive,一般可以忍受
- 手動版
- 註入 + reactive + function:官網建議通過 function 改 reactive,而不是直接改 reactive。
- 狀態管理的getter、mutation、action:狀態管理,其實也涉及到了單向數據流。
- props是否可以直接改?(從代碼的角度來分析)
- 值類型:不可改,否則響應性就崩了。
- 引用類型:地址不可改,但是屬性可以改。對於引用類型,其實都是通過 reactive 實現響應性的。
- 有無意義的角度 (這是一個挨罵的話題)
- 有意義的方式:實現響應性的唯一方式,或者有記錄(timeline)、有驗證、限制等。
- 無意義的方式:沒有上面說的功能,還自認為是嚴格遵守規矩。
- 限制的是誰?
- 發起者:如果是限制子組件不能發起修改的話,那麼任何方式都應該不能被允許,emit 也不行。
- 方式(手段):如果只是限制一些方式的話,那麼為啥 emit 可以,reactive 就不能直接改?有啥區別呢?
- 二者都沒有做記錄(timeline),
- 沒有做任何限制、驗證。
畫個表格對比一下:
再來看看各種方式的對比:
方式 | 實現手段 | 有無記錄 | 有無限制、驗證 | 官網意見 | 適合場景 |
---|---|---|---|---|---|
v-model + emit | 拋出事件 | 無 | 無 | 可以 | 以前的方式 |
v-model + defineModel | 拋出事件 | 無 | 無 | 推薦 | V3.4 推薦的方式 |
props + reactive | 代理,set | 無 | 無 | 不推薦 | 適合傳遞引用類型 |
註入 + reactive | 代理,set | 無 | 無 | 不建議直接改reactive | 適合多層級的組件結構 |
註入 + reactive + function | 調用指定的函數 | 可以有 | 可以有 | 推薦方式 | 適合特殊需求 |
pinia.$patch、$state | 代理,set等 | timeline | 無 | ||
pinia 的 getter、 action | 調用指定的函數 | timeline | 可以有 |
這樣應該有一個明確的總體感覺了吧。
props 的單向數據流
為啥弄得這麼複雜?還不是因為兩點:
- vue 自帶響應性,主要是 reactive有點太“逆天”。
- composition API,可以把響應性分離出來單獨使用。
如果沒有 reactive,那麼也就不會這麼亂糟糟的了,讓我們細細道來。
props 本身是單向的
https://cn.vuejs.org/guide/components/props.html#one-way-data-flow
官網裡關於 props 的單向數據流是這樣描述的:
所有的 props 都遵循著單向綁定原則,props 因父組件的更新而變化,自然地將新的狀態向下流往子組件,而不會逆向傳遞。
這避免了子組件意外修改父組件的狀態的情況,不然應用的數據流將很容易變得混亂而難以理解。
整理一下重點:
- props 本身是單向的,只能接收父組件傳入的數據,本身不具有改變父組件數據的能力。
- 父組件的(響應性)數據如果變化,會通知 props 進行更新。
- props.xxxx ,自帶響應性。
- props 不具有修改父組件數據的能力,這樣就避免了父組件的數據被意外修改而受到影響。
- 否則,數據流向 會混亂,導致難以理解
其實 props 本來就是單向的,用於子組件接收父組件傳入的數據,完全沒有讓子組件修改父組件里的數據的功能。
那麼為何還要強調單向數據流呢?原因有二:引用類型 和 reactive!
props可以設置兩種數據類型:
- 值類型(數字、字元串等),用於簡單情況,比如 input、select 的值等。
- 引用類型(對象、數組等),用於複雜情況,比如表單、驗證信息、查詢條件等。
現在,僅從代碼的角度看看 props 在什麼情況可以改、不可以改。
- 值類型,那是肯定不能直接改,直接改就破壞了響應性,父子組件的數據也對應不上。
- 引用類型,又分為兩種情況:改地址、改屬性。
- 改地址,那當然也是不行滴!同上,地址換了怎麼找到你家?
- 如果傳入的是普通對象,雖然可以改屬性,但是沒有響應性;
- 如果傳入的是 reactive 的話,那就可以改其屬性了,因為 reactive 自帶響應性。
那麼問題來了:
- reactive 在父組件可以改,不會難以理解。
- reactive 通過依賴註入的方式給子組件,雖然官網不建議直接改,但是就問問你,你會不會直接改?
- reactive 通過 props 的方式給子組件,為啥一改就混亂而難以理解了呢?
- 【重點】單向數據流,限制的是發起者,還是“渠道”?
所以重點就是這個 reactive !如果沒有他,props 即使直接改了,也無法保證響應性,從而被我們所拋棄,也就不用糾結和爭論了。
那麼 reactive 到底是怎麼回事?大家先不要著急,先看看官網允許的情況,然後再對比思考。那誰不是說了嗎,沒有對比就沒有那啥。。。
為什麼會混亂?想到了一種可能性:父組件定義了一個 reactive 的數據,然後通過 props 傳遞個多個子組件,然後某個子組件裡面還有很多子子組件,也傳入了這個數據。
某個時候發現狀態異常變更,那麼問題來了:到底是誰改了狀態?(後續跟進)
emit 怎麼可以改了?
emit 本意是子組件向父組件拋出一個事件,然後 vue 內部提供了一種方式(update:XXXXX),可以實現子組件修改父組件的需求。
<!-- Child.vue -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<input
:value="props.modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</template>
update:XXX 可以視為內部標識,會特殊處理這個 emit。
好了,這裡不討論具體是如何實現了,而是要討論一下,不是說好的單向數據流,子組件不能改父組件的嗎?不是說改了會導致混亂而難以理解嗎?
官方的說法:emit 並不是直接修改,而是通過向父組件拋出一個事件,父組件響應這個事件來實現的。所以,不是直接改,並沒有破壞單向數據流。
這個說法嘛,確實很官方。只是從結果來看,還是子組件發起了狀態的變更,那麼問題來了,如果是上面的那種情況,可以方便獲知是誰改了狀態嗎?(似乎也會導致混亂和難以理解吧)
那麼問題來了:單向數據流,是限制發起者,還是手段?
- 如果限制的是發起者的話,那麼 emit 也不行,因為也是在子組件發起的,啥時候改,怎麼改都是由子組件決定,emit只是一個無障礙通道的起始端,另一端是 v-model。
- 如果限制手段的話,那麼不同的手段到底有啥區別?為啥 emit 可以,reactive 就不可以?
不要鑽牛角尖了,其實是有一個很實際的需求:
- 父子組件之間要保持響應性
- 子組件有“直接”改的要求
舉個例子,各種 UI庫 都有 xx-input 組件,外面用 v-model 綁定一個變數,然後 xx-input 裡面必須可以修改傳入的變數,而且要保持響應性對吧,否則咋辦?
v-model + emit 就是解決這個實際需求的。(解決問題,給大家帶來方便,然後才會選擇vue,其餘其他的嘛。。。)
當然,可以使用 ref,但是 ref 的本體是一個class,屬於引用類型,如果傳入 ref 本體的話,相當於傳入一個對象給子組件。這個咋算?
vue 現在的做法是,template 會預設把 ref.value 傳給子組件,而不是 ref 本體,這樣傳入的還是基礎類型。
所以,這是實現父子組件之間,值類型的響應性的唯一方法。
defineModel,是直接改?
https://cn.vuejs.org/guide/components/v-model.html
defineModel 是 vue3.4 推出來的語法糖(穩定版),內部依然使用了 emit 的方式,所以可以視為和 emit 等效。
官網示例代碼:
<!-- Child.vue -->
<script setup>
const model = defineModel()
function update() {
model.value++
}
</script>
<template>
<div>Parent bound v-model is: {{ model }}</div>
</template>
官方的示例代碼,特意展示了一下可以在子組件“直接改”的特點。
看過內部實現代碼的都知道,其內部有一個內部變數,然後返回的是一個customerRef(官方說是ref),所以我們不是直接改 props,而是改 ref.value,然後內部通過 set 攔截,調用 emit 向父組件提交申請。
如果對內部原理感興趣可以看這裡:
依賴註入(provide/inject)也有單向數據流?
https://cn.vuejs.org/guide/components/provide-inject.html#working-with-reactivity
父子組件之間傳值,就不得不說說依賴註入,那麼是否存在“單向數據流”的問題呢?那也是必然應該存在呀,只是官網沒有直接明確說。
註意:依賴註入只負責傳遞數據,並不負責響應性。
官網的意思,是讓我們在父組件實現狀態的變更,然後把狀態和負責狀態變更的函數一起傳給(註入到)子組件,子組件不要直接改狀態,而是通過調用 【父組件傳入的函數】 來變更狀態。
官網原文:
當提供 / 註入響應式的數據時,建議儘可能將任何對響應式狀態的變更都保持在供給方組件中。這樣可以確保所提供狀態的聲明和變更操作都內聚在同一個組件內,使其更容易維護。
有的時候,我們可能需要在註入方組件中更改數據。在這種情況下,我們推薦在供給方組件內聲明並提供一個更改數據的方法函數:
官網推薦的方式是這樣的:
<!-- 在供給方組件內 -- > 父組件
<script setup>
import { provide, ref } from 'vue'
// 數據、狀態
const location = ref('North Pole')
// 變更狀態的函數
function updateLocation() {
location.value = 'South Pole'
}
// 提供數據和操作方法(function)
provide('location', {
location,
updateLocation
})
</script>
<!-- 在註入方組件 --> 子組件
<script setup>
import { inject } from 'vue'
// 被註入(得到)狀態和方法
const { location, updateLocation } = inject('location')
</script>
<template>
<!--調用函數修改狀態-->
<button @click="updateLocation">{{ location }}</button>
</template>
看著是不是有點眼熟?這讓我想起了 react 的 useState。
其實想一想,為啥非得學 react?react 的特點就是:不能變。所以當需要變更的時候,必須調用專門的 hooks 來處理。
但是 vue 的特點就是響應性呀,和 react 恰恰相反。
當然了,自己寫一個函數也是有好處的,比如:
const 張三 = reactive({name:'zs',age:20})
const setAge = (age) => {
if (age < 0) {
// 年齡不能是負數
}
// 其他驗證
// 通過驗證,賦值
張三.age = age
// 還可以做記錄(timeline)
}
這樣就不能瞎改年齡了。或者根據出生日期自動計算年齡。
不是說不能自己寫函數,而是說這個函數要有點意義。
狀態管理也涉及單向數據流嗎?
props 和註入說完了,那麼就來到了狀態管理,這裡以 pinia 為例。
狀態管理也涉及單向數據流嗎?那當然是必須滴呀,否則 Vuex 的時候,為啥總強調要通過 mutation 去變更狀態,而不要直接去改狀態?
$state 是直接改嗎?
那麼 pinia 為什麼提供了 $state 用於“直接”改狀態呢?這還得看看源碼:
- pinia.mjs 1541 行
Object.defineProperty(store, '$state', {
get: () => ((process.env.NODE_ENV !== 'production') && hot ? hotState.value : pinia.state.value[$id]),
set: (state) => {
/* istanbul ignore if */
if ((process.env.NODE_ENV !== 'production') && hot) {
throw new Error('cannot set hotState');
}
$patch(($state) => {
assign($state, state);
});
},
});
不太會TypeScript,所以我們來看看編譯後的代碼,是不是有點眼熟。
雖然錶面上看是直接修改,但是卻被 set 給攔截了,實際上是通過 $patch 和 Object.assign 實現的賦值操作。
這個和 defineModel 有點類似,錶面上看直接改,其實都是間接修改。
而 $patch 裡面還有一些操作,比如做記錄(timeline)。
store.xxx 是直接修改嗎?
可能你會說,$state 並不是狀態自己的屬性,當然不算直接修改了,那麼我們來試試直接修改狀態。
通過測試我們可以發現:
- 可以直接改狀態
- 可以產生記錄(timeline)
那麼是怎麼實現的呢?
- 其實 pinia 的狀態(store)也是 reactive。
pinia.mis:1436行
const store = reactive((process.env.NODE_ENV !== 'production') || USE_DEVTOOLS
? assign({
_hmrPayload,
_customProperties: markRaw(new Set()), // devtools custom properties
}, partialStore
// must be added later
// setupStore
)
: partialStore);
- 然後對 reactive 進行了監聽
pinia.mis:1409行
const partialStore = {
_p: pinia,
// _s: scope,
$id,
$onAction: addSubscription.bind(null, actionSubscriptions),
$patch,
$reset,
$subscribe(callback, options = {}) {
const removeSubscription = addSubscription(subscriptions, callback, options.detached, () => stopWatcher());
const stopWatcher = scope.run(() => watch(() => pinia.state.value[$id], (state) => {
if (options.flush === 'sync' ? isSyncListening : isListening) {
callback({
storeId: $id,
type: MutationType.direct,
events: debuggerEvents,
}, state);
}
}, assign({}, $subscribeOptions, options)));
return removeSubscription;
},
$dispose,
};
這裡的第10行,用 watch 對狀態的屬性進行了監聽,然後寫記錄(timeline)。
pinia 不僅沒有阻止我們直接改屬性,還很貼心的做了記錄。
pinia 的 timeline
以前就一直對這個 timeline 非常好奇,想知道記錄的是什麼,但是奈何各種原因總是看不到,現在vue 推出了,終於看到了。
這裡的記錄非常詳細,有狀態名稱、動作、屬性名稱、新舊值、觸發時間等等信息,只是有個小問題,到底是誰改了狀態? 沒發現有定位代碼位置的功能。
reactive 怎麼算?
好了,終於到了比較有爭議的 reactive 了,大家有沒有等著急?
首先 reactive 的本質是 Proxy,而 Proxy 是代理,這個想必大家都知道,所以我們可以設置這樣的代碼:
const 張三 = {
name:'zhangsan',
age:20
}
const 張三的代理 = reactive(張三)
const setAge = (age) => {
if (age < 0) {
// 年齡不能是負數
}
// 其他驗證
// 通過驗證後才能賦值
張三的代理.age = age
}
平時大家都是一步成,現在分成了兩步,是不是就很明確了呢。
張三是一個普通的對象,沒有響應性,張三的代理是 reactive 有響應性,是張三的代理。
所以,我們傳遞給子組件的是張三的代理,並不是張三本尊。
既然子組件根本就得不到張三的本尊,那麼又何來直接修改呢?
如果說通過 emit 是間接修改(拋出事件),那麼通過 reactive 也是通過代理間接修改的。
雖然一個是事件,一個是代理,但是有啥本質區別呢?事件是函數,Proxy 里的 set 也是函數呀。
同樣都是沒有記錄(timeline)、判斷、驗證、限制,想怎麼改就怎麼改。
如果你還不理解,可以看看這個演化過程。
階段一:參考官網裡面依賴註入的推薦方式
// 階段一:按照官網裡面註入的推薦方式
const person = reactive({
name:'zhangsan',
age:20
})
const setAge = (age) => {
person.age = age
}
// 通過 props 或者 依賴註入,把 proxyPerson 傳給子組件,
const proxyPerson = reactive({
// 使用 readonly 變成只讀形式,只能通過 setAge 修改。
person: readonly(person),
setAge
})
這樣子組件只能使用 setAge 修改,代理套上 readonly 之後,通過代理的修改方式都給堵死了,是嚴格遵守單向數據流了吧。
階段二:充血實體類,把數據和方法合在一起
// 階段二:充血實體類,把數據和方法合在一起
const person2 = {
name:'zhangsan',
_age:20, // 內部成員,相當於“本尊”
// set 攔截,其實也是一個函數,類似於代理。
set age(age) { // 攔截設置屬性
// 可以做驗證
this._age = age
},
get age(){ // 攔截讀取屬性
return this._age
}
}
// 給子組件用
const proxyPerson2 = reactive(person2)
// 子組件
// 表名上看是通過屬性修改,但是實際上被 set 攔截了,調用的是一個函數
proxyPerson2.age = 30
在父組件裡面把數據和變更方法合併,也是符合官網的建議對吧。
那麼看看階段二是不是有點眼熟?如果你熟悉 Proxy 和 reactive 內部原理的話,這不就是 reactive 內部代碼的一小部分嗎?
既然 reactive 都自帶了這種功能,那麼我們又何必自己手擼?
當然 reactive 也有點小問題,沒有內置記錄,不過我們可以用 watch 的 onTrigger 做記錄,詳細看下麵:
給 Pinia 加一個定位代碼的功能(支持 reactive)
小結
-
v-model + emit
目的是實現父子組件之間,值類型數據的響應性,如果不用 emit 的話,如何實現? -
defineModel
語法糖(巨集),封裝複雜的代碼,讓我們使用起來更方便。 -
狀態管理
pinia 提供了 timeline,彌補了 reactive 的不足,方便我們調試代碼,提供 $state 方便我們直接賦值。
給 Pinia 加一個定位代碼的功能(支持 reactive) -
reactive
我覺得可以直接改,因為本身就是一個代理(Proxy),直接用就好了。
如果外面再套一個 Proxy 有何意義呢?當然了,如果可以加上 timeline,或者是判斷、驗證等,那麼就有意義了。 -
數據 + 方法
可以在方法裡面做一些操作,比如驗證、判斷等,那麼就有意義,如果是個“空”函數,除了賦值啥都沒做,那麼有何意義呢?