技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

谈谈前端组件化

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/103-谈谈前端组件化
本文目录10 个章节
  1. 一、什么是组件化
  2. 二、如何理解组件化:拆分与组合
  3. 三、组件如何拆分
  4. 四、用聊天页面理解拆分
  5. 五、组件怎么组合:渐进式和集中式
  6. 六、Vue 组件 API 的现代设计
  7. 七、组件拆分的常见问题
  8. 八、Vue 2 与 Vue 3 的迁移提示
  9. 总结
  10. 参考资料

谈谈前端组件化

原文从基础组件、业务组件、区块组件、页面组件讲到“渐进式”和“离散式”组合。本文保留这套便于入门的拆分模型,修正 React/Vue 代码混用、把 v-model 简化成双向绑定、把 Vue 和 React 固定对应两种组合方式等表述,并补充 Vue 3.5 的 props/emits、插槽、provide/inject、composable、Pinia 和组件 API 设计。

一、什么是组件化

组件化是一种组织代码的方式:把界面和逻辑拆成具有明确职责、输入、输出和生命周期的可复用单元,再通过组合构成页面。

一个组件通常包含:

  • 模板或渲染逻辑;
  • 样式和可访问性语义;
  • 状态与副作用;
  • 对外输入(props、slots);
  • 对外输出(events、v-model、暴露的方法);
  • 与其他模块约定的类型和边界。

在 Vue 中,单文件组件(SFC)通常把 <template><script setup><style> 放在一起;组件也可以通过 render function、JSX 或自定义 renderer 实现。

组件化的目标不只是“把代码拆成更多文件”,还包括:

  • 让每个模块有较高内聚性;
  • 让模块之间通过稳定 API 低耦合;
  • 便于复用、测试、替换和协作;
  • 让状态、数据流和副作用有清晰的归属。

二、如何理解组件化:拆分与组合

设计组件时经常要回答两个问题:

  1. 组件应该如何拆分?
  2. 拆分后如何组合和传递数据?

组件边界没有适用于所有项目的唯一答案。通常可以从下面几个信号判断是否需要拆分:

  • 一段 UI 在多个地方出现,且变化需要保持一致;
  • 一段逻辑有独立的状态、请求或副作用;
  • 页面某个区域有独立的加载、错误和空状态;
  • 一个组件的 props、条件分支和样式已经难以理解;
  • 需要独立测试或由不同团队负责。

不要为了追求“组件数量越多越好”而拆分。只有一个页面使用、没有独立职责、拆分后反而产生大量透传的碎片,也可能不适合作为独立组件。

三、组件如何拆分

原文借鉴了前端资产市场和 Vue 的组件化概念,将组件分为四类。这不是 Vue 官方强制分类,但作为团队约定很实用:

3.1 基础组件

基础组件提供稳定的通用 UI 和交互,例如:

  • Button、Input、Select、Dialog;
  • 图标、表格单元格、加载指示器;
  • 设计系统提供的通用组件。

它们通常不应该直接依赖某个页面的业务接口,而应通过 props、events 和 slots 接收配置。

3.2 业务组件

业务组件由基础组件组合而成,包含某个业务领域的可复用视图或交互,例如:

  • UserAvatar;
  • OrderStatus;
  • MessageItem;
  • AddressSelector。

“后端封装了相应的 class”不是业务组件的必要条件。业务组件可以依赖领域类型、API service 或 composable,但应避免把整个页面的流程和全局状态都塞进一个组件。

3.3 区块组件

区块组件由多个基础组件和业务组件组合成一个页面区域,例如消息列表、筛选栏、订单摘要或侧边栏。它通常拥有一部分局部状态,但不一定拥有整个页面的数据源。

3.4 页面组件

页面组件负责路由入口、页面级数据获取、布局和区域组合,例如 ChatPageOrderDetailPage。Nuxt 中页面通常位于 pages/,页面可以把数据请求和布局组合委托给 composable、store 和区块组件。

四种组件可以类比为点、线、面、体,但实际项目中边界可能重叠。重要的是团队对命名、依赖方向、状态归属和公共 API 达成一致。

基础组件、业务组件、区块组件和页面组件的层级示意图(原文图片已本地化)

四、用聊天页面理解拆分

假设页面由消息列表和功能栏组成,可以拆成:

ChatPage
├── MessageList
│   └── MessageItem
└── FunctionBar

4.1 业务组件:MessageItem

原文中的示例是 React 风格伪代码,下面给出有效的 Vue 3 写法:

<!-- MessageItem.vue -->
<script setup lang="ts">
defineProps<{
  message: {
    id: number | string
    userName: string
    avatar: string
    text: string
  }
}>()

const emit = defineEmits<{
  select: [id: number | string]
}>()
</script>

<template>
  <li class="message-item" @click="emit('select', message.id)">
    <img :src="message.avatar" :alt="message.userName" />
    <h3>{{ message.userName }}</h3>
    <p>{{ message.text }}</p>
  </li>
</template>

组件接收一个 message,只负责展示单条消息和发出选择事件,不负责获取整个消息列表。

4.2 区块组件:MessageList

<!-- MessageList.vue -->
<script setup lang="ts">
import MessageItem from './MessageItem.vue'

defineProps<{
  messages: Array<{
    id: number | string
    userName: string
    avatar: string
    text: string
  }>
}>()

const emit = defineEmits<{
  select: [id: number | string]
}>()
</script>

<template>
  <ul class="message-list">
    <MessageItem
      v-for="message in messages"
      :key="message.id"
      :message="message"
      @select="emit('select', $event)"
    />
  </ul>
</template>

这里保留了原文 messages.map 的思想,但修正为 Vue 模板,并补上了稳定的 :key。如果使用 React,对应的写法应是 JSX 而不是 HTML:

function MessageList({ messages, onSelect }) {
  return (
    <ul>
      {messages.map(message => (
        <MessageItem
          key={message.id}
          message={message}
          onSelect={onSelect}
        />
      ))}
    </ul>
  )
}

4.3 区块组件:FunctionBar

<!-- FunctionBar.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const text = ref('')
const emit = defineEmits<{
  send: [text: string]
}>()

function submit() {
  const value = text.value.trim()
  if (!value) return

  emit('send', value)
  text.value = ''
}
</script>

<template>
  <form class="function-bar" @submit.prevent="submit">
    <input v-model="text" placeholder="说点什么" />
    <button type="submit">发送</button>
  </form>
</template>

FunctionBar 管理输入框的局部状态,只把“发送”这个意图通知父组件,不直接修改页面的 messages 数组。

4.4 页面组件:ChatPage

<!-- ChatPage.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import MessageList from './MessageList.vue'
import FunctionBar from './FunctionBar.vue'

type Message = {
  id: string
  userName: string
  avatar: string
  text: string
}

const messages = ref<Message[]>([])

function handleSend(text: string) {
  messages.value.push({
    id: crypto.randomUUID(),
    userName: '我',
    avatar: '/avatar.png',
    text,
  })
}

function handleSelect(id: string | number) {
  console.log('选择消息', id)
}
</script>

<template>
  <main class="chat-page">
    <MessageList
      :messages="messages"
      @select="handleSelect"
    />
    <FunctionBar @send="handleSend" />
  </main>
</template>

这里页面组件持有消息列表这一页面级状态,子组件通过 props 接收数据,通过 emits 向上通知。页面可以进一步把请求和发送逻辑移到 useChat() composable 或 Pinia store,使组件更专注于视图。

聊天页面拆分成 MessageList、MessageItem 和 FunctionBar(原文图片已本地化)

组件组合后的聊天页面示意图(原文图片已本地化)

页面组件和区块组件的关系示意图(原文图片已本地化)

五、组件怎么组合:渐进式和集中式

原文把组合方式概括为“渐进式”和“离散式”。这不是 Vue/React 官方分类,但可以作为两种数据组织思路来理解。

5.1 逐层组合(原文称渐进式)

数据沿组件树逐层传递,每个组件负责自己的局部逻辑:

ChatPage
  └─ messages → MessageList
                 └─ message → MessageItem

优点:

  • 数据来源和责任边界清楚;
  • 组件较容易单独测试;
  • props/emits 形成显式的数据流;
  • 子组件可以脱离页面复用。

缺点:

  • 层级过深时可能出现 props drilling;
  • 事件需要逐层转发;
  • 跨多个分支共享状态不够方便。

逐层传递不等于 Vue 的“专属模式”。React 同样可以通过 props 逐层传递,Vue 也可以通过 store 或 provide/inject 进行跨层共享。

5.2 集中处理(原文称离散式)

将页面级或领域级状态放在 composable、store 或页面容器中,多个子组件直接通过明确的依赖读取和修改:

// stores/chat.ts,Pinia 示例
import { defineStore } from 'pinia'

type Message = {
  id: string
  text: string
}

export const useChatStore = defineStore('chat', {
  state: (): { messages: Message[]; loading: boolean } => ({
    messages: [],
    loading: false,
  }),
  actions: {
    async send(text: string) {
      // 请求服务端并更新 messages
    },
  },
})
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useChatStore } from '@/stores/chat'

const chat = useChatStore()
const { messages, loading } = storeToRefs(chat)
</script>

<template>
  <MessageList :messages="messages" />
  <span v-if="loading">发送中...</span>
</template>

优点是减少长距离 props 和事件转发;缺点是依赖变得隐式,过度集中会让 store 变成难以维护的“全局大对象”。集中状态仍应按领域拆分,并明确哪些状态应该局部化。

5.3 两种方式可以混用

实际项目往往是:

  • 页面或 Pinia 保存共享领域状态;
  • 区块组件通过 props 接收所需数据;
  • 子组件通过 emits 报告用户意图;
  • 深层稳定依赖使用 provide/inject;
  • 视图差异通过 slots 扩展;
  • 可复用的请求、分页和表单逻辑放进 composable。

因此,更准确的原则是:状态尽量靠近使用它的地方,同时把真正共享的状态放在明确的领域边界中。

六、Vue 组件 API 的现代设计

6.1 props 和 emits

Vue 3 <script setup> 推荐显式声明输入和输出:

<script setup lang="ts">
const props = defineProps<{
  modelValue: string
  disabled?: boolean
}>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

function update(value: string) {
  emit('update:modelValue', value)
}
</script>

组件不应直接修改 props。父组件通过 v-model 使用它:

<SearchInput v-model="keyword" />

Vue 3.4+ 可以用 defineModel 简化常见的双向绑定声明:

<script setup lang="ts">
const model = defineModel<string>({ default: '' })
</script>

<template>
  <input v-model="model" />
</template>

v-model 的表面体验是双向绑定,但组件内部仍然是“父传值、子发出更新事件”的协议;它不是让子组件直接修改父组件状态。

6.2 slots:扩展视图而不是复制组件

如果组件的大部分结构固定,只有标题、列表项或操作区需要由调用方定制,可以使用具名插槽和作用域插槽:

<!-- Card.vue -->
<template>
  <article>
    <header>
      <slot name="title">默认标题</slot>
    </header>
    <slot :items="items" />
  </article>
</template>

这比在 Card 内加入越来越多 showIconhideIndextitleType 开关更容易扩展。插槽的详细写法见 99 号文章。

6.3 provide/inject:避免不必要的逐层透传

// 祖先组件
import { provide, ref } from 'vue'

const locale = ref('zh-CN')
provide('locale', locale)
// 后代组件
import { inject } from 'vue'

const locale = inject('locale')

生产代码建议使用 Symbol 作为 injection key,并定义类型。provide/inject 适合主题、表单上下文、当前列表控制器等跨层依赖,不应把所有应用状态都隐藏在注入对象中。

6.4 defineExpose

<script setup> 组件默认不会把内部绑定暴露给父组件。如果确实需要通过模板 ref 暴露少量命令式能力,可以显式声明:

<script setup>
function focus() {
  // ...
}

defineExpose({ focus })
</script>

应优先使用 props、events 和 slots;defineExpose 适合 focus、open、validate 等命令式边界,不适合让父组件直接操纵子组件全部内部状态。

七、组件拆分的常见问题

7.1 过度拆分

如果一个组件只包含一个不会复用的 <span>,拆成文件可能增加 import、props 和事件成本。可以先保持局部结构,等职责或复用需求明确后再拆。

7.2 巨型组件

一个组件同时负责页面布局、请求、表单校验、列表渲染、弹窗、权限和埋点时,修改风险很高。可以按“页面容器—区块—业务组件—基础组件”拆分,并把非 UI 逻辑移到 composable/service/store。

7.3 为每个差异添加布尔开关

<!-- 开关越来越多时,组件 API 会难以组合 -->
<Card
  :show-icon="true"
  :hide-index="true"
  :compact="false"
  :title-type="'danger'"
/>

若差异是内容结构,优先考虑 slots;若差异是数据和行为,考虑 props、emits 或拆成更明确的变体组件。

7.4 状态归属错误

  • 输入框正在编辑的临时文本通常属于输入组件;
  • 页面消息列表属于页面或聊天领域;
  • 登录用户属于应用级状态;
  • 菜单展开状态通常属于局部布局;
  • 服务端缓存数据可以由 query composable 或 store 管理。

状态放错位置会导致过多事件转发、全局状态膨胀或组件难以复用。

7.5 忽略可访问性和性能

组件化不只是视觉拆分。基础组件应统一处理键盘操作、焦点、ARIA、禁用状态、错误提示和 loading;大型列表还要配合稳定 key、分页或虚拟列表,不能只靠拆组件解决性能问题。

八、Vue 2 与 Vue 3 的迁移提示

原文使用 Vue 2/Element UI 风格,迁移到 Vue 3 时注意:

  • slotslot-scope 改为 v-slot/#
  • .sync 的常见场景改为 v-modelupdate: 事件;
  • $listeners 在 Vue 3 已移除,明确声明 emits
  • $children 已移除,不要依赖父组件遍历内部子实例;
  • 全局事件总线的 $on/$off/$once 已移除;
  • Element UI 通常迁移到 Element Plus;
  • data、生命周期、响应式和 setup 需要按组件边界重新设计,而不是机械改名。

总结

  1. 组件化是以明确职责和稳定 API 拆分、复用、组合 UI 与逻辑;
  2. 基础、业务、区块、页面组件是一种团队分类,不是 Vue 的强制规则;
  3. 组件拆分要平衡内聚、耦合、复用、测试和认知成本;
  4. Vue 中常用 props/emits、slots、provide/inject、composable 和 Pinia 组织组件关系;
  5. v-model 仍然遵循父传值、子发更新事件的单向数据流协议;
  6. “逐层传递”和“集中状态”可以混用,不能简单归结为 Vue 与 React 的固定差异;
  7. 页面容器负责页面级状态,业务组件负责领域 UI,基础组件保持通用和可访问;
  8. 组件 API 应优先清晰的 props、events 和 slots,谨慎暴露内部实例方法。

参考资料

原文作者:zhiqiang。原文关于组件化、基础/业务/区块/页面组件、聊天页面拆分以及逐层/集中组合的主线予以保留;React 风格伪 HTML、组件层级绝对分类、Vue/React 固定对应关系和 v-model 双向绑定的简化表述已修正,并补充 Vue 3.5 组件设计实践。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS