一次 Vue3 项目菜单的重构和全局状态管理 Pinia 的使用以及需求开发的总结

背景

项目框架 Vue3。项目展示多个不同的活动。

需求

这些活动(页面)都是一次性的。有新活动需要前端页面的更新,因为这些活动都是个性化的充满设计。①

所以并没有所谓的管理后台生成活动------前端有个活动列表直接通过接口读取、活动详情也是一样的模板。

一般菜单的数据由后端接口返回。这里也有接口返回所有活动信息,可以组成菜单信息。看起来好像是可以直接使用接口返回的数据,但是有几个问题:

  • 因为前面所说的情况,既然是个手工的活,那么菜单不如也是前端组装。
  • 有二级菜单,问题是二级菜单和一级菜单是分开的。Menu 展示一级菜单,某一个活动页面中展示二级菜单。
  • 接口返回的数据是扁平的,形如:
ts 复制代码
{
  'activity1': {
    start_time: number
    is_new: number
    is_claimed_reward: number
    end_time: number
    active: number
  },
  'activity2': {
    start_time: number
    is_new: number
    is_claimed_reward: number
    end_time: number
    active: number
  },
}

也就是说它没有嵌套层级的关系,也没有指示父子关系,最终必然要手动去写需要的一级、二级菜单信息。

要说直接前端使用固定菜单数据,好像可以,因为有新活动,再变更菜单嘛。但是:

  • 既然有接口返回活动信息,最好还是以接口信息为准。

  • 而且,活动有活动时间,菜单应该只展示可用的活动。

所以,还是要从接口获取可用的活动信息,组成前端菜单数据。每一次打开页面时请求接口获取最新数据,更新菜单。

第一版

一开始的实现是只有一个页面(路由),不同的活动用不同的组件展示。菜单也是页面中的一个组件。点击菜单只是切换活动组件的显示和隐藏。

结构和逻辑大概如下:

html 复制代码
<div id="activity" class="page" ref="page">
  <nav class="nav">
    <ul>
      <li
        v-if="isActivityActive.activity1"
        class="nav-item nav-item-first"
        :class="{ active: isNavOneActive, new: isShowRedDot.activity1 }"
        @click="handleChangeMainTab(1)"
      >
        活动1
      </li>
      <li
        v-if="isActivityActive.activity2 || isActivityActive.activity3"
        class="nav-item nav-item-second"
        :class="{
          active: isNavTwoActive,
          new: isShowRedDot.activity2 || isShowRedDot.activity3,
        }"
        @click="handleChangeMainTab(2)"
      >
        活动包含二级菜单
      </li>
    </ul>
  </nav>
  <nav
    v-if="isActivityActive.activity2 || isActivityActive.activity3"
    class="sub-nav z-10"
    :class="{
      active: isSubNavActive,
      'pointer-events-none': !isSubNavActive,
    }"
    ref="subNav"
  >
    <ul class="sub-nav-list">
      <li
        v-if="isActivityActive.activity2"
        class="sub-nav-item sub-nav-item-one"
        :class="{
          active: isSubNavOneActive,
          new: isShowRedDot.activity2,
        }"
        @click="handleChangeSubTab(1)"
      >
        活动2
      </li>
      <li
        v-if="isActivityActive.activity3"
        class="sub-nav-item sub-nav-item-two"
        :class="{
          active: isSubNavTwoActive,
          new: isShowRedDot.activity3,
        }"
        @click="handleChangeSubTab(2)"
      >
        活动3
      </li>
    </ul>
  </nav>
  <!-- 活动1 -->
  <div
    class="page-common page-one active"
    ref="pageOne"
  >
    <activity1
      @on-reward="handleSetIsClaimedReward('activity1', $event)"
    />
  </div>
  <!-- 活动2 -->
  <div
    class="page-common page-two"
    ref="pageTwo"
  >
    <activity2
      @on-reward="handleSetIsClaimedReward('activity2', $event)"
    />
  </div>
  <!-- 活动3 -->
  <div
    class="page-common page-three"
    ref="pageThree"
  >
    <activity3
      @on-reward="handleSetIsClaimedReward('activity3', $event)"
    />
  </div>
</div>

可以看到,一级菜单、二级菜单是并列的,三个活动组件也是并列的。菜单页面的切换,依靠菜单点击事件,每一次点击手动控制当前菜单活动及其他菜单活动的显示和隐藏,非常的繁琐,不符合程序的特点。这还只是 3 个活动,当活动更多之后,就会变得不可维护。

逻辑如上,不再赘述。其中菜单项有3个状态:

  • 是否选中:isActive
  • 是否是新活动:isNew
  • 是否已领奖:isClaimedReward

其中菜单项的红点 isShowRedDot 显示由 isNew 和 isClaimedReward 计算:isNew === 1 || isClaimedReward === 0,也就是 是新活动或者未领奖

isNew、isClaimedReward、isShowRedDot 都是对象,包括不同活动的状态。另外选中状态设置了所有的 isNavOneActive、isSubNavOneActive 等等,再次说明,因为页面是完全设计的,每一块的设计(位置、背景、大小等)都是不一样的,所以才会这样。

改版构思

设计因素,菜单存在感不强,于是设计了新版菜单,相对独立于页面。

新的设计,一方面更向程序化偏移,程序上应该可以设计一个更通用的菜单,虽然还是有较强的设计在。

另一方面还有个公告页面,这个页面和活动不相关。

同时,之前不同的活动用组件来呈现,导致页面交互逻辑复杂(虽然已经拆分组件)。活动本身有很多逻辑,更应该当做单独的页面。

引入路由

现在进行了 Vue Router 的改版。页面结构如:

html 复制代码
<!-- App.vue -->
<div class="page">
  <nav class="nav">
    <Menu :data="menuData"></Menu>
  </nav>
  <main class="">
    <router-view></router-view>
  </main>
</div>

二级路由

其中一个一级页面一级活动包含两个二级页面活动2活动3,一级活动页面如下:

html 复制代码
<!-- Subject.vue -->
<div>
  <nav>
    <ul>
      <li
        v-for="item in subMenuData"
        :key="item.label"
        @click="handleNav(item)"
      >
        {{ item.label }}
      </li>
    </ul>
  </nav>
  <main class="sub-main">
    <router-view></router-view>
  </main>
</div>

路由信息

ts 复制代码
import type { RouteRecordRaw } from 'vue-router'
export const routes: RouteRecordRaw[] = [
  {
    path: '/',
    redirect: '/activity1',
  },
  {
    path: '/activity1',
    name: 'Activity1',
    component: () => import('@/views/Activity1'),
    meta: {
      title: '活动1',
      module: 'activity_1',
    },
  },
  {
    path: '/subject',
    name: 'Subject',
    redirect: '/subject/activity2',
    component: () => import('@/views/Subject'),
    meta: {
      title: '活动包含二级菜单',
      module: 'subject',
    },
    children: [
      {
        path: 'activity2',
        name: 'Activity2',
        component: () => import('@/views/Subject/Activity2'),
        meta: {
          title: '活动2',
          module: 'activity_2',
        },
      },
      {
        path: 'activity3',
        name: 'Activity3',
        component: () => import('@/views/Subject/Activity3'),
        meta: {
          title: '活动3',
          module: 'activity_3',
        },
      },
    ],
  },
]

后端数据转换为前端菜单数据

App.vue

ts 复制代码
<Menu :data="MenuData" @on-update="handleClick"></Menu>                                            

const menuData = ref<MenuItem[]>([])
const menuDataOrigin = [
  {
    label: '活动1',
    value: 'activity_1',
    routeName: 'Activity1',
    isActive: true,
    isNew: true,
    isClaimedReward: true,
    children: [],
  },
  {
    label: '活动包含二级菜单',
    value: 'subject',
    routeName: 'Subject',
    isActive: false,
    isNew: true,
    isClaimedReward: true,
    children: [
      {
        label: '活动2',
        value: 'activity_2',
        routeName: 'Activity2',
        isActive: true,
        isNew: true,
        isClaimedReward: true,
      },
      {
        label: '活动3',
        value: 'activity_3',
        routeName: 'Activity3',
        isActive: false,
        isNew: true,
        isClaimedReward: true,
      },
    ],
  },
]

menuDataOrigin 是代码中根据设计提供的菜单数据列表,也就是前端在当前版本写死的菜单数据。后续通过接口返回数据进行过滤有效活动(可能有活动失效),生成 menuData,就是最终的菜单数据。

数据类型

菜单的数据项类型和接口活动的数据类型:

ts 复制代码
interface MenuItem {
  label: string
  value: string
  routeName: string
  isActive: boolean
  isNew: boolean
  isClaimedReward: boolean
  children?: MenuItem[]
}

interface ActivityData {
  start_time: number
  is_new: number
  is_claimed_reward: number
  end_time: number
  active: number
}

Menu 组件通过 prop data 传递数据,点击事件 emit 触发父组件更新数据。

Menu 组件:

html 复制代码
<div v-for="item in menuData" :key="item.label" class="">
  <div
    :class="[
      'nav-item item-center group flex items-center justify-center text-center',
      'hover:cursor-pointer',
      item.isActive ? 'nav-item--active' : '',
      item.isNew || !item.isClaimedReward ? 'nav-item--new' : '',
    ]"
    @click="handleNav(item)"
  >
    <img
      class="nav-icon"
      alt=""
    />
    <span class="nav-text">{{ item.label }}</span>
  </div>
</div>

通过 isActive 控制菜单项选中状态样式,通过 isNew、isClaimedReward 控制菜单项显示红点样式。

一级、二级菜单数据是分开的

二级菜单在 Subject 中,和 Menu 差不多。只是没有父子组件的数据事件传递。

一样使用组件的状态:

ts 复制代码
const menuDataOrigin = [
  {
    label: '活动2',
    value: 'activity_2',
    routeName: 'Activity2',
    isActive: true,
    isNew: true,
    isClaimedReward: true,
  },
  {
    label: '活动3',
    value: 'activity_3',
    routeName: 'Activity3',
    isActive: false,
    isNew: true,
    isClaimedReward: true,
  },
]

以上,一级菜单和二级菜单数据是分开的。

为了便于集中管理数据,我们将菜单数据仍然保持二级嵌套的结构,在二级菜单页面,直接读取菜单数据的子级数据就好。这个在后面集中管理数据时就能看到用法。

本地存储?

解决完展示的问题,来解决交互的问题。在活动页面,领取奖励之后,需要通过接口更新是否已领奖状态 isClaimedReward,此时菜单如何才能知道呢?

活动在不同的路由页面中,显然不能够再通过 prop 形式的数据传递。本地存储也不能。能够同步更新本地存储,但是如何让页面更新呢?只有 Vue 的响应式数据才能做到。

所以引入全局状态管理 Pinia。

引入全局状态管理

Pinia 比 Vuex 简单很多,以下是基本用法:

直接在 /stores 下新建 useMenuStore.ts

既然是在 Vue3 项目中,就使用 Setup Store。(让人不解的是,都使用 Pinia 了,肯定是 Vue3 了呀,为什么官网还在推荐 Options 写法?)

ts 复制代码
import { defineStore } from 'pinia'
import { type MenuItem } from '@/types'

export const useMenuStore = defineStore('menu', () => {
  // 菜单数据
  const menuData = ref<MenuItem[]>([
    // 省略初始数据
  ])

  // 更新菜单数据
  function updateMenuData(newMenuData: MenuItem[]): void {
    menuData.value = newMenuData
  }

  return {
    menuData,
    updateMenuData,
  }
})

获取菜单数据

在 App.vue 中,在接口返回数据之后更新菜单数据:

ts 复制代码
import { useMenuStore } from '@/stores/menuStore'
  
const menuStore = useMenuStore()
const menuData = computed(() => menuStore.menuData)

menuStore.updateMenuData(menuData.value)

菜单的点击更新数据

在 Menu 和 Subject 中,点击菜单,更新菜单数据 isActive、isNew。

更新 isActive:

ts 复制代码
import { useMenuStore } from '@/stores/menuStore'
  
const menuStore = useMenuStore()
const menuData = computed(() => menuStore.menuData)

function handleNav(event: string): void {
  const newMenuData = menuData.value.map((item) => {
    if (
      item.children &&
      item.children.length > 0
    ) {
      return {
        ...item,
        children: item.children.map((child) => {
          return {
            ...child,
            isActive: child.value === event,
          }
        }),
      }
    } else {
      return {
        ...item,
        isActive: item.value === event,
      }
    }
  })
  menuStore.updateMenuData(newMenuData)
}

更新 isNew:

ts 复制代码
import { useMenuStore } from '@/stores/menuStore'
  
const menuStore = useMenuStore()
const menuData = computed(() => menuStore.menuData)

function handleNav(event: string): void {
  const newMenuData = menuData.value.map((item) => {
    if (
      item.value === 'subject' &&
      item.children &&
      item.children.length > 0
    ) {
      // 注意 isNew 需要用最新的 children 计算
      // ...item,
      // children: item.children.map((child) => {
      //   return {
      //     ...child,
      //     isNew: child.value === event ? false : child.isNew,
      //   };
      // }),
      // isNew: item.children.some((child) => child.isNew),
      let isAnyChildNew = false
      const children = item.children.map((child) => {
        const isNew = child.value === event ? false : child.isNew
        if (isNew) {
          isAnyChildNew = true
        }
        return {
          ...child,
          isNew,
        }
      })
      return {
        ...item,
        children,
        isNew: isAnyChildNew,
      }
    }
    return {
      ...item,
      isNew: item.value === event ? false : item.isNew,
    }
  })
  menuStore.updateMenuData(newMenuData)
}

活动页面更新菜单数据

在活动页面中,领奖成功,更新菜单数据 isClaimedReward:

ts 复制代码
import { useMenuStore } from '@/stores/menuStore'
  
const menuStore = useMenuStore()
const menuData = computed(() => menuStore.menuData)

function updateMenuDataByIsClaimedReward(
  event: string,
  isClaimedReward: boolean,
): void {
  const newMenuData = menuData.value.map((item) => {
    if (
      item.value === 'subject' &&
      item.children &&
      item.children.length > 0
    ) {
      // 注意 isClaimedReward 需要用最新的 children 计算
      const children = item.children.map((child) => {
        return {
          ...child,
          isClaimedReward:
            child.value === event ? isClaimedReward : child.isClaimedReward,
        }
      })
      const isAllClaimedReward = children.every(
        (child) => child.isClaimedReward,
      )
      return {
        ...item,
        children,
        isClaimedReward: isAllClaimedReward,
      }
    }
    return {
      ...item,
      isClaimedReward:
        item.value === event ? isClaimedReward : item.isClaimedReward,
    }
  })
  menuStore.updateMenuData(newMenuData)
}

总结全局状态管理 Pinia 的使用

使用要点

和 Vue3 的响应式概念一致,知道下面这些大概就够用了:

  • 可以直接在 store 中获取属性使用。
  • 你不能解构 Store。
  • 可以使用 computed 构造响应式变量。
  • 如果要解构使用,可以使用 storeToRefs。
ts 复制代码
// 可以
menuStore.menuData = xxx

// 不能,失去响应式
const { menuData } = menuStore

// 可以,menuData 是响应式的
const menuData = computed(() => menuStore.menuData)

// 可以,menuData 是响应式的
const { menuData } = storeToRefs(menuStore)

// 作为 action 的 updateMenuData 可以直接解构
const { updateMenuData } = menuStore

理清数据的获取和更新

重要的是,理清相关概念和流程,我一开始使用的时候,既用了前面的组件状态,又使用了全局状态,更新全局状态怎么会有效果呢:

  • 全局状态初始化、初始值、更新方法
  • App 组件中更新全局状态 menuData
  • Activity1、Activity2、Activity3 组件更新全局状态
  • Menu 组件、Subject 组件的菜单数据取自全局状态

数据持久化

刷新页面状态丢失的问题

刷新页面,接口重新请求数据,因为 isNew、isClaimedReward 会通过接口保存,所以它们是最新的状态。但是 isActive 就会丢失,使用初始状态。

数据持久化

想要保存当前的菜单状态,于是就想到了数据持久化,本质上就是保存到本地,下次页面加载的时候再从本地获取。可以手动操作 localStorage,也可以使用插件 pinia-plugin-persistedstate。

但是仔细想想就会发现问题,刷新页面,App 请求接口获取数据,用接口的数据过滤前端原始菜单数据,那么每次刷新之后,数据都是初始状态,持久化完全没用啊?

数据持久化的用途

什么时候需要数据持久化呢?问了一下 ChatGPT:

  1. 用户会话信息:用户登录后的会话信息(如令牌、用户偏好设置)通常需要在页面刷新后依然保持,以避免用户需要重复登录。
  2. 购物车数据:电商类应用中,用户可能希望即使在关闭浏览器后再重新打开时,购物车中的商品仍然存在。
  3. 表单状态:如果用户正在填写一个较长的表单,但尚未提交,可能需要将表单的中间状态保存下来,以便用户能够在之后回来继续填写。
  4. 本地偏好设置:如主题设置(暗模式或亮模式)、布局偏好、语言选项等用户自定义的设置,通常需要持久化以提升用户体验。
  5. 重要数据备份:对于一些重要数据,以防止意外的页面刷新或关闭导致数据丢失,可以持久化存储。

是否需要数据持久化

那么怎么保存选中状态呢?此时想到的是不需要保存所有数据到本地,只要保存 isActive 状态到本地就好。但是怎么做呢,保存 isActive 状态也需要整个的菜单结构。每次刷新页面时和本地数据进行对比,以本地 isActive 作为最新的选中状态。

但是这样似乎有点复杂了?

重新思考菜单设计

让我们来重新思考菜单的设计,好像一开始我们需要什么就往菜单里面加,自然想到用 isActive 控制当前菜单的选中,点击事件设置 isActive。

但是没有考虑到,每次页面加载请求数据之后,要进行初始化的设置。

而且,点击一级菜单时,二级菜单并不会触发点击,我们默认跳转第一个二级菜单。如果在第二个二级菜单 Activity3,刷新页面,那么初始会设置为第一个二级菜单 Activity2。我们发现最好是根据路由来匹配菜单。

如果我们使用 Element UI,会怎么做?

Menu 上设置default-active属性表示当前 active 的菜单 index,在 Menu-Item 上设置index属性表示唯一的索引,所以通过给 Menu 设置default-active就可以设置当前的选中项。但是似乎没有层级的关系。

一般的菜单结构和选中状态应该怎么设计?

一个常见的菜单数据结构可能如下:

ts 复制代码
const menuData = [
  {
    id: 'dashboard',       // 唯一标识符
    title: 'Dashboard',     // 菜单项标题
    icon: 'icon-dashboard', // 图标的class名称或路径
    path: '/dashboard',     // 对应的路由路径
    isActive: false,        // 是否激活(选中状态)
    children: []            // 子菜单项数组
  },
  {
    id: 'settings',
    title: 'Settings',
    icon: 'icon-settings',
    path: '/settings',
    isActive: false,
    children: [
      {
        id: 'profile',
        title: 'Profile',
        path: '/settings/profile',
        isActive: false
      },
      // 更多子菜单项...
    ]
  },
  // 更多菜单项...
];

处理激活状态的方法

选中状态的设计通常与当前的路由状态进行关联,这样菜单激活状态可以自动反映当前页面的位置。这里有几种方法来处理激活状态:

路由匹配

菜单项的激活状态可以通过比较菜单项的路径与当前路由的路径实现。在 Vue 中,可以使用 $route 对象来获取当前路由信息。

ts 复制代码
// 判断菜单项是否激活
function isMenuActive(menuItem) {
  return this.$route.path === menuItem.path;
}

全局状态管理

如果使用了 Pinia,可以在全局状态中维护当前激活的菜单ID,并在菜单组件中使用计算属性来判断激活状态。

ts 复制代码
// Pinia store 中的 state
const state = () => ({
  activeMenuId: null,
});

Watch路由变化

在 Vue 组件中可以 watch 路由变化,并相应地更新菜单激活状态。

ts 复制代码
watch(() => $route.path, (newPath) => {
  // 逻辑来更新菜单激活状态
});

路由守卫

可以在路由守卫中设置菜单激活状态,这样每次导航变化时都会更新状态。

通过全局状态管理当前菜单项id似乎也挺麻烦的,直接通过路由匹配最简单。

最佳做法通常是将菜单选中状态与当前路由进行同步,这样可以保持前端导航的一致性,并简化状态管理。这意味着你不需要在菜单数据结构中显式地存储 isActive 状态。相反,你可以通过计算属性或方法来动态判断菜单项是否应该被视为激活状态。

重新实现菜单激活状态

重新设计菜单项结构,删除 isActive:

ts 复制代码
interface MenuItem {
  label: string
  value: string
  routeName: string
  isNew: boolean
  isClaimedReward: boolean
  children?: MenuItem[]
}

然后在路由守卫中:

ts 复制代码
import { useMenuStore } from '@/stores/menuStore'

router.beforeEach((to, _, next) => {
  const module = to?.meta?.module
  if (module && typeof module === 'string') {
    const menuStore = useMenuStore()
    menuStore.updatedMenuDataByRoute(module)
  }
  next()
})

这里是在路由守卫中统一处理访问逻辑,处理 isNew。这样就不需要在每个页面的加载中写逻辑了。

一级菜单或二级菜单中:

ts 复制代码
<div
  :class="[
    'nav-item item-center group flex items-center justify-center text-center',
    'hover:cursor-pointer',
    isMenuItemActive(item) ? 'nav-item--active' : '',
    item.isNew || item.isClaimedReward === false ? 'nav-item--new' : '',
  ]"
  @click="handleNav(item)"
>
import { useRoute } from 'vue-router'
const route = useRoute()

// 一级菜单
// 判断当前菜单是否选中 isActive
function isMenuItemActive(item: MenuItem): boolean {
  const module = route?.meta?.module
  if (module && typeof module === 'string') {
    return (
      item.value === module ||
      (item.value === 'subject' &&
        ['activity_2', 'activity_3'].includes(module))
    )
  }
  return false
}

// 页面中的二级菜单
// 判断当前菜单是否选中 isActive
function isMenuItemActive(item: MenuItem): boolean {
  const module = route?.meta?.module
  if (module && typeof module === 'string') {
    return item.value === module
  }
  return false
}

至此,激活状态也不需要保存到本地。

封装全局状态的更新操作

一开始是在不同的页面中更新全局状态。

App

  • 接口获取数据,更新整个菜单数据 updateMenudata(menuData)

其中,一级菜单的 isNew 取决于二级菜单的 isNew:

ts 复制代码
menuItem.isNew = menuItem.children.some((child) => child.isNew)

一级菜单的 isClaimedReward 取决于二级菜单的 isClaimedReward:

ts 复制代码
menuItem.isClaimedReward = menuItem.children.every((child) => child.isClaimedReward)
  • 一级菜单点击事件,更新菜单数据的 isActive
  • 一级菜单点击事件,更新菜单数据的 isNew

其中,一级菜单的 isNew 取决于二级菜单的 isNew

二级活动的承载页面 Subject

  • 二级菜单点击事件,更新菜单数据的 isActive
  • 二级菜单点击事件,更新菜单数据的 isNew

其中,一级菜单的 isNew 取决于二级菜单的 isNew

具体的活动页面,如 Activity2

  • 领奖成功,更新菜单数据的 isClaimedReward

其中,一级菜单的 isClaimedReward 取决于二级菜单的 isClaimedReward

封装 action

改造之后,menuStore 新增 action updateMenuDataByIsNew:

ts 复制代码
// 更新菜单数据 isNew
function updateMenuDataByIsNew(event: string): void {
  let curItem = null
  if (['activity_1', 'other'].includes(event)) {
    curItem = menuData.value.find((item) => item.value === event)
  } else if (['activity_2', 'activity_3'].includes(event)) {
    curItem = menuData.value
      .find((item) => item.value === 'subject')
      ?.children?.find((child) => child.value === event)
  }
  // 判断是否需要更新
  if (curItem && curItem.isNew) {
    // 接口更新红点状态
    setWebRedDot({ event }, function () {
      menuData.value = menuData.value.map((item) => {
        if (
          item.value === 'subject' &&
          item.children &&
          item.children.length > 0
        ) {
          let isAnyChildNew = false
          const children = item.children.map((child) => {
            const isNew = child.value === event ? false : child.isNew
            if (isNew) {
              isAnyChildNew = true
            }
            return {
              ...child,
              isNew,
            }
          })
          return {
            ...item,
            children,
            isNew: isAnyChildNew,
          }
        }
        return {
          ...item,
          isNew: item.value === event ? false : item.isNew,
        }
      })
    })
  }
}

这样就可以在不同的页面中使用,updateMenuDataByIsClaimedReward 同理。

重新思考一下,Menu 更新的设计是否合理?

我们曾将 Menu 从分开的数据结构改为嵌套的二级结构,便于集中管理。现在包含二级菜单的一级菜单 Subject,依赖于其子菜单的表现。所以我们每次更新这种菜单就要注意同步更新一级、二级菜单。

那么我们有没有办法来集中更新呢,类似前面封装全局状态的操作?其实我们可以在展示的时候更新,也就是在 Menu 中。我们在更新状态的时候,只管更新二级菜单,在 Menu 中再根据二级菜单更新一级菜单。

因为一级菜单的 isNew 和 isClaimedReward 是没有意义的,我们使用 computedMenuData,用于 Menu 的展示就好:

ts 复制代码
// 获取全局菜单数据
const menuStore = useMenuStore()
const menuData = computed(() => menuStore.menuData)

// 根据子菜单状态重新计算菜单数据
const computedMenuData = computed<MenuItem[]>(() => {
  return menuData.value.map((menuItem) => {
    if (menuItem.children && menuItem.children.length > 0) {
      const isNew = menuItem.children.some((child) => child.isNew)
      const isClaimedReward = menuItem.children.every(
        (child) => child.isClaimedReward,
      )
      return {
        ...menuItem,
        isNew,
        isClaimedReward,
      }
    }
    return menuItem
  })
})

这样,我们就不用在每一个二级菜单的更新时,还要同时更新其一级菜单:

ts 复制代码
// 更新菜单数据 isClaimedReward
function updateMenuDataByIsClaimedReward(
  event: string,
  isClaimedReward: boolean,
): void {
  menuData.value = menuData.value.map((item) => {
    if (
      item.value === 'subject' &&
      item.children &&
      item.children.length > 0
    ) {
      const children = item.children.map((child) => {
        return {
          ...child,
          isClaimedReward:
            child.value === event ? isClaimedReward : child.isClaimedReward,
        }
      })
      return {
        ...item,
        children,
      }
    }
    return {
      ...item,
      isClaimedReward:
        item.value === event ? isClaimedReward : item.isClaimedReward,
    }
  })
}

总结

通过这个案例的回顾和总结,我大概总结了以下的需求开发步骤:

  1. 想要的实现的效果。在本案例中是一个难点。
  2. 规划确定目录结构、路由的设计、二级路由等。
  3. 确定交互的流程:页面的加载、数据请求、页面更新、不同页面组件中的事件、事件对数据的更新。
  4. 数据状态的共享,父子组件通信,全局状态管理,本地缓存等。
  5. 设计数据结构。如一级、二级菜单的设计,从分开到嵌套。
  6. 封装逻辑,如不同页面对全局状态的更新。
  7. 重构,不断优化。

其中,总体上应该是:

  • 先构思,再编码
  • 重构的思维,小改动->测试

构思就是确定需求,熟悉所有的交互,最好以文字的方式写出来,类似写代码的注释,写好注释,具体的编码就简单了,写好交互流程,具体的实现就简单。不要一开始想到一点就去实现一点,最后往往是重复的工作。

重构的思维指的是要尽量优化代码,但是对于多文件复杂改动,要改动一点测试一点,不然一次过多的改动,最后出错也不知道错在哪里,如果知识点不熟,还会怀疑对的为错的。再就是要有封装的思想。

相关推荐
恋猫de小郭5 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅12 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606113 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了13 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅13 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅13 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅14 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment14 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅14 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊14 小时前
jwt介绍
前端