谈谈前端组件化
原文从基础组件、业务组件、区块组件、页面组件讲到“渐进式”和“离散式”组合。本文保留这套便于入门的拆分模型,修正 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 低耦合;
- 便于复用、测试、替换和协作;
- 让状态、数据流和副作用有清晰的归属。
二、如何理解组件化:拆分与组合
设计组件时经常要回答两个问题:
- 组件应该如何拆分?
- 拆分后如何组合和传递数据?
组件边界没有适用于所有项目的唯一答案。通常可以从下面几个信号判断是否需要拆分:
- 一段 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 页面组件
页面组件负责路由入口、页面级数据获取、布局和区域组合,例如 ChatPage、OrderDetailPage。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,使组件更专注于视图。



五、组件怎么组合:渐进式和集中式
原文把组合方式概括为“渐进式”和“离散式”。这不是 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 内加入越来越多 showIcon、hideIndex、titleType 开关更容易扩展。插槽的详细写法见 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 时注意:
slot、slot-scope改为v-slot/#;.sync的常见场景改为v-model或update:事件;$listeners在 Vue 3 已移除,明确声明emits;$children已移除,不要依赖父组件遍历内部子实例;- 全局事件总线的
$on/$off/$once已移除; - Element UI 通常迁移到 Element Plus;
data、生命周期、响应式和setup需要按组件边界重新设计,而不是机械改名。
总结
- 组件化是以明确职责和稳定 API 拆分、复用、组合 UI 与逻辑;
- 基础、业务、区块、页面组件是一种团队分类,不是 Vue 的强制规则;
- 组件拆分要平衡内聚、耦合、复用、测试和认知成本;
- Vue 中常用 props/emits、slots、provide/inject、composable 和 Pinia 组织组件关系;
v-model仍然遵循父传值、子发更新事件的单向数据流协议;- “逐层传递”和“集中状态”可以混用,不能简单归结为 Vue 与 React 的固定差异;
- 页面容器负责页面级状态,业务组件负责领域 UI,基础组件保持通用和可访问;
- 组件 API 应优先清晰的 props、events 和 slots,谨慎暴露内部实例方法。
参考资料
- Vue 3:Component Basics
- Vue 3:Props
- Vue 3:Component Events
- Vue 3:Slots
- Vue 3:Provide / Inject
- Vue 3:
defineModel - Vue 3:状态管理
- Pinia
原文作者:zhiqiang。原文关于组件化、基础/业务/区块/页面组件、聊天页面拆分以及逐层/集中组合的主线予以保留;React 风格伪 HTML、组件层级绝对分类、Vue/React 固定对应关系和 v-model 双向绑定的简化表述已修正,并补充 Vue 3.5 组件设计实践。