pinia 新一代的vue状态管理

0.前言

本来vue全家桶系列本来打算写个vuex的教程的,但是现在有了新的pinia,咱们就来学习新的pinia
现在正好pinia还没有中文的官方文档,我就试着翻译一下
我现在开始的是日期是2021年10月29日,
我安装的pinia的版本是2.0.0
下面就正式开始学习pinia了

1.简介

pinia在2019年11月开始时候是一个实验项目,目的就是重新设计一个与组合API匹配的vue状态存储。基本原则和原来还是一样的,pinia同时支持vue2和vue3,比不要求你必须使用组合API。不管是使用vue2或者vue3,pinia的API是相同的,文档是基于vue3写的,同时在需要的地方也标注了vue2的用法,所以不管你是使用vue2还是vue3开发,都能够通过这个文档来学习。

你为什么应该使用pinia

pinia是一个vue的状态存储库,你可以使用它来存储、共享一些跨组件或者页面的数据。如果你对组合API很熟,你也许会想到你可以使用组合API来做一个简单的全局共享状态存储,像这样

export const state = reactive({})

对于spa来说确实可以这么用,如果是服务端渲染(ssr)那么这样会给你的项目带来安全风险。
但是,就算是spa,你使用pinia也可以有很多优势:

dev tools支持

有跟踪action和mutation的时间轴
按需导入状态存储
可进行时光旅行调试,而且调试更方便

热更新

不刷新页面更新你的状态
保持在开发中已有的状态

插件:使用插件来扩展pinia的功能
更好的ts支持和代码自动补全
支持ssr

基础示例

下面就是使用pinia的一个例子(一定要看后面的文档,从头学习)。这样你就创建了一个状态存储。

// stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => {
    return { count: 0 }
  },
  // 也可以这样定义状态
  // state: () => ({ count: 0 })
  actions: {
    increment() {
      this.count++
    },
  },
})

在组件中使用:

import { useCounterStore } from '@/stores/counter'

export default {
  setup() {
    const counter = useCounterStore()

    counter.count++
    // 编辑器会有代码提示 ✨
    counter.$patch({ count: counter.count + 1 })
    // 也可以使用action来代替
    counter.increment()
  },
}

你甚至可以用一个函数(像setup函数一样)来定义你的store:

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  function increment() {
    count.value++
  }

  return { count, increment }
})

如果你不是很喜欢setup函数和组合API,不用担心,pinia也有类似vuex的map的功能。你可以用上面的方式定义你的store,但是使用时用mapStores(), mapState(),或者 mapActions():

const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    double: (state) => state.count * 2,
  },
  actions: {
    increment() {
      this.count++
    }
  }
})

const useUserStore = defineStore('user', {
  // ...
})

export default {
  computed: {
    // 其他计算属性
    // ...
    // 可以使用 this.counterStore 和 this.userStore获取
    ...mapStores(useCounterStore, useUserStore)
    // 可以使用 this.count 和this.double获取
    ...mapState(useCounterStore, ['count', 'double']),
  },
  methods: {
    // 可以使用 this.increment()调用
    ...mapActions(useCounterStore, ['increment']),
  },
}

你可以在核心概念中发现map的更多使用方法。

为什么名字是pinia

pinia(在英语中发音类似/peenya/)是和pina(西班牙语的pineapple)最近接的词(pineapple是菠萝的意思,译者注)。刚好别人没有使用过这个包名。菠萝实际上是好多独立的花聚合在一起形成了一个水果。这和状态管理类似,每一个状态都是独立的,但是最后要把它们聚合在一起。菠萝本身是一种原产自南美的很好吃的热带水果。

一个更完整的例子

下面是一个更完整的pinia的例子。对于一些人来说不用看更深入的文档,直接看这个例子就可以学会pinia了,但是我们还是推荐看完整个文档,甚至可以跳过不看这个例子,等你看完核心概念再返回来这个例子。

import { defineStore } from 'pinia'

export const todos = defineStore('todos', {
  state: () => ({
    /** @type {{ text: string, id: number, isFinished: boolean }[]} */
    todos: [],
    /** @type {'all' | 'finished' | 'unfinished'} */
    filter: 'all',
    // 累心会被自动推断为number类型
    nextId: 0,
  }),
  getters: {
    finishedTodos(state) {
      // 会有代码自动补全! ✨
      return state.todos.filter((todo) => todo.isFinished)
    },
    unfinishedTodos(state) {
      return state.todos.filter((todo) => !todo.isFinished)
    },
    /**
     * @returns {{ text: string, id: number, isFinished: boolean }[]}
     */
    filteredTodos(state) {
      if (this.filter === 'finished') {
        // 调用其他的getter,同样会有代码自动补全 ✨
        return this.finishedTodos
      } else if (this.filter === 'unfinished') {
        return this.unfinishedTodos
      }
      return this.todos
    },
  },
  actions: {
    // 传入任意参数,可以返回promise,也可以不返回
    addTodo(text) {
      // 可以直接更改状态
      this.todos.push({ text, id: this.nextId++, isFinished: false })
    },
  },
})

与vuex对比

pinia尽量和vuex的理念保持一致。我们设计它的目的就是为下一代vuex做一个试验,pinia很成功。所以我们设计的vuex5的API和pinia很类似,现在已经有RFC了。我(Eduardo),pinia的作者,也是vue核心团队的一名成员,在Router和vuex中都有我设计的API。我做这个项目的个人目的就是为了重新设计一个符合vue理念的全局状态管理。我让pinia的API尽量接近vuex,这样以后pinia的用户转到vuex的时候更简单,甚至这两个项目以后可能会合并成为一个(Vuex)。

RFC

vuex是通过RFC在社区得到很多反馈的,pinia并不是这样。我是基于我的开发经验、阅读别人的代码、与使用pinia的人交流和在Discord上回答问题来设计pinia的。这样,我可以使pinia善于处理各种情况、适用于大小项目。我经常更新,并且在保持API不变的同时,pinia的内部代码不断地提升。

与vuex 3.x/4.x对比

vuex 3.x对应的是vue2,vuex 4.x对应的是vue3

与vue4之前的版本相比,pinia的API是有很多不同的,即:

  • 去掉了mutation。因为好多人认为mutation是多余的。以前它方便devtools集成,现在这不是个问题了。
  • 不用在写复杂的ts类型包装,所有的都是有类型的,API设计的都是尽量符合ts的类型推断
  • 不再使用一个莫名其妙的字符串了,只需要导入一个函数,调用他们就行了,同时还有代码自动补全
  • 不需要动态添加store了,因为它们现在本来就是动态。如果你想,你随时可以手动去写一个store。
  • 没有复杂的嵌套模块了。你仍然可以在一个store中导入其他的store来实现嵌套模块,但是pinia还是推荐使用一个扁平的结构。但是即使你使用循环依赖也没关系。
  • 不再需要命名空间了。因为现在store本来就是扁平结构了。你也可以理解为所有的store本来就有命名空间了。

开始

安装

你可以使用你喜欢的包管理工具安装pinia

yarn add pinia
# 或者使用npm
npm install pinia

提示
如果你使用的是vue2,你要安装@vue/composition-api,如果你使用的是nuxt,你需要看这里

如果你使用vue cli,你可以试一试这个非官方插件

创建一个pinia(根store),并且把它传给app:

import { createPinia } from 'pinia'

app.use(createPinia())

如果你使用的是vue2,你需要安装一个插件,并且把创建的pinia传给根app:

import { createPinia, PiniaVuePlugin } from 'pinia'

Vue.use(PiniaVuePlugin)
const pinia = createPinia()

new Vue({
  el: '#app',
  // 其他选项...
  // ...
  // 注意,一个pinia实例可以在同一个页面的多个vue app中共用
  pinia,
})

这样可以通过devtools调试了。在vue3中,有些特性,比如时间旅行调试和编辑还不支持,因为devtools还没有开发相关的API,但是devtools还有很多其他特性,开发体验会很好。在vue2中,pinia用的是vuex的现成的接口。

什么是store(状态存储)?

像pinia这样的状态存储就是要保存一些状态和一些业务逻辑,并且要与你的组件树解耦。换句话说,它要保存全局数据。就好像有一个公共数据组件,其他的组件都可以从它那里读取数据和更改数据。它有三个核心概念,state、getters和actions。就好比是组件中的data、computed和methods。

什么情况下你需要用store?

你的应用中的全局数据需要保存在store中。在很多地方你都要使用这些数据,比如说,用户信息需要在导航栏中显示,也需要在个人中心显示。还有些数据,需要暂存起来,比如一个需要分好几页填写的表单。
另一方面,一些只有在一个页面用的局部数据就不要放到全局的store中,比如一个页面上某个弹窗显示不显示,就没有必要放在store中了。
不是所有的应用都需要全局状态管理,但是如果你需要使用,pinia会是一个不错的选择。

核心概念

定义store

在开始学习核心概念之前,我们需要知道store是通过defineStore()方法定义的,它的第一个参数就是一个唯一的名字:

import { defineStore } from 'pinia'

// useStore 可以定义为其他的名字,比如 useUser, useCart
//第一个参数是store的名字,在整个app中它必须是唯一的
export const useStore = defineStore('main', {
  // other options...
})

名字,可以说是id,它是必填的,pinia就是用名字来连接store和devtools的。使用useXXX名字defineStore返回的函数是一个通用习惯。

使用store

我们上面只是定义了store,在setup函数中调用了useStore()时,才会创建store:

import { useStore } from '@/stores/counter'

export default {
  setup() {
    const store = useStore()

    return {
      // 你可以返回store这个对象,然后就可以在template中使用了
      store,
    }
  },
}

你想定义多少个store都可以,不过你最好给每一个store新建一个文件(这样在打包时可以更好的进行代码分割)。
如果你不使用setup函数,你可以使用map方式

在store实例化以后,你就可以获取到store中定义的state、getters和actions了。这些我们后面会学习,并且会有代码自动补全。

记着store是一个reactive响应式的对象,所以不用写.value。像setup中props一样,我们不可以解构它:

export default defineComponent({
  setup() {
    const store = useStore()
    // ❌ 这样不可以,因为会失去响应性
    // 和解构props是一样的`
    const { name, doubleCount } = store

    name // "eduardo"
    doubleCount // 2

    return {
      // 一直是 "eduardo"
      name,
      // 一直是  2
      doubleCount,
      // 这个值是响应式的
      doubleValue: computed(() => store.doubleCount),
      }
  },
})

为了让解构的值还保持响应式,你需要用到storeToRefs()方法。它会给响应式的数据创建ref。如果你只使用store中的stata不调用action,这么写很简单:

import { storeToRefs } from 'pinia'

export default defineComponent({
  setup() {
    const store = useStore()
    // `name` 和 `doubleCount` 是响应式的
    // 插件增加的属性也会创建ref
    // 但是会自动跳过action或者不是响应性的属性
    const { name, doubleCount } = storeToRefs(store)

    return {
      name,
      doubleCount
    }
  },
})

state

大多数时候,state是store的中心。大家一般都是从定义state开始写store的。在pinia中,是调用一个函数来返回初始的state。这样pinia既可以在客户端运行,就可以在服务端运行。

import { defineStore } from 'pinia'

const useStore = defineStore('storeId', {
  // 推荐使用箭头函数
  state: () => {
    return {
      // 这些属性都会自动推断类型
      counter: 0,
      name: 'Eduardo',
      isAdmin: true,
    }
  },
})

提示
如果你使用的是vue2,在初始化state的方式和vue组件中data的方式一样,比如,state对象一定是扁平的,而且如果你要给它加新的属性的话,需要调用Vue.set()

获取state

默认情况下,你可以在store实例上直接获取或者修改state:

const store = useStore()

store.counter++

重置state

你可以调用$reset()方法来把state恢复为初始值:

const store = useStore()

store.$reset()

选项API示例

如果你不使用组合API,而使用computed、methods。。。你可以使用mapState(),获取state的值:

import { mapState } from 'pinia'

export default {
  computed: {
    // 在组件中可以是用this.counter获取
    // 和使用store.counter获取一样
    ...mapState(useStore, ['counter'])
    // 和上面一样,不过使用了别名this.myOwnName获取
    ...mapState(useStore, {
      myOwnName: 'counter',
      // 你也可以写一个方法
      double: store => store.counter * 2,
      // 可以访问this指针,但是不能自动推断类型了
      magicValue(store) {
        return store.someGetter + this.counter + this.double
      },
    }),
  },
}
可修改的state

如果你想修改state里面的属性(比如在你的表单中),你可以使用mapWritableState()。注意不能像使用mapState()一样传递函数:

import { mapWritableState } from 'pinia'

export default {
  computed: {
    // 可以使用this.counter修改它的值
    // this.counter++
    // 和使用store.counter获取它的值一样
    ...mapWritableState(useStore, ['counter'])
    // 使用别名也一样this.myOwnName
    ...mapWritableState(useStore, {
      myOwnName: 'counter',
    }),
  },
}

提示
如果是数组,修改时不必要使用mapWritableState(),除非你要改变它的指针cartItems = [],使用mapState()时,你可以调用数组的方法。

改变state

除了直接修改store里的值store.counter++,你也可以是用$patch方法。你可以同时修改多个值:

store.$patch({
  counter: store.counter + 1,
  name: 'Abalam',
})

但是,这么写有时不方便,有时太消耗性能,比如说你要修改一个数组时,还得新建一个数组。出于这个原因,$patch方可可以接收一个函数作为参数,来简化改变数组的写法:

cartStore.$patch((state) => {
  state.items.push({ name: 'shoes', quantity: 1 })
  state.hasChanged = true
})

这么写还有一个好处,就是这一组改变在devtools中查看时,是一次改变。注意,直接改变state和使用$patch方法,都可以在devtools里面查看,都可以实现时间旅行(在vue3中暂时不能).

替换state

你可以通过store的$state属性,整个替换state对象:

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

你也可以使用pinia实例的state属性替换你的应用的全局state。可以在ssr中使用:

pinia.state.value = {}

订阅state的改变

你可以用$subscribe()来侦听state的改变,和vuex的subscribe
方法类似。

cartStore.$subscribe((mutation, state) => {
  // import { MutationType } from 'pinia' 改变触发的类型
  mutation.type // 'direct' | 'patch object' | 'patch function'
  // same as cartStore.$id
  mutation.storeId // 'cart'
  // only available with mutation.type === 'patch object'
  mutation.payload // patch object passed to cartStore.$patch()

  // 侦听到state变化时,把state存在localStorage中
  localStorage.setItem('cart', JSON.stringify(state))
})

默认情况下,state侦听会和组件绑定在一起(如果store是在组件的setup中)。这意味着,当组件卸载时,侦听会自动被移除。如果你需要在组件被卸载时,侦听仍然保持,需要给$subscribe()方法传递第二个参数true:

export default {
  setup() {
    const someStore = useSomeStore()

    // 组件卸载后,侦听也会有
    someStore.$subscribe(callback, true)

    // ...
  },
}

提示
你可以在pinia实例上侦听整个state

watch(
  pinia.state,
  (state) => {
    // 在state改变时,保存在localStorage中
    localStorage.setItem('piniaState', JSON.stringify(state))
  },
  { deep: true }
)

Getters

getters就相当于的state的计算属性。可以在defineStore()方法中的getters属性中定义它们。getter的第一个参数就是state,可以是用箭头函数来定义:

export const useStore = defineStore('main', {
  state: () => ({
    counter: 0,
  }),
  getters: {
    doubleCount: (state) => state.counter * 2,
  },
})

大多数情况下,getter只依赖于state的值,但是,有时也会依赖于其他getter。所以,在getter中可以用this指针访问store对象,这是要使用普通的function来定义getter,如果使用ts,记得定义返回值的类型。

export const useStore = defineStore('main', {
  state: () => ({
    counter: 0,
  }),
  getters: {
    // 可以自动推断返回值类型是数字
    doubleCount(state) {
      return state.counter * 2
    },
    // 这是必须指定返回值类型(ts)
    doublePlusOne(): number {
      return this.counter * 2 + 1
    },
  },
})

你可以在store实例上直接获取getter:

<template>
  <p>Double count is {{ store.doubleCount }}</p>
</template>

<script>
export default {
  setup() {
    const store = useStore()

    return { store }
  },
}
</script>

在getter中访问其他getter

就想计算属性一样,getter也可以通过this指针访问其他的getter。即使你不使用ts,你也可以写JSDoc,这样你的idea就可以知道数据的类型了:

export const useStore = defineStore('main', {
  state: () => ({
    counter: 0,
  }),
  getters: {
    // 因为没有用this,可以正确推断类型
    doubleCount: (state) => state.counter * 2,
    //  也可以写JSDoc来说明类型
    /**
     * 返回的值是counter乘以2再加1
     *
     * @returns {number}
     */
    doubleCountPlusOne() {
      // 会有代码自动补全 ✨
      return this.doubleCount + 1
    },
  },
})

给getter传递参数

getter本来就是计算属性,所以不能给它传递参数。但是,你可以在getter中返回一个函数,这个函数可以接收参数:

export const useStore = defineStore('main', {
  getters: {
    getUserById: (state) => {
      return (userId) => state.users.find((user) => user.id === userId)
    },
  },
})

在组件中使用:

<script>
export default {
  setup() {
    const store = useStore()

    return { getUserById: store.getUserById }
  },
}
</script>

<template>
User 2: {{ getUserById(2) }}
</template>

需要注意的是,这种情况下,getter的结果不会被缓存了,它们只是你调用的一个函数了。不过你自己手动在你的getter中缓存结果,当然这个做法不是很常见,而且消耗更多性能:

export const useStore = defineStore('main', {
  getters: {
    getActiveUserById(state) {
      const activeUsers = state.users.filter((user) => user.active)
      return (userId) => activeUsers.find((user) => user.id === userId)
    },
  },
})

获取其他store实例中的getter

你可以在getter中直接获取其他store中的getter:

import { useOtherStore } from './other-store'

export const useStore = defineStore('main', {
  state: () => ({
    // ...
  }),
  getters: {
    otherGetter(state) {
      const otherStore = useOtherStore()
      return state.localData + otherStore.data
    },
  },
})

在setup中使用

就行state一样,你可以通过store直接获取getter:

export default {
  setup() {
    const store = useStore()

    store.counter = 3
    store.doubleCount // 6
  },
}

在选项API中使用

你可以像之前学习过的使用mapState()方法,获取getter:

import { mapState } from 'pinia'

export default {
  computed: {
    // 在这个组件中可以这样访问this.doubleCounter 
    ...mapState(useStore, ['doubleCount'])
    // 使用别名 this.myOwnName
    ...mapState(useStore, {
      myOwnName: 'doubleCounter',
      // 也可以写一个function,参数可以获取到store对象
      double: store => store.doubleCount,
    }),
  },
}

actions

action相当于组件中的methods。可以在defineStore()方法中定义action。我们应该在业务逻辑定义在action中:

export const useStore = defineStore('main', {
  state: () => ({
    counter: 0,
  }),
  actions: {
    increment() {
      this.counter++
    },
    randomizeCounter() {
      this.counter = Math.round(100 * Math.random())
    },
  },
})

就行getter一样,action可以使用this指针访问整个store实例,并且有类型推断和代码补全。不同的是,action支持异步,你可以在action内部去调用后台接口,甚至调用其他action。下面是一个使用Mande调用接口的例子。注意,你用哪个库没关系,只要返回的是一个Promise,你甚至可以直接使用原生的fetch:

import { mande } from 'mande'

const api = mande('/api/users')

export const useUsers = defineStore('users', {
  state: () => ({
    data: userData,
    // ...
  }),

  actions: {
    async registerUser(login, password) {
      try {
        this.userData = await api.post({ login, password })
        showTooltip(`Welcome back ${this.userData.name}!`)
      } catch (error) {
        showTooltip(error)
        // 显示错误提示
        return error
      }
    },
  },
})

你可以随意的给action定义参数和返回值。调用action时,所有值都会正确地推断类型。
调用action就像调用methods一样:

export default defineComponent({
  setup() {
    const main = useMainStore()
    // 调用action就像调用methods一样
    main.randomizeCounter()

    return {}
  },
})

在action中访问其他的store

可以在action中直接使用其他的store:

import { useAuthStore } from './auth-store'

export const useSettingsStore = defineStore('settings', {
  state: () => ({
    // ...
  }),
  actions: {
    async fetchUserPreferences(preferences) {
      const auth = useAuthStore()
      if (auth.isAuthenticated) {
        this.preferences = await fetchPreferences()
      } else {
        throw new Error('User must be authenticated')
      }
    },
  },
})

在setup中使用

你可以直接调用action,就行调用一个方法一样:

export default {
  setup() {
    const store = useStore()

    store.randomizeCounter()
  },
}

在选项API中使用

如果你不使用组合API,而使用computed、methods。。。你可以使用在methods中使用mapAction(),这样就可以调用action了:

import { mapActions } from 'pinia'

export default {
  methods: {
    // 可以在组件中使用this.increment() 调用
    // 和使用store实例调用是相同的 store.increment()
    ...mapActions(useStore, ['increment'])
    // 使用别名 this.myOwnName()
    ...mapActions(useStore, { myOwnName: 'doubleCounter' }),
  },
}

订阅action

可以是用store.$onAction()方法侦听、订阅action的调用与结果。在action调用以前就会调用这个回调。你可以在after回调中改变action的返回结果。在onError回调中你可以处理错误。这样你就可以在运行时追踪错误了,跟vue中类似。
下面是一个例子,在action调用前和调用后输出了一些内容:

const unsubscribe = someStore.$onAction(
  ({
    name, // action的名字
    store, // store的实例
    args, // action的参数数组
    after, //action调用完成后 return或者resolved
    onError, // 抛出错误或者reject
  }) => {
    // 在这里可以定义一些这几回调都可以访问的公有变量
    const startTime = Date.now()
    // 在action执行前,会执行这里
    console.log(`Start "${name}" with params [${args.join(', ')}].`)

    // action调用成功会执行after
    // 它会等待promise完成
    after((result) => {
      console.log(
        `Finished "${name}" after ${
          Date.now() - startTime
        }ms.\nResult: ${result}.`
      )
    })

    // 发送错误或者promise reject时调用
    onError((error) => {
      console.warn(
        `Failed "${name}" after ${Date.now() - startTime}ms.\nError: ${error}.`
      )
    })
  }
)

// 手动移除侦听
unsubscribe()

默认情况下,actions的侦听是在组件初始化是加上的(如果store是在一个组件的setup中使用)。这意味着组件卸载时,侦听会自动被移除。如果你想要组件卸载时,不移除侦听,在组件中调用$onAction时加上第二个参数true:

export default {
  setup() {
    const someStore = useSomeStore()

    // this subscription will be kept after the component is unmounted
    someStore.$onAction(callback, true)

    // ...
  },
}

插件

归功于pinia的low level API,pinia可以支持全面的拓展。下面是你可以扩展的:

  • 给store增加新属性
  • 在定义store时增加新选项
  • 给store增加新方法
  • 包装现有的方法
  • 改变、甚至取消action
  • 实现其他功能,比如本地存储
  • 给特定store添加功能

使用pinia.use()给pinia实例添加插件。下面是一个最简单的例子,给所有store添加一个静态的属性,这个属性返回一个对象:

import { createPinia } from 'pinia'

// 在插件被使用后,给所有的store增加了一个secret的属性
// 这段代码可以放在一个单独的文件里
function SecretPiniaPlugin() {
  return { secret: 'the cake is a lie' }
}

const pinia = createPinia()
// 使用use方法添加插件
pinia.use(SecretPiniaPlugin)

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

这个方式很有用,可以添加全局的路由、模态框或者toast。

介绍

pinia的插件就是一个方法,它返回的对象会添加在store对象上。它有一个参数context,作为选项:

export function myPiniaPlugin(context) {
  context.pinia // 通过`createPinia()`创建的pinia实例
  context.app //  `createApp()` 创建的vueApp的实例(Vue 3 )
  context.store // store的实例
  context.options // 调用`defineStore()`时的选项
  // ...
}

然后把这个方法传递给pinia.use():

pinia.use(myPiniaPlugin)

只有在pinia实例传给vue app时,插件才会应用在store中,其他情况它们不会起作用。

增强store

在插件里返回一个对象,然后每个store都会增加这个属性:

pinia.use(() => ({ hello: 'world' }))

你也可以在store对象上直接添加属性,但是还是尽量使用返回对象的方式,因为这样可以被devtools自动追踪:

pinia.use(({ store }) => {
  store.hello = 'world'
})

插件返回的任何属性都可以被devtools自动追踪,这样在devtools中就可以看到‘hello’属性了。
如果是直接在store上定义新属性,一定要调用store._customProperties,这样才能在devtools中调试这个属性:

pinia.use(({ store }) => {
  store.hello = 'world'
  if (process.env.NODE_ENV === 'development') {
    store._customProperties.add('secret')
  }
})

注意,store都是被reactive包装的,它会自动解包任何的ref类型(ref(), computed(), ...):

const sharedRef = ref('shared')
pinia.use(({ store }) => {
  // 每个store都有独立的hello属性
  store.hello = ref('secret')
  // 自动解包
  store.hello // 'secret'

  // 所有的的store都会有shared这个属性
  store.shared = sharedRef
  store.shared // 'shared'
})

这就是为什么你可以不使用.value访问所有的属性,并且他们是reactive。

增加新的state

如果你要给store增加新的state,你有两种方式:

  • 你可以使用store.myState的方式
  • 使用store.$state的方式,可以在devtools和ssr中生效
    记着,这样你可以共享一个ref或者computed属性:
const globalSecret = ref('secret')
pinia.use(({ store }) => {
  // `secret` 属性在所有的store中共享
  store.$state.secret = globalSecret
  store.secret = globalSecret
  // 自动解包
  store.secret // 'secret'

  const hasError = ref(false)
  store.$state.hasError = hasError
  // 必须这么写
  store.hasError = toRef(store.$state, 'hasError')

  // 这时最好不要return ‘hasError’,因为这样在devtools中会显示两次
})

警告
如果你使用的是vue2,你需要使用@vue/composition-api的set方法来添加新的属性:

import { set } from '@vue/composition-api'
pinia.use(({ store }) => {
  if (!store.$state.hasOwnProperty('hello')) {
    const secretRef = ref('secret')
    // 如果你使用ssr,需要在$state上定义它
    set(store.$state, 'secret', secretRef)
    // 在store对象上直接设置
    // 两种方式: `store.$state.secret` / `store.secret`
    set(store, 'secret', secretRef)
    store.secret // 'secret'
  }
})

添加外部属性

当在给pinia添加外部属性时,比如其他库的实例对象,或者不是reactive的属性,你需要用markRaw()方法先包裹它们,然后再传给pinia。下面是吧router对象添加给所有的store:

import { markRaw } from 'vue'
import { router } from './router'

pinia.use(({ store }) => {
  store.router = markRaw(router)
})

在插件内部调用$subscribe

你可以在插件里面调用store.subscribe和store.onAction方法:

pinia.use(({ store }) => {
  store.$subscribe(() => {
    // store变化时调用
  })
  store.$onAction(() => {
    // action触发时调用
  })
})

添加新的选项

可以在插件中给定义store时增加新的选项。例如,你可以新增一个debounce选项,这样你可以在action调用时实现防抖:

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

  // 这个属性之后会在插件里用到
  debounce: {
    //  给searchContacts这个action加了300毫秒防抖
    searchContacts: 300,
  },
})

插件可以读取选项,包装action,并且替换原来那个:

// 导入一个debounce方法
import debounce from 'lodash/debunce'

pinia.use(({ options, store }) => {
  if (options.debounce) {
    // 我们新的action会替换原来的
    return Object.keys(options.debounce).reduce((debouncedActions, action) => {
      debouncedActions[action] = debounce(
        store[action],
        options.debounce[action]
      )
      return debouncedActions
    }, {})
  }
})

注意,在使用setup语法时,自定义的选项时通过第三个参数传的:

defineStore(
  'search',
  () => {
    // ...
  },
  {
    // 这个值会在插件中使用
    debounce: {
      // 给searchContacts这个action加了300毫秒防抖
      searchContacts: 300,
    },
  }
)

TypeScript

上面这些代码都可以使用ts来写,所以你就不需要使用any或者@ts-ignore了。

类型化插件

类型化插件可以这么写:

import { PiniaPluginContext } from 'pinia'

export function myPiniaPlugin(context: PiniaPluginContext) {
  // ...
}

类型化新的store属性

给store添加属性时,需要使用PiniaCustomProperties接口:

import 'pinia'

declare module 'pinia' {
  export interface PiniaCustomProperties {
    // 在setter中,允许string类型和ref类型
    set hello(value: string | Ref<string>)
    get hello(): string

    // 你也可以这样简单的定义一个值
    simpleNumber: number
  }
}

这些数据可以安全地修改和获取:

pinia.use(({ store }) => {
  store.hello = 'Hola'
  store.hello = ref('Hola')

  store.number = Math.random()
  // @ts-expect-error:  这个类型不正确
  store.number = ref(Math.random())
})

PiniaCustomProperties允许你可以拿到store的属性。拿下面的例子来说,我们把原来的options复制一份,叫做$options:

pinia.use(({ options }) => ({ $options: options }))

我们可以使用PiniaCustomProperties让它们有正确的类型:

import 'pinia'

declare module 'pinia' {
  export interface PiniaCustomProperties<Id, S, G, A> {
    $options: {
      id: Id
      state?: () => S
      getters?: G
      actions?: A
    }
  }
}

提示
当拓展这些基本类型时,它们必须和源码中的命名一致。Id不能命名为id或者I,S不能被命名为State。下面是这些简写对应的单词:

  • S: State
  • G: Getters
  • A: Actions
  • SS: Setup Store / Store

类型化新的state

当添加新的state属性属性时(不管是store还是store.$state),你需要在PiniaCustomStateProperties中添加。和PiniaCustomProperties不同,它只接受State类型:

import 'pinia'

declare module 'pinia' {
  export interface PiniaCustomStateProperties<S> {
    hello: string
  }
}

类型化新的选项

在给defineStore()增加新的选项时,你需要使用DefineStoreOptionsBase。和PiniaCustomProperties不同,它只有两个类型:State类型和Store类型,用来让你去限制都可以定义哪些内容。例如,你可以使用action的名字:

import 'pinia'

declare module 'pinia' {
  export interface DefineStoreOptionsBase<S, Store> {
    // 可以为任何的action定义一个数字
    debounce?: Partial<Record<keyof StoreActions<Store>, number>>
  }
}

提示
getter也有一个对应的StoreGetters。你也可以使用DefineStoreOptions、DefineSetupStoreOptions来拓展store的选项。

Nuxt.js

在Nuxt中使用pinia时,你需要使用Nuxt plugin。这样你就可以获取pinia实例了:

// plugins/myPiniaPlugin.js
import { PiniaPluginContext } from 'pinia'
import { Plugin } from '@nuxt/types'

function MyPiniaPlugin({ store }: PiniaPluginContext) {
  store.$subscribe((mutation) => {
    // 在store变化时打印
    console.log(`[🍍 ${mutation.storeId}]: ${mutation.type}.`)
  })

  return { creationTime: new Date() }
}

const myPlugin: Plugin = ({ pinia }) {
  pinia.use(MyPiniaPlugin);
}
export default myPlugin

注意,上面使用的是ts,如果你使用的是js,你要把PiniaPluginContext和Plugin的类型声明去掉。

在组件之外使用store

pinia依靠的是pinia实例来共享同一个store实例。大多数情况下,你可以使用useStore()方法来获取store实例。
例如,在setup函数中,你不需要再做别的了。但是如果是在组件之外,会有一些不同。其实,useStore()会被自动注入你的app的pinia实例。这意味着,如果pinia实例不能被自动注入时,你必须手动把它传给useStore()。你可以用多种方式解决这个问题,这取决于你在做的应用是什么类型的。

单页应用

如果你不使用ssr,在你调用app.use(pinia)后,你就可以直接使用useStore()方法了。

import { useUserStore } from '@/stores/user'
import { createApp } from 'vue'
import App from './App.vue'

// ❌  失败,因为pinia实例还没有
const userStore = useUserStore()

const pinia = createPinia()
const app = createApp(App)
app.use(pinia)

// ✅ 成功,因为pinia实例已经有了
const userStore = useUserStore()

最简单的方式就是,在确保pinia实例已经有了之后,app.use(pinia)调用后,再去调用useStore()。

我们看一个使用vue router导航守卫的例子:

import { createRouter } from 'vue-router'
const router = createRouter({
  // ...
})

// ❌取决于pinia和router导入顺序的先后
const store = useStore()

router.beforeEach((to, from, next) => {
  // 我们想在这里使用store
  if (store.isLoggedIn) next()
  else next('/login')
})

router.beforeEach((to) => {
  // ✅ 成功,因为router是在router被使用后才喀什导航的,现在pinia肯定也被使用了
  const store = useStore()

  if (to.meta.requiresAuth && !store.isLoggedIn) return '/login'
})

服务端渲染的应用

在使用srr时,你一定要把pinia实例传给useStore()。这样防止里pinia在不同的应用实例中共享同一个全局数据了。后面有完整ssr中使用pinia的例子。

ssr

Cookbook

结束语

最后两部分ssr和高级内容我就不翻译了,我估计用途也不大,而且对于ssr我也不太熟。
相信看完官方文档好多小伙伴还是很蒙的,其实官方文档并不适合入门学习
首先大部分官方文档并不是安装由易到难的顺序编写的,因为它要讲一个模块时,要把这个模块所有内容尽量讲到;其次官方文档是按模块划分内容的,为了是方便查阅,并不适合按这个顺序学习;还有就是我有的地方也不太熟悉,比如ts部分我只是学过一些内容。
所以我打算后面自己来写比较适合从零入门的教程,目的是可以由简入难的学习,还有就是结合实际工作中会遇到的场景。
这篇翻译到这里就结束了,完结撒花。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335

推荐阅读更多精彩内容