前端组件三层架构:页面/业务/基础组件划分,高内聚低耦合|组件化设计基础篇

【Vue3 + TypeScript + 业务组件化】中后台前端实战:从组件设计原则到真实业务落地,掌握可复用业务组件写法,避开组件臃肿、耦合、难维护6大高频坑!

📑 文章目录

同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。

(Eugene 发音 /juːˈdʒiːn/,大家怎么顺口怎么叫就好)

当你能写出规范、可维护的代码后,下一个真正的瓶颈,就是架构

面对大型项目、复杂业务,你是否也会遇到:组件越写越乱、重复开发越来越多;需求一变全链路改动;不知道怎么分层、怎么抽象、怎么设计才能支撑长期迭代;想晋升、想带项目,却缺少架构思维

这一系列《前端组件化与架构实战》,我会继续用大白话 + 真实业务场景 ,不讲玄学、不啃晦涩源码,只教你能落地、能抗复杂项目的架构思路。

帮你从「写页面的开发者」,真正升级为「能做架构、能带项目、能搞定复杂需求的前端工程师」。


一、先说人话:为什么你写的组件"看起来能用",却越来越难维护?

很多项目里,组件最后会变成下面这样:

  • 一个组件里塞了 20 多个 props
  • 逻辑、UI、接口请求全耦合在一起
  • 不同页面只改了 10% 需求,却不得不 copy 一份再魔改
  • 一个月后自己都不敢动,怕改崩

这不是你能力差,而是缺了"业务组件设计"的基本方法。

[⬆ 返回目录](#⬆ 返回目录)


二、什么是"业务组件"?

先区分两个概念:

1)基础组件(UI 组件)

只关注样式和基础交互,比如:ButtonInputModal

特点:通用性高,业务语义低

[⬆ 返回目录](#⬆ 返回目录)

2)业务组件(Domain Component)

是把"某个业务场景"抽象成模块,比如:

  • 商品卡片 ProductCard
  • 订单状态条 OrderStatus
  • 地址选择器 AddressSelector

特点:有业务语义,可复用,但不会过度追求全场景通吃

[⬆ 返回目录](#⬆ 返回目录)


三、核心原则(记住这 5 条就够你避开大坑)

1)单一职责:一个组件只解决一个业务问题

别把"展示 + 编辑 + 请求 + 权限判断 + 埋点"都塞进一个组件。

2)输入稳定(Props)+ 输出明确(Events)

组件不是黑盒魔法。

你要让同事一眼看懂:

  • 我传什么进去
  • 组件会吐什么出来

3)默认值友好,防御式设计

新手最常见报错:Cannot read properties of undefined

给默认值、做空值兜底,是组件"可复用"的基础。

4)业务逻辑可下沉到 composable

重复的业务逻辑放进 useXxx,组件保持"可读、可测、可维护"。

5)先满足当前 2~3 个场景,不要提前设计宇宙级抽象

"过度抽象"比"轻微重复"更可怕。

先跑通真实需求,再迭代抽象层。

[⬆ 返回目录](#⬆ 返回目录)


四、实战案例:封装一个可复用"商品卡片业务组件"

目标:同一个组件在列表页、活动页、推荐位都能用,但展示细节可配置。

1)需求拆解

公共能力:

  • 展示商品图、标题、价格
  • 显示库存状态
  • 点击"加入购物车"
  • 点击卡片可跳详情

场景差异:

  • 某些场景需要角标(如"限时折扣")
  • 某些场景不展示库存
  • 按钮文案可自定义("立即购买"/"加入购物车")

[⬆ 返回目录](#⬆ 返回目录)

2)组件代码(完整示例)

components/ProductCard.vue

html 复制代码
<template>
  <article class="product-card" @click="handleCardClick">
    <div class="thumb-wrap">
      <img :src="safeProduct.cover" :alt="safeProduct.title" class="thumb" />
      <span v-if="badgeText" class="badge">{{ badgeText }}</span>
    </div>

    <div class="content">
      <h3 class="title">{{ safeProduct.title }}</h3>
      <p class="price">¥ {{ formatPrice(safeProduct.price) }}</p>

      <p v-if="showStock" class="stock" :class="{ danger: safeProduct.stock <= 10 }">
        库存:{{ safeProduct.stock }}
      </p>

      <button
        class="action-btn"
        :disabled="safeProduct.stock <= 0"
        @click.stop="handleAddCart"
      >
        {{ buttonText }}
      </button>
    </div>
  </article>
</template>

<script setup lang="ts">
import { computed } from 'vue'

interface Product {
  id: string
  title: string
  cover: string
  price: number
  stock: number
}

const props = withDefaults(
  defineProps<{
    product: Product
    badgeText?: string
    showStock?: boolean
    buttonText?: string
  }>(),
  {
    badgeText: '',
    showStock: true,
    buttonText: '加入购物车'
  }
)

const emit = defineEmits<{
  (e: 'card-click', productId: string): void
  (e: 'add-cart', payload: { productId: string; quantity: number }): void
}>()

// 防御式兜底:避免外部传参不完整导致模板报错
const safeProduct = computed<Product>(() => ({
  id: props.product?.id ?? '',
  title: props.product?.title ?? '未知商品',
  cover: props.product?.cover ?? 'https://via.placeholder.com/300x300?text=No+Image',
  price: Number(props.product?.price ?? 0),
  stock: Number(props.product?.stock ?? 0)
}))

function formatPrice(price: number) {
  return price.toFixed(2)
}

function handleCardClick() {
  if (!safeProduct.value.id) return
  emit('card-click', safeProduct.value.id)
}

function handleAddCart() {
  if (!safeProduct.value.id || safeProduct.value.stock <= 0) return
  emit('add-cart', { productId: safeProduct.value.id, quantity: 1 })
}
</script>

<style scoped>
.product-card {
  width: 260px;
  border: 1px solid #eee;
  border-radius: 12px;
  overflow: hidden;
  cursor: pointer;
  transition: box-shadow 0.2s ease;
  background: #fff;
}
.product-card:hover {
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
}
.thumb-wrap {
  position: relative;
}
.thumb {
  width: 100%;
  height: 180px;
  object-fit: cover;
  display: block;
}
.badge {
  position: absolute;
  top: 10px;
  left: 10px;
  background: #ff4d4f;
  color: #fff;
  font-size: 12px;
  padding: 2px 8px;
  border-radius: 999px;
}
.content {
  padding: 12px;
}
.title {
  margin: 0 0 8px;
  font-size: 15px;
  line-height: 1.4;
}
.price {
  margin: 0 0 6px;
  color: #e60012;
  font-weight: 700;
}
.stock {
  margin: 0 0 10px;
  color: #666;
  font-size: 13px;
}
.stock.danger {
  color: #fa541c;
}
.action-btn {
  width: 100%;
  height: 34px;
  border: none;
  border-radius: 8px;
  background: #1677ff;
  color: #fff;
}
.action-btn:disabled {
  background: #bfbfbf;
  cursor: not-allowed;
}
</style>

[⬆ 返回目录](#⬆ 返回目录)

3)父组件如何使用(3 个场景)

views/ProductList.vue

html 复制代码
<template>
  <section class="list-wrap">
    <ProductCard
      v-for="item in products"
      :key="item.id"
      :product="item"
      badge-text="热卖"
      :show-stock="true"
      button-text="加入购物车"
      @card-click="goDetail"
      @add-cart="addCart"
    />
  </section>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import ProductCard from '@/components/ProductCard.vue'

const products = ref([
  { id: 'p1', title: '机械键盘 K87', cover: 'https://picsum.photos/300?1', price: 299, stock: 25 },
  { id: 'p2', title: '无线鼠标 M3', cover: 'https://picsum.photos/300?2', price: 89, stock: 8 },
  { id: 'p3', title: '4K 显示器', cover: 'https://picsum.photos/300?3', price: 1899, stock: 0 }
])

function goDetail(productId: string) {
  console.log('跳转商品详情:', productId)
  // router.push(`/product/${productId}`)
}

function addCart(payload: { productId: string; quantity: number }) {
  console.log('加入购物车:', payload)
  // 调接口 addCartApi(payload)
}
</script>

<style scoped>
.list-wrap {
  display: grid;
  grid-template-columns: repeat(3, 260px);
  gap: 16px;
}
</style>

[⬆ 返回目录](#⬆ 返回目录)


五、你最容易踩的 6 个坑(高频)

  • 把请求写进组件内部:导致组件强依赖某个接口,难复用。建议父组件请求,子组件只发事件。
  • props 过多且语义混乱:超过 10 个就要警惕,考虑拆子组件或合并配置对象。
  • 事件命名不清晰clickchange 太泛;优先 add-cartcard-click 这种业务语义。
  • 默认值缺失:线上最常见是空数据导致模板报错。
  • 把"可配置"做成"可随便改":配置项太多会变成"新项目重写旧组件"。
  • 没有约定文档:没人知道组件怎么用,最后还是复制粘贴。

[⬆ 返回目录](#⬆ 返回目录)


六、一套能直接落地的业务组件规范(建议贴团队 wiki)

命名规范

  • 组件名:业务域 + 语义,如 ProductCardOrderTimeline
  • 事件名:动词开头,表达动作:add-cartsubmit-order
  • props:布尔使用 is/has/show 前缀,如 showStock

[⬆ 返回目录](#⬆ 返回目录)

目录建议

复制代码
components/
  product/
    ProductCard.vue
    ProductPrice.vue
composables/
  useCart.ts
types/
  product.ts

[⬆ 返回目录](#⬆ 返回目录)

设计约束

  • 单组件 props 尽量 <= 8
  • 必选 props 必须写类型
  • 可选 props 必须有默认值
  • 对外事件必须在注释/文档标明 payload 结构
  • 至少给 1 个完整使用示例

[⬆ 返回目录](#⬆ 返回目录)


七、进阶一点:什么时候拆成"容器组件 + 展示组件"?

当一个业务组件开始出现这些信号时就该拆了:

  • 同时处理接口请求、权限判断、复杂状态流转
  • 模板越来越长(200 行+)
  • 新需求总在"if/else"里叠逻辑

拆法:

  • 容器组件:管数据与业务流程(请求、状态、权限)
  • 展示组件:只管接收数据并展示 + 抛出用户行为事件

这一步能显著提升可测试性和可维护性。

[⬆ 返回目录](#⬆ 返回目录)


八、给 7 年前端的一句"校准建议"

你已经能把需求做出来了,下一阶段拼的是:

可维护性、可协作性、可演进性。

业务组件设计不是"追求完美抽象",而是:

  • 用稳定输入输出建立协作边界
  • 用适度抽象减少重复劳动
  • 用清晰结构降低未来改动成本

[⬆ 返回目录](#⬆ 返回目录)


九、总结

如果你也遇到过"组件越写越重、改一处崩三处"的问题,建议从今天开始做三件事:

  1. 每写一个组件,先问自己:它只解决一个业务问题吗?
  2. 先设计 propsevents,再动手写模板
  3. 每个业务组件都给一个"最小可运行示例"

把这三步坚持一个月,你的组件质量会非常明显地提升。


🔍 系列模块导航

📝 组件化设计基础

持续更新中,敬请期待~

👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~

📚 系列总览

前端体系化学习完全体:基础 → 规范 → 架构 → 大厂面试

四套系列、百余篇高质量实战文,从入门到进阶,一站式补齐前端核心能力

每个系列完结后,都会整理成一篇完整导航文并附上直达链接,方便大家按顺序、体系化学习。

全套内容持续更新中,敬请期待~

[⬆ 返回目录](#⬆ 返回目录)


前端的成长路径很清晰:

会写代码 → 写规范代码 → 做可扩展架构。

每一步,都是职业晋升的关键台阶。

后续我会持续输出组件化、配置驱动、权限架构、工程化、复杂业务实战干货,帮你真正建立架构思维,在工作与面试中更有竞争力。

觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇硬核内容。

我是 Eugene,与你一起从业务走向架构,搞定复杂项目,我们下篇干货见~

相关推荐
迈巧克力3 小时前
用OpenClaw实现小红书自动发布:从零到一的完整技术方案
前端·人工智能·创业
花千树-0103 小时前
Claude Code / Codex 架构推测 + 可实现版本设计(从0到1复刻一个Agent系统)
人工智能·ai·架构·aigc·ai编程
givemeacar3 小时前
十七:Spring Boot依赖 (2)-- spring-boot-starter-web 依赖详解
前端·spring boot·后端
前端郭德纲3 小时前
JavaScript原生开发与鸿蒙原生开发对比
开发语言·javascript·harmonyos
辻戋4 小时前
从零开始手写mini-webpack
前端·webpack·node.js
cch89184 小时前
PHP vs 易语言:Web开发与桌面编程大对决
开发语言·前端·php
百撕可乐4 小时前
NextJS官网实战02:项目的基础骨架搭建
前端·javascript·react.js
陈天伟教授4 小时前
人工智能应用- 人工智能风险与伦理:01.数据安全
前端·人工智能·安全·xss·csrf
用户69371750013844 小时前
Android 17 完整更新详解:Beta 3 已达平台稳定,这些新功能值得期待
android·前端·android studio