◆

Vue Vuex 状态管理流程

2023-04-14 00:430

为什么需要 Vuex

组件之间共享状态在小项目里靠 props 向下传、事件向上抛就够了。但当出现「兄弟组件要读同一份数据」「跨了四五层的组件要改同一份数据」时,props 逐层透传会让中间那些根本不关心这份数据的组件也被迫接收和转发,改一个字段要动一串文件。

Vuex 的思路是把这类共享状态从组件树里拎出来,放进一个全局的单一数据源(single source of truth),所有组件从同一个地方读,通过约定好的路径改。它换来的不只是「能共享」,更重要的是状态变更可追踪——每一次改动都对应一个具名的 mutation,配合 devtools 能看到完整的变更历史。

核心的五个概念

  • state:存放状态的地方,唯一的数据源
  • getter:从 state 派生出的计算值,类似组件的 computed,有缓存
  • mutation:唯一允许修改 state 的地方,必须是同步函数
  • action:处理异步逻辑,内部通过 commit 调用 mutation
  • module:把 store 按业务拆分成多个模块,避免单个文件膨胀

完整的数据流

Vuex 规定的是一条单向数据流:

组件 (Vue Components)
  │  dispatch('actionName', payload)
  ↓
Actions ──── 异步操作(请求接口等)
  │  commit('MUTATION_NAME', payload)
  ↓
Mutations ── 同步修改 state
  │
  ↓
State ────── 响应式,变化后自动通知
  │  mapState / getters
  ↓
组件重新渲染

如果没有异步操作,组件也可以直接 commit 跳过 action。但反方向是不允许的:组件不能直接改 state,mutation 里不能写异步。

一个完整的例子

// store/index.js
import { createStore } from 'vuex';

export default createStore({
  state: () => ({
    count: 0,
    userList: [],
    loading: false,
  }),

  getters: {
    // 第一个参数是 state,第二个是其他 getters
    doubleCount: state => state.count * 2,
    activeUsers: state => state.userList.filter(u => u.active),
    activeCount: (state, getters) => getters.activeUsers.length,
  },

  mutations: {
    // 必须同步,第一个参数是 state
    INCREMENT(state, step = 1) {
      state.count += step;
    },
    SET_USER_LIST(state, list) {
      state.userList = list;
    },
    SET_LOADING(state, flag) {
      state.loading = flag;
    },
  },

  actions: {
    // 第一个参数是 context,可解构出 commit / state / getters / dispatch
    async fetchUsers({ commit, state }) {
      if (state.loading) return;
      commit('SET_LOADING', true);
      try {
        const res = await fetch('/api/users').then(r => r.json());
        commit('SET_USER_LIST', res.data);
      } finally {
        commit('SET_LOADING', false);
      }
    },
  },
});

组件里使用:

<script setup>
import { computed } from 'vue';
import { useStore } from 'vuex';

const store = useStore();

// 读 state 和 getter 要用 computed 包一层才能保持响应性
const count = computed(() => store.state.count);
const doubleCount = computed(() => store.getters.doubleCount);

const inc = () => store.commit('INCREMENT', 2);
const load = () => store.dispatch('fetchUsers');
</script>

这里的 computed 不能省。如果写 const count = store.state.count,取到的是当次的原始值,之后 store 里变了这个变量不会跟着变。

为什么 mutation 必须同步

这个约束不是随便定的。devtools 的时间旅行调试依赖「每次 mutation 执行前后各拍一次 state 快照」,如果 mutation 里有异步回调,回调执行的时候快照早就拍完了,devtools 记录到的状态和实际不符,调试信息就失去了意义。

所以下面这种写法是错的,Vuex 不会报错,但状态变更变得不可追踪:

mutations: {
  // 错误:mutation 里不能有异步
  BAD_FETCH(state) {
    fetch('/api/users').then(res => {
      state.userList = res.data;
    });
  },
}

模块与命名空间

项目大了要拆模块。加上 namespaced: true 之后,模块内的 getter、mutation、action 都带上模块名前缀:

// store/modules/user.js
export default {
  namespaced: true,
  state: () => ({ profile: null }),
  getters: {
    nickname: state => state.profile?.nickname ?? '游客',
  },
  mutations: {
    SET_PROFILE(state, p) {
      state.profile = p;
    },
  },
  actions: {
    async login({ commit }, form) {
      const p = await api.login(form);
      commit('SET_PROFILE', p);
    },
  },
};
// store/index.js
import user from './modules/user';

export default createStore({
  modules: { user },
});

调用时的路径差异要注意:

// state 是嵌套访问,不带命名空间前缀
store.state.user.profile;

// getter / mutation / action 是扁平的字符串路径,带前缀
store.getters['user/nickname'];
store.commit('user/SET_PROFILE', p);
store.dispatch('user/login', form);

state 嵌套而其他三个扁平,这是最容易记错的一处。另外模块的 state 必须写成函数返回对象,写成对象字面量的话,同一个模块被复用(比如动态注册多次)会共享同一份 state。

模块内部的 action 想调用根级别的 action 或 mutation,要传第三个参数 { root: true }:

actions: {
  someAction({ commit, dispatch }) {
    commit('ROOT_MUTATION', null, { root: true });
    dispatch('otherModule/act', null, { root: true });
  },
}

实际使用中的坑

mapState 等辅助函数在 setup 里不好用。 mapState、mapGetters 是为选项式 API 设计的,依赖 this.$store。组合式 API 里直接写 computed(() => store.state.x) 更清楚,虽然啰嗦。

getter 不能传参,但可以返回函数。 需要按 id 查的场景要返回一个函数,代价是这样就没有缓存了,每次调用都会重新计算:

getters: {
  userById: state => id => state.userList.find(u => u.id === id),
}
// 使用:store.getters.userById(3)

mutation 里给对象加新属性。 Vue 3 用 Proxy 实现响应式,state.obj.newKey = 1 是响应式的。但 Vue 2 + Vuex 3 时代必须用 Vue.set,看老代码时注意这个版本差异。

刷新页面状态丢失。 Vuex 的 state 在内存里,刷新就归零。需要持久化得自己写插件把 state 同步到 localStorage,或者用 vuex-persistedstate 这类库。持久化时要注意别把 loading 这类瞬时状态也存进去。

关于 Pinia

Vue 3 之后官方推荐 Pinia,Vuex 进入维护状态。Pinia 去掉了 mutation(直接在 action 里改 state),去掉了嵌套模块(改成扁平的多个 store),TS 类型推导也好得多。新项目直接上 Pinia,Vuex 的这套流程主要还是用于理解老项目和面试。

💬留言板0

还没有留言,来发表第一条吧~

PAGE LOAD / LIVEVue Vuex 状态管理流程加载中