早早聊 Vue 专场弹幕频出的 Pinia 是什么玩物?

初识

在 10 月 23 号的前端早早聊 Vue 生态大会上,Q&A 环节中 Pinia 这个关键词出现的频次可以说是相当高,几乎每个老师分享完,弹幕中都会出现“皮你呀(Pinia)”相关的问题。这是何方圣物?本文将带你去认识它。

在认识 Pinia 之前,我们先来看 vue 上一个关于 Vuex 5.xRFC

描述中可以看到,Vue 5.x 主要改善以下几个特性:

  • 同时支持 composition apioptions api 的语法;
  • 去掉 mutations,只有 stategettersactions
  • 不支持嵌套的模块,通过组合 store 来代替;
  • 更完善的 Typescript 支持;
  • 清晰、显式的代码拆分;

Pinia 正是基于 RFC 所生成的一个玩物。

它的定位和特点也很明确:

  • 直观,像定义组件一样地定义 store,并且能够更好地组合它们;
  • 完整的 Typescript 支持;
  • 关联 Vue Devtools 钩子,提供更好地开发体验;
  • 模块化设计,能够构建多个 stores 并实现自动地代码拆分;
  • 极其轻量(1kb),甚至感觉不到它的存在 ;
  • 同时支持同步和异步 actions

这么 niubility 特性,接下来就 show you the code 去逐一学习。

接触

光说不练,等于白学。这一小节通过介绍 PiniaAPI,感受上一小节讲到的特性。

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: '码农小余'
    }
  }
})
复制代码

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 <==> defineStoreid <==> namestate <==> setup直观,像定义组件一样地定义 store 到这里是能够体会到该特性的含义了。上述代码是在 composition api 中 setup 的用法,在 options api 中使用跟 Vuex 类似,通过 mapState 或者 mapWritableState 辅助函数来读写 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 ,小余当然得往购物车里面塞几本“葵花宝典”,于是乎就需要一个 cartstore

import { defineStore } from 'pinia'

export default defineStore('cart', {
  state () {
    return {
      books: [
        {
          name: '金瓶梅',
          price: 50
        },
        {
          name: '微服务架构设计模式',
          price: 139
        },
        {
          name: '数据密集型应用系统设计',
          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: '码农小余',
      books: cartStore.books
    }
  }
})
复制代码

最终被 App 组件消费。⚠️ 直接解构 store 会使其失去响应式,为了在保持其响应式的同时从 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 对象,不能直接解构哦,使用 pinia 提供的 storeToRefs API
const { name, books } = storeToRefs(useAppStore())
</script>
复制代码

此时的页面效果如图所示:

$patch

除了直接修改 store.xxx 的值,还可以通过 $patch 修改多个字段信息;下面在例子中添加购买数量、总价,并添加付款人:

<template>
    <div>{{ name }}</div>
    <p>购物车清单:</p>
    <p v-for="book of books" :key="book.name">
        书名:{{ book.name }} 价格:{{ book.price }} 数量: {{ 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: "微服务架构设计模式",
        price: 139,
        count: 10,
      },
      {
        name: "数据密集型应用系统设计",
        price: 128,
        count: 10,
      },
    ],
  });
};
</script>
复制代码

例子中添加了 “全部加到10本”和 “重置”按钮,点击前者会将全部书籍数量添加到 10 本,点击后者会重置成 0,下面是执行效果:

假如你只想将《微服务架构设计模式》的数量修改成10,通过 $patch 传对象的方法需要这么操作:

<template>
	<button @click="batchAddMicroService">微服务加到10本</button>
</template>

<script>
	const batchAddMicroService = () => {
      store.$patch({
        name: '小I',
        books: [
          {
            name: "金瓶梅",
            price: 50,
            count: 0,
          },
          {
            name: "微服务架构设计模式",
            price: 139,
            count: 10,
          },
          {
            name: "数据密集型应用系统设计",
            price: 128,
            count: 0,
          },
        ],
      });
    }
</script>
复制代码

可以看到,就算你只是修改数组(集合)的第二项,还是需要将整个 books 数组传入,于是就产生了将函数作为 $patch 参数的写法:

<script>
const batchAddMicroService = () => {
  store.$patch((state) => {
    state.books[1].count = 10;
  });
}
</script>
复制代码

上述代码重写了 batchAddMicroService 方法。

$subscribe

该方法跟 vuexsubscribe 类似,用于监听 state 及其 mutation 动作。上述例子中我们订阅 appStore 的状态:

const store = useAppStore();

store.$subscribe((mutation, state) => {
  console.log(mutation);
  console.log(state);
});
复制代码

当我们添加一本《金瓶梅》时,会触发 $subscribelog 结果如下图:

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)
    }
  }
})
复制代码

我们将 stategetters 都改成箭头函数,这样就能在 App.vue 中正确推断出 price 的类型。我们来验证一下:

Bingo!如果在 getters 中使用 this 去访问 state 的话,需要显式声明返回值才能正确标记类型,我们来试试:

export default defineStore('app', {
  // ...
  getters: {
    price () {
      return this.books.reduce((init: number, curValue: BookItem) => {
        return init += curValue.price * curValue.count
      }, 0)
    }
  }
})
复制代码

结果如下:

我们给 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、在 setupoptions api 中使用、传参等等都跟 State 类似,本节就不展开细述。

Actions

Actions 相当于组件里的 methods。双 11 买东西当然免不了折扣,商家也在折扣这环节上设计了活动,能够让顾客自己随机一个折扣比率,于是在 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

当我们想统计 actions 的时间或者记录折扣点击总次数的时候,$onAction 订阅器能够很方便地实现,下面是一个官方的示例:

// 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结束promise
      reject(rate * Math.random())
    }, 1000)
  })
}

export default defineStore('app', {
  // ...

  actions: {
    async changeDiscountRate () {
      try {
        this.discountRate = await getNewDiscountRate(this.discountRate)
      } catch (e) {
        // 示例执行这部分逻辑
        throw Error(e)
      }
    }
  }
})
复制代码

上述代码会以 rejected 的状态结束 promise$onAction 能够执行到 onError 钩子,这个钩子是跟 vue3errorHandler 挂钩,想知道这部分怎么实现滴可以三连持续关注最新内容哦,下篇文章将会从 pinia 源码角度分析。最后执行效果如下图所示:

从上图可以看到,第一个 warn 是订阅器 $onAction 返回的,第二个 warn 则是 errorHandler 中返回。

最后,$onAction 一般是在组件的 setup 建立,它会随着组件的 unmounted 而自动取消。如果你不想让它取消订阅,可以将第二个参数设置为 true

store.$onAction(callback, true)
复制代码

深入(Plugins)

通过一些底层 API,我们能够各种各样的扩展:

  • store 添加新的属性;
  • store 添加新的选项;
  • store 添加新的方法;
  • 包装已经存在的方法;
  • 修改或者删除 actions
  • 基于特定的 store 做扩展;

光说不练,等于白学。下面就来实现一个 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() 中创建的 app 实例、defineStore 中的配置、从 createPinia() 中创建的 pinia 实例、当前 store 对象。有了这些信息,你要完成上述那些扩展无非只是基于 context 去发挥。 下面列对两个比较常见的用法举例说明。

给 store 添加新的属性

我们实现一个添加 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 监听到,但是可以通过 store._customProperties.add('pluginVar') store.$state.pluginVar 的改变会被 devtools 监听到。

基于特定的 store 做扩展

action 支持异步,我们在 action 中发送请求的需求会比较多,我们就可以将 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 调用的时候,我们就只需要直接通过 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 状态管理的后起之秀和官方团队维护的 Vuex,什么时候该用哪个?这一小节来简单地对比一下。

首先下载量和社区方面:

Pinia 作为后起之秀,不管是 Stack Overflow 上的问题解决方案还是下载量上,肯定都不如 Vue 核心团队推荐的 Vuex。其他方面的对比可以直接阅读 pinia-vs-vuex ,这篇文章非常详细地比较了二者。言而总之:小项目可以试水 Pinia,大项目还是用成熟的 Vuex。(另外,外链的对比时间比较早,目前 Pinia 好像是支持了时间旅行功能)

总结

本文详细地介绍了 Pinia 的基础使用,从 stateGettersActionsPlugins,都有例子辅助学习。Pinia 的特性也都在文中有涉及,比如composable storeTypeScript 的支持,devtool 插件的集成如下图所示:

最后简单地跟 Vuex 做了比较,详细记得点击链接去查阅引文哦。相信你读完本文,对于 Pinia 的基础使用应该是没问题的。下篇文章就让我们从源码的角度去深入理解 Pinia 带来的这些特性,关注我,第一时间收获精彩内容。

猜你喜欢

转载自juejin.im/post/7037238816712687629