抛弃 Vuex,使用 Pinia

8,705 阅读5分钟

Pinia

一个全新的用于Vue的状态管理库

下一个版本的Vuex,也就是Vuex 5.0

Pinia已经被纳入官方账户下了,github.com/vuejs/pinia

Pinia官网链接

image-20220125203303545

1.介绍

Pinia最初是一个实验,目的是在2019年11月左右重新设计Vue状态管理在Composite APl上的样子,也就是下一代Vuex。

  • 之前的vuex主要服务于Vue 2,选项式API
  • 如果想要在Vue 3中使用Vuex,需要使用它的版本4
    • 只是一个过渡的选择,还有很大的缺陷
  • 所以在Vue3伴随着组合式API诞生之后,也设计了全新的Vuex: Pinia,也就是Vuex 5

image-20220125211018148

提案链接: github.com/vuejs/rfcs/…

  • Vue 2和 Vue 3都支持

    • 除了初始化安装和SSR配置之外,两者的API都是相同的
    • 官方文档中主要针对Vue3进行说明,必要的时候会提供Vue2的注释
  • 支持Vue DevTools

    • 跟踪actions、mutations 的时间线
    • 在使用容器的组件中就可以观察到容器本身
    • 支持 time travel更容易的调试功能
    • 在 Vue 2中 Pinia使用Vuex的现有接口,所以不能与Vuex一起使用
    • 但是针对Vue 3中的调试工具支持还不够完美,比如还没有time-travel调试功能
  • 模块热更新

    • 无需重新加载页面即可修改您的容器。热更新的时候保持任何现有状态支持使用插件扩展Pinia功能
  • 支持使用插件扩展Pinia功能

  • 相比 Vuex有更好完美的TypeScript支持支持

  • 服务端渲染

2.核心概念

Pinia 从使用角度和之前的 Vuex几乎是一样的

Store(如 Pinia)是一个保存状态和业务逻辑的实体,它不绑定到您的组件树。换句话说**,它承载全局state**。它有点像一个始终存在的组件,每个人都可以读取和写入。它有三个核心概念。

state

类似组件的data,用来存储全局状态

{
    todos : [
        { id: 1title: '吃饭'done: fa1se },
        { id: 2title: '睡觉', done: true },
        { id: 3title: '打云牧', done: false }
    ]
}

getters

  • getters:类似组件的computed,根据已有的state封装派生数据,也有缓存特性
doneCount() {
    return todos.filter(item => item.done).length
}

actions

  • actions:类似组件的methods,用来封装业务逻辑,同步异步都可以
    • VueX需要同步使用 mutations,异步操作使用actions

注意:Pinia中没有mutations

3.基本示例

// store/counter.js
import { defineStore } from "pinia";

// defineStore 调用后返回一个函数,调用该函数获得 Store 实体
export const useCounterStore = defineStore("counter",{
  // state: 返回对象的函数
  state: ()=> {
      return { count: 0 }
  },
  actions: {
      increment() {
          // 在Vuex实现需要两步  1.定义mutations 2.提交mutations
          this.count++
      }
  }
});

组件里面使用

<template>
	<div>
    {{store.count}}
    </div>
</template>
<script>
    // 导入 Store, 使用自己的路径
    import { useCounterStore } from "@/store/counter";
    export default {
        setup() {
            // 调用函数 获得Store
            const counter = useCounterStore();

            counter.count++;
 			// with autocompletion ✨
            counter.$patch({ count: counter.count + 1 })
  			// or using an action instead
            counter.increment();

            return {
                counter
            }
        }
    }
</script>

您甚至可以使用函数(类似于组件setup())为更高级的用例定义 Store:

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

  return { count, increment }
})

如果不熟悉setup()Composition API,别担心,Pania 也支持类似 Vuex的 map helpers。您以相同的方式定义stores,使用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: {
    // other computed properties
    // ...
    // gives access to this.counterStore and this.userStore
    ...mapStores(useCounterStore, useUserStore)
    // gives read access to this.count and this.double
    ...mapState(useCounterStore, ['count', 'double']),
  },
  methods: {
    // gives access to this.increment()
    ...mapActions(useCounterStore, ['increment']),
  },
}

4.Pinia vs Vuex

Pinia API与Vuex≤4有很大不同,即:

  • 没有mutations 。mutations被认为是非常冗长的。最初带来了devtools集成,但这不再是问题。
  • 不再有模块的嵌套结构。您仍然可以通过在另一个store 中导入和使用store来隐式嵌套store,但Pinia通过设计提供扁平结构,同时仍然支持store之间的交叉组合方式。您甚至可以拥有store的循环依赖关系。
  • 更好 typescript支持。无需创建自定义的复杂包装器来支持TypeScript,所有内容都是类型化的,并且API的设计方式尽可能地利用TS类型推断。
  • 不再需要注入、导入函数、调用它们,享受自动补全!
  • 无需动态添加stores,默认情况下它们都是动态的,您甚至不会注意到。请注意,您仍然可以随时手动使用store来注册它,但因为它是自动的,所以您无需担心。
  • 没有命名空间模块。鉴于store 的扁平架构,“命名空间" store是其定义方式所固有的,您可以说所有stores都是命名空间的。

Pinia就是更好的Vuex,建议在你的项目中可以直接使用它了,尤其是使用了TypeScript的项目。

5.快速入门

5.1. 安装

安装需要 @next 因为 Pinia 2 处于 beta 阶段, Pinia 2 是对应 Vue3 的版本

yarn add pinia
# or with npm
npm install pinia

5.2. 初始化配置

创建一个 pinia(根存储)并将其传递给应用程序:

import { createPinia } from 'pinia'

app.use(createPinia())

5.3. 定义State

创建 src/store/index.ts

import { defineStore } from 'pinia'

// 参数1:容器的ID, 必须唯一,将来 Pinia会把所有的容器挂载到跟容器
// 参数2:选项对象
// 返回值: 一个函数,调用得到容器实例
export const useMainStore = defineStore("main", {
    // id: 'main', // 此处也可定义id
    // 类似组件data,用来存储全局状态的
    // 1.必须是函数:为了在服务端渲染的时候避免交叉请求导致的数据状态污染
    // 2. 必须是箭头函数,为了更好的 TS 类型推导
    state: () => {
        return {
            count: 100,
            foo: "bar",
            arr: [1,2,3]
        }
    }
})

5.4. 获取 state

<template>
	<div>{{ mainStore.count }}</div>
</template>

<script lang="ts" setup>
    import { useMainStore } from '@/store'

    const mainStore = useMainStore()
</script>

结合 computed 获取

const count = computed(() => mainStore.count)

state 也可以使用解构,但使用解构会使其失去响应式,这时候可以用 pinia 的 storeToRefs

import { storeToRefs } from 'pinia'
const { count } = storeToRefs(mainStore)

const { count } = mainStore  // 错误的写法,会丢失数据响应式

5.5. 修改state

方式一:最简单的方式

mainStore.count++;
mainStore.foo = "yunmu"

方式二:如果要修改多个数据,建议$patch批量更新

mainStore.$patch({
	count: mainStore.count + 1,
	foo: "yunmu",
	arr: [...mainStore.arr, 4]
})

方式三:更好的批量更新的方式 $patch一个函数

mainStore.$patch(state => {
	state.count++
	state.foo = "yunmu"
	state.arr.push(4)
})

方式四: 逻辑比较多可以封装到actions处理

mainStore.changeState(10)
import { defineStore } from 'pinia'

export const useMainStore = defineStore('main', {
  state: () => {
    return {
      count: 100,
      foo: "bar",
      arr: [1,2,3]
    }
  },
  // 类似组件的 mthods, 封装业务逻辑,修改state
  actions: {
      // 不要使用箭头函数修改action,会导致this指向丢失,因为箭头函数绑定的是外部this
      changeState(num: number) {
          // 通过this可以访问state里面的数据进行修改
          this.count += num
          this.foo = "yunmu"
          this.arrr.push(4)
          // 同样也可以使用 this.$patch({}) 或 this.$patch(state => {})
      }
  }
})

5.6. Getters

import { defineStore } from 'pinia'
import { otherState } from "@/store/otherState.js";

export const useMainStore = defineStore('main', {
  state: () => {
    return {
      count: 100,
      foo: "bar",
      arr: [1,2,3]
    }
  },
  // 类似组件的 computed, 用来封装计算属性,有缓存的功能
  gettters: {
      // 函数接受一个可选参数 state 状态对象
      countPlus10(state) {
          console.log('countPlus调用了')
          return state.count + 10
      }
      // 如果getters 中使用了this不接受state参数,则必须手动指定返回值的类型,否则无法推导出来
       countPlus20(): number{
          return this.count + 10
      }
      
       // 获取其它 Getter, 直接通过 this
      countOtherPlus() {
          return this.countPlus20;
      }

      // 使用其它 Store
      otherStoreCount(state) {
          // 这里是其他的 Store,调用获取 Store,就和在 setup 中一样
          const otherStore = useOtherStore();
          return otherStore.count;
      },
      
  }
})

组件使用

mainStore.countPlus10

5.7. 异步action

action 支持 async/await 的语法,轻松应付异步处理的场景。

export const useUserStore = defineStore('user', {
    actions: {
        async login(account, pwd) {
            const { data } = await api.login(account, pwd)
            return data
        }
    }
})

5.8. action 间相互调用

action 间的相互调用,直接用 this 访问即可。

 export const useUserStore = defineStore('user', {
  actions: {
    async login(account, pwd) {
      const { data } = await api.login(account, pwd)
      this.sendData(data) // 调用另一个 action 的方法
      return data
    },
    sendData(data) {
      console.log(data)
    }
  }
})

在 action 里调用其他 store 里的 action 也比较简单,引入对应的 store 后即可访问其内部的方法了。

// src/store/user.ts
import { useAppStore } from './app'
export const useUserStore = defineStore('user', {
    actions: {
        async login(account, pwd) {
            const { data } = await api.login(account, pwd)
            const appStore = useAppStore()
            appStore.setData(data) // 调用 app store 里的 action 方法
            return data
        }
    }
})

5.9. 数据持久化

插件 pinia-plugin-persist 可以辅助实现数据持久化功能。

1.安装

npm i pinia-plugin-persist

2.使用

// src/store/index.ts
import { createPinia } from 'pinia'
import piniaPluginPersist from 'pinia-plugin-persist'

const store = createPinia()
store.use(piniaPluginPersist)

export default store

在对应的 store 里开启 persist 即可

export const useUserStore = defineStore('user', {
 // 开启数据缓存,数据默认存在 sessionStorage 里,并且会以 store 的 id 作为 key。
  persist: {
    enabled: true
  },
  state: () => {
    return {
      name: 'yunmu'
    }
  }
})

3.自定义 key

  • 你也可以在 strategies 里自定义 key 值,并将存放位置由 sessionStorage 改为 localStorage。
persist: {
  enabled: true,
  strategies: [
    {
      key: 'userInfo',
      storage: localStorage,
    }
  ]
}

4.持久化部分 state

  • 默认所有 state 都会进行缓存,你可以通过 paths 指定要持久化的字段,其他的则不会进行持久化。
state: () => {
  return {
    name: 'yunmu',
    age: 18,
    gender: '男'
  }  
},
// 只持久存储name和age到localStorage
persist: {
  enabled: true,
  strategies: [
    {
      storage: localStorage,
      paths: ['name', 'age']
    }
  ]
}

6.Pinia实战案例

1.需求说明

  • 商品列表

    • 展示商品列表
    • 添加到购物车
  • 购物车

    • 展示购物车商品列表
    • 展示总价格
    • 订单结算
    • 展示结算状态

2.创建启动项目

npm init vite@latest

Need to install the following packages:
	create-vite@1atest
ok to proceed? (y)
√ Project name: ... shopping-cart
√ select a framework: > vue
√ select a variant: > vue-ts

scaffo1ding project in c:\Users\yun\Projects\pinia-examp1es\shopping-cart. . .
Done. Now run:

    cd shopping-cart
    npm insta11
    npm run dev

3.页面模板

<!-- src/App.vue -->
<template>
  <div>
    <h1>Pinia - 购物车示例</h1>
    <hr />
    <h2>商品列表</h2>
    <ProductList />
    <hr />
    <ShoppingCart />
  </div>
</template>

<script setup lang="ts">
import ProductList from "./components/ProductList.vue";
import ShoppingCart from "./components/ShoppingCart.vue";
</script>

<style lang="scss" scoped></style>
<!-- src/ProductList.vue -->
<template>
  <ul>
    <li>商品名称 - 商品价格<br /><button>添加到购物车</button></li>
    <li>商品名称 - 商品价格<br /><button>添加到购物车</button></li>
    <li>商品名称 - 商品价格<br /><button>添加到购物车</button></li>
  </ul>
</template>

<script setup lang="ts"></script>

<style lang="scss" scoped></style>
<!-- src/ShoppingCart.vue -->
<template>
  <div class="cart">
    <h2>你的购物车</h2>
    <p><i>请添加一些商品到购物车</i></p>
    <ul>
      <li>商品名称 - 商品价格 × 商品数量</li>
      <li>商品名称 - 商品价格 × 商品数量</li>
      <li>商品名称 - 商品价格 × 商品数量</li>
    </ul>
    <p>商品总价:xxx</p>
    <p><button>结算</button></p>
    <p>结算成功 / 失败</p>
  </div>
</template>

<script setup lang="ts"></script>

<style lang="scss" scoped></style>

4.数据接口

// src/api/shop.ts
export interface IProduct {
  id: number;
  title: string;
  price: number;
  inventory: number;
}

const _products: IProduct[] = [
  { id: 1, title: "苹果12", price: 600, inventory: 3 },
  { id: 2, title: "小米13", price: 300, inventory: 5 },
  { id: 3, title: "魅族12", price: 200, inventory: 6 },
];

// 获取商品列表
export const getProducts = async () => {
  await wait(100);
  return _products;
};

// 结算商品
export const buyProducts = async () => {
  await wait(100);
  return Math.random() > 0.5;
};

async function wait(delay: number) {
  return new Promise((resolve) => setTimeout(resolve, delay));
}

5.展示商品列表

// src/store/products.ts
import { defineStore } from "pinia";
import { getProducts, IProduct } from "../api/shop";
export const useProductsStore = defineStore("products", {
  state: () => {
    return {
      all: [] as IProduct[], // 所有商品列表
    };
  },
  getters: {},
  actions: {
    async loadAllProducts() {
      const result = await getProducts();
      this.all = result;
    },
  },
});
<!-- ProductList.vue -->
<template>
  <ul>
     <li v-for="item in productsStore.all" :key="item.id">
      {{ item.title }} - {{ item.price }}¥ - 库存{{ item.inventory }}<br />
       <button>添加到购物车</button>
    </li>
  </ul>
</template>

<script setup lang="ts">
import { useProductsStore } from "../store/products";
const productsStore = useProductsStore();

// 加载所有数据
productsStore.loadAllProducts();
</script>

<style lang="scss" scoped></style>

6.添加到购物车

// src/store/cart.ts
import { defineStore } from "pinia";
import { IProduct, buyProducts } from "../api/shop";
import { useProductsStore } from "./products";

// 添加quantity类型并且合并IProduct除了inventory,最终数据 {id, title, price, quantity}
type CartProduct = {
  quantity: number;
} & Omit<IProduct, "inventory">;

export const useCartStore = defineStore("cart", {
  state: () => {
    return {
      cartProducts: [] as CartProduct[], // 购物车列表
    };
  },
  getters: {},
  actions: {
    addProductToCart(product: IProduct) {
      console.log("addProductToCart", product);
      // 检查商品是否有库存
      if (product.inventory < 1) {
        return;
      }
      // 检查购物车是否已有该商品
      const cartItem = this.cartProducts.find((item) => item.id === product.id);

      if (cartItem) {
        // 如果有则商品数量 + 1
        cartItem.quantity++;
      } else {
        // 如果没有则添加到购物车列表
        this.cartProducts.push({
          id: product.id,
          title: product.title,
          price: product.price,
          quantity: 1, // 第一次添加到购物车数量就是 1
        });
      }
      // 更新商品库存 引入另一个store
      // product.inventory--; 不建议这么做,不要相信函数参数,建议找到源数据修改
      const productsStore = useProductsStore();
      productsStore.decrementProduct(product);
    },
  },
});
// src/store/products.ts
actions: {
    async loadAllProducts() {
      const result = await getProducts();
      this.all = result;
    },
    // 减少库存
    decrementProduct(product: IProduct) {
      const result = this.all.find((item) => item.id === product.id);
      if (result) {
        result.inventory--;
      }
    },
  },
<!-- ProductList.vue -->
<template>
  <ul>
    <li v-for="item in productsStore.all" :key="item.id">
      {{ item.title }} - {{ item.price }}¥ - 库存{{ item.inventory }}<br />
      <button @click="cartStore.addProductToCart(item)" :disabled="!item.inventory">添加到购物车</button>
    </li>
  </ul>
</template>

<script setup lang="ts">
import { useProductsStore } from "../store/products";
import { useCartStore } from "../store/cart";

const productsStore = useProductsStore();
const cartStore = useCartStore();

// 加载所有数据
productsStore.loadAllProducts();
</script>

<style lang="scss" scoped></style>
<!-- ShoppingCart.vue -->
<template>
  <div class="cart">
    <h2>你的购物车</h2>
    <p><i>请添加一些商品到购物车</i></p>
    <ul>
      <li v-for="item in cartStore.cartProducts" :key="item.id">
        {{ item.title }} - {{ item.price }}¥ × 数量{{ item.quantity }}
      </li>
    </ul>
    <p>商品总价:xxx</p>
    <p><button>结算</button></p>
    <p>结算成功 / 失败</p>
  </div>
</template>

<script setup lang="ts">
import { useCartStore } from "../store/cart";
const cartStore = useCartStore();
</script>

<style lang="scss" scoped></style>

7.展示购物车总价

// src/store/cart.ts
getters: {
    // 总价
    totalPrice(state) {
      return state.cartProducts.reduce((total, item) => {
        return total + item.price * item.quantity;
      }, 0);
    },
  },
<!-- ShoppingCart.vue -->
<p>商品总价:{{ cartStore.totalPrice }}</p>

8.购物车案例完成

// src/store/cart.ts
import { defineStore } from "pinia";
import { IProduct, buyProducts } from "../api/shop";
import { useProductsStore } from "./products";

// 添加quantity类型并且合并IProduct除了inventory,最终数据 {id, title, price, quantity}
type CartProduct = {
  quantity: number;
} & Omit<IProduct, "inventory">;

export const useCartStore = defineStore("cart", {
  state: () => {
    return {
      cartProducts: [] as CartProduct[], // 购物车列表
      checkutStatus: null as null | string, // 结算状态
    };
  },
  getters: {
    // 总价
    totalPrice(state) {
      return state.cartProducts.reduce((total, item) => {
        return total + item.price * item.quantity;
      }, 0);
    },
  },
  actions: {
    addProductToCart(product: IProduct) {
      console.log("addProductToCart", product);
      // 检查商品是否有库存
      if (product.inventory < 1) {
        return;
      }
      // 检查购物车是否已有该商品
      const cartItem = this.cartProducts.find((item) => item.id === product.id);

      if (cartItem) {
        // 如果有则商品数量 + 1
        cartItem.quantity++;
      } else {
        // 如果没有则添加到购物车列表
        this.cartProducts.push({
          id: product.id,
          title: product.title,
          price: product.price,
          quantity: 1, // 第一次添加到购物车数量就是 1
        });
      }
      // 更新商品库存 引入另一个store
      //   product.inventory--;
      const productsStore = useProductsStore();
      productsStore.decrementProduct(product);
    },
    async checkout() {
      const result = await buyProducts();
      this.checkutStatus = result ? "成功" : "失败";
	  // 清空购物车
      if (result) {
        this.cartProducts = [];
      }
    },
  },
});
<!-- ShoppingCart -->
<p><button @click="cartStore.checkout">结算</button></p>
<p v-show="cartStore.checkutStatus">结算{{ cartStore.checkutStatus }}</p>

谢谢观看,本文部分引自:新一代状态管理工具,Pinia.js 上手指南 - 掘金 (juejin.cn)