Files
EmailBill/.sisyphus/notepads/calendar-refactor/learnings.md
SunCheng 952c75bf08
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 54s
Docker Build & Deploy / Deploy to Production (push) Successful in 9s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
1
2026-02-03 17:56:32 +08:00

9.1 KiB

Vue 3 Composition API Research - Modular Architecture Best Practices

研究日期: 2026-02-03


1. 官方 Vue 3 组件组织原则

1.1 Composables 用于代码组织

来源: Vue 官方文档 - https://vuejs.org/guide/reusability/composables

核心原则:

  • Composables 不仅用于复用,也用于代码组织
  • 当组件变得过于复杂时,应该将逻辑按关注点分离提取到更小的函数中
  • 可以将提取的 composables 视为组件级别的服务,它们可以相互通信

官方示例模式:

<script setup>
import { useFeatureA } from './featureA.js'
import { useFeatureB } from './featureB.js'
import { useFeatureC } from './featureC.js'

const { foo, bar } = useFeatureA()
const { baz } = useFeatureB(foo)
const { qux } = useFeatureC(baz)
</script>

关键洞察:

  • Composables 应返回普通对象包含多个 refs,保持响应式
  • 避免返回 reactive 对象,因为解构会失去响应性
  • Composables 可以接收其他 composables 的返回值作为参数

2. 代码分割与懒加载

2.1 defineAsyncComponent 用于模块懒加载

来源: Vue 官方文档 - https://github.com/vuejs/docs/blob/main/src/guide/best-practices/performance.md

适用场景:

  • 将大型组件树分割成独立的 chunks
  • 仅在组件渲染时才加载,改善初始加载时间
import { defineAsyncComponent } from 'vue'

// Foo.vue 及其依赖被单独打包成一个 chunk
// 只有在组件被渲染时才会按需获取
const Foo = defineAsyncComponent(() => import('./Foo.vue'))

2.2 动态导入用于 JS 代码分割

// lazy.js 及其依赖会被分割成单独的 chunk
// 只在 loadLazy() 被调用时才加载
function loadLazy() {
  return import('./lazy.js')
}

3. 真实世界的模块化架构模式

3.1 Dashboard 模块化架构 - 成功案例

案例 1: Soybean Admin (MIT License) 来源: https://github.com/soybeanjs/soybean-admin/blob/main/src/views/home/index.vue

<script setup lang="ts">
import { computed } from 'vue';
import { useAppStore } from '@/store/modules/app';
import HeaderBanner from './modules/header-banner.vue';
import CardData from './modules/card-data.vue';
import LineChart from './modules/line-chart.vue';
import PieChart from './modules/pie-chart.vue';
import ProjectNews from './modules/project-news.vue';
import CreativityBanner from './modules/creativity-banner.vue';

const appStore = useAppStore();
const gap = computed(() => (appStore.isMobile ? 0 : 16));
</script>

架构特点:

  • Index.vue 作为容器组件,只负责布局和响应式计算
  • 每个 modules/*.vue 是独立的功能模块
  • 模块命名清晰: header-banner, card-data, line-chart 等
  • 使用 Pinia store 进行状态共享

案例 2: Art Design Pro (MIT License) 来源: https://github.com/Daymychen/art-design-pro/blob/main/src/views/dashboard/ecommerce/index.vue

<script setup lang="ts">
  import Banner from './modules/banner.vue'
  import TotalOrderVolume from './modules/total-order-volume.vue'
  import TotalProducts from './modules/total-products.vue'
  import SalesTrend from './modules/sales-trend.vue'
  import SalesClassification from './modules/sales-classification.vue'
  import TransactionList from './modules/transaction-list.vue'
  import HotCommodity from './modules/hot-commodity.vue'
  import RecentTransaction from './modules/recent-transaction.vue'
  import AnnualSales from './modules/annual-sales.vue'
  import ProductSales from './modules/product-sales.vue'
  import SalesGrowth from './modules/sales-growth.vue'
  import CartConversionRate from './modules/cart-conversion-rate.vue'
  import HotProductsList from './modules/hot-products-list.vue'

  defineOptions({ name: 'Ecommerce' })
</script>

架构特点:

  • 电商 dashboard 包含 13 个独立模块
  • 每个模块代表一个业务功能卡片
  • Index.vue 不传递数据,模块自治

4. 模块间通信模式

4.1 defineEmits 用于子到父通信

来源: Vue 核心仓库 - https://github.com/vuejs/core/blob/main/packages/runtime-core/src/apiSetupHelpers.ts

TypeScript 类型声明模式:

const emit = defineEmits<{
  'update:modelValue': [value: string];
  'change': [event: Event];
  'custom-event': [payload: CustomPayload];
}>();

Runtime 声明模式:

const emit = defineEmits(['change', 'update'])

4.2 Props 模式 - 数据传递 vs 自取数据

案例研究: Halo CMS (GPL-3.0) 来源: https://github.com/halo-dev/halo/blob/main/ui/console-src/modules/system/users/components/GrantPermissionModal.vue

<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useFetchRoles, useFetchRoleTemplates } from "../composables/use-role";

const props = withDefaults(
  defineProps<{
    user?: User;
  }>(),
  {
    user: undefined,
  }
);

const emit = defineEmits<{
  (event: "close"): void;
}>();

// 模块自己获取数据
onMounted(async () => {
  await fetchRoles();
});
</script>

模式总结:

  • Props 传递身份标识 (如 user ID),而非完整数据
  • 模块自己获取详细数据 (通过 composables)
  • 这样保持模块的高内聚低耦合

5. 何时模块应该自取数据 vs 接收 Props

5.1 自取数据的场景

  • 模块是独立的业务单元(如日历、统计卡片)
  • 数据获取逻辑属于模块内部关注点
  • 模块需要定期刷新重新加载数据
  • 多个平行模块各自管理自己的状态

示例:

<!-- 统计卡片模块 - 自己获取数据 -->
<script setup lang="ts">
const { data, loading } = useBudgetStats()

onMounted(() => {
  loadStats()
})
</script>

5.2 接收 Props 的场景

  • 模块是展示组件(Presentational Component)
  • 父组件需要协调多个子组件的数据
  • 数据来源于全局状态管理(如 Pinia store)
  • 需要在父组件层面做数据聚合或转换

示例:

<!-- 数据展示组件 - 接收 props -->
<script setup lang="ts">
const props = defineProps<{
  stats: BudgetStats
  loading: boolean
}>()
</script>

6. TypeScript vs JavaScript 在 Vue 3 项目中

6.1 EmailBill 项目的选择

当前状况:

  • ESLint 配置中禁用了 TypeScript 规则
  • 使用 <script setup lang="ts"> 但不强制类型检查
  • 轻量级类型提示,不追求严格类型安全

何时避免 TypeScript:

  • 小型项目,团队更熟悉 JavaScript
  • 快速原型开发
  • 避免 TypeScript 配置和类型定义的复杂度
  • 保持构建速度和开发体验的流畅

何时使用 TypeScript:

  • 大型团队协作
  • 复杂的状态管理和数据流
  • 需要严格的 API 契约
  • 长期维护的企业级应用

7. 模块化架构的最佳实践总结

7.1 目录结构推荐

views/
  calendar/
    Index.vue              # 容器组件,布局和协调
    modules/
      CalendarView.vue     # 日历展示模块(自取数据)
      MonthlyStats.vue     # 月度统计模块(自取数据)
      QuickActions.vue     # 快捷操作模块(事件驱动)
    composables/
      useCalendarData.ts   # 日历数据获取逻辑
      useMonthlyStats.ts   # 统计数据获取逻辑

7.2 组件职责划分

Index.vue (容器组件):

  • 布局管理和响应式设计
  • 协调模块间的通信(如果需要)
  • 全局状态初始化
  • 不应包含业务逻辑

modules/*.vue (功能模块):

  • 独立的业务功能单元
  • 自己管理数据获取和状态
  • 通过 emits 向父组件通信
  • 高内聚,低耦合

composables/*.ts (可复用逻辑):

  • 数据获取逻辑
  • 业务规则计算
  • 状态管理辅助
  • 可在多个组件间共享

7.3 通信模式推荐

模块向上通信 (Child → Parent):

const emit = defineEmits<{
  'date-changed': [date: Date]
  'item-clicked': [item: CalendarItem]
}>()

模块间通信 (Sibling ↔ Sibling):

  • 通过父组件中转事件
  • 或使用全局事件总线(如 mitt)
  • 或使用Pinia store 共享状态

8. 关键洞察和建议

8.1 高内聚模块设计

  • 每个模块应该是自治的,包含自己的数据获取、状态管理和事件处理
  • Index.vue 应该是轻量级的协调者,而非数据的中央枢纽

8.2 Props vs 自取数据的平衡

  • 身份标识和配置通过 props (如 userId, date, theme)
  • 业务数据通过模块自取 (如 stats, calendar items)

8.3 避免过度抽象

  • 不要为了复用而复用
  • 优先考虑代码的清晰度而非极致的 DRY
  • Composables 应该解决真实的重复问题,而非预测性的抽象

9. 参考资源

官方文档:

真实项目参考: