普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月10日首页

Vue Router 进阶指南:打造丝滑的滚动控制与惊艳的路由动画

作者 小马写码
2025年12月10日 07:20

在现代单页应用(SPA)开发中,页面切换的流畅体验已成为衡量应用品质的重要标准。用户期望获得媲美原生应用的顺滑感受,而不仅仅是简单的页面跳转。Vue Router 作为 Vue.js 生态中的核心路由解决方案,提供了强大的滚动行为控制和路由过渡动画能力,让我们能够打造出令人印象深刻的用户体验。

一、掌握滚动行为:让页面“记住”它的位置

1.1 什么是滚动行为?

滚动行为指的是用户在路由切换时,页面滚动位置的智能管理。想象一下这样的场景:您在一个长列表页面滚动到中间位置,点击某个项目进入详情页,然后点击浏览器返回按钮——您是否希望直接回到刚才的列表位置?这正是滚动行为要解决的问题。

1.2 Vue Router 的滚动行为配置

Vue Router 提供了 scrollBehavior 选项,让我们可以定义路由切换时的滚动行为:

const router = createRouter({
  history: createWebHistory(),
  routes: [...],
  scrollBehavior(to, from, savedPosition) {
    // 返回滚动位置信息
    if (savedPosition) {
      // 有保存的位置时(如浏览器前进/后退)
      return savedPosition
    } else if (to.hash) {
      // 存在哈希锚点时
      return {
        el: to.hash,
        behavior: 'smooth' // 平滑滚动
      }
    } else {
      // 默认滚动到顶部
      return { top: 0, left: 0 }
    }
  }
})

1.3 高级滚动控制技巧

延迟滚动与异步组件结合

scrollBehavior(to, from, savedPosition) {
  // 等待页面渲染完成后再滚动
  return new Promise((resolve) => {
    setTimeout(() => {
      if (savedPosition) {
        resolve(savedPosition)
      } else if (to.hash) {
        resolve({
          el: to.hash,
          behavior: 'smooth',
          // 添加偏移量,避免被固定导航栏遮挡
          top: 80
        })
      } else {
        resolve({ top: 0, left: 0 })
      }
    }, 300) // 等待300ms,确保动态内容已加载
  })
}

页面特定滚动策略

scrollBehavior(to, from, savedPosition) {
  // 为特定路由禁用自动滚动
  if (to.meta.noScroll) {
    return false
  }
  
  // 在特定路由中保持滚动位置
  if (from.meta.keepScroll && to.meta.keepScroll) {
    return {}
  }
  
  // 默认行为
  return savedPosition || { top: 0 }
}

二、打造惊艳的路由切换动画

2.1 Vue 过渡系统与路由的完美结合

Vue 的 <Transition><TransitionGroup> 组件为路由动画提供了强大的基础。结合 Vue Router,我们可以创建各种炫酷的过渡效果。

基础路由过渡实现

<template>
  <RouterView v-slot="{ Component }">
    <Transition name="fade" mode="out-in">
      <component :is="Component" />
    </Transition>
  </RouterView>
</template>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

2.2 进阶动画:滑动过渡效果

水平滑动动画

<template>
  <RouterView v-slot="{ Component, route }">
    <Transition :name="transitionName">
      <component :is="Component" :key="route.path" />
    </Transition>
  </RouterView>
</template>

<script setup>
import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'

const router = useRouter()
const transitionName = ref('slide-left')

watch(() => router.currentRoute.value, (to, from) => {
  // 根据路由深度判断滑动方向
  const toDepth = to.path.split('/').length
  const fromDepth = from.path.split('/').length
  transitionName.value = toDepth < fromDepth ? 'slide-right' : 'slide-left'
})
</script>

<style>
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
  transition: all 0.3s ease;
  position: absolute;
  width: 100%;
}

.slide-left-enter-from {
  transform: translateX(100%);
  opacity: 0;
}

.slide-left-leave-to {
  transform: translateX(-100%);
  opacity: 0;
}

.slide-right-enter-from {
  transform: translateX(-100%);
  opacity: 0;
}

.slide-right-leave-to {
  transform: translateX(100%);
  opacity: 0;
}
</style>

2.3 基于路由元信息的动态动画

为不同路由配置不同动画

// 路由配置
const routes = [
  {
    path: '/dashboard',
    component: Dashboard,
    meta: { transition: 'zoom' }
  },
  {
    path: '/settings',
    component: Settings,
    meta: { transition: 'fade' }
  }
]
<template>
  <RouterView v-slot="{ Component, route }">
    <Transition 
      :name="route.meta.transition || 'fade'"
      mode="out-in"
    >
      <component :is="Component" :key="route.path" />
    </Transition>
  </RouterView>
</template>

<style>
.zoom-enter-active,
.zoom-leave-active {
  transition: all 0.4s cubic-bezier(0.68, -0.55, 0.27, 1.55);
}

.zoom-enter-from {
  transform: scale(0.8);
  opacity: 0;
}

.zoom-leave-to {
  transform: scale(1.2);
  opacity: 0;
}
</style>

三、滚动行为与动画的协同优化

3.1 动画期间的滚动管理

在路由过渡动画期间,合理的滚动控制可以避免视觉混乱:

scrollBehavior(to, from, savedPosition) {
  // 如果启用了路由动画,延迟滚动
  if (to.meta.withAnimation) {
    return new Promise(resolve => {
      // 等待动画完成
      setTimeout(() => {
        resolve(savedPosition || { top: 0 })
      }, 500) // 与动画时长保持一致
    })
  }
  return savedPosition || { top: 0 }
}

3.2 性能优化建议

  1. 硬件加速:为动画元素添加 transform: translateZ(0)will-change: transform 启用GPU加速
  2. 动画简化:避免同时为过多属性添加动画,优先使用 transformopacity
  3. 节流处理:在快速连续导航时,取消未完成的动画

四、实战:完整的用户体验优化方案

下面是一个综合应用滚动行为和路由动画的完整示例:

<template>
  <div class="app-container">
    <AppHeader />
    <main class="main-content">
      <RouterView v-slot="{ Component, route }">
        <Transition
          :name="getTransitionName(route, $route)"
          @before-enter="onBeforeEnter"
          @after-enter="onAfterEnter"
          mode="out-in"
        >
          <KeepAlive :include="cachedRoutes">
            <component
              :is="Component"
              :key="route.fullPath"
              class="page-content"
            />
          </KeepAlive>
        </Transition>
      </RouterView>
    </main>
    <AppFooter />
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'

const router = useRouter()
const previousRoute = ref(null)

// 动态计算过渡名称
const getTransitionName = (to, from) => {
  if (!from.name) return 'fade'
  
  const toDepth = to.meta.depth || 0
  const fromDepth = from.meta.depth || 0
  
  if (toDepth < fromDepth) return 'slide-right'
  if (toDepth > fromDepth) return 'slide-left'
  
  return to.meta.transition || 'fade'
}

// 动画生命周期钩子
const onBeforeEnter = () => {
  // 动画开始前的准备工作
  document.body.style.overflow = 'hidden'
}

const onAfterEnter = () => {
  // 动画结束后的清理工作
  document.body.style.overflow = ''
}

// 需要缓存的组件
const cachedRoutes = computed(() => {
  return router.getRoutes()
    .filter(route => route.meta.keepAlive)
    .map(route => route.name)
    .filter(Boolean)
})
</script>

结语:打造极致的用户体验

通过精心设计的滚动行为和流畅的路由过渡动画,我们可以将普通的单页应用提升到新的高度。Vue Router 提供的这些功能不仅仅是技术实现,更是连接用户与内容的桥梁。记住,最好的用户体验往往是用户感受不到的——自然的滚动恢复、流畅的页面过渡,这些细节共同构成了应用的品质感。

从简单的淡入淡出到复杂的多层动画,从基本的滚动定位到智能的位置记忆,每一处优化都是对用户体验的深思熟虑。正如我们开始时提到的,现代用户期待的是丝滑流畅的交互体验。通过掌握 Vue Router 的滚动行为控制与自定义路由动画技术,我们不仅满足了这一期待,更创造了超越用户预期的愉悦体验。

在下一个 Vue 项目中,不妨尝试实现这些技巧,让您的应用在众多竞争者中脱颖而出,用细节打动每一位用户。

昨天 — 2025年12月9日首页

用一篇文章带你手写Vue中的reactive响应式

作者 momo06117
2025年12月9日 17:42

关于reactive

最近小编在刷vue面经时,有看到ref与reacive的区别,关于ref大家都耳熟能详,但是对于ractive,小编一开始只了解到与ref的区别是不需要.value访问值并且只能绑定引用数据类型,而ref既能绑定基本类型又能绑定引用数据类型,那么reactive的存在的意义在哪里?

这样的困惑驱使小编不能仅满足于表面的理解。在 Vue 庞大而精密的体系里,reactive 必然承载着特殊的使命。通过查阅资料和源码,小编这了解到reactive背后的奥秘,下面就一一道来。

github.com/vuejs/core/… 附上github上reactive的源码

reactive的设计理念

首先,为什么reactive只能接受引用数据类型?这是因为reactive是基于ES6的Proxy实现的响应式,而Proxy只能代理引用数据类型。

而ref在绑定基本数据类型时是基于Object.defineProperty通过数据劫持变化来实现数据响应式的。对于引用数据类型,defineProperty的方法存在一些弊端:比如无法监听到对象属性的新增和删除,也无法监听数组索引的直接设置和length变化。这里简单对比一下vue响应式方式。

实现方式 适用类型 核心缺陷
Object.defineProperty(ref 底层) 基本类型(包装为对象) 1. 无法监听对象新增 / 删除属性;2. 无法监听数组索引 / 长度变化;3. 只能劫持单个属性
Proxy(reactive 底层) 引用类型(对象 / 数组 / Map 等) 无上述缺陷,可代理整个对象,支持动态增删属性、数组操作

因此,ref在代理对象时也是借助到reactive

reactive基于Proxy的响应式系统能完美解决这些问题,下面我们来写一个简单的reactive响应式

reactive代理对象

要实现reactive实现数据响应式,我们需要先创建一个reactive方法,通过Proxy进行代理。 其中,proxy代理的target需要是对象并且没有被代理过。

//创建一个Map来保存代理过的reactive
const reactiveMap = new Map()

function isReactive(target){
  if(reactiveMap.has(target)){
      return true
  }
  return false
}

export function reactive(target){
  //检查是否已经被代理
  if(isReactive(target)){
    return target
  }


  return createReactiveObject(
      target,
      mutableHandlers
  )
}

export function createReactiveObject(target,mutableHandlers){
   //检查是否为对象
    if(typeof target !== 'object' || target == null){
      return target
    }
    //Proxy 接受俩个参数 代理对象和代理方法
    const newReactive =  new Proxy(target,mutableHandlers)
    reactiveMap.set(target,newReactive)
    return newReactive
}

Get & Set

我们新建一个文件导出mutableHandlers方法供proxy使用,mutableHandlers需要有一个get与set,分别在访问和修改target时触发。get需要实现依赖收集,当访问对象属性时将对应的副作用函数收集到依赖集合,set需要实现当对象属性更改时,更新依赖,通知副函数执行。

import {track,trigger} from './effect.js'

const get = (target, key) => {
  // target代理对象 key键名
  track(target, key) // 副作用收集
  // 相当于target[key]
  const value = Reflect.get(target, key)
  if (typeof value === 'object' && value !== null) {
    return reactive(value)
  }
  return value
}

const set = (target, key, value) => {
  // console.log('target被设置值', key, value)
  const oldValue = Reflect.get(target, key)
  // 比较是否更新
  if (oldValue !== value) {
    const result = Reflect.set(target, key, value)
    trigger(target, key, value, oldValue)
    return result
  }
  return true // 如果值没有变化,返回true表示设置成功
}

export const mutableHandlers = {
  get,
  set
}

副作用的收集与触发

接下来,我们要完成依赖收集函数track和副作用触发函数trigger。做之前我们要思考一下他们要做的事情: track在get时触发,主要负责将副作用函数effect载入targetMap中,tigger在set时触发 主要负责执行副作用函数。

提一嘴 *WeakMap是es6的新特性 特殊的是键必须是引用类型 *

// 存储依赖关系
const targetMap = new WeakMap() // 可以看set({}:map())
let activeEffect //当前执行的副作用函数

// 副作用的执行函数
export const effect = (callback) => {
  activeEffect = callback
  callback()
  activeEffect = null
}

//依赖收集
export const track = (target, key) => {
  // 如果该依赖没有副作用直接返回
  if (!activeEffect) return
  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map())) // 第一次收集该依赖
  }

  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Set())) // 依赖的第一个副作用
  }
  dep.add(activeEffect)
}

//触发
export const trigger = (target, key) => {
  const depsMap = targetMap.get(target)
  if (!depsMap) return

  const dep = depsMap.get(key)
  if (dep) {
    dep.forEach(effect => effect())
  }
}

测试

到此为止,我们已经简单完成reactive的demo。接下来新建一个vue文件来测试一下这个简易的reactiveDemo

<template>
    <div>
        <button @click="handleClick">count++</button>
    </div>
</template>

<script setup>
import {reactive} from './utils/reactivity/reactive.js'
import {effect} from './utils/reactivity/effect.js'
const count = reactive({
  value: 0
})

effect(()=>{
  console.log('count的值是:', count.value) 
})

effect(()=>{
  console.log(count.value, '正在执行计算')
})

effect(()=>{
  console.log(count.value, '正在渲染页面')
})


const handleClick = ()=>{
  count.value++
}
</script>

当我们点击按钮触发count.value++时,到触发代理proxy的set,执行targget,从而触发此前访问过count.value相关的副作用函数,完成更新。

image.png

总结

reactive是Vue中实现响应式的Api,它通过proxy实现代理,为ref代理对象提供支持。reactive不是“多余选项”了,而是vue响应式的核心支柱。

而要实现reactive的核心在于:

  • 使用proxy代理
  • 收集与触发副作用函数

*以上是小编在学习过程中的一点小见解 如果有写得不对的 欢迎在评论区指出 *

image.png

前端基础数据中心:从混乱到统一的架构演进

2025年12月9日 17:41

本文记录了我们团队在 Vue 3 + TypeScript 项目中,如何将散乱的基础数据管理逻辑重构为统一的「基础数据中心」。如果你的项目也有类似的痛点,希望这篇文章能给你一些参考。

一、问题是怎么来的

做过 B 端系统的同学应该都有体会——基础数据无处不在。港口、船舶、航线、货币、字典……这些数据在几乎每个页面都会用到,要么是下拉选择,要么是代码翻译,要么是表格筛选。

我们项目一开始的做法很「朴素」:哪里用到就哪里请求。后来发现这样不行,同一个港口列表接口一个页面能请求三四次。于是开始加缓存,问题是加着加着,代码变成了这样:

store/basicData/cache.ts      <- Pinia 实现的缓存
composables/basicData/cache.ts  <- VueUse + localStorage 实现的缓存
store/port.ts                   <- 独立的港口缓存(历史遗留)

三套缓存系统,各自为政。更要命的是 CACHE_KEYS 这个常量在两个地方都有定义,改一处忘一处是常态。

某天排查一个 bug:用户反馈页面显示的港口名称和实际不一致。查了半天发现是两套缓存系统的数据版本不同步——A 组件用的 Pinia 缓存已经过期刷新了,B 组件用的 localStorage 缓存还是旧数据。

是时候重构了。

二、想清楚再动手

重构之前,我们先梳理了需求优先级:

需求 优先级 说明
跨组件数据共享 P0 同一份数据,全局只请求一次
缓存 + 过期机制 P0 减少请求,但数据要能自动刷新
请求去重 P1 并发请求同一接口时,只发一次
持久化 P1 关键数据存 localStorage,提升首屏速度
DevTools 调试 P2 能在 Vue DevTools 里看到缓存状态

基于这些需求,我们确定了架构原则:

Store 管状态,Composable 封业务,Component 只消费。

三、分层架构设计

最终的架构分三层:

┌─────────────────────────────────────────────────┐
│               Component Layer                    │
│              (Vue 组件/页面)                     │
│  只使用 Composables,不直接访问 Store            │
└─────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────┐
│              Composable Layer                    │
│           (composables/basicData/)              │
│  usePorts / useVessels / useDict / ...          │
│  封装 Store,提供业务友好的 API                  │
└─────────────────────────────────────────────────┘
                        │
                        ▼
┌─────────────────────────────────────────────────┐
│                Store Layer                       │
│             (store/basicData/)                  │
│  useBasicDataStore                              │
│  统一缓存、加载状态、请求去重、持久化            │
└─────────────────────────────────────────────────┘

为什么要分这么多层?

  • Store 层:单一数据源,解决「数据从哪来」的问题
  • Composable 层:业务封装,解决「数据怎么用」的问题
  • Component 层:纯消费,只关心「界面怎么展示」

这样分层之后,职责边界就清晰了。组件开发者不用关心缓存策略,只管调 usePorts() 拿数据就行。

四、核心实现

4.1 Store 层:请求去重是关键

Store 层最核心的逻辑是 loadData 方法。这里要处理三种情况:

  1. 缓存命中 → 直接返回
  2. 有相同请求正在进行 → 复用已有 Promise
  3. 发起新请求 → 请求完成后写入缓存
// store/basicData/useBasicData.ts
export const useBasicDataStore = defineStore('basic-data', () => {
  const cacheMap = ref<Map<BasicDataType, CacheEntry>>(new Map())
  const pendingRequests = new Map<BasicDataType, Promise<unknown>>()

  async function loadData<T>(
    type: BasicDataType,
    fetcher: () => Promise<T>,
    config?: CacheConfig
  ): Promise<T | null> {
    // 1. 缓存命中
    const cached = getCache<T>(type)
    if (cached !== null) return cached

    // 2. 请求去重——这是关键
    const pending = pendingRequests.get(type)
    if (pending) return pending as Promise<T | null>

    // 3. 发起新请求
    const request = (async () => {
      try {
        const data = await fetcher()
        setCache(type, data, config)
        return data
      } finally {
        pendingRequests.delete(type)
      }
    })()

    pendingRequests.set(type, request)
    return request
  }

  return { loadData, getCache, setCache, clearCache }
})

请求去重的实现很简单:用一个 Map 存储正在进行的 Promise。当第二个请求进来时,直接返回已有的 Promise,不发新请求。

这样即使页面上 10 个组件同时调用 usePorts(),实际 API 请求也只有 1 次。

4.2 Composable 层:工厂函数批量生成

港口、船舶、航线……这些 Composable 的逻辑高度相似,用工厂函数批量生成:

// composables/basicData/hooks.ts
function createBasicDataComposable<T extends BaseDataItem>(
  type: BasicDataType,
  fetcher: () => Promise<T[]>,
  config?: CacheConfig
) {
  return () => {
    const store = useBasicDataStore()

    // 响应式数据
    const data = computed(() => store.getCache<T[]>(type) || [])
    const loading = computed(() => store.getLoadingState(type).loading)
    const isReady = computed(() => data.value.length > 0)

    // 自动加载
    store.loadData(type, fetcher, config)

    // 业务方法
    const getByCode = (code: string) => 
      data.value.find(item => item.code === code)

    const options = computed(() => 
      data.value.map(item => ({
        label: item.nameCn,
        value: item.code
      }))
    )

    return { data, loading, isReady, getByCode, options, refresh }
  }
}

// 一行代码定义一个 Composable
export const usePorts = createBasicDataComposable('ports', fetchPorts, { ttl: 15 * 60 * 1000 })
export const useVessels = createBasicDataComposable('vessels', fetchVessels, { ttl: 15 * 60 * 1000 })
export const useLanes = createBasicDataComposable('lanes', fetchLanes, { ttl: 30 * 60 * 1000 })

这样做的好处是:

  • 新增一种基础数据,只需加一行代码
  • 所有 Composable 的 API 完全一致,学习成本低
  • 类型安全,TypeScript 能正确推断返回类型

4.3 字典数据:特殊处理

字典数据稍微复杂一些,因为它是按类型分组的。我们单独封装了 useDict

export function useDict() {
  const store = useBasicDataStore()

  // 加载全量字典数据
  store.loadData('dict', fetchAllDict, { ttl: 30 * 60 * 1000 })

  const getDictItems = (dictType: string) => {
    const all = store.getCache<DictData>('dict') || {}
    return all[dictType] || []
  }

  const getDictLabel = (dictType: string, value: string) => {
    const items = getDictItems(dictType)
    return items.find(item => item.value === value)?.label || value
  }

  const getDictOptions = (dictType: string) => {
    return getDictItems(dictType).map(item => ({
      label: item.label,
      value: item.value
    }))
  }

  return { getDictItems, getDictLabel, getDictOptions }
}

使用起来非常直观:

<script setup>
const dict = useDict()
const cargoTypeLabel = dict.getDictLabel('CARGO_TYPE', 'FCL') // "整箱"
</script>

<template>
  <el-select>
    <el-option 
      v-for="opt in dict.getDictOptions('CARGO_TYPE')" 
      :key="opt.value" 
      v-bind="opt" 
    />
  </el-select>
</template>

五、实际使用场景

场景一:下拉选择器

最常见的场景。以前要自己请求数据、处理格式,现在一行搞定:

<script setup>
import { usePorts } from '@/composables/basicData'

const { options: portOptions, loading } = usePorts()
const selectedPort = ref('')
</script>

<template>
  <el-select v-model="selectedPort" :loading="loading" filterable>
    <el-option v-for="opt in portOptions" :key="opt.value" v-bind="opt" />
  </el-select>
</template>

场景二:表格中的代码翻译

订单列表里显示港口代码,用户看不懂,要翻译成中文:

<script setup>
import { usePorts } from '@/composables/basicData'

const { getByCode } = usePorts()

// 翻译函数
const translatePort = (code: string) => getByCode(code)?.nameCn || code
</script>

<template>
  <el-table :data="orderList">
    <el-table-column prop="polCode" label="起运港">
      <template #default="{ row }">
        {{ translatePort(row.polCode) }}
      </template>
    </el-table-column>
  </el-table>
</template>

场景三:字典标签渲染

状态、类型这类字段,通常要显示成带颜色的标签:

<script setup>
import { useDict } from '@/composables/basicData'

const dict = useDict()
</script>

<template>
  <el-tag :type="dict.getDictColorType('ORDER_STATUS', row.status)">
    {{ dict.getDictLabel('ORDER_STATUS', row.status) }}
  </el-tag>
</template>

场景四:数据刷新

用户修改了基础数据,需要刷新缓存:

import { usePorts, clearAllCache } from '@/composables/basicData'

const { refresh: refreshPorts } = usePorts()

// 刷新单个
await refreshPorts()

// 刷新全部
clearAllCache()

六、缓存策略

不同数据的变化频率不同,缓存策略也不一样:

数据类型 TTL 持久化 原因
国家/货币 1 小时 几乎不变
港口/码头 15-30 分钟 偶尔变化
船舶 15 分钟 数据量大(10万+),不适合 localStorage
航线/堆场 30 分钟 相对稳定
字典 30 分钟 偶尔变化

持久化用的是 localStorage,配合 TTL 一起使用。数据写入时记录时间戳,读取时检查是否过期。

船舶数据量太大,存 localStorage 会导致写入超时,所以不做持久化,每次刷新页面重新请求。

七、调试支持

用 Pinia 还有一个好处:Vue DevTools 原生支持。

打开 DevTools,切到 Pinia 面板,能看到:

  • 当前缓存了哪些数据
  • 每种数据的加载状态
  • 数据的具体内容

排查问题时非常方便。

另外我们还提供了 getCacheInfo() 方法,可以在控制台查看缓存统计:

import { getCacheInfo } from '@/composables/basicData'

console.log(getCacheInfo())
// {
//   ports: { cached: true, size: 102400, remainingTime: 600000 },
//   vessels: { cached: false, size: 0, remainingTime: 0 },
//   ...
// }

八、踩过的坑

坑 1:响应式丢失

一开始我们这样写:

// ❌ 错误写法
const { data } = usePorts()
const portList = data.value // 丢失响应式!

datacomputed,取 .value 之后就变成普通值了,后续数据更新不会触发视图刷新。

正确做法是保持响应式引用:

// ✅ 正确写法
const { data: portList } = usePorts()
// 或者
const portList = computed(() => usePorts().data.value)

坑 2:循环依赖

Store 和 Composable 互相引用导致循环依赖。解决办法是严格遵守分层原则:Composable 可以引用 Store,Store 不能引用 Composable。

坑 3:SSR 兼容

localStorage 在服务端不存在。如果你的项目需要 SSR,持久化逻辑要加判断:

const storage = typeof window !== 'undefined' ? localStorage : null

九、总结

重构前后的对比:

维度 重构前 重构后
缓存系统 3 套并存 1 套统一
代码复用 到处复制粘贴 工厂函数批量生成
请求优化 无去重,重复请求 自动去重
调试 只能打 log DevTools 原生支持
类型安全 部分 any 完整类型推断

核心收益:

  1. 开发效率提升:新增基础数据类型从半天缩短到 10 分钟
  2. Bug 减少:数据不一致问题基本消失
  3. 性能优化:重复请求减少 60%+

如果你的项目也有类似的基础数据管理问题,可以参考这个思路。关键是想清楚分层,把「状态管理」和「业务封装」分开,剩下的就是体力活了。


本文基于实际项目经验整理,代码已做脱敏处理。欢迎讨论交流。

❌
❌