阅读视图

发现新文章,点击刷新页面。

module federation,monorepo分不清楚?

一代版本一代神,现代的前端已经不是会用一个react就能混过去的了,虽然正式工作上还是打螺丝,调包侠+切图仔,但是有些时候,新知识不可不学。 有两个概念近些年很火,一个是module federation一个是monorepo,光看名字可能觉得有点像,但是其实是两个东西。

模块联邦module federation

这是webpack在v5被投入生产,并作为v5的核心特性之一。它的出现解决了一些问题,或者说它适用于以下场景:

  1. 微前端架构:实现独立部署的子应用动态集成(如电商平台的首页、商品页拆分)。
  2. 大型应用拆分:逐步重构单体应用,降低维护成本。
  3. 跨团队代码共享:避免重复发布 npm 包,直接运行时复用模块。

基本上可以说他是微前端的方式。当然市面上肯定大部分工具也会跟上webpack,比如vite就通过rollup钩子实现了(vite-plugin-federation),又比如@module-federation/rollup插件,next-mf插件,Rspack(基于webpack)。 接下来看下他的主要配置

主应用webpack.config.js:

const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  ...
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        app1: 'remote@http://localhost:3001/app1Entry.js', // 子应用地址
      },
      shared: { 
        react: { singleton: true }, 'react-dom': { singleton: true }                 },
    }),
  ],
};

这里看到了一个shared配置singleton,指明了哪些模块应该作为单例共享,也就是单例模式,true的话父子应用共用一个实例,避免重复加载,但是当插件需要完全隔离的依赖如react环境时,可以设置成false;

remotes字段指定了远程微应用的名称和其远程入口文件URL。 当主应用需要用到子应用的玩意时,如下:

// 动态加载子应用的Button组件 
const RemoteButton = React.lazy(() => import('app1/Button'));

子应用webpack配置

const { ModuleFederationPlugin } = require('webpack').container; module.exports = { 
    entry: './src/moduleOutput.js', // 必须通过moduleOutput.js间接引入 
    plugins: [ 
        new ModuleFederationPlugin({ 
            name: 'app1', // 子应用名称(全局唯一) 
            filename: 'app1Entry.js', // 入口文件名(默认),模块清单名
            exposes: { 
                './Button': './src/Button.js', // 暴露组件路径 
            }, 
            shared: { 
                react: { singleton: true }, 'react-dom': { singleton: true } 
            },             
        }), 
    ], 
};
// moduleOutput.js
import('./index'); // 延迟加载业务代码

注意,这里我们看到entry不是我们平时项目脚手架自带的index.js/ts,而是通过其他文件moduleOutput.js,这个文件的存在是为了正确执行模块联邦的动态加载机制和代码执行顺序,而主要导致这样的原因是:

  1. 主应用加载子应用时,会先下载app1Entry.js模块清单文件,然后在按需加载子模块,比如exposes中的Button,如果子应用直接以index.js作为entry,可能会在子应用的子模块模块被主应用加载时,子应用的依赖(如react)未准备就绪,毕竟子应用也是配置了按需加载,这就会导致运行错误
  2. app1Entry.js这文件的作用就是延迟执行,通过动态导入(import())将子应用的业务代码(如 index.js)的加载推迟到 所有共享依赖(如 React)已就绪后。 当然,如果父子应用没有共享的模块,那么这个文件也就没必要了,另外shared的依赖中,有一个requiredVersion字段,可以让父子协商是否共享模块。

monorepo

这其实不是具体工具,而是一种思想:强关联性,同一业务线的项目,可以将项目放在同一个版本管理工具中(比如git),这么做的好处有很多,比如

  1. 代码共享与复用,一些公共的ts定义,和api接口层,组件能直接引用,并且所有项目共用顶层node_modules,减少重复依赖安装(通过workspaces功能)
  2. 统一工程化配置,比如eslint,pritter,jest和webpack等构建工具等,这会让维护成本降低。
  3. 统一版本管理,通过changesets等工具自动化版本号和changeLog管理
  4. 版本提交的完整性,当修改底层库时,可同时更新依赖他的所有应用,这保证了提交的完整性
  5. 依赖关系可视化,可用preune等命令工具生成关系图,便于框架优化
  6. 统一CI配置,所有项目共用一套CI/CD流程 当然也不是所有业务线都要这么做,这适用于部分场景:
  7. 微前端架构
  8. 全栈项目(对我来说当然是js的全栈)
  9. 多应用平台,比如pc,mobile共用业务逻辑
  10. 大型团队协作,减少代码碎片化
  11. 替代npm的频繁更替 常用来实现monorepo的工具有pnpm,lerna,turborepo,我一般使用pnpm

总结

这么一盘,好像两者也不是毫无联系,这都和微前端扯到了关系,但是两者场景并不是非常一致,且手段不同。最共同的点是,他们都是要学的东西。

Vue 3 统一面包屑导航系统:从配置地狱到单一数据源

本文分享我们在 Vue 3 + TypeScript 项目中重构面包屑导航系统的实践经验,通过将面包屑配置迁移到路由 meta 中,实现了配置的单一数据源,大幅降低了维护成本。

一、问题背景

1.1 原有架构的痛点

在重构之前,我们的面包屑系统采用独立的配置文件 breadcrumb.ts,存在以下问题:

// 旧方案:独立的面包屑配置文件(700+ 行)
export const breadcrumbConfigs: BreadcrumbItemConfig[] = [
  {
    path: '/export/booking',
    name: 'BookingManage',
    title: '订舱管理',
    showInBreadcrumb: true,
    children: [
      { path: '/export/booking/create', name: 'BookingCreate', title: '新建订舱' },
      { path: '/export/booking/edit', name: 'BookingEdit', title: '编辑订舱' },
      // ... 更多子页面
    ],
  },
  // ... 几十个类似的配置
]

主要痛点:

  1. 配置分散:路由定义在 router/modules/*.ts,面包屑配置在 config/breadcrumb.ts,新增页面需要修改两处
  2. 维护成本高:配置文件超过 700 行,嵌套结构复杂,容易出错
  3. 同步困难:路由变更后容易忘记更新面包屑配置,导致显示异常
  4. 类型安全差:配置与路由之间缺乏类型关联

1.2 期望目标

  • 单一数据源:面包屑配置与路由定义合并,一处修改全局生效
  • 类型安全:利用 TypeScript 确保配置正确性
  • 易于维护:新增页面只需在路由配置中添加一行
  • 向后兼容:平滑迁移,不影响现有功能

二、技术方案

2.1 核心思路

将面包屑路径配置到路由的 meta 字段中,通过 Composable 自动解析生成面包屑导航。

路由配置 (meta.breadcrumb) → useBreadcrumb() → BreadCrumb.vue

2.2 扩展路由 Meta 类型

首先,扩展 Vue Router 的 RouteMeta 接口:

// src/router/types.ts
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    /** 页面标题 */
    title?: string
    /** 国际化 key */
    i18nKey?: string
    /** 面包屑路径(路由名称数组) */
    breadcrumb?: string[]
    /** 是否缓存 */
    keepAlive?: boolean
    // ... 其他字段
  }
}

/** 面包屑项类型 */
export interface BreadcrumbItem {
  title: string
  path: string
  name: string
  i18nKey?: string
  isClickable: boolean
}

2.3 路由配置示例

在路由模块中添加 breadcrumb meta:

// src/router/modules/export.ts
export const exportRoutes: RouteRecordRaw[] = [
  {
    path: '/export/booking',
    name: 'BookingManage',
    component: () => import('~/views/export/booking/index.vue'),
    meta: {
      title: '订舱管理',
      keepAlive: true,
      breadcrumb: ['Export', 'BookingManage'], // 出口 > 订舱管理
    },
  },
  {
    path: '/export/booking/create/:mode',
    name: 'BookingCreate',
    component: () => import('~/views/export/booking/create.vue'),
    meta: {
      title: '新建订舱',
      breadcrumb: ['Export', 'BookingManage', 'BookingCreate'], // 出口 > 订舱管理 > 新建订舱
    },
  },
]

配置规则:

  • 数组元素为路由名称(name)或虚拟节点名称
  • 按层级顺序排列:[一级菜单, 二级菜单, 当前页面]
  • 空数组 [] 表示不显示面包屑(如首页)

2.4 useBreadcrumb Composable

核心逻辑封装在 Composable 中:

// src/composables/useBreadcrumb.ts
import type { BreadcrumbItem } from '~/router/types'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'

/**
 * 虚拟路由配置(菜单分类节点)
 * 这些节点在路由系统中不存在,但需要在面包屑中显示
 */
const VIRTUAL_ROUTES: Record<string, { title: string, i18nKey: string }> = {
  Mine: { title: '我的', i18nKey: 'mine' },
  Export: { title: '出口', i18nKey: 'export' },
  Import: { title: '进口', i18nKey: 'import' },
  Finance: { title: '财务', i18nKey: 'finance' },
  BoxManage: { title: '箱管', i18nKey: 'boxManage' },
}

export function useBreadcrumb() {
  const route = useRoute()
  const router = useRouter()
  const { t } = useI18n()

  /** 根据路由名称获取路由信息 */
  function getRouteByName(name: string) {
    return router.getRoutes().find(r => r.name === name)
  }

  /** 获取面包屑项的标题(支持国际化) */
  function getTitle(name: string, routeRecord?: RouteRecordNormalized): string {
    // 优先使用虚拟路由配置
    if (VIRTUAL_ROUTES[name]) {
      return t(`system.routes.${VIRTUAL_ROUTES[name].i18nKey}`, VIRTUAL_ROUTES[name].title)
    }
    // 使用路由 meta 配置
    if (routeRecord?.meta?.i18nKey) {
      return t(`system.routes.${routeRecord.meta.i18nKey}`, routeRecord.meta.title || name)
    }
    return routeRecord?.meta?.title || name
  }

  /** 计算面包屑列表 */
  const breadcrumbs = computed<BreadcrumbItem[]>(() => {
    const routeName = route.name as string
    if (!routeName) return []

    // 从路由 meta 获取面包屑配置
    const breadcrumbPath = route.meta?.breadcrumb as string[]
    if (!breadcrumbPath || breadcrumbPath.length === 0) {
      return []
    }

    // 构建面包屑列表
    return breadcrumbPath.map((name, index) => {
      const routeRecord = getRouteByName(name)
      const isLast = index === breadcrumbPath.length - 1
      const isVirtual = !!VIRTUAL_ROUTES[name]

      return {
        title: getTitle(name, routeRecord),
        path: isLast ? route.path : (routeRecord?.path || ''),
        name,
        i18nKey: isVirtual ? VIRTUAL_ROUTES[name].i18nKey : routeRecord?.meta?.i18nKey,
        isClickable: !isLast && !isVirtual && !!routeRecord,
      }
    })
  })

  /** 是否应该显示面包屑 */
  const shouldShow = computed<boolean>(() => {
    // 首页、登录页等不显示面包屑
    const hiddenPaths = ['/', '/index', '/dashboard', '/login', '/register']
    if (hiddenPaths.includes(route.path)) {
      return false
    }
    return breadcrumbs.value.length > 0
  })

  return { breadcrumbs, shouldShow }
}

2.5 面包屑组件

组件只需调用 Composable 即可:

<!-- src/layout/components/BreadCrumb/BreadCrumb.vue -->
<script setup lang="ts">
import { useBreadcrumb } from '~/composables'

const { breadcrumbs, shouldShow } = useBreadcrumb()
</script>

<template>
  <el-breadcrumb v-if="shouldShow" separator="/">
    <el-breadcrumb-item
      v-for="item in breadcrumbs"
      :key="item.name"
      :to="item.isClickable ? item.path : undefined"
    >
      {{ item.title }}
    </el-breadcrumb-item>
  </el-breadcrumb>
</template>

三、迁移策略

3.1 渐进式迁移

为了确保平滑过渡,我们采用渐进式迁移策略:

  1. 阶段一:新增 useBreadcrumb Composable,支持从路由 meta 读取配置
  2. 阶段二:逐个模块添加 breadcrumb meta 字段
  3. 阶段三:验证所有页面面包屑正常后,删除旧配置文件

3.2 迁移清单

模块 文件 页面数
核心页面 core.ts 4
用户管理 user.ts 3
查询服务 search_service.ts 6
出口业务 export.ts 25+
进口业务 import.ts 4
财务结算 payment_settlement.ts 6
箱管业务 equipment-control.ts 12

3.3 国际化配置

确保所有菜单分类节点都有对应的国际化配置:

// src/i18n/zh/system.ts
export default {
  routes: {
    // 菜单分类节点
    mine: '我的',
    export: '出口',
    import: '进口',
    finance: '财务',
    boxManage: '箱管',
    
    // 具体页面
    bookingManage: '订舱管理',
    bookingCreate: '新建订舱',
    // ...
  },
}

四、效果对比

4.1 代码量对比

指标 重构前 重构后 变化
配置文件行数 737 行 0 行(已删除) -100%
新增页面修改文件数 2 个 1 个 -50%
类型安全

4.2 新增页面对比

重构前:

// 1. 修改路由配置
{ path: '/new-page', name: 'NewPage', component: ... }

// 2. 修改面包屑配置(容易遗漏!)
{ path: '/new-page', name: 'NewPage', title: '新页面', ... }

重构后:

// 只需修改路由配置
{
  path: '/new-page',
  name: 'NewPage',
  component: ...,
  meta: {
    title: '新页面',
    breadcrumb: ['ParentMenu', 'NewPage'],
  },
}

五、最佳实践

5.1 面包屑配置规范

// ✅ 推荐:使用路由名称数组
breadcrumb: ['Export', 'BookingManage', 'BookingCreate']

// ❌ 避免:使用路径
breadcrumb: ['/export', '/export/booking', '/export/booking/create']

5.2 虚拟节点使用场景

当菜单分类本身不是一个可访问的页面时,使用虚拟节点:

// "出口" 是菜单分类,不是实际页面
const VIRTUAL_ROUTES = {
  Export: { title: '出口', i18nKey: 'export' },
}

// 路由配置
breadcrumb: ['Export', 'BookingManage'] // 出口 > 订舱管理

5.3 动态路由处理

对于带参数的动态路由,Composable 会自动使用当前路由的完整路径:

// 路由定义
{ path: '/export/lading/edit/:id', name: 'BookingLadingEdit', ... }

// 面包屑配置
breadcrumb: ['Export', 'BookingLadingManagement', 'BookingLadingEdit']

// 实际显示:出口 > 提单管理 > 编辑提单
// 最后一项路径:/export/lading/edit/123(保留实际 ID)

六、总结

通过将面包屑配置迁移到路由 meta 中,我们实现了:

  1. 单一数据源:路由配置即面包屑配置,消除了配置分散的问题
  2. 维护成本降低:删除了 700+ 行的独立配置文件
  3. 开发效率提升:新增页面只需修改一处
  4. 类型安全增强:TypeScript 类型检查确保配置正确性
  5. 国际化支持:无缝集成 vue-i18n

这种方案特别适合中大型 Vue 3 项目,尤其是菜单结构复杂、页面数量多的企业级应用。


相关技术栈:

  • Vue 3.5+ (Composition API)
  • Vue Router 4
  • TypeScript 5+
  • vue-i18n

参考资料:

你的组件 API 为什么像个垃圾场?—— React 复合组件模式 (Compound Components) 实战教学

前言:一种名为“配置地狱”的组件

接上回。咱们用 React Query 把服务端状态剥离了,用 Context 把全局状态理顺了。现在你的数据流很干净。

但是,当你打开 components 文件夹,看着那个被你改了无数次的 Tabs 组件,是不是又想骂人了?

为了满足产品经理五彩斑斓的需求,你的组件 props 越加越多,最后变成了这样:

// ❌ 典型的“配置型”组件
 }, { title: '设置', content:  }]}
  activeTab={currentTab}
  onTabChange={setCurrentTab}
  tabBarClassName=&#34;bg-gray-100&#34; // 想改 Tab 栏背景?加个 prop
  tabItemClassName=&#34;text-lg&#34;    // 想改文字大小?加个 prop
  activeTabClassName=&#34;text-blue&#34; // 想改选中态颜色?再加个 prop
  renderTabBarExtraContent={新建} // 想在右边加个按钮?又要加 prop
  tabPosition=&#34;top&#34; // 想把 Tab 放左边?还得加逻辑
/>

这就叫**“配置地狱”**。 你试图通过 props 暴露出所有的 UI 细节,结果就是这个组件变得巨臃肿,且极难复用。如果我想给第二个 Tab 加个红点(Badge),你是不是还得给 items 数组的数据结构里加个字段?

src=http___image109.360doc.com_DownloadImg_2023_04_1510_264406834_10_20230415101010334.gif&refer=http___image109.360doc.gif 兄弟,别再折磨自己了。今天我们来学学 Compound Components(复合组件模式) 。看看人家 HTML 原生标签是怎么教我们做人的。

灵感来源:向 `` 致敬

你仔细想想,原生的 `` 标签是怎么用的?

  苹果
  香蕉

你并没有传一个 options 数组给 ,而是直接把 塞到了 `` 里面。

  • `` 负责管理状态(当前选了谁)。
  • 负责渲染每一项,并且告诉 “我被点了”。

这种**“父组件管状态,子组件管渲染,通过隐式契约通信”**的模式,就是复合组件模式。


实战重构:把 Tabs 拆开

我们要把那个臃肿的 Tabs 组件,拆成 Tabs, TabList, Tab, TabPanels, Panel 这一套乐高积木。

第一步:创建上下文 (Context)

父组件需要一个地方来告诉子组件:现在的 activeTab 是谁,以及提供一个 setActiveTab 的方法。


const TabsContext = createContext(null);

// 这是一个自定义 Hook,方便子组件拿数据,顺便做个错误检查
const useTabs = () => {
  const context = useContext(TabsContext);
  if (!context) throw new Error('Tabs 子组件必须包裹在  里面!');
  return context;
};

第二步:父组件 (Tabs) —— 状态的大管家

它不负责画 UI,只负责提供 Context。

  const [selectedIndex, setSelectedIndex] = useState(defaultIndex);

  return (
    
      <div>{children}</div>
    
  );
};

第三步:子组件 (Tab & Panel) —— 真正的打工人

Tab 按钮:

  const { selectedIndex, setSelectedIndex } = useTabs();
  const isActive = selectedIndex === index;

  return (
     setSelectedIndex(index)}
    >
      {children}
    
  );
};

Panel 内容区:

  const { selectedIndex } = useTabs();
  // 只有选中时才渲染
  return selectedIndex === index ? <div>{children}</div> : null;
};

见证奇迹的时刻:调用方式

重构完之后,我们在页面里怎么用呢?


  {/* 你可以在这里随便加 div,随便写样式,完全不受 props 限制 */}
  <div>
    <div>
      用户管理
      
      {/* 居然可以给单独某一个 Tab 加红点,甚至加 Tooltip,随你便! */}
      
        系统设置 <span>●</span>
      
    </div>

    {/* 想在右边加个按钮?直接写啊!不用传什么 renderExtraContent */}
    刷新
  </div>

  <div>
    
    
  </div>

对比一下之前的代码,现在的优势在哪里?

  1. UI 结构完全解耦:你想把 Tab 列表放在下面?想把 Panel 放在上面?随便你怎么排版 HTML,组件逻辑完全不需要改。
  2. 内容随心所欲:你想在 Tab 标题里加图标?加红点?加 loading 动画?直接在 children 里写 JSX 也就是了,不需要去改组件源码。
  3. 没有 Props Drilling:状态通过 Context 隐式传递,你不用手动把 activeTab 传给每一个 Tab。

进阶技巧:这就是 Headless UI 的雏形

聪明的你可能发现了,这种模式其实就是我在前几篇提到的 Headless UI 的一种实现方式。

像著名的 UI 库 Radix UI 或者 Headless UI (Tailwind) ,全是这个路子。

  • ``
  • ``
  • ``
  • ``

它们把组件拆得稀碎,把**“怎么组合”**的权力交还给了你。

当然,这种模式也有个小缺点:代码量变多了。 以前写个 `` 只要一行,现在要写十几行。

怎么解? 你可以基于这个复合组件,再封装一层“傻瓜式”组件给没特殊需求的场景用。但是底层的实现,一定要保持这种灵活性。


总结

当你发现你的组件需要接受 xxxStyle, xxxClassName, renderXxx 这种 props 的时候,请立刻停下来。

这说明你在试图控制你控制不了的事情(外部的 UI 展示)。

把控制权交出去。用 Compound Components 模式,让使用者像拼乐高一样组装你的组件。 你会发现,你再也不用因为设计稿改了一个 margin 或者加了一个 icon 而去改组件源码了。这才是真正的高内聚、低耦合

好了,我要去把那个传了 20 个 props 的 Modal 组件给拆了,祝大家的组件 API 永远性感。

lg_90841_1619336946_60851ef204362.png


下期预告:Tabs 切换是搞定了,但有个问题:用户刷新页面后,又回到了第一个 Tab,辛辛苦苦填的表单也没了。 还有,我想把“当前在第二个 Tab”这个状态分享给同事,怎么做? 下一篇,我们来聊聊 “URL 即状态 (URL as State)” 。教你如何把 React 状态同步到 URL 参数里,让你的应用拥有“记忆”。

微前端:从“大前端”到“积木式开发”的架构演进

记得那些年我们维护的“巨石应用”吗?一个package.json里塞满了几百个依赖,每次npm install都像是一场赌博;团队协作时,git merge冲突解决到怀疑人生;技术栈升级?那意味着“全盘推翻重来”……

随着前端复杂度的爆炸式增长,传统单体架构已不堪重负。而微前端,正是为了解决这些痛点而生的一种架构范式。本文将以qiankun为切入点,学习一下微前端的模式。

基础概念

微前端是什么?

微前端不是框架,而是一种架构理念 ——将大型前端应用拆分为多个独立开发、独立部署、技术栈无关的小型应用,再将其组合为一个完整的应用。

一句话,它让前端开发从“造大楼”变成了 “搭乐高”

为什么需要微前端?

痛点真实存在:

  • 🐌 开发效率低下:几百人维护一个仓库,每次上线都需全量回归
  • 🔒 技术栈锁定:三年前选的框架,现在想升级?代价巨大
  • 👥 团队协作困难:功能边界模糊,代码相互渗透
  • 🚢 部署风险高:一个小改动,可能导致整个系统崩溃

微前端带来的改变:

  • ✅ 独立自治:每个团队负责自己的“微应用”,从开发到部署全流程自主
  • ✅ 技术栈自由:React、Vue、Angular、甚至jQuery,和平共处
  • ✅ 增量升级:老系统可以一点点替换,而不是“一夜重构”
  • ✅ 容错隔离:一个子应用崩溃,不影响其他功能

微前端的核心思想:

  • 拆分:将大型前端应用拆分为多个独立的小型应用。
  • 集成:通过某种方式将这些小型应用集成在一起,形成一个整体。
  • 自治:每个小型应用都可以独立开发、测试、部署。
// 微前端架构
├── container/      // 主应用(基座)
├── app-react/      // React子应用(团队A)
├── app-vue/        // Vue子应用(团队B)
├── app-angular/    // Angular子应用(团队C)
└── app-legacy/     // 老系统(jQuery)

// 优势:
// 1. ✅ 技术栈无关
// 2. ✅ 独立开发、独立部署
// 3. ✅ 增量更新
// 4. ✅ 容错性高(一个子应用挂了不影响其他)

应用场景

渐进式重构:对于一个老项目一点点进行架构的升级

老系统(jQuery + PHP) → 逐步替换为现代框架
   ↓
保留核心业务模块 + 逐步添加React/Vue新模块

多团队协作:不同部门人员之间技术栈存在差异,需要单独开发

团队A(React专家) → 负责电商商品模块
团队B(Vue专家)   → 负责购物车模块
团队C(Angular专家)→ 负责用户中心
主应用协调所有模块

中后台系统:复杂系统的功能拆分

一个后台管理系统包含:
- 权限管理(React)
- 数据报表(Vue + ECharts)
- 工作流(Angular)
- 监控面板(React + Three.js)

四种架构模式

基座模式(也称为中心化路由模式)

  • 基座模式是最常见的微前端架构。它有一个主应用(通常称为基座或容器),负责整个应用的布局、路由和公共逻辑。子应用根据路由被动态加载和卸载。
  ┌─────────────────────────────────────────┐
  │            主应用(Container)           │
  │ 负责:路由、鉴权、布局、共享状态、公共依赖   │
  ├─────────────────────────────────────────┤
  │  ┌──────────┐  ┌──────────┐  ┌──────────┐ 
  │  │ 子应用A  │  │ 子应用B  │  │ 子应用C  │ │
  │  │ (React)  │  │  (Vue)   │  │(Angular) │ 
  │  └──────────┘  └──────────┘  └──────────┘ 
  └─────────────────────────────────────────┘

工作流程

graph TD
用户访问主应用-->主应用根据当前URL匹配子应用--> A["加载对应子应用的资源(JS、CSS)"]-->将子应用渲染到指定容器中-->子应用运行并处理自己的内部路由和逻辑

优点

  • 集中控制,易于管理
  • 路由逻辑清晰
  • 公共依赖容易处理(基座可提供共享库)
  • 子应用间隔离性好

缺点

  • 主应用成为单点故障
  • 基座和子应用耦合(通过协议通信)
  • 基座需要知道所有子应用的信息

适用场景

  • 企业级中后台系统
  • 需要统一导航和布局的应用
  • 子应用技术栈差异大

自组织模式(也称为去中心化模式)

  • 在自组织模式中,没有中心化的基座。每个微前端应用都是独立的,它们通过某种通信机制(如自定义事件、消息总线)来协调。通常,每个应用都可以动态发现和加载其他应用。
┌──────────┐    ┌──────────┐    ┌──────────┐
│  应用A   │     │  应用B   │    │  应用C    │
│ (React)  │    │  (Vue)   │    │(Angular) │
└────┬─────┘    └────┬─────┘    └────┬─────┘
     │               │               │
     └───────────────┼───────────────┘
                     │
            ┌────────┴─────────┐
            │  运行时协调器     │
            │  (Runtime Bus)   │
            └──────────────────┘
graph TD
1["应用A启动,并注册到消息总线"]
-->2["应用B启动,并注册到消息总线"]
-->用户操作触发应用A需要应用B的某个功能
-->应用A通过消息总线请求应用B的资源
-->3["应用B响应请求,提供资源(或直接渲染)"]

优点

  • 去中心化,避免单点故障
  • 应用之间完全解耦
  • 更灵活的通信方式

缺点

  • 通信复杂,容易混乱
  • 难以统一管理(如路由、权限)
  • 依赖公共协议,版本更新可能破坏通信

适用场景

  • 高度自治的团队
  • 应用间功能相对独立
  • 需要动态组合的页面

微件模式(也称为组合式模式)

  • 微件模式类似于传统门户网站,页面由多个独立的微件(Widget)组成。每个微件都是一个独立的微前端应用,可以独立开发、部署,然后动态组合到页面中。
┌───────────────────────────────────┐
│          Dashboard页面            │
│  ┌────────┬────────┬─────────┐    │
│  │ 天气    │ 新闻   │ 股票    │     │
│  │ Widget │ Widget │ Widget  │    │
│  ├────────┼────────┼─────────┤    │
│  │ 待办    │ 日历   │ 邮件    │     │
│  │ Widget │ Widget │ Widget  │    │
│  └────────┴────────┴─────────┘    │
└───────────────────────────────────┘
graph TD
用户访问页面
    -->
页面布局引擎根据配置加载微件
    -->
每个微件独立加载资源并渲染
    -->
微件之间通过预定义的接口通信

优点

  • 组件可以复用
  • 用户可以自定义布局
  • 所有widget在同一个页面
  • 可以按需加载widget

缺点

  • 样式管理复杂,需要处理widget间样式冲突
  • 通信限制,widget间通信需要经过主应用
  • 版本管理,大量widget的版本管理困难
  • 性能问题,太多widget可能影响性能

适用场景

  1. 数据可视化大屏
  2. 门户网站首页
  3. 个人工作台
  4. 可配置的管理后台

混合模式(实战中最常见)

  • 在实际项目中,我们常常根据需求混合使用以上模式。例如,在基座模式中,某个子应用内部使用微件模式来组合多个微前端模块。
  • 比如一个电商系统的架构
主应用(基座模式)
    ├── 商品管理(React子应用)
    ├── 订单管理(Vue子应用)
    └── 用户管理(Angular子应用)
        在用户管理内部,使用微件模式:
            ├── 用户统计(微件A)
            ├── 用户列表(微件B)
            └── 用户权限(微件C)
┌─────────────────────────────────────────────────┐
│                主应用(基座模式)                 │
│   统一路由、权限、用户中心、消息中心、全局状态       │
└─────────────────┬───────────────────────────────┘
                  │
    ┌─────────────┼─────────────┐
    │             │             │
┌───▼───┐   ┌────▼────┐   ┌────▼────┐
│订单中心│   │商品管理 │   │用户管理   │
│(React)│   │ (Vue)   │   │(Angular)│
└───┬───┘   └────┬────┘   └────┬────┘
    │            │             │
    └────────────┼─────────────┘
                 │
          ┌──────▼──────┐
          │ 数据分析模块 │
          │ (微件模式)   │
          │┌───┬───┬───┐│
          ││图表│地图│报表│
          │└───┴───┴───┘│

快速上手

  • 新建三个项目,分别为main-app,sub-app1,sub-app2,项目结构一目了然:
   ├── main-app/      // 主应用(基座)
   ├── sub-app1/      // vue3子应用(团队A)
   ├── app-vue/        // vue3子应用(团队B)

安装qiankun

yarn add qiankun # 或者 npm i qiankun -S

主项目中注册微应用

// 主应用main-app/main.js
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { registerMicroApps, start } from 'qiankun'

createApp(App).mount('#app1')

registerMicroApps(
  [
    {
      name: 'sub-app1', // app name registered
      entry: 'http://localhost:5175',
      container: '#micro-app-container',
      activeRule: (location) => location.hash.startsWith('#/app-a'),
      props: {
        name: 'kuitos'
      }
    }
  ],
  {
    beforeLoad: (app) => console.log('before load', app.name),
    beforeMount: [(app) => console.log('before mount', app.name)]
  }
)
// start()

// 启动 qiankun,配置沙箱模式
start({
  sandbox: {
    strictStyleIsolation: true,
  },
})

微应用导出钩子

  • 由于qiankun不支持module,所以对于vue3项目,需要使用vite-plugin-qiankun来集成
  • renderWithQiankun用来对外暴露钩子
  • qiankunWindow替代window变量
// 子应用 sub-app1/mian.js
import { createApp } from 'vue'
import {
  renderWithQiankun,
  qiankunWindow
} from 'vite-plugin-qiankun/dist/helper'

import './style.css'
import App from './App.vue'
let instance = null

function render(props = {}) {
  const container = props.container || '#app'
  console.log('子应用挂载容器:', container)

  instance = createApp(App)
  instance.mount(container)
}
console.log('qiankunWindow',qiankunWindow);
console.log('window.__POWERED_BY_QIANKUN__',window.__POWERED_BY_QIANKUN__);

// 独立运行时,直接渲染
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
  console.log('独立运行时,直接渲染')
  render()
}

renderWithQiankun({
  mount(props) {
    console.log(props)
    render(props)
  },
  bootstrap() {
    console.log('bootstrap')
  },
  unmount(props) {
    console.log('unmount', props)
  },
  update(props) {
    console.log('update', props)
  }
})

在子应用的vite.config.js中注册插件

// 子应用 sub-app1/vite.config.js
plugins: [
    vue(),
    qiankun('sub-app1', {
      useDevMode: true,
    })
],

进阶场景

应用通信

应用拆分后,不可避免的会涉及到通信问题,那么如何让它们“愉快地对话”?

props

  • 最简单的方式,正如目前的主流框架,qiankun也提供了一个props属性,可以实现父->子之间的数据通信,当主应用注册registerMicroApps子应用的时候,利用props传递
// 主应用 main-app/main.js
registerMicroApps(
  [
    {
      name: 'sub-app1', // app name registered
      entry: 'http://localhost:5175',
      container: '#micro-app-container',
      activeRule: (location) => location.hash.startsWith('#/app-a'),
      props: {
        // name: 'kuitos' //该属性会被覆盖?
        count: 100,
        time: new Date().getTime()
      }
    }
  ],
  {
    beforeLoad: (app) => console.log('before load', app.name),
    beforeMount: [(app) => console.log('before mount', app.name)]
  }
)

// 子应用 sub-app1/main.js
renderWithQiankun({
  mount(props) {
    render(props)
  },
})
// 子应用 sub-app1/main.js
function render(props = {}) {
  const container = props.container || '#app'
  console.log('子应用挂载容器:', container)

  instance = createApp(App)
  instance.config.globalProperties.__SUBAPP__ = props //vue3的globalProperties全局挂载
  window.__SUBAPP__ = props //挂载到子应用的window对象

  instance.mount(container)
}

子应用的其他组件使用时

//子应用 sub-app1/src/components/HelloWord.vue
<script setup>
    import { ref,getCurrentInstance } from 'vue'

    defineProps({
      msg: String,
    })

    const count = ref(0)
    console.log('window方式获取数据',window.__SUBAPP__)
    console.log('getCurrentInstance方式获取数据', getCurrentInstance().proxy.__SUBAPP__)
</script>

image-20251208221830086

initGlobalState

父->子传递

首先在父应用中创建一个globalState.js初始化一下state

//main-app/globalState.js
import { initGlobalState } from 'qiankun';

// 定义初始状态
export const initialState = {
  user: { id: null, name: '', token: '' },
  globalConfig: { theme: 'light', language: 'zh-CN' },
  sharedData: {},
  currentRoute: {}
};

// 当前全局状态
export let currentGlobalState = { ...initialState };

// 全局状态管理器实例
export let globalActions = null;

// 初始化全局状态管理
export const initGlobalStateManager = () => {
  // 初始化 state
  const actions = initGlobalState(initialState);
  
  // 监听状态变更
  actions.onGlobalStateChange((state, prev) => {
    currentGlobalState = { ...state };
    console.log('主应用:全局状态变更', { newState: state, prevState: prev });
  });
  
  // 设置初始状态
  actions.setGlobalState(initialState);
  
  globalActions = actions;
  return actions;
};

// 更新全局状态
export const updateGlobalState = (newState) => {
  if (!globalActions) {
    globalActions = initGlobalStateManager();
  }
  globalActions.setGlobalState(newState);
};


其中关键方法:

// 定义初始状态
export const initialState = {
  user: { id: null, name: '', token: '' },
  globalConfig: { theme: 'light', language: 'zh-CN' },
  sharedData: {},
  currentRoute: {}
};
//初始化 state
const actions = initGlobalState(initialState);
// 监听状态变更
actions.onGlobalStateChange((state, prev) => {
    currentGlobalState = { ...state };
    console.log('主应用:全局状态变更', { newState: state, prevState: prev });
});
// 更新全局状态
actions.setGlobalState(newState);
// 取消监听
actions.offGlobalStateChange();

// main-app/login.vue
import { updateGlobalState } from './globalState'
const handleLogin =()=>{
  // 。。。主应用的业务逻辑
  // 更新state  
  updateGlobalState({
      isLoggedIn: true,
    });
}

在子应用中监听

// sub-app1/main.js
function render(props = {}) {
  const container = props.container || '#app'

  instance = createApp(App)
  
  // 监听全局状态变化
  //props 里面有setGlobalState和onGlobalStateChange 方法,可用于监听和修改状态 
  if (props.onGlobalStateChange) {
    props.onGlobalStateChange((state, prev) => {
      console.log('子变更后的状态', state, '子变更前的状态', prev);
    });
  }
  // 挂载一下props,以便于在其他组件中使用setGlobalState和onGlobalStateChange
  // 挂载的方式有很多, pinia等,总之其他地方能获取到props对象就行  
  window.__SUBAPP__ = props
  pinia = createPinia()
  instance.use(pinia)
  instance.mount(container)
}
image-20251209221654305
子->父传递

在子应用创建的时候,已经将props保存了window.__SUBAPP__ = props,在子应用的任何组件中都可以使用

所以只需要在某个组件中调用setGlobalState方法就可

// sub-app1/HelloWord.vue
// 获取全局状态管理方法
const { setGlobalState } = window.__SUBAPP__ || {}
if (setGlobalState) {
// 更新全局状态
    setGlobalState({
      sharedData: {
        count: newValue
      }
    })
}

image-20251209222325435

微前端选型指南:何时用?用哪个?

适合场景 ✅

  • 大型企业级应用(100+页面)
  • 多团队协作开发(3+前端团队)
  • 老系统渐进式重构
  • 需要支持多技术栈
  • 独立部署需求强烈

不适合场景 ❌

  • 小型项目(页面<20)
  • 单人/小团队开发
  • 对性能要求极致(首屏加载时间<1s)
  • 无技术栈异构需求

结语

千万不要手里攥着锤子看啥都像钉子。 微前端不是银弹,而是一种架构选择。它用复杂度换来了灵活性、独立性和可维护性。就像乐高积木,单个模块简单,但组合起来却能构建出无限可能的世界。

后续有时间将继续深入学习一下微前端的生命周期、样式隔离、部署发布这几个部分。

最后,觉得有用的话三连一下~

前端数据字典技术方案实战

前言

在后台与中台系统开发领域,数据字典是极为常见且至关重要的概念,相信大多数从事相关开发工作的朋友都对其耳熟能详。几乎每一个成熟的项目,都会专门设置一个字典模块,用于精心维护各类字典数据。这些字典数据在系统中扮演着举足轻重的角色,为下拉框、转义值、单选按钮等组件提供了不可或缺的基础数据支撑。

我自工作以来参与过很多个项目,既有从零开始搭建的,也有接手他人项目的。在实践过程中,我发现不同项目对字典的实现方式各不相同,且各有侧重。例如,对于项目中的字典基本不会发生变化的,项目通常会采用首次全部加载到本地缓存的方式。这种方式能显著节省网络请求次数,提升系统响应速度。然而,对于项目中的字典经常变动的,项目则会采用按需加载的方式,即哪里需要使用字典值,就在哪里进行加载。但这种方式也存在弊端,当某个页面需要使用十多个字典值时,首次进入页面会一次性发出十多个请求来获取这些字典值,影响用户体验。

常见字典方案剖析

在当下,数据字典的实现方案丰富多样,各有优劣。下面将详细介绍几种常见的方案,并分析其特点。我将详细介绍几种常见的方案,并深入剖析其特点。这几种方案皆是我通过实践精心总结而来,其中方案四的思路是由我不爱吃鱼啦提供。

方案一:首次全部加载到本地进行缓存

方案描述

系统启动或用户首次访问时,将所有字典数据一次性加载到本地缓存中。后续使用过程中,直接从缓存中获取所需字典数据,无需再次向服务器发起请求。

优点

  • 访问速度快:后续访问时直接从本地缓存读取数据,无需等待网络请求,响应速度极快。
  • 减少网络请求:一次性加载后,后续使用无需频繁发起网络请求,降低了网络开销。
  • 网络依赖小:即使在网络不稳定的情况下,也能正常使用已缓存的字典数据,保证了系统的稳定性。

缺点

  • 首次加载时间长:若字典数据量较大,首次加载时可能需要较长时间,影响用户体验。
  • 占用存储空间:将所有字典数据存储在本地,会占用较多的本地存储空间,尤其是当字典数据量庞大时。
  • 缓存更新复杂:若字典数据频繁更新,需要设计复杂的缓存同步和更新机制,否则容易出现数据不一致的问题。

方案二:按需加载不缓存

方案描述

当用户触发特定操作,需要使用字典数据时,才从后端实时加载所需数据,且不进行本地缓存。每次使用字典数据时,都重新从服务器获取最新数据。

优点

  • 节省存储空间:不进行本地缓存,节省了本地存储空间,尤其适用于存储资源有限的设备。
  • 数据实时性高:每次获取的数据都是最新的,不存在缓存数据与后端不一致的问题,保证了数据的准确性。

缺点

  • 网络请求频繁:每次使用都需要发起网络请求,在网络状况不佳时,会导致加载时间变长,影响用户体验。
  • 增加服务器负担:频繁的网络请求会增加服务器的负担,尤其是在高并发场景下,可能影响服务器的性能。

方案三:首次按需加载并缓存

方案描述

用户首次访问某个字典数据时,从后端加载该数据并缓存到本地。后续再次访问该字典数据时,直接从缓存中读取,无需再次向服务器发起请求。

优点

  • 减少网络请求:结合了前两种方案的部分优点,既在一定程度上减少了网络请求次数,又不会一次性加载过多数据。
  • 节省存储空间:相较于首次全部加载到本地缓存的方式,不会一次性占用大量本地存储空间,节省了部分存储资源。

缺点

  • 缓存管理复杂:需要记录哪些数据已缓存,以便后续判断是否需要从缓存中读取或重新加载,增加了缓存管理的复杂度。
  • 缓存占用问题:对于不常使用的字典数据,缓存可能会占用不必要的存储空间,造成资源浪费。
  • 缓存更新难题:同样面临缓存更新的问题,需要设计合理的缓存更新策略,以保证数据的准确性和一致性。

方案四:按需加载 + 版本校验更新缓存

方案描述

用户按需发起字典数据请求,首次访问某个字典数据时,从后端加载并缓存到本地。在后端响应头中携带该字典数据的版本信息,后续每次请求该字典数据时,前端对比本地缓存的版本信息和响应头中的版本信息。若版本信息不一致,则清除本地缓存中对应的字典数据,并重新从后端加载最新数据;若版本信息一致,则直接使用本地缓存的数据。

优点

  • 数据实时性有保障:通过版本校验机制,能够及时获取到字典数据的更新,确保前端使用的数据与后端保持一致,避免了因缓存数据未及时更新而导致的业务问题。
  • 减少不必要的网络请求:在字典数据未更新时,直接使用本地缓存,无需发起网络请求,节省了网络带宽和服务器资源。
  • 平衡存储与性能:既不会像首次全部加载那样占用大量本地存储空间,又能在一定程度上减少网络请求,在存储和性能之间取得了较好的平衡。

缺点

  • 版本管理复杂:后端需要维护字典数据的版本信息,并且要确保版本号的准确性和唯一性,这增加了后端开发的复杂度和维护成本。
  • 额外开销:每次请求都需要进行版本信息对比操作,虽然开销较小,但在高并发场景下,可能会对系统性能产生一定影响。
  • 首次加载体验:首次加载字典数据时,依然需要从后端获取数据,若数据量较大或网络状况不佳,可能会影响用户体验。

方案选型建议

建议根据项目特性选择方案,没有最好的技术方案,只有最适合项目的技术方案:

  • 字典稳定且量小:方案一全量缓存
  • 字典频繁更新:方案四版本校验缓存
  • 存储敏感场景:方案三按需缓存
  • 实时性要求极高:方案二无缓存方案

ps:如果大家有更好的方案,也可以在评论区提出,让我们大家一起学习成长

代码实现(方案四)

下述代码的实现基于vue3+pinia,该代码实现了统一管理全局字典数据,支持按需加载、缓存复用、版本控制、动态更新、批量处理字典数据等功能。

pinia store的实现

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getDictDetails, type Details } from '@/api/system/dict'

export const useDictStore = defineStore('dict', () => {
    // 存储字典数据,键为字典名称,值为字典详情数组
    const dictData = ref<Record<string, Details[]>>({})
    // 存储字典版本信息,键为字典名称,值为版本号
    const dictVersions = ref<string>('')

    /**
     * 更新字典版本信息
     * @param version 新的字典版本号
     */
    const updateDictVersion = (version: string) => {
        dictVersions.value = version
    }

    /**
     * 获取字典版本
     * @returns 字典版本号
     */
    const getDictVersion = () => {
        return dictVersions.value || ''
    }

    /**
     * 加载字典数据
     * @param dictNames 字典名称数组
     * @returns 加载的字典数据对象
     */
    const getDicts = async (dictNames: string[]) => {
        try {
            if (!Array.isArray(dictNames)) {
                return {};
            }
            // 过滤并去重有效字典名称
            const uniqueNames = [...new Set(dictNames.filter(name => 
                typeof name === 'string' && name.trim()
            ))];
            
            if (uniqueNames.length === 0) {
                return {};
            }

            const result: Record<string, Details[]> = {};
            const unloadedDicts: string[] = [];

            // 分离已加载和未加载的字典
            dictNames.forEach(name => {
                if (dictData.value[name]) {
                    result[name] = dictData.value[name];
                } else {
                    unloadedDicts.push(name);
                }
            });

            // 如果有未加载的字典,从接口请求获取
            if (unloadedDicts.length > 0) {
                const { data } = await getDictDetails(unloadedDicts);

                // 合并新加载的数据到结果
                Object.assign(result, data);

                // 更新全局字典缓存
                Object.assign(dictData.value, data);
            }

            return result;
        } catch (error) {
            console.error('加载字典数据失败:', error);
            return {};
        }
    };

    /**
     * 根据字典名称获取字典数据
     * @param name 字典名称
     * @returns 字典详情数组
     */
    const getDict = (name: string) => {
        return dictData.value[name] || []
    }

    /**
     * 根据字典名称和值获取字典标签
     * @param name 字典名称
     * @param value 字典值
     * @returns 字典标签
     */
    const getDictLabel = (name: string, value: string) => {
        const dict = getDict(name)
        const item = dict.find(item => item.value === value)
        return item?.label || ''
    }

    /**
     * 根据字典名称和标签获取字典值
     * @param name 字典名称
     * @param label 字典标签
     * @returns 字典值
     */
    const getDictValue = (name: string, label: string) => {
        const dict = getDict(name)
        const item = dict.find(item => item.label === label)
        return item?.value || ''
    }

    /**
     * 清除指定字典数据
     * @param names 字典名称
     */
    const clearDicts = (names: string[]) => {
        names.forEach(name => {
            clearDict(name)
        })
    }


    /**
     * 清除指定字典数据
     * @param name 字典名称
     */
    const clearDict = (name: string) => {
        delete dictData.value[name]
    }

    /**
     * 清除所有字典数据
     */
    const clearAllDict = () => {
        dictData.value = {}
    }

    return {
        dictData,
        updateDictVersion,
        getDictVersion,
        getDict,
        getDicts,
        getDictLabel,
        getDictValue,
        clearDict,
        clearDicts,
        clearAllDict
    }
})

useDict 实现

为组件提供字典数据的统一访问入口,封装了字典数据的初始化加载、详情查询、标签/值转换等高频操作,简化组件层对字典数据的调用逻辑。

import { type Details } from '@/api/system/dict'
import { useDictStore } from '@/store/dict'

// 根据字典值的name获取字典详情
export const useDict = (params: string[] = []) => {

  const dict = ref<Record<string, Details[]>>()
  const dictStore = useDictStore()

  const getDicts = async () => {
    dict.value = await dictStore.getDicts(params)
  }

  // 初始化字典数据
  getDicts()

  // 根据字典名称获取字典数据
  const getDict = (name: string) => {
    return dictStore.getDict(name)
  }

  // 根据字典值获取字典label
  const getDictLabel = (name: string, value: string) => {
    return dictStore.getDictLabel(name, value)
  }

  return {
    dict,
    getDict,
    getDictLabel
  }
}

响应拦截

主要用于获取字典的版本信息,通过对比版本信息,从而确定是否清除本地的字典缓存数据,并更新本地缓存的版本信息

// 响应拦截器
service.interceptors.response.use(
  // AxiosResponse
  (response: AxiosResponse) => {
    const dictVersion = response.headers['x-dictionary-version']
    if (dictVersion) {
      const dictStore = useDictStore()
      // 对比版本是否有更新
      if (dictStore.getDictVersion() !== dictVersion) {
        dictStore.clearAllDict()
        dictStore.updateDictVersion(dictVersion || '')
      }
    }
    // ...项目中的业务逻辑
  }
)

项目中的具体使用

下述的怎么使用封装的字典管理的简单demo

<script setup lang="ts">
import { useDict } from '@/hooks/useDict'
// 获取dict
const { dict, getDictLabel } =  useDict(['status', 'sex'])
console.log(dict.status, dict.sex)
</script>

项目源码地址

nest后端

Unusual-Server (github)

Unusual-Server (gitee)

vue3前端

Unusual-Admin (github)

Unusual-Admin (gitee)

结语

本文介绍了四种主流的数据字典实现方案,从全量加载到按需加载,从无缓存到版本校验缓存,每种方案都展现了其独特的优势与缺点。通过对比分析,我们不难发现,没有一种方案能够适用于所有场景,而是需要根据项目的具体特性进行灵活选择。对于字典稳定且量小的项目,全量缓存方案能够带来极致的响应速度;对于字典频繁更新的场景,版本校验缓存方案则能在保障数据实时性的同时,实现存储空间与网络请求的平衡优化。未来,随着技术的不断进步与应用场景的不断拓展,数据字典的实现方案也将持续演进。

博客主要记录一些学习的文章,如有不足,望大家指出,谢谢。

数据字典技术方案实战

前言

在后台与中台系统开发领域,数据字典是极为常见且至关重要的概念,相信大多数从事相关开发工作的朋友都对其耳熟能详。几乎每一个成熟的项目,都会专门设置一个字典模块,用于精心维护各类字典数据。这些字典数据在系统中扮演着举足轻重的角色,为下拉框、转义值、单选按钮等组件提供了不可或缺的基础数据支撑。

我自工作以来参与过很多个项目,既有从零开始搭建的,也有接手他人项目的。在实践过程中,我发现不同项目对字典的实现方式各不相同,且各有侧重。例如,对于项目中的字典基本不会发生变化的,项目通常会采用首次全部加载到本地缓存的方式。这种方式能显著节省网络请求次数,提升系统响应速度。然而,对于项目中的字典经常变动的,项目则会采用按需加载的方式,即哪里需要使用字典值,就在哪里进行加载。但这种方式也存在弊端,当某个页面需要使用十多个字典值时,首次进入页面会一次性发出十多个请求来获取这些字典值,影响用户体验。

常见字典方案剖析

在当下,数据字典的实现方案丰富多样,各有优劣。下面将详细介绍几种常见的方案,并分析其特点。我将详细介绍几种常见的方案,并深入剖析其特点。这几种方案皆是我通过实践精心总结而来,其中方案四的思路是由我不爱吃鱼啦提供。

方案一:首次全部加载到本地进行缓存

方案描述

系统启动或用户首次访问时,将所有字典数据一次性加载到本地缓存中。后续使用过程中,直接从缓存中获取所需字典数据,无需再次向服务器发起请求。

优点

  • 访问速度快:后续访问时直接从本地缓存读取数据,无需等待网络请求,响应速度极快。
  • 减少网络请求:一次性加载后,后续使用无需频繁发起网络请求,降低了网络开销。
  • 网络依赖小:即使在网络不稳定的情况下,也能正常使用已缓存的字典数据,保证了系统的稳定性。

缺点

  • 首次加载时间长:若字典数据量较大,首次加载时可能需要较长时间,影响用户体验。
  • 占用存储空间:将所有字典数据存储在本地,会占用较多的本地存储空间,尤其是当字典数据量庞大时。
  • 缓存更新复杂:若字典数据频繁更新,需要设计复杂的缓存同步和更新机制,否则容易出现数据不一致的问题。

方案二:按需加载不缓存

方案描述

当用户触发特定操作,需要使用字典数据时,才从后端实时加载所需数据,且不进行本地缓存。每次使用字典数据时,都重新从服务器获取最新数据。

优点

  • 节省存储空间:不进行本地缓存,节省了本地存储空间,尤其适用于存储资源有限的设备。
  • 数据实时性高:每次获取的数据都是最新的,不存在缓存数据与后端不一致的问题,保证了数据的准确性。

缺点

  • 网络请求频繁:每次使用都需要发起网络请求,在网络状况不佳时,会导致加载时间变长,影响用户体验。
  • 增加服务器负担:频繁的网络请求会增加服务器的负担,尤其是在高并发场景下,可能影响服务器的性能。

方案三:首次按需加载并缓存

方案描述

用户首次访问某个字典数据时,从后端加载该数据并缓存到本地。后续再次访问该字典数据时,直接从缓存中读取,无需再次向服务器发起请求。

优点

  • 减少网络请求:结合了前两种方案的部分优点,既在一定程度上减少了网络请求次数,又不会一次性加载过多数据。
  • 节省存储空间:相较于首次全部加载到本地缓存的方式,不会一次性占用大量本地存储空间,节省了部分存储资源。

缺点

  • 缓存管理复杂:需要记录哪些数据已缓存,以便后续判断是否需要从缓存中读取或重新加载,增加了缓存管理的复杂度。
  • 缓存占用问题:对于不常使用的字典数据,缓存可能会占用不必要的存储空间,造成资源浪费。
  • 缓存更新难题:同样面临缓存更新的问题,需要设计合理的缓存更新策略,以保证数据的准确性和一致性。

方案四:按需加载 + 版本校验更新缓存

方案描述

用户按需发起字典数据请求,首次访问某个字典数据时,从后端加载并缓存到本地。在后端响应头中携带该字典数据的版本信息,后续每次请求该字典数据时,前端对比本地缓存的版本信息和响应头中的版本信息。若版本信息不一致,则清除本地缓存中对应的字典数据,并重新从后端加载最新数据;若版本信息一致,则直接使用本地缓存的数据。

优点

  • 数据实时性有保障:通过版本校验机制,能够及时获取到字典数据的更新,确保前端使用的数据与后端保持一致,避免了因缓存数据未及时更新而导致的业务问题。
  • 减少不必要的网络请求:在字典数据未更新时,直接使用本地缓存,无需发起网络请求,节省了网络带宽和服务器资源。
  • 平衡存储与性能:既不会像首次全部加载那样占用大量本地存储空间,又能在一定程度上减少网络请求,在存储和性能之间取得了较好的平衡。

缺点

  • 版本管理复杂:后端需要维护字典数据的版本信息,并且要确保版本号的准确性和唯一性,这增加了后端开发的复杂度和维护成本。
  • 额外开销:每次请求都需要进行版本信息对比操作,虽然开销较小,但在高并发场景下,可能会对系统性能产生一定影响。
  • 首次加载体验:首次加载字典数据时,依然需要从后端获取数据,若数据量较大或网络状况不佳,可能会影响用户体验。

方案选型建议

建议根据项目特性选择方案,没有最好的技术方案,只有最适合项目的技术方案:

  • 字典稳定且量小:方案一全量缓存
  • 字典频繁更新:方案四版本校验缓存
  • 存储敏感场景:方案三按需缓存
  • 实时性要求极高:方案二无缓存方案

ps:如果大家有更好的方案,也可以在评论区提出,让我们大家一起学习成长

代码实现(方案四)

下述代码的实现基于vue3+pinia,该代码实现了统一管理全局字典数据,支持按需加载、缓存复用、版本控制、动态更新、批量处理字典数据等功能。

pinia store的实现

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getDictDetails, type Details } from '@/api/system/dict'

export const useDictStore = defineStore('dict', () => {
    // 存储字典数据,键为字典名称,值为字典详情数组
    const dictData = ref<Record<string, Details[]>>({})
    // 存储字典版本信息,键为字典名称,值为版本号
    const dictVersions = ref<string>('')

    /**
     * 更新字典版本信息
     * @param version 新的字典版本号
     */
    const updateDictVersion = (version: string) => {
        dictVersions.value = version
    }

    /**
     * 获取字典版本
     * @returns 字典版本号
     */
    const getDictVersion = () => {
        return dictVersions.value || ''
    }

    /**
     * 加载字典数据
     * @param dictNames 字典名称数组
     * @returns 加载的字典数据对象
     */
    const getDicts = async (dictNames: string[]) => {
        try {
            if (!Array.isArray(dictNames)) {
                return {};
            }
            // 过滤并去重有效字典名称
            const uniqueNames = [...new Set(dictNames.filter(name => 
                typeof name === 'string' && name.trim()
            ))];
            
            if (uniqueNames.length === 0) {
                return {};
            }

            const result: Record<string, Details[]> = {};
            const unloadedDicts: string[] = [];

            // 分离已加载和未加载的字典
            dictNames.forEach(name => {
                if (dictData.value[name]) {
                    result[name] = dictData.value[name];
                } else {
                    unloadedDicts.push(name);
                }
            });

            // 如果有未加载的字典,从接口请求获取
            if (unloadedDicts.length > 0) {
                const { data } = await getDictDetails(unloadedDicts);

                // 合并新加载的数据到结果
                Object.assign(result, data);

                // 更新全局字典缓存
                Object.assign(dictData.value, data);
            }

            return result;
        } catch (error) {
            console.error('加载字典数据失败:', error);
            return {};
        }
    };

    /**
     * 根据字典名称获取字典数据
     * @param name 字典名称
     * @returns 字典详情数组
     */
    const getDict = (name: string) => {
        return dictData.value[name] || []
    }

    /**
     * 根据字典名称和值获取字典标签
     * @param name 字典名称
     * @param value 字典值
     * @returns 字典标签
     */
    const getDictLabel = (name: string, value: string) => {
        const dict = getDict(name)
        const item = dict.find(item => item.value === value)
        return item?.label || ''
    }

    /**
     * 根据字典名称和标签获取字典值
     * @param name 字典名称
     * @param label 字典标签
     * @returns 字典值
     */
    const getDictValue = (name: string, label: string) => {
        const dict = getDict(name)
        const item = dict.find(item => item.label === label)
        return item?.value || ''
    }

    /**
     * 清除指定字典数据
     * @param names 字典名称
     */
    const clearDicts = (names: string[]) => {
        names.forEach(name => {
            clearDict(name)
        })
    }


    /**
     * 清除指定字典数据
     * @param name 字典名称
     */
    const clearDict = (name: string) => {
        delete dictData.value[name]
    }

    /**
     * 清除所有字典数据
     */
    const clearAllDict = () => {
        dictData.value = {}
    }

    return {
        dictData,
        updateDictVersion,
        getDictVersion,
        getDict,
        getDicts,
        getDictLabel,
        getDictValue,
        clearDict,
        clearDicts,
        clearAllDict
    }
})

useDict 实现

为组件提供字典数据的统一访问入口,封装了字典数据的初始化加载、详情查询、标签/值转换等高频操作,简化组件层对字典数据的调用逻辑。

import { type Details } from '@/api/system/dict'
import { useDictStore } from '@/store/dict'

// 根据字典值的name获取字典详情
export const useDict = (params: string[] = []) => {

  const dict = ref<Record<string, Details[]>>()
  const dictStore = useDictStore()

  const getDicts = async () => {
    dict.value = await dictStore.getDicts(params)
  }

  // 初始化字典数据
  getDicts()

  // 根据字典名称获取字典数据
  const getDict = (name: string) => {
    return dictStore.getDict(name)
  }

  // 根据字典值获取字典label
  const getDictLabel = (name: string, value: string) => {
    return dictStore.getDictLabel(name, value)
  }

  return {
    dict,
    getDict,
    getDictLabel
  }
}

响应拦截

主要用于获取字典的版本信息,通过对比版本信息,从而确定是否清除本地的字典缓存数据,并更新本地缓存的版本信息

// 响应拦截器
service.interceptors.response.use(
  // AxiosResponse
  (response: AxiosResponse) => {
    const dictVersion = response.headers['x-dictionary-version']
    if (dictVersion) {
      const dictStore = useDictStore()
      // 对比版本是否有更新
      if (dictStore.getDictVersion() !== dictVersion) {
        dictStore.clearAllDict()
        dictStore.updateDictVersion(dictVersion || '')
      }
    }
    // ...项目中的业务逻辑
  }
)

项目中的具体使用

下述的怎么使用封装的字典管理的简单demo

<script setup lang="ts">
import { useDict } from '@/hooks/useDict'
// 获取dict
const { dict, getDictLabel } =  useDict(['status', 'sex'])
console.log(dict.status, dict.sex)
</script>

结语

本文介绍了四种主流的数据字典实现方案,从全量加载到按需加载,从无缓存到版本校验缓存,每种方案都展现了其独特的优势与缺点。通过对比分析,我们不难发现,没有一种方案能够适用于所有场景,而是需要根据项目的具体特性进行灵活选择。对于字典稳定且量小的项目,全量缓存方案能够带来极致的响应速度;对于字典频繁更新的场景,版本校验缓存方案则能在保障数据实时性的同时,实现存储空间与网络请求的平衡优化。未来,随着技术的不断进步与应用场景的不断拓展,数据字典的实现方案也将持续演进。

博客主要记录一些学习的文章,如有不足,望大家指出,谢谢。

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

本文记录了我们团队在 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%+

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


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

❌