作者:樹上的男爵
鏈接:https://zhuanlan.zhihu.com/p/345963989
來源:知乎
vue3已經(jīng)出來一段時間了,而我最近也在公司的一個項目中充分使用了vue3的特性。相比vue2,vue3的整個的編碼方式有不小變化,如果要寫出簡潔優(yōu)雅的代碼,可能還是需要一定的時間去摸索。在摸索的過程中,需要面對的其中一個問題就是:vue3我怎么去更好的進(jìn)行狀態(tài)管理?
在vue2時代,官方給我們提供了現(xiàn)成的狀態(tài)管理工具vuex,它的使用方式借鑒了react生態(tài)的redux,定義一個狀態(tài)變量,然后再定義它的getter, setter,以及異步變更action方法??偟膩碚f,這套方案滿足日常業(yè)務(wù)開發(fā)是完全沒有問題的——只不過寫起來稍顯繁瑣。
然而,現(xiàn)在已經(jīng)進(jìn)入vue3時代,vuex的弊端就更加明顯。首當(dāng)其沖的第一點:不能很好地貼合typescript。vue3已經(jīng)用ts重寫,充分發(fā)揮了ts類型系統(tǒng)的作用;vuex目前整個設(shè)計來看,我在使用狀態(tài)變更方法的時候,傳入的居然都是字符串名?!看看官方文檔中的示例:
actions: {
async actionA ({ commit }) {
commit('gotData', await getData())
},
async actionB ({ dispatch, commit }) {
await dispatch('actionA') // 等待 actionA 完成
commit('gotOtherData', await getOtherData())
}
}
這樣完全沒法做到智能化的類型提示,對重度依賴ts的本人來說,真的無法接受。
vuex還有第2點弊端:啰嗦的語法,以及和vue3函數(shù)式風(fēng)格api的割裂——雖然相比第一點倒還不算太大的問題,但用起來還是覺得膈應(yīng)。總之對我而言,有了這兩大弊端基本就宣告了vuex這套方案的死刑。
于是,我在實際項目中開始摸索一套較為合理的狀態(tài)管理方案。我將目光轉(zhuǎn)移到了vue3自有的一套api:provide/inject。
文檔中的解釋是,在父組件中調(diào)用provide函數(shù),第一個參數(shù)傳入字符串token,第二個參數(shù)傳入子組件需要訪問的對象。接著在子組件在調(diào)用inject函數(shù),傳入同樣一個token,就能拿到該對象值了。代碼如下:
// Parent.vue
import { provide } from 'vue';
provide('person', {name: 'bob', age: 20});
//Child.vue
import { inject } from 'vue';
const person = inject('person');
這是最簡單的示例,告訴我們怎么通過provide/inect在父子組件中傳值。初看之下好像并沒多大卵用,因為傳下去的只是個普通對象啊,并不具備響應(yīng)式更新的能力。但是,請記住,我們現(xiàn)在使用的是vue3,想要有響應(yīng)式能力,我們傳ref/reactive對象就可以了嘛!于是把代碼稍微改改:
(父組件)
// Parent.vue
<script lang="ts" setup>
import { provide, reactive } from "vue";
const person = reactive({name: 'bob', age:32});
provide('person', person);
</script>
<template>
<div>
<child></child>
</div>
</template>
(子組件)
// Child.vue
<script lang="ts" setup>
import { inject, onMounted } from 'vue';
const person = inject('person');
onMounted(() => {
person.age = 25;
})
</script>
<template>
<div>
我叫{{person.name}} 我的年齡:{{person.age}}
</div>
</template>
在父組件,通過provide提供了一個reactive響應(yīng)式對象;然后在子組件通過inject注入該對象。在子組件修改對象的age屬性,視圖就會響應(yīng)式更新!同樣的,如果child組件也有自己的子組件,調(diào)用inject同樣有效。這點我就不多講了,畢竟這對古老的api在vue2時代就已經(jīng)存在,只不過在vue3,他倆終于不再雞肋,反而是可堪大用!
現(xiàn)在,問題似乎已經(jīng)得到解決。有了provide/inject和ref/reactive配合,父子組件/兄弟組件共享狀態(tài)的問題已經(jīng)迎刃而解。但隨著業(yè)務(wù)的深入,我陷入了沉思,這個方案還是有嚴(yán)重問題,需要搶救。主要體現(xiàn)在兩點:
第一點,我們的provide方法,要傳的參數(shù)仍然是個字符串??!
provide/inject目前的設(shè)計是,他們之間是靠一個字符串(或symbol)來建立暗號的,暗號對上了,我就把相應(yīng)的值給你??梢菢I(yè)務(wù)復(fù)雜了,暗號多了,一不留神其中一個拼寫錯誤,那是不是得找花眼?而且,這種字符串傳參大法仍然沒有很好的類型提示,看看我在vscode 編輯器中寫的代碼:

person下的age和name屬性,直接有一條紅線提示:類型“unkown”上不存在age屬性。為啥,因為inject函數(shù)本身是需要傳泛型的,如果不傳,系統(tǒng)就會認(rèn)為inject返回的對象類型位unkown。
也就是講,我每次調(diào)用inject方法,還得手動寫個類型聲明?像下面這樣:
const person = inject<{name: string; age: string}>('person');
不好意思,typescript不是這樣用的。
第二點:直接在provide中傳一個響應(yīng)式對象,缺少封裝性和邏輯復(fù)用能力。
現(xiàn)在回頭看看vuex,雖然寫法挺啰嗦,類型提示不友好,但是它提供的是整套狀態(tài)管理方案,它使得組件之間不僅共享狀態(tài),還能復(fù)用一系列更改狀態(tài)的邏輯方法。mutation action干的就是這個事。
再看看我們先前的做法:直接往下傳reactive對象,至于狀態(tài)更改的邏輯,還是寫在組件里了,這就很不科學(xué)。這樣一想,我們是不是得在單獨(dú)一個文件,建一個大點的對象,把狀態(tài)和狀態(tài)變更邏輯都包含進(jìn)去?比如:
//person.ts
const personStore = {
state: reactive({
name: bob,
age: 20
}),
setAge(n: number) {
this.state.name = n;
}
}
看上去有點vuex那味了,而且還用上了vue3致力于拋棄掉的this。怎么看都極其不優(yōu)雅。甚至還做不到class面向?qū)ο蟮某跏蓟芰头庋b性。畢竟有些方法可能只是內(nèi)部調(diào)用,并不希望全部暴露出去。而且在編碼過程中我發(fā)現(xiàn)自己封裝的通用hook函數(shù)不能很方便地在這種狀態(tài)對象中使用??偠灾?,在vue3整個框架內(nèi)內(nèi)顯得水土不服。
提了這么多問題,那我們該怎么辦?就這樣妥協(xié)下去,早點完成業(yè)務(wù)功能然后下班?
欸。。等等,我們從頭捋一遍,回到最初的問題——尤雨溪為什么要發(fā)布變化如此之大的vue3? 我們?yōu)槭裁从址堑脧膙ue2切換到vue3?
官方的解釋是,使用composition api,能得到更強(qiáng)的邏輯復(fù)用能力。意思就是,以前相當(dāng)多的業(yè)務(wù)邏輯寫在組件里,現(xiàn)在可以很方便的抽象出去,單獨(dú)放在一個函數(shù)內(nèi)了。官方文檔還給了個示例:
// src/composables/useRepositoryNameSearch.js
import { ref, computed } from 'vue'
export default function useRepositoryNameSearch(repositories) {
const searchQuery = ref('')
const repositoriesMatchingSearchQuery = computed(() => {
return repositories.value.filter(repository => {
return repository.name.includes(searchQuery.value)
})
})
return {
searchQuery,
repositoriesMatchingSearchQuery
}
}
上面是將一個搜索功能抽象為組合式函數(shù)的示例,當(dāng)然我更愿意和react統(tǒng)一稱為hook函數(shù),在hook函數(shù)內(nèi)部,我們可以使用組件生命周期鉤子函數(shù)如onMounted,也就是可以通過hook函數(shù)執(zhí)行一些初始化邏輯而不是非得在組件內(nèi)。與此同時,在hook中定義的響應(yīng)式變量可以return出去給組件訪問。如果hook內(nèi)部做出了變更操作,組件視圖也會進(jìn)行相應(yīng)更新。
這就是vue3在邏輯復(fù)用方面的強(qiáng)大能力。但是,這跟狀態(tài)管理有啥關(guān)系呢?
關(guān)系太大了。
思考到現(xiàn)在,結(jié)論已經(jīng)很明顯。而且我覺得這很可能是vue3官方的一個疏漏,只著重宣傳hook函數(shù)(官方文檔叫組合式函數(shù))的邏輯復(fù)用能力,卻不提這套方案用來做狀態(tài)管理也是極為自然的。大概由于vuex/redux這類方案對開發(fā)者的影響太深,所以一提到狀態(tài)管理,就總是以他們?yōu)槠瘘c開始思考,最終搞出來的也只是個變種版的redux。其實,有了hook這樣的編碼方式,狀態(tài)管理的問題就注定被其染指。現(xiàn)在,是時候拋棄vuex這類跟函數(shù)式風(fēng)格及其不搭的思想包袱了。
想一想,每個組件使用一次hook函數(shù),函數(shù)都會調(diào)用一次從而形成全新的執(zhí)行上下文和閉包。假設(shè)有hook函數(shù)f,返回響應(yīng)式變量 x,若在組件A, B分別使用hook函數(shù)f,他們得到的只是一個專屬于自己的變量x。可是,在某些業(yè)務(wù)場景下,我想讓A和B共享同一個變量x怎么辦?
provide/inject這對cp又重新登場了。
思考如下業(yè)務(wù)場景:我寫了一個hook函數(shù),里面全是與用戶相關(guān)的邏輯:比如用戶信息的修改以及登錄與注銷。一般情況下,跟用戶相關(guān)的狀態(tài)和邏輯有可能在各處組件都能用到,顯而易見,它應(yīng)該是全局唯一的,如果是每個組件單獨(dú)去使用這個hook函數(shù),就沒法共享用戶相關(guān)的狀態(tài)變量。
export function useUserInfo() {
const userInfo = reactive({ });
const login = (data) => {...};
const logout = () => {...};
const editUserInfo => (data) => {};
return {
userInfo,
login,
logout,
editUserInfo
}
}
那么直接在根組件調(diào)用provide,將userHook函數(shù)傳入會怎樣?
//app.vue
<script>
import {provide} from 'vue';
import {useUserInfo} from '@/hooks/userUserInfo';
provide('user', useUserInfo())
</script>
恭喜你發(fā)現(xiàn)了華點!現(xiàn)在一切都豁然開朗了:有了provide之后,可以在下面的login組件使用inject訪問useUserInfo函數(shù)返回的對象,調(diào)用該對象的login方法;在logOut組件調(diào)用該對象返回的logOut方法,在userInfo組件使用返回的userInfo變量渲染用戶信息……一切都是那么自然,只要任意一處執(zhí)行了態(tài)變更的邏輯,所有相關(guān)組件都能響應(yīng)更新。
視圖以外,皆是HOOK。
vue3時代,這就是我的編程理念。組件只負(fù)責(zé)訪問hook返回的響應(yīng)式變量丟給模板,其余的事情比如業(yè)務(wù)邏輯,狀態(tài)管理,全是hook的事情。這就是視圖與邏輯與狀態(tài)的徹底分離。所以說,react hook 和vue3推出之后,前端時代真的變了(當(dāng)然,作為angular的鐵粉,我認(rèn)為angular 3年前已經(jīng)走到這一步,可惜來得太早成為了先烈)。
總之,講了這么久,狀態(tài)管理的最終方案是有了,那就是回歸hook。如果希望hook內(nèi)部的狀態(tài)與邏輯在多個組件內(nèi)共享,那只需要在hook的基礎(chǔ)上加上一個provide/inject;如果你希望hook函數(shù)在每個組件都生成全新的狀態(tài),像以前那樣組件內(nèi)照常使用就行。這樣一想,我們的解決辦法好像并沒有做什么新的動作,相比vuex那一套簡單明了多了。所以說,hook出現(xiàn)之后,狀態(tài)管理的問題已經(jīng)從根本上被消解了。
但是等等……ts類型提示的問題好像還是沒解決啊。其實解決這個問題只需要對provide/inject進(jìn)行一層封裝就好了。廢話不多說,直接上我在項目實踐中寫的代碼:
//定義一個用于狀態(tài)共享的hook函數(shù)的標(biāo)準(zhǔn)接口
export interface FunctionalStore<T extends object> {
(...args: any[]): T;
token?: symbol;
root?: T;
}
//對原生provide進(jìn)行封裝
//由于inject函數(shù)只會從父組件開始查找,所以useProvider默認(rèn)返回hook函數(shù)的調(diào)用結(jié)果,以防同組件層級需要使用
export function useProvider<T extends object>(func: FunctionalStore<T>): T {
!func.token && (func.token = Symbol('functional store'));
const depends = func();
provide(func.token, depends);
return depends;
}
// 可以一次傳入多個hook函數(shù), 統(tǒng)一管理
export function useProviders(...funcs: FunctionalStore<any>[]) {
funcs.forEach( func => {
!func.token && (func.token = Symbol('functional store'));
provide(func.token, func());
});
}
//對原生inject進(jìn)行封裝
type InjectType = 'root' | 'optional';
//接收第二個參數(shù),'root'表示直接全局使用;optional表示可選注入,防止父組件的provide并未傳入相關(guān)hook
export function useInjector<T extends object>(func: FunctionalStore<T>, type?: InjectType) {
const token = func.token;
const root = func.root;
switch(type) {
case 'optional':
return inject<T>(token) || func.root || null;
case 'root':
if(!func.root) func.root = func();
return func.root;
default:
if(inject(token)) {
return inject<T>(token)
};
if(root) return func.root;
throw new Error(`狀態(tài)鉤子函數(shù)${func.name}未在上層組件通過調(diào)用useProvider提供`);
}
}
以上,就是我基于vue3 provide/inject 實現(xiàn)的狀態(tài)管理方案,只有2個基本api,useProvider和useInjector,具有完全的hook函數(shù)能力,以及完備的自動類型提示。請注意這兩函數(shù)傳入的都是hook函數(shù)本身而不是字符串token。token我是直接掛在函數(shù)的屬性里了,省去了編碼時手動填token的動作。
下面是我在公司項目實際使用的案例:

app.vue根組件一次性傳入5個hook函數(shù)
[圖片上傳失敗...(image-ce1bba-1629858474421)]
其中一個hook函數(shù)的簡單實現(xiàn)(useState是對vue ref的封裝,為了看起來像react ...)

在子組件注入對應(yīng)的hook函數(shù),可以看到編輯器的類型提示(完全不用手動申明類型)
因為這個項目業(yè)務(wù)不算復(fù)雜,所以看上去并沒有很好體現(xiàn)這套方案的強(qiáng)大之處。當(dāng)然可伸縮性也是它的一個優(yōu)點,項目不復(fù)雜也隨便用。隨著業(yè)務(wù)擴(kuò)展,我認(rèn)為這套方案也是可以一直hold住整個項目的。