pinia新手司机指南

754 阅读15分钟

前言

在开始学习pinia之前你需要知道什么是pinia?

Pinia 是 Vue.js 的轻量级状态管理库

说到vue的状态管理库,除了pinia之外我们最多用到的估计是vuex,关于他们两之间的区别,pinia的官网给出了解释,这里简单摘录一段

Pinia 最初是为了探索 Vuex 的下一次迭代会是什么样子,结合了 Vuex 5 核心团队讨论中的许多想法。最终,我们意识到 Pinia 已经实现了我们在 Vuex 5 中想要的大部分内容,并决定实现它 取而代之的是新的建议。

与 Vuex 相比,Pinia 提供了一个更简单的 API,具有更少的规范,提供了 Composition-API 风格的 API,最重要的是,在与 TypeScript 一起使用时具有可靠的类型推断支持。

通过以上我们就能有一个大致的认识:

  1. pinia的使用相比vuex更简单(不再有mutations、模块更加友好灵活)
  2. pinia的使用风格上更贴近于vue3的Composition-API 风格
  3. pinia对于ts的支持性更好,有着更可靠的类型推导
  4. 最重要一点pinia将会是vuex的未来形态(早晚都要学,早学早解脱)

看到了以上的描述,至少从开发者体验上来讲,如果你对vuex的mutations和分模块命名空间冗杂的调用方式感到厌烦,如果你想获得更好的ts支持和类型推导、如果你想使用一个语法更加简洁的vue状态管理库,那么你无法拒绝学习pinia,尤其是在使用Composition-API 风格风格下

在这里我要提前说的是pinia的中文官方文档挺友好的,因为pinia本身的api并不多,使用起来相对于vuex要简单,因此我建议你去通读官方文档,获得最权威的指引,但如果你和我一样(懒批),上手新技术时看到长篇文档就头疼,又想快速上手pinia,那么本篇新手司机指南也够用了

基础使用

在介绍pinia的使用之前,先声明文中所有的例子,都是基于vue3的实例展示,如果你不熟悉vue3,那么建议你先去熟悉一下vue3的setup及其语法糖

安装和使用

安装比较简单,根据你使用npm或者yarn自己选择安装就行,这没什么好说的,注意安装时是生产依赖不是开发依赖

//npm管理
yarn add pinia 

//yarn管理
npm i pinia

基础使用直接上代码展示

main.js

// 在main.js中注册pinia

import { createPinia } from 'pinia'

app.use(createPinia())

这样就成功在项目中注册了pinia,接下来让我们定义一个store,在pinia中每一个store都是通过defineStore方法创建的,该方法第一个参数是store的唯一id,第二个参数是一个配置对象,具体配置我们在核心概念中讲解,现在你可以先体会这种书写方式

store/counter.js


import { defineStore } from 'pinia'
// 第一个参数counter是该store的唯一id标示,第二个参数是一个配置对象
export const useCounter = defineStore('counter', {
  state: () => {
    return {
      counter: 0,
      name:"COUNTER"
    }
  }
})

注意:你并不一定要命名为useCounter,你可以在store下定义多个.ts或者.js文件,用以划分模块,比如你还有一个用户信息store模块,你可以创建一个userInfo.js文件,同样的格式然后命名为useUserInfo,以此类推你可以划分更多的storeModule来满足你的业务需要

在组件中使用:
components/Demo.vue

 <script setup>
 import useCounterStore from './store/counter'
 
 const counterStore = useCounterStore() //这样我们就可以在组件中使用该store了
 </script>
 
 <template> 
     <h1>{{ counterStore.count }}</h1> 
     <h1>{{ counterStore.name }}</h1> 
 </template>

以上就是pinia的基础安装及引入使用,下面我们看下pinia的核心概念及其使用方法

核心概念

pinia同vuex有5大核心不同(state、getters、mutations、actions、modules),它的只有3大核心(state、getters、actions),下面分别介绍一下:

核心一:state

一般情况下state是整个pinia创建的store的核心,在 Pinia 中,state被定义为返回初始状态的函数

import { defineStore } from 'pinia'

export const useCounter = defineStore('counter', {
  // state是一个返回初始值的函数
  state: () => {
    return {
      counter: 0,
      name:"COUNTER"
    }
  }
})

根据上面的例子,在组件中使用,我们可以接在store中访问其值并读写

 import useCounterStore from './store/counter'
 const counterStore = useCounterStore() 
 //对!没错!不再需要通过mutations,你可以直接进行修改store中state的值,且这种修改时响应式的会触发ui的更新
 counterStore.counter++ // counter:1

当然你可以重置这个state

counterStore.$reset() // counter:0

批量修改的方式

    counterStore.$patch({
      counter: counterStore.counter + 1,
      name: 'Abalam',
    })
    // 当然还有另一种处理复杂逻辑的方式
    counterStore.$patch((state) => {
      // state是当前store的索引,你可以函数中处理更复杂的逻辑,并修改这个state
      state.counter = state.counter + 1
      state.name = 'Abalam'
    })

即使你只改变一个属性,你依然可以使用批量修改的方式,对于批量修改和单独读写修改的方式的使用建议是,如果你一次性修改多个值或者有复杂逻辑需要处理,那么建议你使用$patch,相比单个逐一修改性能更高,反之建议你单独读写修改

替换state(注意使用这个需要慎重,这个方法将直接替换掉你原有的state)

counterStore.$state = { counter: 666, name: 'Paimon' }

关于解构的问题

请注意,store 是一个用reactive 包裹的对象,这意味着不需要在getter 之后写.value,但是,就像setup 中的props 一样,我们不能对其进行解构

    const { name, counter } = counterStore // 这样是不行的,会导致name、couter失去响应式

但是就想我们以前解决解构reactive数据使用的toRefs一样,pinia也给我们提供了一个方法storeToRefs

    const { name, counter } = storeToRefs(counterStore)

通过这种方式我们就可以保持解构出来的name、counter依旧保持响应式,但是注意storeToRefs会跳过action其他非响应式的数据,只会为响应式数据创建refs

订阅状态的方法

可以通过store.$subscribe()来订阅state的变化,当然使用watch也可以做到这点,使用 $subscribe() 的优点是 subscriptions 只会在 patches 之后触发一次

比如我们想在store的state发生改变时,将其存入localStorage

counterStore.$subscribe((mutation, state) => {
  // import { MutationType } from 'pinia' 
  mutation.type // 'direct'(直接赋值修改) | 'patch object'($patch传入对象修改) | 'patch function($patch传入函数修改)'
  mutation.storeId // 'counter' 创建store时的id
  // 仅适用于 mutation.type === 'patch object'
  mutation.payload // 就是通过$patch(object)这种方式传入的对象

  // 每当它发生变化时,将整个状态持久化到本地存储
  localStorage.setItem('counter', JSON.stringify(state))
})

当然你也可以使用watch,但是如果是单纯监听store的state改变,更建议你使用订阅的方式

watch(
  demoStore.$state,
  (state) => {
    // 每当它发生变化时,将整个状态持久化到本地存储
    localStorage.setItem('counter', JSON.stringify(state))
  },
  { deep: true }
)

核心二:getters

这个相对来说比较简单,如果你使用过vuex,我相信你一定不陌生这个属性,如果你没有使用过也没关系,getters 是幕后的 computed 属性,对于computed你一定很熟悉,getters就是这个效果,就是将state的数据经过加工处理后再返回,下面我一次展示完所有的基础用法

store/demo.js

import { defineStore } from "pinia";
import { useOtherStore } from './other-store' //这是在store目录下定义的另一个store

export const useDemo = defineStore("demo", {
  state: () => {
    return {
      counter: 0,
      name: "demo",
      users: [],
    };
  },
  getters: {
    // 第一种方式 state会当做参数传入
    doubleCount(state) {
      return state.counter * 2;
    },
    // 第二种方式 this就是当前的store可以通过this访问当前store中属性
    doubleplusOne(): number {
      //注意:如果你使用了ts那么返回值类型必须定义,如果你没有ts这里返回类型不需要写
      return this.counter * 2 + 1;
    },
    // 第三种 箭头函数 你必须使用参数的形式,和第一种其实属于一种
    // doubleCount: (state) => {
    //   return state.counter * 2;
    // },

    // 使用其他的getters
    doubleplusTwo(): number {
      return this.doubleCount + 2;
    },

    //传递参数的getters,和computed一样,如果想要getters传入参数就必须返回一个函数,但是该函数的值并不会被缓存
    getUserByName(state) {
      const activeUsers = state.users.filter((user) => user.active);
      return (userId) => activeUsers.find((user) => user.id === userId);
    },

    //使用其他模块的state 这里我们已经在上面通过import引入了otherStore
    otherGetter(state) {
        const otherStore = useOtherStore()
        return state.counter + otherStore.data
      },
  },
});

在组件的使用上同state的使用方式一样,都是可以直接通过store的属性访问,这里就不做演示了,但是注意getters是不能够赋值的

核心三:actions

终于来到pinia的第三个核心actions了,actions就相当于组件中的methods,我们可以在actions中定义一些业务处理逻辑,包裹请求接口、修改state等,actions是支持异步的,你可以使用async await操作,具体使用展示如下

import { defineStore } from "pinia";
import { useAuthStore } from "./auth-store";

export const useMain = defineStore("main", {
  state: () => {
    return {
      preferences: [],
      counter: 0,
      users: [],
    };
  },
  actions: {
    // 基础用法一: 修改state中的值
    increment() {
      this.counter++;
    },
    // 基础用法二:在actions中使用异步操作
    async initUsers() {
      this.users = await fetchUsers(); //伪代码 假设我们有一个请求方法
    },
    // 基础用法三: 在actions中使用别的store
    async fetchUserPreferences() {
      const auth = useAuthStore(); //可以在当前store中引入别的store
      if (auth.isAuthenticated) {
        this.preferences = await fetchPreferences(); //伪代码 假设我们有一个请求方法
      } else {
        throw new Error("User must be authenticated");
      }
    },
  },
});

在组件中使用

<script setup> 
import { useMain } from './store/main'
const mainStore = useMain() 
</script>
<template> 
<h1>{{ mainStore.counter }}</h1>
<button @click="mainStore.increment">+1</button> 
</template>

订阅Acitons的方法

订阅actions可以通过store.$onActions方法,传入一个回调函数,该函数会在action之前执行,如果你想在action之后处理一些操作,可以在after内传入一个回调函数,具体用法如下

const unsubscribe = someStore.$onAction(
  ({
    name, // action 的名字
    store, // store 实例
    args, // 调用这个 action 的参数
    after, // 在这个 action 执行完毕之后,执行这个函数
    onError, // 在这个 action 抛出异常的时候,执行这个函数
  }) => {
    // 如果 action 成功并且完全运行后,after 将触发。
    // 它将等待任何返回的 promise
    after((result) => {
        console.log('action执行成功后返回的promise值',result)
    });

    // 如果 action 抛出或返回 Promise.reject ,onError 将触发
    onError((error) => {
      console.log("出错了", error);
    });
  }
);
// 手动移除订阅
unsubscribe();

默认情况下pinia的所有订阅包裹state的订阅都不需要手动去移除,除非你的业务逻辑需要进行这样的处理,因为订阅默认都绑定到了添加他们组件上,如果组件被销毁那么订阅也会随着销毁,如果你不想这么做,那么在订阅函数的第二个参数中传入true,那么订阅即使在组件销毁后也会依然保持

Plugins插件

到目前pinia的基础使用已经介绍完了,如果你只想了解pinia的基础使用,后面的内容就可以略过了,如果你想涉及一些pinia的进阶内容,那么可以继续向下,首先我们先来介绍一下Plugins插件

Plugins是pinia提供的一个底层api,通过插件我们可以拓展pinia store的功能,具体操作列表官方提供如下

  • 向 Store 添加新属性
  • 定义 Store 时添加新选项
  • 为 Store 添加新方法
  • 包装现有方法
  • 更改甚至取消操作
  • 实现本地存储等副作用
  • 适用于特定 Store

首先以一个为pinia添加一个全局属性开始

import { createPinia } from 'pinia'

function SecretPiniaPlugin() {
  return { secret: 'the cake is a lie' }
}
const pinia = createPinia()
pinia.use(SecretPiniaPlugin) //通过use的方式,这样其他store都会获得一个secret属性

// 在另一个文件中
const store = useStore()
store.secret // 'the cake is a lie'

以上这里例子就是给pinia添加了一个全局属性,但是你需要注意属性和状态并不是一个概念,我们这里添加的是全局pinia的属性,pinia的state虽然也是通过store.的方式访问,但是这里不是一个概念,需要注意区分

下面简单介绍下pinia的插件格式:

pinia的插件是一个函数,可以返回需要添加到store的属性,其内部接受一个context参数

    function myPiniaPlugin(context){
      context.pinia // 使用 `createPinia()` 创建的 pinia
      context.app // 使用 `createApp()` 创建的当前应用程序(仅限 Vue 3)
      context.store // 插件正在扩充的 store
      context.options // 定义存储的选项对象传递给`defineStore()`
    }

下面介绍一些基本的使用方式

    // 使用方式一:扩充store-添加全局属性或方法
    pinia.use(()=>{key:value}) //伪代码 需要填加的属性方法直接作为对象返回就可以
    // 当然你也可以通过另一种方式 (解构context获取store,然后直接在store定义)
    pinia.use(({store})=>{
        store.key = value
    })
    // 注意:
    // 1. 如果你是想添加外部静态属性,则应该在添加前使用markRaw()包装对象
    // 2. 如果你的项目启用了ts,那么你需要使用Typing插件(具体可在官网查找用法,比较简单这里不做展示了)
    
    // 使用方式二: 添加新的状态
    // 注意如果你是添加新的状态,那么必须在store和store.$state上同时添加
    const globalSecret = ref('secret')
    pinia.use(({store})=>{
        store.$state.secret = globalSecret
        store.secret = globalSecret
    })
    
    // 使用方式三: 插件中调用订阅(相当于添加全局订阅事件)
    pinia.use(({ store }) => {
      store.$subscribe(() => {
        // 在存储变化的时候执行
      })
      store.$onAction(() => {
        // 在 action 的时候执行
      })
    })
    

还有一种最重要的使用方式,这里单独展示,那就是添加新选项,添加新选项允许我们在定义store时额外配置选项,比如官网的防抖配置的案例

defineStore('search', {
  actions: {
    searchContacts() {
      // ...
    },
  },

  // 稍后将由插件读取
  debounce: {
    // 将动作 searchContacts 防抖 300ms
    searchContacts: 300,
  },
})

然后插件可以读取该自定义选项,然后完成特殊的逻辑处理

// 使用任何防抖库
import debounce from 'lodash/debunce'

pinia.use(({ options, store }) => {
  if (options.debounce) {
    // 我们正在用新的action覆盖这些action
    return Object.keys(options.debounce).reduce((debouncedActions, action) => {
      // 这里简单解释一下,以防一些同学迷糊
      // 通过keys获取到所在 debounce 配置中的 keys ,而这个keys就是我们在acitons中的方法名,对应的values就是其各自设置的防抖的时间
      // 然后调用reduce一次从头遍历所由在debounce配置了防抖的函数名,生成一个新的防抖actions返回,最终新的actions会被添加到当前的store上,从而覆盖掉原来的actions,完成防抖配置
      debouncedActions[action] = debounce(
        store[action],
        options.debounce[action]
      )
      return debouncedActions
    }, {})
  }
})

以上就是pinia插件的相关知识点了,当然初次接触后,你可能不知道插件到底在什么样的情况下使用,笔者刚看到这块的时候也很疑惑,正好下面有一个地方就可以使用到这个插件的知识点了,别着急慢慢来

模块化思路

关于pinia的模块化,pinia其实本身就是按模块定义store的,笔者的个人理解就是它是天生自带模块化的,因为它不需要像vuex那样再去声明modules,一个stroe文件就是一个模块

但是一般项目中我们都习惯统一从一个store文件当做仓库的入口,笔者就提供以下两种方式,大家看着选择,也可提供自己的思路

假设现在 src/store 文件下我们定义了 2 个模块 user模块、counter模块

// src/store/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => {
    return {
        name:"jian",
        age: 12
    }
  },
})
// src/store/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => {
    return {
    }
  },
})

方式一:

// src/store/index.js
import { useUserStore } from './user'
import { useCounterStore } from './counter'

export default function useStore() {
  return {
    userStore: useUserStore(),
    counterStore: useCounterStore(),
  }
}

组件中使用

<script setup>
    import { storeToRefs } from 'pinia'
    import useStore from './store'
    const { userStore } = useStore()
    const { name, age } = storeToRefs(userStore)
</script>

方式二: 使用Es6的模块化语法引出并导出

// src/store/index.js
export { useUserStore } from './user'
export { useCounterStore } from './counter'

组件中使用

<script setup>
    import { useUserStore } from './store'
    const userStore = useUserStore()
    const { name, age } = storeToRefs(userStore)
</script>

持久化思路

所有的状态管理库无论是,vuex、pinia、redux,都绕不开一个问题,页面刷新后的数据持久化问题,说到持久化存储前端一般都会想到利用sessionstorage和localstorage,那么现在我们就通过localstorage实现pinia的数据持久化

首先我们需要指定pinia中的哪些数据需要被持久化,在哪里指定比较合适呢,很显然是在定义store时比较合适,我们在定义store的时候,就指定哪些数据是要被持久化的

如下案例我们想持久存储用户的token信息,最好的办法是,在定义store的时候,就添加一个配置,指定token需要被持久化

import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => {
    return {
        name:"jian",
        age: 12,
        token:'xxxxxxx'
    }
  },
  // 持久存储配置
  persist:{
      enabled: true //是否启用持久存储
      strategies: [ // 存储策略 可以指定多个策略
          {
              key:"user", // 存储的key
              storage: localstorage, // 使用什么storage进行存储
              paths:["token"] // 具体要存储该store中的哪些值
          }
      ]
  }
})

如上述的配置就算是比较详细的了,我们在定义store的时候传入一个控制持久存储的配置persist,在这个配置中我们可以控制是否开启持久存储,和指定具体的存储策略,包括存储哪些值,存储当前store对应的key,以及利用什么进行存储,这里需要注意 storage对象 如果是用户可以指定的话,那我们就要对其进行类型的限制,传入的storage我们不在乎其具体实现,但是要有setItem、getItem方法,因为后续我们需要手动去调用这些方法写入和读取值

现在配置添加好了,下一步就是去如何处理这些配置,之前我们有提到过,pinia给我提供了一种自定义配置的方法,那就是plugins,现在正好我们可以使用这个方法了,这也整是插件的使用场景之一自定义配置

下面我们开始实现这个插件

   function persistencePlugin({ options, store }) {
   // 判断当前的store有没有配置persist
    if (options.persist?.enabled) {
        //定义一个默认的存储策略
        const defaultStrat = [{
            key: store.$id, // 默认存储的key前缀
            storage: sessionStorage //默认存储方式用sessionStorage
        }]
        // 判断用户是否指定了持久化策略,如果指定了就使用用户的否则就使用默认的
        const strategies = options.persist?.strategies?.length ? options.persist?.strategies : defaultStrat
        strategies.forEach(strategy => {
            const storage = strategy.storage || sessionStorage
            const storeKey = strategy.key || store.$id
            const storageResult = storage.getItem(storeKey)
            // 如果我们配置的key,在持久化的storage中有对应的value值,那么就将这个值存入当前store中(刷新也不丢失的核心处理就在这)
            if (storageResult) {
                store.$patch(JSON.parse(storageResult))
                updateStorage(strategy, store)
            }
        });
        // 设置全局订阅事件,如果用户指定了开启持久化,那么当用户修改当前的state时,就按照指定策略更新storage(updateStorage单独抽取封装)
        store.$subscribe(() => {
            strategies.forEach((strategy) => {
                updateStorage(strategy, store)
            })
        })
    }
}

先整理出插件的轮廓后,最后实现更新storage的方法

function updateStorage(strategy, store) {
    const storage = strategy.storage || sessionStorage
    const storeKey = strategy.key || store.$id
    // 默认是全部全部更新持久化,如果用户指定了paths,那么只更新用户指定的
    if (strategy.paths) {
        const partialState = strategy.paths.reduce((finalObj, key) => {
            finalObj[key] = store.$state[key]
            return finalObj
        }, {})
        storage.setItem(storeKey, JSON.stringify(partialState))
    } else {
        storage.setItem(storeKey, JSON.stringify(store.$state))
    }
}

通过以上方法我们就实现了利用插件完成pinia的数据持久化配置,这里只是展示pinia数据持久化的实现思路,当然这方面已经有完善的插件了,在项目中除非其不能满足你的业务需要,否则也没有必要重复造轮子,开发中你可以直接使用,下面就介绍一个pinia的持久化插件

pinia-plugin-persistedstate

安装方式

根据你自己项目选择的包管理工具进行安装

pnpm : pnpm i pinia-plugin-persistedstate
npm : npm i pinia-plugin-persistedstate
yarn : yarn add pinia-plugin-persistedstate

引入

    import App from "./App.vue";
    import { createApp } from "vue";
    import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' 
    import { createPinia } from "pinia";
    
    const pinia = createPinia();
    pinia.use(piniaPluginPersistedstate)
    createApp(App).use(pinia);

使用

import { defineStore } from 'pinia'

export const useStore = defineStore('main', {
  state: () => {
    return {
      token: 'xxxx',
      userInfo: {
        name: 'zh',
      },
    }
  },
  // 如果你想当前store的state所有数据都持久化,就直接配置为true
  // persist: true,
  
  // 如果你想自定义持久化策略
  persist: {
    // 自定义在存储中的key值,默认是当前store的id,当前例子如果此处不指定,默认就是main
    key: 'customKey',
    // 指定存储的storage,默认为localStorage
    storage: window.sessionStorage,
    // 指定持久化的路径,不传默认存储所有,传 [ ] 则什么都不存储
    paths: ['userInfo.name'"token"],
  },
})

当然该插件还支持更多的配置功能,如序列化配置、afterRestore钩子等,这些笔者也没有用过,这里简单的提一嘴,有需要的同学可以直接查看pinia-plugin-persistedstate的官方文档,先说好该文档么得中文翻译......

总结

pinia体积更小只有1kb,相较于vuex语法更加简化,对于typescript的支持性更好,更适用于vue3的组合式api风格,如果你项目是vue3+typescript,那么在状态管理库上可以考虑选择pinia(虽然我也没在公司项目中真实落地过,但是demo项目体验下来,真香~)