导读

Note

组件之间原本不能实现数据共享,需要借助其他技术,比如pina

在Vue2的时候使用Vuex

但到了Vue3,官方推荐pina

假设我们有一个需求:在组件 p1 里更新了数据,主页组件也同步更新显示

image-20220930172635166

  • storage 虽然可以实现多个组件的数据共享,但是需要【主动访问】才能获取更新后的数据
  • 本例中由于没有涉及主页组件的 mounted 操作,因此并不会【主动】获取 storage 的数据

pinia

安装

npm install pinia

在 main.ts 中引入

import { createPinia } from 'pinia'

// ...
createApp(A6).use(antdv).use(router).use(createPinia()).mount('#app')

定义Store

再新建 store 目录来管理共享数据,下面是 /src/store/UserInfo.ts

import axios from '../api/request'
import { defineStore } from "pinia"
import { UserInfoDto } from '../model/Model8080'

export const useUserInfo = defineStore('userInfo', {
  state: () => {
    return { username: '', name: '', sex: '' }
  },
  actions: {
    async get(username: string) {
      const resp = await axios.get(`/api/info/${username}`)
      Object.assign(this, resp.data.data)
    },
    async update(dto: UserInfoDto) {
      await axios.post('/api/info', dto)
      Object.assign(this, dto)
    }
  }
})
  • 定义了 useUserInfo 函数,用来获取共享数据,它可能用于多个组件

    • 命名习惯上,函数变量以 use 打头
  • state 定义数据格式

  • actions 定义操作数据的方法

    • get 方法用来获取用户信息

    • update 方法用来修改用户信息

  • 由于 useRequest 必须放在 setup 函数内,这里简化起见,直接使用了 axios

获取用户信息

<template>
  <div class="a6main">
    <a-layout>
      <a-layout-header>
        <span>{{serverUsername}} 【{{userInfo.name}} - {{userInfo.sex}}】</span>
      </a-layout-header>
      <a-layout>
        <!-- ... -->
      </a-layout>
    </a-layout>
  </div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import AIcon from '../components/AIcon3' // jsx icon 组件
import { serverMenus, serverUsername } from '../router/a6router'
import { useUserInfo } from '../store/UserInfo'
const userInfo = useUserInfo()

onMounted(()=>{
  userInfo.get(serverUsername.value)
})
</script>

修改用户信息

<template>
  <div class="a6p1">
    <h3>修改用户信息</h3>
    <hr>
    <a-form>
      <a-form-item label="用户名">
        <a-input readonly v-model:value="dto.username"></a-input>
      </a-form-item>
      <a-form-item label="姓名" v-bind="validateInfos.name">
        <a-input v-model:value="dto.name"></a-input>
      </a-form-item>
      <a-form-item label="性别">
        <a-radio-group v-model:value="dto.sex">
          <a-radio-button value="男">男</a-radio-button>
          <a-radio-button value="女">女</a-radio-button>
        </a-radio-group>
      </a-form-item>
    </a-form>
    <a-button type="primary" @click="onClick">确定</a-button>
  </div>
</template>
<script setup lang="ts">
import { Form } from 'ant-design-vue'
import { onMounted, ref } from 'vue'
import { UserInfoDto } from '../model/Model8080'
import { useUserInfo } from '../store/UserInfo';
const dto = ref<UserInfoDto>({ username: '', name: '', sex: '' })
const userInfo = useUserInfo()
onMounted(()=>{
  Object.assign(dto.value, userInfo)
})
const rules = ref({
  name: [
    {required: true, message:'姓名必填'}
  ]
})
const { validateInfos, validate } = Form.useForm(dto, rules)
async function onClick() {
  try {
    await validate()
    await userInfo.update(dto.value)
  } catch (e) {
    console.error(e)
  }
}
</script>
  • 不能直接把 userInfo 绑定到表单,需要 dto 中转一下
    • 因为我们不能让未实际修改的内容双向绑定到页面其他组件的属性值导致跟着变化;
    • 只有提交实际修改后才修改页面组件双向绑定的属性值
  • userInfo.update 和 useInfo.get 返回的都是 Promise 对象,可以配合 await 一起用

关于pina的state解构赋值问题

首先回顾一下vue3的解构赋值是否是响应式的,取决于解构赋值的类型和方式。

  • 如果解构赋值的是基本数据类型(如字符串、数字、布尔值等),那么解构赋值后会失去响应式,因为访问解构赋值的变量时绕过了对象的get方法,无法触发依赖收集和更新。
  • 如果解构赋值的是引用数据类型(如对象、数组等),那么解构赋值后不会失去响应式,因为访问解构赋值的变量时仍然会通过对象的get方法,可以触发依赖收集和更新。
  • 如果想要保持解构赋值后的响应式,可以使用toRefs函数,它会将响应式对象转换为普通对象,但是每个属性都是一个ref,可以保持响应性。

那么回到pina,它使用的响应式系统和Vue3用的不是同一套,所以:

  • 如果直接使用解构赋值,例如const { name, age } = useUsersStore(),那么解构赋值后会失去响应式,因为访问解构赋值的变量时绕过了对象的get方法,无法触发依赖收集和更新。
  • 如果想要保持解构赋值后的响应式,可以使用pina提供的storeToRefs函数,它会将响应式对象转换为普通对象,但是每个属性都是一个ref,可以保持响应性。例如const { name, age } = storeToRefs(useUsersStore())

So,我个人建议自己尽量少使用解构赋值,感觉意义不大事还多

关于pinia对数据安全的设计与考量

现象

getters 中,state 参数是被 pinia 库自动注入的,所以可以直接在方法中使用。而在 actions 中,如果不显式声明参数类型,那么参数类型会被推断为 any,这样可能会导致一些类型错误。因此,为了确保参数类型的正确性,需要显式声明 actions 中的参数类型。

设计原因

为什么pinia官方在actions 中不把state 参数自动注入?

在 Pinia 的 actions 中,参数是通过解构传递的,这与 Vue 3 中的 Composables 有关。当你从一个组件或者 store 中引入一个 Composable 时,你只需要解构这个 Composable,这个 Composable 就会自动得到它所需要的数据。类似地,在 actions 中,你需要通过解构 context 来访问 stategetterscommitdispatch 等。这是为了确保在 actions 中只能访问到需要的状态和操作,而不是暴露所有状态和操作,从而提高了应用程序的安全性和可维护性。

当你在 actions 中访问 state,你实际上是通过 context 参数来访问 state。如果你不想解构 context 参数,可以在 setup 函数中使用 useStore hook 来访问 state。但是在 actions 中,直接访问 state 是被 Pinia 设计为不被支持的。

解决方法和案例

import { defineStore } from 'pinia'
import { User } from './type'

// 创建 User Store
export const useUserStore = defineStore({
    id: "user",
    state: (): User => ({
        id: 0,
        name: "",
        isLoggedIn: false,
    }),
    // 在 `getters` 中,`state` 参数是被 `pinia` 库自动注入的,所以可以直接在方法中使用。
    getters: {
        getUserId(state): number {
            return state.id;
        },
        getUserName(state): string {
            return state.name;
        },
        isLoggedIn(state): boolean {
            return state.isLoggedIn;
        },
    },
    // 而在 `actions` 中,如果不显式声明参数类型,那么参数类型会被推断为 `any`,这样可能会导致一些类型错误。因此,为了确保参数类型的正确性,需要显式声明 `actions` 中的参数类型。
    actions: {
        // Pinia 官方推荐的写法
        login({ state }: { state: User }, { id, name }: { id: number; name: string }): void {
            state.id = id;
            state.name = name;
            state.isLoggedIn = true;
        },
        // 也可以使用 this.$state 访问 state 中的数据,这样可以避免在 actions 中传递 state 对象。在Pinia中,state属性是store实例的一个对象。为了避免state对象的属性与store实例的方法/属性名称冲突,Pinia会将state属性作为store实例的一个私有属性,通过$state属性来访问。所以在store实例的方法中,使用this.$state来访问state对象的属性,以区分store实例的方法和state对象的属性。
        logout(): void {
            this.$state.id = 0;
            this.$state.name = "";
            this.$state.isLoggedIn = false;
        },
    },
});

pinia持久化插件

Pinia 存储的数据是存储在内存中的,因此在关闭网页或浏览器时,数据也会随之消失。这意味着,Pinia 并不适合用于需要永久存储数据的场景,比如用户的个人资料等。对于这种情况,可以考虑使用浏览器的本地存储方案,比如 localStorage 或者 IndexedDB 等。

解决方案

可以使用Pinia插件 pinia-plugin-persist 来实现将store的数据持久化到浏览器的 sessionStorage 或 localStorage 中,这样在页面刷新或关闭后,数据也可以被保留下来。

你可以按照以下步骤使用 pinia-plugin-persist:

安装插件:

npm install pinia-plugin-persist

导入并使用插件:

import { createPinia } from 'pinia'
import { createPersistedState } from 'pinia-plugin-persist'

const pinia = createPinia()

pinia.use(createPersistedState())

在使用插件时,你可以传递一些选项来自定义插件的行为。例如,你可以指定要使用的持久化存储类型(默认为 sessionStorage),以及要持久化的 store。

import { createPinia } from 'pinia'
import { createPersistedState } from 'pinia-plugin-persist'

const pinia = createPinia()

pinia.use(
  createPersistedState({
    storage: localStorage, // 指定要使用的持久化存储类型
    paths: ['user'], // 指定要持久化的 store
  })
)

注意:使用 pinia-plugin-persist 会将 store 中的数据保存到浏览器中,因此你需要仔细考虑要保存哪些数据,以避免安全问题。同时,存储在 sessionStorage 和 localStorage 中的数据会在浏览器关闭或过期后被删除。

实践之为什么用Pinia而不是Vuex

最近,有个作业还让我们用Vuex,明明教的是Vue3但明显感觉老师老喜欢选项式那一套。自从有了<script setup>语法糖之后我认为Vue3才是Vue3,可是呢,老师谈都没谈。虽然我是用Pinia做的但在帮别人改作业的时候却学了Vuex的语法。最后不得不感叹Vuex真的是很low的东西,Pinia真的是清晰方便!

状态定义

不管怎么说,两者实现状态管理的基本思路还是一致的,首先都得先定义要管理的状态吧?区别只是究竟是交给Pinia还是交给Vuex管理罢了。

主要记住state(data对象)、getter(计算属性)、action(操作函数)即可。

但其实除了上述三者外还有Mutation,它是用于修改状态的函数,它是同步执行的。通过提交一个mutation,你可以在Pinia和Vuex中变更状态。Mutation应该是纯粹的操作,用于修改state的值。而action则是异步的,可以认为使多个mutation组成,所以我们完全可以只用action因为无论是简单的state状态修改还是复杂的操作他都能搞定。

  • Pinia:使用defineStore函数来定义状态存储,并在存储对象中使用state字段来定义状态。
  • Vuex:通过createStore函数来创建一个状态管理实例,在实例中使用state字段来定义状态。

Pinia

import { defineStore } from 'pinia';

export const useStore = defineStore('store', {
    // :State 是存储应用程序数据的地方。它类似于组件的 data 对象,但是可以被多个组件访问和共享。State 在 store 中定义,并且可以通过 getters 和 actions 进行访问和修改。
    state: () => ({
        cart: [], // 购物车列表(id、菜名、单价、数量)
    }),
    // Getter 是用于从 state 中派生出衍生数据的函数。它可以将 state 的一部分或全部进行计算、转换或筛选,并返回一个新的值。Getter 可以被认为是 store 的计算属性,可以在组件中使用。
    getters: {
        // 获取结果栏的最终统计总数和合计金额
        getTotal(state) {
            const total = {
                totalPrice: 0,
                totalNum: 0,
            };

            for (const item of state.cart) {
                total.totalPrice += item.price * item.quantity;
                total.totalNum += item.quantity;
            }

            return total;
        },
    },
    // Action 是用于处理业务逻辑、触发状态变更的函数。它可以包含异步操作、调用 API、提交 mutations 等。Action 接收一个上下文对象(context),可以通过它来访问和操作 state、调用其他 actions,甚至提交 mutations。
    actions: {
        // 添加商品到购物车
        addToCart({ food, quantity }) {
            const cartItem = this.$state.cart.find((item) => item.id === food.id);
            if (cartItem) {
                // 购物车已存在
                cartItem.quantity += quantity;
            } else {
                // 购物车不存在
                this.$state.cart.push({ ...food, quantity });
            }
        },
        // 更新购物车单项商品数量(加减按钮通用)
        updateCartItem(item) {
            const cartItem = this.$state.cart.find((i) => i.id === item.id);

            if (cartItem) {
                cartItem.quantity = item.quantity;
            }
        },
        // 从购物车中移除指定商品
        removeFromCart(item) {
            const index = this.$state.cart.findIndex((i) => i.id === item.id);
            // 如果存在则移除
            if (index !== -1) {
                this.$state.cart.splice(index, 1);
            }
        },
    },
});

Vuex

import { createStore } from 'vuex';

const store = createStore({
  state() {
    return {
      cart: [], // 购物车列表(id、菜名、单价、数量)
    };
  },
  getters: {
    // 获取结果栏的最终统计总数和合计金额
    getTotal(state) {
      const total = {
        totalPrice: 0,
        totalNum: 0,
      };

      for (const item of state.cart) {
        total.totalPrice += item.price * item.quantity;
        total.totalNum += item.quantity;
      }

      return total;
    },
  },
  mutations: {
    addToCart(state, { food, quantity }) {
      const cartItem = state.cart.find((item) => item.id === food.id);
      if (cartItem) {
        // 购物车已存在,+1
        cartItem.quantity += quantity;
      } else {
        // 购物车中没有则添加
        state.cart.push({ ...food, quantity });
      }
    },
    //商品的增加和减少都会使得cart变化,调用该方法以更新。则不用添加两个具体方法增加和减少
    updateCartItem(state, item) {
      const cartItem = state.cart.find((i) => i.id === item.id);

      if (cartItem) {
        cartItem.quantity = item.quantity;
      }
    },
    removeFromCart(state, item) {
      const index = state.cart.findIndex((i) => i.id === item.id);
      // 如果存在则移除
      if (index !== -1) {
        state.cart.splice(index, 1);
      }
    },
  },
  actions: {
    addToCart({ commit }, payload) {
      commit('addToCart', payload);
    },
    updateCartItem({ commit }, payload) {
      commit('updateCartItem', payload);
    },
    removeFromCart({ commit }, payload) {
      commit('removeFromCart', payload);
    },
  },
});

export default store;

访问和调用方式⚠️

访问和调用方式才是重点,这是他们的差异所在(至少从语法上来看,Pinia比Vuex方便的多)

Pinia采用了函数对象调用的方式。这意味着在Pinia中,你可以直接通过调用函数来触发状态的变更。例如,对于一个名为counter的状态,你可以直接调用该函数来增加计数器的值:

counter.increment()

这种调用方式更加直观和直观,因为你可以像调用普通函数一样使用状态的变更操作。

而在Vuex中,通常采用字符串触发的方式。你需要通过提交(commit)一个字符串类型的mutation或者分发(dispatch)一个字符串类型的action来触发状态的变更。例如,对于一个名为counter的模块,你需要使用字符串来指定需要触发的mutation或action:

store.commit('counter/increment')
store.dispatch('counter/increment')

这种方式相对于Pinia的函数对象调用来说,更加显式地表明了你想要触发的具体变更操作。

这些不同的调用方式是由于Pinia和Vuex在设计理念和API风格上的差异所导致的。Pinia倾向于使用函数对象调用,更加贴近普通函数的使用方式;而Vuex则采用了字符串触发,更加明确地指定了需要触发的变更操作。但在语法上尤其定义了多个状态模块的情况下这一点代码将变得非常冗余

Pina

使用Pinia时,我们通过useStore()函数创建一个store实例,然后可以直接访问store的状态和调用actions。我们可以在setup()函数中获取状态,定义方法,并在模板中使用它们。

要注意的是useStore }是从"../store/index"中导入的一个状态函数对象。区别于Vuex库的useStore(),这一点要非常清除!

<script setup>
import { reactive } from "vue";
import { useStore } from "../store/index";
import { ElMessage } from "element-plus";
const store = useStore();
const foodList = reactive([
  { id: 1, name: "油焖鸡腿", src: "/img/food (1).jpg", price: 38 },
    ...
]);

// 加入购物车
function handleAdd(index) {
  const food = foodList[index];
  const cartItem = store.cart.find((item) => item.id === food.id);

  if (cartItem) {
    cartItem.quantity++;
  } else {
    store.addToCart({ food, quantity: 1 });
  }
  ElMessage({
    message: `${food.name}成功加入了购物车!`,
    type: "success",
  });
}
</script>

Vuex

从Vuex库导入useStore(),通过库的useStore()得到的是一个Vuex全局的状态管理对象;如果Vuex中只定义了一个状态那么可以直接通过state、getter和借助dispatch等方法对Action等直接访问,如下所示。但如果定义了多个状态模块的话就要更麻烦些了,。

<script setup>
import { reactive } from "vue";
import { useStore } from "vuex";
import { ElMessage } from "element-plus";
const store = useStore();

const foodList = reactive([
  { id: 1, name: "白灼菜心", src: require('@/assets/1.png'), price: 9 },
    ...
]);

// 加入购物车
function handleAdd(index) {
  const food = foodList[index];
  const cartItem = store.state.cart.find((item) => item.id === food.id);

  if (cartItem) {
    cartItem.quantity++;
  } else {
    store.dispatch('addToCart',{ food, quantity: 1 });
  }
  ElMessage({
    message: `${food.name}成功加入了购物车!`,
    type: "success",
  });
}
</script>

多模块状态的差异性⚠️

如果要定义多个状态模块,可以使用createStore函数的modules选项。下面是一个在Vue 3中使用最新语法定义多个状态模块的示例:

import { createApp } from 'vue';
import { createStore } from 'vuex';

const moduleA = {
  state() {
    return {
      // 状态属性
    };
  },
  mutations: {
    // 状态变更
  },
  actions: {
    // 异步操作
  },
  getters: {
    // 计算属性
  }
};

const moduleB = {
  state() {
    return {
      // 状态属性
    };
  },
  mutations: {
    // 状态变更
  },
  actions: {
    // 异步操作
  },
  getters: {
    // 计算属性
  }
};

const store = createStore({
  modules: {
    moduleA,
    moduleB
  }
});

const app = createApp(App);
app.use(store);
app.mount('#app');

在上述示例中,我们定义了两个模块moduleAmoduleB,并将它们作为modules选项的属性添加到createStore函数中。每个模块都具有自己的statemutationsactionsgetters

要在组件中访问和调用不同的模块,可以使用useStore函数来获取Vuex Store的实例,并使用模块名称作为命名空间来访问和调用模块中的状态、变更、动作和计算属性。例如:

<template>
  <div>
    <p>Module A Count: {{ moduleACount }}</p>
    <p>Module B Count: {{ moduleBCount }}</p>
    <button @click="incrementModuleA">Increment Module A</button>
    <button @click="decrementModuleB">Decrement Module B</button>
  </div>
</template>

<script>
import { useStore } from 'vuex';

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

    const moduleACount = computed(() => store.state.moduleA.count);
    const moduleBCount = computed(() => store.state.moduleB.count);

    const incrementModuleA = () => {
      store.commit('moduleA/increment');
    };

    const decrementModuleB = () => {
      store.dispatch('moduleB/decrement');
    };

    return {
      moduleACount,
      moduleBCount,
      incrementModuleA,
      decrementModuleB
    };
  }
};
</script>

在上述例子中,我们通过访问store.state.moduleA.countstore.state.moduleB.count来获取模块moduleAmoduleB的状态属性。使用store.commit来触发moduleA模块中的变更,使用store.dispatch来调度moduleB模块中的动作。

总结

Pinia和Vuex是用于Vue应用程序状态管理的两个库,它们之间存在一些主要区别:

  • Vuex是基于Vue2的选项式开发和设计的,所以在
  1. API设计:Pinia是在Vue 3的Composition API基础上构建的全新状态管理库,而Vuex是基于Vue 2的选项API设计的。Pinia利用了Composition API的优势,提供了更简洁、灵活和直观的API,使开发人员可以更好地组织和管理状态。

  2. 类型支持:Pinia提供了对TypeScript的原生支持,包括类型推断、类型安全的状态访问和代码提示。它充分利用了Vue 3的类型系统,使得在使用TypeScript时能够更轻松地编写类型安全的状态管理代码。Vuex在Vue 2中的类型支持相对较弱,需要借助插件或其他工具来实现类型检查。

  3. 性能优化:Pinia在内部实现上进行了一些性能优化,例如使用响应式缓存,将状态和订阅关联起来,以便在状态变更时只更新相关的订阅者。这种优化可以提高大型应用程序的性能,并减少不必要的更新。Vuex在性能方面相对较慢,特别是对于大型应用程序,需要额外的优化措施来提高性能。

  4. 生态系统和迁移:由于Vuex是Vue生态系统中广泛使用的状态管理库,因此具有更广泛的插件和工具支持。它也有大量的文档和社区资源可供参考。然而,Pinia作为相对较新的库,其生态系统和资源相对较小。迁移现有的Vue 2应用程序使用Vuex到Vue 3和Pinia可能需要一些努力和调整。

综上所述,Pinia在Vue 3中提供了更现代、类型安全和性能优化的状态管理解决方案,而Vuex则是在Vue 2中广泛使用的传统状态管理库,具有更丰富的生态系统和插件支持。选择使用Pinia还是Vuex取决于项目需求、团队技术栈和个人偏好。

我的话:用新不用旧,用易不用难