【Vuejs】1182- 歐耶!Pinia 正式成為 Vue.js 的一員

Pinia?正式成為 vuejs 官方的狀態(tài)庫,意味著????就是?Vuex 5.x?。先來看早期?vue 上一個關(guān)于 Vuex 5.x 的 RFC :

描述中可以看到,Vue 5.x 主要改善以下幾個特性:
同時支持 composition api和options api的語法;去掉 mutations,只有state、getters和actions;不支持嵌套的模塊,通過組合 store來代替;更完善的 Typescript支持;清晰、顯式的代碼拆分;
而 Pinia 正是基于 RFC 所生成的一個玩物。

它的定位和特點也很明確:
直觀,像定義組件一樣地定義 store,并且能夠更好地組合它們;完整的 Typescript支持;關(guān)聯(lián) Vue Devtools鉤子,提供更好地開發(fā)體驗;模塊化設(shè)計,能夠構(gòu)建多個 stores并實現(xiàn)自動地代碼拆分;極其輕量(1kb),甚至感覺不到它的存在 ; 同時支持同步和異步 actions;
這么 niubility 特性,接下來就 show you the code 去逐一學(xué)習(xí)。
接觸
光說不練,等于白學(xué)。這一小節(jié)通過介紹 Pinia 的 API,感受上一小節(jié)講到的特性。
用 vite 快速起一個 vue 模板的項目:
yarn?create?@vitejs/app?pinia-learning?--template?vue-ts
cd?pinia-learning
yarn
yarn?dev
項目運行起來后,安裝 pinia 并初始化一個 store:
yarn?add?pinia
在 src/main.ts 下定義引用 pinia 插件:
import?{?createApp?}?from?'vue'
import?{?createPinia?}?from?'pinia'
import?App?from?'./App.vue'
createApp(App).use(createPinia()).mount('#app')
了解(State)
defineStore
之后就可以定義我們的 store 并在組件中使用,我們新建 src/store/index.ts 文件并定義一個 store:
import?{?defineStore?}?from?'pinia'
export?default?defineStore({
??id:?'app',
??state?()?{
????return?{
??????name:?'碼農(nóng)小余'
????}
??}
})
在 App.vue 引入上述文件就可以使用該 store:
<template>
??<div>{{?store.name?}}div>
template>
<script?setup?lang="ts">
import?useAppStore?from?'./store/index'
const?store?=?useAppStore()
console.log(store)
script>
defineComponent <==> defineStore、id <==> name、 state <==> setup,直觀,像定義組件一樣地定義 store 到這里是能夠體會到該特性的含義了。上述代碼是在 composition api 中 setup 的用法,在 options api 中使用跟 Vuex 類似,通過 mapState 或者 mapWritableState 輔助函數(shù)來讀寫 state:
<template>
??<div>{{?this.username?}}div>
??<div>{{?this.interests.join(',')?}}div>
template>
<script?lang="ts">
import?{?mapState,?mapWritableState?}?from?'pinia'
import?infoStore?from?'../store/info'
export?default?{
??name:?'HelloWorld',
??computed:?{
????//?只讀計算屬性
????...mapState(infoStore,?['interests']),
????
????//?讀寫計算屬性
????...mapWritableState(infoStore,?{
??????username:?'name'
????})
??},
??mounted?()?{
????this.interests.splice(1,?1,?'足球')
????this.username?=?'Jouryjc'
??}
}
script>
storeToRefs
那么后半句是啥意思呢?并且能夠更好地組合它們 。舉個例子,馬上就 11.11 ,小余當(dāng)然得往購物車?yán)锩嫒麕妆尽翱▽毜洹?,于是乎就需要一個 cart 的 store:
import?{?defineStore?}?from?'pinia'
export?default?defineStore('cart',?{
??state?()?{
????return?{
??????books:?[
????????{
??????????name:?'金瓶梅',
??????????price:?50
????????},
????????{
??????????name:?'微服務(wù)架構(gòu)設(shè)計模式',
??????????price:?139
????????},
????????{
??????????name:?'數(shù)據(jù)密集型應(yīng)用系統(tǒng)設(shè)計',
??????????price:?128
????????}
??????]
????}
??}
})
然后在 AppStore 組合 cartStore :
//?store/index.ts
import?{?defineStore?}?from?'pinia'
import?useCartStore?from?'./cart'
export?default?defineStore('app',?{
??state?()?{
????//?直接使用?cartStore
????const?cartStore?=?useCartStore()
????return?{
??????name:?'碼農(nóng)小余',
??????books:?cartStore.books
????}
??}
})
最終被 App 組件消費。?? 直接解構(gòu) store 會使其失去響應(yīng)式,為了在保持其響應(yīng)式的同時從 store 中提取屬性要使用 storeToRefs ,如下述代碼所示:
<template>
??<div>{{?name?}}div>
??<p>購物車清單:p>
??<p?v-for="book?of?books"?:key="book.name">
????書名:{{ book.name }}?價格:{{ book.price }}
??p>
template>
<script?setup?lang="ts">
import?{?storeToRefs?}?from?'pinia'
import?useAppStore?from?'./store/index'
//??????返回的是一個?reactive?對象,不能直接解構(gòu)哦,使用?pinia?提供的?storeToRefs?API
const?{?name,?books?}?=?storeToRefs(useAppStore())
script>
此時的頁面效果如圖所示:

$patch
除了直接修改 store.xxx 的值,還可以通過 $patch 修改多個字段信息;下面在例子中添加購買數(shù)量、總價,并添加付款人:
<template>
????<div>{{?name?}}div>
????<p>購物車清單:p>
????<p?v-for="book?of?books"?:key="book.name">
????????書名:{{ book.name }}?價格:{{ book.price }}?數(shù)量:?{{ book.count }}
????????<button?@click="add(book)">+button>
????????<button?@click="minus(book)">-button>
????????p>
????<button?@click="batchAdd">全部加到10本button>
????<p>總價:{{ price }}p>
????<button?@click="reset()">重置button>
template>
<script?setup?lang="ts">
import?{?storeToRefs?}?from?"pinia";
import?useAppStore?from?"./store/index";
import?type?{?BookItem?}?from?"./store/cart";
const?store?=?useAppStore();
const?{?name,?books,?price?}?=?storeToRefs(store);
const?reset?=?()?=>?{
??store.$reset();
};
const?add?=?(book:?BookItem)?=>?{
??//?直接修改?store.book
??book.count++;
};
const?minus?=?(book:?BookItem)?=>?{
??//?直接修改?store.book
??book.count--;
};
const?batchAdd?=?()?=>?{
??//?通過?$patch?方法修改?store?多個字段
??store.$patch({
????name:?'小I',
????books:?[
??????{
????????name:?"金瓶梅",
????????price:?50,
????????count:?10,
??????},
??????{
????????name:?"微服務(wù)架構(gòu)設(shè)計模式",
????????price:?139,
????????count:?10,
??????},
??????{
????????name:?"數(shù)據(jù)密集型應(yīng)用系統(tǒng)設(shè)計",
????????price:?128,
????????count:?10,
??????},
????],
??});
};
script>
例子中添加了 “全部加到10本”和 “重置”按鈕,點擊前者會將全部書籍?dāng)?shù)量添加到 10 本,點擊后者會重置成 0,下面是執(zhí)行效果:

假如你只想將《微服務(wù)架構(gòu)設(shè)計模式》的數(shù)量修改成10,通過 $patch 傳對象的方法需要這么操作:
<template>
?<button?@click="batchAddMicroService">微服務(wù)加到10本button>
template>
<script>
?const?batchAddMicroService?=?()?=>?{
??????store.$patch({
????????name:?'小I',
????????books:?[
??????????{
????????????name:?"金瓶梅",
????????????price:?50,
????????????count:?0,
??????????},
??????????{
????????????name:?"微服務(wù)架構(gòu)設(shè)計模式",
????????????price:?139,
????????????count:?10,
??????????},
??????????{
????????????name:?"數(shù)據(jù)密集型應(yīng)用系統(tǒng)設(shè)計",
????????????price:?128,
????????????count:?0,
??????????},
????????],
??????});
????}
script>
可以看到,就算你只是修改數(shù)組(集合)的第二項,還是需要將整個 books 數(shù)組傳入,于是就產(chǎn)生了將函數(shù)作為 $patch 參數(shù)的寫法:
<script>
const?batchAddMicroService?=?()?=>?{
??store.$patch((state)?=>?{
????state.books[1].count?=?10;
??});
}
script>
上述代碼重寫了 batchAddMicroService 方法。
$subscribe
該方法跟 vuex 的 subscribe 類似,用于監(jiān)聽 state 及其 mutation 動作。上述例子中我們訂閱 appStore 的狀態(tài):
const?store?=?useAppStore();
store.$subscribe((mutation,?state)?=>?{
??console.log(mutation);
??console.log(state);
});
當(dāng)我們添加一本《金瓶梅》時,會觸發(fā) $subscribe,log 結(jié)果如下圖:

Getters
Getters 就是 store 的計算屬性(computed)。大部分時候,Getter 通過 state 值去做計算,這種情況下 TypeScript 能夠正確的推斷出類型。例如:
export?default?defineStore('app',?{
??state:?()?=>?{
????const?userInfoStore?=?useUserInfoStore()
????const?cartStore?=?useCartStore()
????return?{
??????name:?userInfoStore.name,
??????books:?cartStore.books
????}
??},
??getters:?{
????price:?(state)?=>?{
??????return?state.books.reduce((init:?number,?curValue:?BookItem)?=>?{
????????return?init?+=?curValue.price?*?curValue.count
??????},?0)
????}
??}
})
我們將 state 和 getters 都改成箭頭函數(shù),這樣就能在 App.vue 中正確推斷出 price 的類型。我們來驗證一下:

Bingo!如果在 getters 中使用 this 去訪問 state 的話,需要顯式聲明返回值才能正確標(biāo)記類型,我們來試試:
export?default?defineStore('app',?{
??//?...
??getters:?{
????price?()?{
??????return?this.books.reduce((init:?number,?curValue:?BookItem)?=>?{
????????return?init?+=?curValue.price?*?curValue.count
??????},?0)
????}
??}
})
結(jié)果如下:

我們給 price 顯示聲明返回類型:
export?default?defineStore('app',?{
??//?...
??getters:?{
????price?():?number?{
??????return?this.books.reduce((init:?number,?curValue:?BookItem)?=>?{
????????return?init?+=?curValue.price?*?curValue.count
??????},?0)
????}
??}
})
此時又能正確地提示 price 的類型。

Getters 其他用法比如組合 Getters、在 setup 或 options api 中使用、傳參等等都跟 State 類似,本節(jié)就不展開細述。
Actions
Actions 相當(dāng)于組件里的 methods。雙 11 買東西當(dāng)然免不了折扣,商家也在折扣這環(huán)節(jié)上設(shè)計了活動,能夠讓顧客自己隨機一個折扣比率,于是在 store 中的 actions 下定義 changeDiscountRate 方法:
export?default?defineStore('app',?{
??state:?()?=>?{
????const?userInfoStore?=?useUserInfoStore()
????const?cartStore?=?useCartStore()
????const?discountRate?=?1
????return?{
??????name:?userInfoStore.name,
??????books:?cartStore.books,
??????discountRate
????}
??},
??actions:?{
????changeDiscountRate?()?{
??????this.discountRate?=?Math.random()?*?this.discountRate
????}
??}
})
跟 Getters 一樣,actions 中也通過 this 去獲取整個 store。我們通過異步 actions 讓修改折扣有一個延遲效果:
function?getNewDiscountRate?(rate:?number):?Promise<number>?{
??return?new?Promise?((resolve)?=>?{
????setTimeout(()?=>?{
??????resolve(rate?*?Math.random())
????},?1000)
??})
}
export?default?defineStore('app',?{
??//?...
??actions:?{
????async?changeDiscountRate?()?{
??????this.discountRate?=?await?getNewDiscountRate(this.discountRate)
????}
??}
})
$onAction
當(dāng)我們想統(tǒng)計 actions 的時間或者記錄折扣點擊總次數(shù)的時候,$onAction 訂閱器能夠很方便地實現(xiàn),下面是一個官方的示例:
//?App.vue
const?unsubscribe?=?store.$onAction(
?({
????name,
????store,
????args,
????after,
????onError,
??})?=>?{
????const?startTime?=?Date.now()
????console.log(`Start?"${name}"?with?params?[${args.join(',?')}].`)
????after((result)?=>?{
??????console.log(
????????`Finished?"${name}"?after?${
??????????Date.now()?-?startTime
????????}ms.\nResult:?${result}.`
??????)
????})
????onError((error)?=>?{
??????console.warn(
????????`Failed?"${name}"?after?${Date.now()?-?startTime}ms.\nError:?${error}.`
??????)
????})
?}
)
function?getNewDiscountRate?(rate:?number):?Promise<number>?{
??return?new?Promise?((resolve,?reject)?=>?{
????setTimeout(()?=>?{
??????//?這里通過reject結(jié)束promise
??????reject(rate?*?Math.random())
????},?1000)
??})
}
export?default?defineStore('app',?{
??//?...
??actions:?{
????async?changeDiscountRate?()?{
??????try?{
????????this.discountRate?=?await?getNewDiscountRate(this.discountRate)
??????}?catch?(e)?{
????????//?示例執(zhí)行這部分邏輯
????????throw?Error(e)
??????}
????}
??}
})
上述代碼會以 rejected 的狀態(tài)結(jié)束 promise,$onAction 能夠執(zhí)行到 onError 鉤子,這個鉤子是跟 vue3 的 errorHandler 掛鉤,想知道這部分怎么實現(xiàn)滴可以三連持續(xù)關(guān)注最新內(nèi)容哦,下篇文章將會從 pinia 源碼角度分析。最后執(zhí)行效果如下圖所示:

從上圖可以看到,第一個 warn 是訂閱器 $onAction 返回的,第二個 warn 則是 errorHandler 中返回。
最后,$onAction 一般是在組件的 setup 建立,它會隨著組件的 unmounted 而自動取消。如果你不想讓它取消訂閱,可以將第二個參數(shù)設(shè)置為 true:
store.$onAction(callback,?true)
深入(Plugins)
通過一些底層 API,我們能夠各種各樣的擴展:
給 store添加新的屬性;給 store添加新的選項;給 store添加新的方法;包裝已經(jīng)存在的方法; 修改或者刪除 actions;基于特定的 store做擴展;
光說不練,等于白學(xué)。下面就來實現(xiàn)一個 pinia-plugin,首先在 store 下建 pinia-plugin.ts:
import?{?PiniaPluginContext?}?from?'pinia'
export?default?function?myPiniaPlugin(context:?PiniaPluginContext)?{
??console.log(context)
}
然后在 main.ts 中引入插件:
import?{?createApp?}?from?'vue'
import?{?createPinia?}?from?'pinia'
import?piniaPlugin?from?'./store/pinia-plugin'
import?App?from?'./App.vue'
const?pinia?=?createPinia()
pinia.use(piniaPlugin)
createApp(App).use(pinia).mount('#app')
appStore 打印出來的 context 如下:

我們可以從 context 中拿到從 createApp() 中創(chuàng)建的 app 實例、defineStore 中的配置、從 createPinia() 中創(chuàng)建的 pinia 實例、當(dāng)前 store 對象。有了這些信息,你要完成上述那些擴展無非只是基于 context 去發(fā)揮。下面列對兩個比較常見的用法舉例說明。
給 store 添加新的屬性
我們實現(xiàn)一個添加 state 的插件:
import?{?PiniaPluginContext?}?from?'pinia'
export?default?function?myPiniaPlugin(context:?PiniaPluginContext)?{
??const?{?store?}?=?context
??store.pluginVar?=?'Hello?store?pluginVar'
??//?可以在?devtools?中使用?
??store.$state.pluginVar?=?'Hello?store?$state?pluginVar'
??if?(process.env.NODE_ENV?===?'development')?{
????//?加上自定義屬性可以被devtool捕獲到
????store._customProperties.add('pluginVar')
??}
??setTimeout(()?=>?{
????store.pluginVar?=?'Hello?store?pluginVar2'
????store.$state.pluginVar?=?'Hello?store?$state?pluginVar2'
??},?1000)
}
上述代碼中 store.pluginVar 的改變不會被 devtools 監(jiān)聽到,但是可以通過 store._customProperties.add('pluginVar') ?store.$state.pluginVar 的改變會被 devtools 監(jiān)聽到。
基于特定的 store 做擴展
action 支持異步,我們在 action 中發(fā)送請求的需求會比較多,我們就可以將 axios 作為擴展加到 store 中:
import?{?PiniaPluginContext?}?from?'pinia'
import?{?markRaw?}?from?'vue'
import?axios?from?'axios'
export?default?function?myPiniaPlugin(context:?PiniaPluginContext)?{
??const?{?store?}?=?context
??store.axios?=?markRaw(axios)
}
store.testAxios 調(diào)用的時候,我們就只需要直接通過 this 即可拿到 axios 對象:
import?{?defineStore?}?from?'pinia'
import?useCartStore,?{?BookItem?}?from?'./cart'
import?useUserInfoStore?from?'./info'
function?getNewDiscountRate?(rate:?number):?Promise<number>?{
??return?new?Promise?((resolve,?reject)?=>?{
????setTimeout(()?=>?{
??????reject(rate?*?Math.random())
????},?1000)
??})
}
export?default?defineStore('app',?{
??//?...
??actions:?{
????testAxios?()?{
??????//?this.testAxios.$get().then().catch(()?=>?{})
??????console.log(this.axios)
????}
??}
})
Pinia or Vuex
Pinia 作為 Vue 狀態(tài)管理的后起之秀和官方團隊維護的 Vuex,什么時候該用哪個?這一小節(jié)來簡單地對比一下。
首先下載量和社區(qū)方面:


Pinia 作為后起之秀,不管是 Stack Overflow 上的問題解決方案還是下載量上,肯定都不如 Vue 核心團隊推薦的 Vuex。其他方面的對比可以直接閱讀 pinia-vs-vuex ,這篇文章非常詳細地比較了二者。言而總之:小項目可以試水 Pinia,大項目還是用成熟的 Vuex。(另外,外鏈的對比時間比較早,目前 Pinia 好像是支持了時間旅行功能)
總結(jié)
本文詳細地介紹了 Pinia 的基礎(chǔ)使用,從 state、Getters、Actions 到 Plugins,都有例子輔助學(xué)習(xí)。Pinia 的特性也都在文中有涉及,比如composable store、TypeScript 的支持,devtool 插件的集成如下圖所示:

最后簡單地跟 Vuex 做了比較,詳細記得點擊鏈接去查閱引文哦。相信你讀完本文,對于 Pinia 的基礎(chǔ)一定是了如指掌。

回復(fù)“加群”與大佬們一起交流學(xué)習(xí)~
點擊“閱讀原文”查看 130+ 篇原創(chuàng)文章
