如何写一个自己的状态管理:Vue 3 Composition API 实战

601 阅读10分钟

概述

在实际的前端项目开发中,我遇到了这样的状态管理场景:

  1. 多实例组件:用户可以创建多个相同类型的组件实例
  2. 共用组件:所有实例都使用同一个共用组件
  3. 动态子组件:共用组件包含多个动态生成的子组件
  4. 数据共享:这些子组件之间需要实时共享数据

对于 Vue 2 项目,我们通常使用 Vuex 进行状态管理,但 Vuex 的代码冗余较多,学习成本较高。对于 Vue 3 项目,Pinia 成为了官方推荐的状态管理方案,它基于 Composition API,更加简洁易用。

然而,无论是 Vuex 还是 Pinia,对于一些小型项目或特定场景,都可能显得过于重量级。基于此,我们基于 Vue Composition API 实现了一个轻量级的状态管理方案,能够优雅地解决这些问题。

本文将详细介绍这个轻量级状态管理方案的实现原理、设计思想和在实际项目中的应用。

核心实现分析

让我们先来看一下这个轻量级状态管理方案的核心实现代码:

import { effectScope, onScopeDispose, getCurrentScope, inject } from 'vue-demi'

export default function createStateStore(id, setup) {
  let storeMap = new Map()
  return () => {
    let INSTANCE_UID

    try {
      INSTANCE_UID = inject('INSTANCE_UID', 'default')
    } catch (e) {
      INSTANCE_UID = 'default'
      console.warn(
        'Reusing Vue components without INSTANCE_UID poses a risk of data pollution`.',
        INSTANCE_UID
      )
    }

    const MODULE_ID = `${INSTANCE_UID}_${id}`

    if (!storeMap.has(MODULE_ID)) {
      const scope = effectScope(true)
      const store = scope.run(setup)

      // 如果当前有 scope(例如在组件中),则注册清理逻辑
      if (getCurrentScope()) {
        onScopeDispose(() => {
          scope.stop()
          storeMap.delete(MODULE_ID)
        })
      }

      storeMap.set(MODULE_ID, store)
    }
    return storeMap.get(MODULE_ID)
  }
}

实现原理解析

这个轻量级状态管理方案的核心实现原理如下:

  1. 闭包存储:使用闭包中的 Map 对象 storeMap 来存储所有的 store 实例,确保状态在多次调用间保持一致性。

  2. 状态隔离:通过 inject('INSTANCE_UID', 'default') 获取组件的唯一标识,结合传入的 id 生成 MODULE_ID,确保不同组件实例的状态互不干扰。

  3. 响应式管理:使用 effectScope(true) 创建一个响应式作用域,在其中运行 setup 函数,确保 store 中的状态是响应式的。

  4. 自动清理:通过 getCurrentScope() 检查是否在组件的作用域内,如果是,则注册 onScopeDispose 回调,在组件销毁时清理相关资源。

  5. 单例模式:通过检查 storeMap.has(MODULE_ID) 确保每个 MODULE_ID 只创建一个 store 实例,实现单例模式。

使用方法

1. 定义 Store

首先,我们需要定义一个 store:

// stores/counter.js
import createStateStore from '@/utils/createStateStore'

export const useCounterStore = createStateStore('counter', () => {
  const count = ref(0)
  
  const increment = () => {
    count.value++
  }
  
  const decrement = () => {
    count.value--
  }
  
  return {
    count,
    increment,
    decrement
  }
})

2. 在组件中使用

然后,我们可以在组件中使用这个 store:

<template>
  <div>
    <p>Count: {{ counterStore.count }}</p>
    <button @click="counterStore.increment">Increment</button>
    <button @click="counterStore.decrement">Decrement</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

const counterStore = useCounterStore()
</script>

3. 在多个组件间共享

由于 createStateStore 实现了单例模式,我们可以在多个组件中使用同一个 store,实现状态共享:

<!-- ComponentA.vue -->
<template>
  <div>
    <p>Component A Count: {{ counterStore.count }}</p>
    <button @click="counterStore.increment">Increment</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

const counterStore = useCounterStore()
</script>

<!-- ComponentB.vue -->
<template>
  <div>
    <p>Component B Count: {{ counterStore.count }}</p>
    <button @click="counterStore.decrement">Decrement</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

const counterStore = useCounterStore()
</script>

实际应用场景

项目背景

在实际项目开发中,我们需要实现一个表单创建功能,用户可以创建多个相同类型的表单实例,每个表单实例都由一个共用组件实现,该组件包含多个动态生成的子组件,这些子组件之间需要实时共享数据。

具体应用

1. 定义表单 Store

我们使用 createStateStore 为每个表单实例创建一个独立的状态管理实例:

// stores/formStore.js
import createStateStore from '@/utils/createStateStore'

export const useFormStore = createStateStore('form', () => {
  const formData = ref({
    basicInfo: {},
    conditions: [],
    rewards: [],
    penalties: []
  })
  
  const currentTab = ref('basicInfo')
  
  const updateFormData = (data) => {
    formData.value = { ...formData.value, ...data }
  }
  
  const setCurrentTab = (tab) => {
    currentTab.value = tab
  }
  
  return {
    formData,
    currentTab,
    updateFormData,
    setCurrentTab
  }
})

2. 在共用组件中使用

在表单的共用组件中,我们使用 useFormStore 来管理状态:

<!-- FormComponent.vue -->
<template>
  <div class="form-component">
    <el-tabs v-model="formStore.currentTab">
      <el-tab-pane label="基本信息" name="basicInfo">
        <BasicInfoForm />
      </el-tab-pane>
      <el-tab-pane label="条件设置" name="conditions">
        <ConditionsForm />
      </el-tab-pane>
      <el-tab-pane label="奖励设置" name="rewards">
        <RewardsForm />
      </el-tab-pane>
      <el-tab-pane label="惩罚设置" name="penalties">
        <PenaltiesForm />
      </el-tab-pane>
    </el-tabs>
  </div>
</template>

<script setup>
import { useFormStore } from '@/stores/formStore'
import BasicInfoForm from './components/BasicInfoForm.vue'
import ConditionsForm from './components/ConditionsForm.vue'
import RewardsForm from './components/RewardsForm.vue'
import PenaltiesForm from './components/PenaltiesForm.vue'

const formStore = useFormStore()
</script>

3. 在动态子组件中共享数据

在各个动态生成的子组件中,我们可以共享同一个表单实例的状态:

<!-- BasicInfoForm.vue -->
<template>
  <div class="basic-info-form">
    <el-form :model="formStore.formData.basicInfo">
      <el-form-item label="表单名称">
        <el-input v-model="formStore.formData.basicInfo.name" />
      </el-form-item>
      <el-form-item label="表单描述">
        <el-input type="textarea" v-model="formStore.formData.basicInfo.description" />
      </el-form-item>
      <!-- 其他表单字段 -->
    </el-form>
  </div>
</template>

<script setup>
import { useFormStore } from '@/stores/formStore'

const formStore = useFormStore()
</script>

<!-- ConditionsForm.vue -->
<template>
  <div class="conditions-form">
    <el-button type="primary" @click="addCondition">添加条件</el-button>
    <div v-for="(condition, index) in formStore.formData.conditions" :key="index">
      <el-form :model="condition">
        <!-- 条件表单字段 -->
      </el-form>
      <el-button type="danger" @click="removeCondition(index)">删除</el-button>
    </div>
  </div>
</template>

<script setup>
import { useFormStore } from '@/stores/formStore'

const formStore = useFormStore()

const addCondition = () => {
  formStore.formData.conditions.push({})
}

const removeCondition = (index) => {
  formStore.formData.conditions.splice(index, 1)
}
</script>

4. 多表单实例管理

由于 createStateStore 使用 INSTANCE_UIDid 的组合来标识不同的 store 实例,我们可以在同一个页面中创建多个表单实例,每个表单实例都有自己独立的状态:

<!-- FormList.vue -->
<template>
  <div class="form-list">
    <el-button type="primary" @click="addForm">添加表单</el-button>
    <div v-for="(form, index) in forms" :key="index">
      <h3>表单 {{ index + 1 }}</h3>
      <FormComponent />
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import FormComponent from './FormComponent.vue'

const forms = ref([])

const addForm = () => {
  forms.value.push({})
}
</script>

解决的问题

  1. 状态隔离:每个表单实例都有自己独立的状态,不会相互干扰
  2. 数据共享:同一个表单实例的不同子组件之间可以实时共享数据
  3. 轻量级:不需要引入重量级的状态管理库,代码简洁易维护
  4. 自动清理:当表单组件销毁时,相关的状态会被自动清理,避免内存泄漏

优势体现

  1. 开发效率:简化了状态管理的代码,提高了开发效率
  2. 代码可读性:基于 Composition API,代码结构清晰,易于理解
  3. 灵活性:可以根据需要灵活地定义和使用 store
  4. 可扩展性:可以轻松添加新的状态和方法

通过 createStateStore,我们成功解决了实际项目中的状态管理问题,实现了多表单实例创建、共用组件和动态子组件间的数据共享,同时保持了代码的简洁性和可维护性。

优势分析

1. 轻量级

这个状态管理方案的实现非常简洁,只有不到 30 行代码,没有引入额外的依赖(只依赖 Vue 自身的 API),非常适合小型项目或不需要复杂状态管理的场景。

2. 灵活性

基于 Composition API,我们可以在 setup 函数中使用任何 Composition API 的特性,如 refreactivecomputedwatch 等,实现各种复杂的状态逻辑。

3. 状态隔离

通过 INSTANCE_UIDid 的组合,确保不同组件实例的状态互不干扰,避免了状态污染的问题。

4. 自动清理

当组件销毁时,相关的 store 实例会被自动清理,避免了内存泄漏的问题。

5. 类型安全

如果使用 TypeScript,我们可以为 store 添加类型定义,获得更好的类型提示和类型检查。

6. 易于集成

由于实现简单,这个状态管理方案可以轻松集成到任何 Vue 项目中,不需要复杂的配置。

与其他状态管理方案的对比

方案适用版本优点缺点
VuexVue 2功能完善,生态成熟代码冗余,学习成本高,Composition API 支持不够友好
PiniaVue 3简洁易用,类型友好,Composition API 支持好对于小型项目可能过于重量级,依赖较多
轻量级状态管理方案Vue 3轻量级,灵活性高,Composition API 支持好,易于集成功能相对简单,适合小型项目或特定场景

优化建议

1. 添加持久化支持

我们可以为 createStateStore 添加持久化支持,将状态保存到 localStorage 或 sessionStorage 中:

export default function createStateStore(id, setup, options = {}) {
  let storeMap = new Map()
  return () => {
    let INSTANCE_UID

    try {
      INSTANCE_UID = inject('INSTANCE_UID', 'default')
    } catch (e) {
      INSTANCE_UID = 'default'
      console.warn(
        'Reusing Vue components without INSTANCE_UID poses a risk of data pollution`.',
        INSTANCE_UID
      )
    }

    const MODULE_ID = `${INSTANCE_UID}_${id}`

    if (!storeMap.has(MODULE_ID)) {
      const scope = effectScope(true)
      
      // 从持久化存储中加载状态
      let persistedState = null
      if (options.persist) {
        const storageKey = `store_${MODULE_ID}`
        try {
          persistedState = JSON.parse(localStorage.getItem(storageKey) || 'null')
        } catch (e) {
          console.warn('Failed to load persisted state', e)
        }
      }
      
      const store = scope.run(() => setup(persistedState))

      // 持久化状态
      if (options.persist) {
        watch(
          store,
          (newState) => {
            const storageKey = `store_${MODULE_ID}`
            localStorage.setItem(storageKey, JSON.stringify(newState))
          },
          { deep: true }
        )
      }

      // 如果当前有 scope(例如在组件中),则注册清理逻辑
      if (getCurrentScope()) {
        onScopeDispose(() => {
          scope.stop()
          storeMap.delete(MODULE_ID)
        })
      }

      storeMap.set(MODULE_ID, store)
    }
    return storeMap.get(MODULE_ID)
  }
}

2. 添加模块化支持

我们可以为 createStateStore 添加模块化支持,允许嵌套使用 store:

export default function createStateStore(id, setup) {
  let storeMap = new Map()
  return () => {
    let INSTANCE_UID

    try {
      INSTANCE_UID = inject('INSTANCE_UID', 'default')
    } catch (e) {
      INSTANCE_UID = 'default'
      console.warn(
        'Reusing Vue components without INSTANCE_UID poses a risk of data pollution`.',
        INSTANCE_UID
      )
    }

    const MODULE_ID = `${INSTANCE_UID}_${id}`

    if (!storeMap.has(MODULE_ID)) {
      const scope = effectScope(true)
      const store = scope.run(setup)

      // 递归处理嵌套的 store
      Object.keys(store).forEach(key => {
        if (typeof store[key] === 'function' && store[key]._isStore) {
          store[key] = store[key]()
        }
      })

      // 标记为 store
      store._isStore = true

      // 如果当前有 scope(例如在组件中),则注册清理逻辑
      if (getCurrentScope()) {
        onScopeDispose(() => {
          scope.stop()
          storeMap.delete(MODULE_ID)
        })
      }

      storeMap.set(MODULE_ID, store)
    }
    return storeMap.get(MODULE_ID)
  }
}

技术要点

  1. 闭包:使用闭包存储所有的 store 实例,确保状态在多次调用间保持一致性。

  2. Effect Scope:使用 effectScope 管理响应式状态的生命周期,确保响应式状态能够被正确清理。

  3. 依赖注入:使用 inject 获取组件的唯一标识,确保不同组件实例的状态互不干扰。

  4. 单例模式:通过检查 storeMap.has(MODULE_ID) 确保每个 MODULE_ID 只创建一个 store 实例。

  5. 生命周期管理:通过 onScopeDispose 注册清理逻辑,在组件销毁时清理相关资源。

注意事项

  1. INSTANCE_UID:确保在组件中提供 INSTANCE_UID,否则会使用默认值 'default',可能会导致状态污染。

  2. 响应式状态:在 setup 函数中,确保使用 refreactive 等 API 创建响应式状态,否则状态变化不会触发组件更新。

  3. 清理逻辑:如果在非组件环境中使用 createStateStore,需要手动管理 store 的生命周期,避免内存泄漏。

  4. 性能考虑:对于大型应用,可能需要考虑使用更成熟的状态管理库,如 Pinia。

总结

在实际项目开发中,我们基于 Vue Composition API 实现了一个轻量级的状态管理方案,成功解决了多表单实例创建、共用组件和动态子组件间的数据共享问题。

核心优势

  1. 轻量级设计:仅依赖 Vue Composition API,代码简洁,无额外依赖,适合小型项目或特定场景。

  2. 状态隔离机制:通过 INSTANCE_UIDid 的组合,确保每个表单实例都有独立的状态,避免了多实例之间的状态污染。

  3. 高效数据共享:同一表单实例的不同子组件可以实时共享数据,简化了组件间的通信。

  4. 自动生命周期管理:当表单组件销毁时,相关的状态会被自动清理,避免了内存泄漏。

  5. 灵活性与可扩展性:基于 Composition API,可以轻松添加新的状态和方法,适应项目的不断变化。

  6. 易于集成:实现简单,不需要复杂的配置,可以轻松集成到任何 Vue 3 项目中。

适用场景

  • 小型项目:不需要复杂状态管理功能的小型项目
  • 局部状态管理:某个页面或组件组的状态管理
  • 组件库开发:在组件库中使用,避免引入重量级依赖
  • 快速原型开发:快速构建原型时使用,减少配置和依赖

未来展望

这个轻量级状态管理方案虽然简单,但已经具备了基本的状态管理功能。在未来的开发中,我们可以根据项目的具体需求,对其进行进一步的扩展,如:

  • 添加持久化支持
  • 实现模块化管理
  • 增加 devtools 支持
  • 提供更多的工具函数

总之,这个轻量级状态管理方案为我们提供了一种新的思路,让状态管理变得更加简单、灵活和高效。它是一种适合特定场景的解决方案,特别是对于那些不需要复杂状态管理功能的小型项目。

如果你正在寻找一个轻量级的状态管理方案,不妨尝试一下这种基于 Composition API 的实现方式,它可能会给你带来惊喜!