组件设计模式
在一个真实的文件管理系统中,你会遇到这样的挑战:文件树需要无限层级展开,文件预览面板要根据格式动态切换渲染方式,权限面板的逻辑在多个页面重复出现,搜索过滤组件的 UI 和数据逻辑纠缠不清。这些问题的本质,是组件设计决策的问题——用什么模式组织组件,才能让系统既灵活又可维护?
本章将以「文件管理器」作为贯穿场景,系统讲解 Vue 3 中六大核心组件设计模式。你将学会在不同工程场景下做出正确的组件架构决策。
📋 开篇自测
在正式学习前,试着回答以下三个问题,检验你对组件设计的现有认知:
- 在一个文件管理页面中,「获取文件列表数据」的逻辑应该放在哪类组件中?它和「渲染文件卡片」的组件应该是同一个吗?
- 如果需要根据文件类型(图片、视频、文档、代码)动态渲染不同的预览组件,你能想到几种实现方式?
- 一个文件目录树可能有无限层级嵌套,用常规的
v-for能否实现?需要什么特殊的组件技术?
带着这些问题,开始本章的学习。
一、容器组件与展示组件——关注点分离
在建筑设计中,承重结构和装饰装修是两套独立的系统。承重墙决定建筑能承载多少重量,而内部装修决定空间如何呈现给使用者。组件设计中的「容器组件」与「展示组件」正是这样的关系——一个负责”承重”(数据获取与状态管理),另一个负责”装修”(UI 渲染与交互呈现)。
1.1 什么是容器组件与展示组件
容器组件(Container Component) 关注的是”数据从哪来、到哪去”。它负责与 Pinia store 交互、调用 API、处理业务逻辑,然后将数据通过 props 传递给子组件。
展示组件(Presentational Component) 关注的是”数据如何呈现”。它通过 props 接收数据,通过 emit 向外发送事件,自身不直接依赖外部状态管理。
以文件管理器的主界面为例:
<!-- FileManagerPage.vue —— 容器组件 -->
<template>
<div class="file-manager">
<SearchFilterBar
:filters="activeFilters"
@update-filters="handleFilterChange"
/>
<FileTreePanel
:tree-data="directoryTree"
:selected-path="currentPath"
@select-folder="navigateTo"
/>
<FileListGrid
:files="filteredFiles"
:view-mode="viewMode"
@open-file="openFilePreview"
@delete-file="confirmDelete"
/>
<FilePreviewDrawer
v-if="previewVisible"
:file="previewTarget"
@close="closePreview"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useFileStore } from '@/stores/fileStore'
import { usePermissionStore } from '@/stores/permissionStore'
const fileStore = useFileStore()
const permissionStore = usePermissionStore()
const currentPath = ref('/')
const viewMode = ref('grid')
const activeFilters = ref({ type: 'all', sortBy: 'name' })
const previewVisible = ref(false)
const previewTarget = ref(null)
const directoryTree = computed(() => fileStore.directoryTree)
const filteredFiles = computed(() => {
const files = fileStore.getFilesByPath(currentPath.value)
return applyFilters(files, activeFilters.value)
})
onMounted(() => {
fileStore.fetchDirectoryTree()
permissionStore.loadCurrentUserPermissions()
})
function navigateTo(path) {
currentPath.value = path
fileStore.fetchFilesByPath(path)
}
function handleFilterChange(newFilters) {
activeFilters.value = { ...activeFilters.value, ...newFilters }
}
function openFilePreview(file) {
previewTarget.value = file
previewVisible.value = true
}
function confirmDelete(file) {
if (!permissionStore.canDelete(file.path)) {
console.warn('权限不足,无法删除')
return
}
fileStore.deleteFile(file.id)
}
function closePreview() {
previewVisible.value = false
previewTarget.value = null
}
function applyFilters(files, filters) {
let result = [...files]
if (filters.type !== 'all') {
result = result.filter(f => f.type === filters.type)
}
result.sort((a, b) => {
if (filters.sortBy === 'name') return a.name.localeCompare(b.name)
if (filters.sortBy === 'size') return a.size - b.size
if (filters.sortBy === 'modified') return b.modifiedAt - a.modifiedAt
return 0
})
return result
}
</script>
再看其中的一个展示组件:
<!-- FileListGrid.vue —— 展示组件 -->
<template>
<div :class="['file-list', `file-list--${viewMode}`]">
<div
v-for="file in files"
:key="file.id"
class="file-card"
@click="$emit('open-file', file)"
>
<FileIcon :type="file.type" :size="32" />
<div class="file-card__info">
<span class="file-card__name">{{ file.name }}</span>
<span class="file-card__meta">{{ formatSize(file.size) }}</span>
</div>
<button
class="file-card__delete"
@click.stop="$emit('delete-file', file)"
>
删除
</button>
</div>
<div v-if="files.length === 0" class="file-list__empty">
当前目录为空
</div>
</div>
</template>
<script setup>
defineProps({
files: { type: Array, required: true },
viewMode: { type: String, default: 'grid' }
})
defineEmits(['open-file', 'delete-file'])
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
</script>
1.2 划分原则与核心收益
两类组件的职责边界可以用下表概括:
| 维度 | 容器组件 | 展示组件 |
|---|---|---|
| 核心关注 | 数据获取、状态更新、业务流程 | UI 渲染、交互反馈 |
| 是否使用 Store | 是(Pinia / Vuex) | 否 |
| 数据来源 | Store、API 调用 | props |
| 对外通信 | 直接调用 Store action | 通过 emit 触发回调 |
| 可复用性 | 低(绑定特定业务流程) | 高(通用 UI 单元) |
这种划分带来三个工程收益:
- 可测试性:展示组件只需要 mock props 就能测试渲染逻辑,无需搭建 Store 环境。
- 可复用性:
FileListGrid可以在”我的文件""共享文件""回收站”等多个页面复用,只需传入不同的files数据。 - 可维护性:当后端 API 变更时,只需修改容器组件的数据获取逻辑,展示组件完全不受影响。
需要注意的是,容器/展示组件的划分是一种指导原则而非铁律。实际项目中,两者的边界并不总是清晰的——一个组件可能同时承担部分数据获取和 UI 渲染职责。不必为了严格遵循模式而过度拆分。当拆分能带来可复用性或可测试性的实际收益时再拆分,否则保持简单即可。
1.3 层次结构的设计策略
组件嵌套不宜超过三层。如果页面复杂,可以将一个大容器拆分为多个子容器,每个子容器独立管理自己的数据域。例如文件管理器中,FileTreePanel 内部可以作为一个子容器,自行管理目录树的展开/折叠状态,而不必将所有状态上提到最外层的 FileManagerPage。
二、渲染函数与 JSX——超越模板的表达力
模板语法是 Vue 最大的优势之一——直观、易读、对设计师友好。但在某些场景下,模板的声明式语法会变成束缚。渲染函数就像是从”填表”切换到”写程序”——你获得了 JavaScript 的全部编程能力。
2.1 理解 h() 函数
Vue 的模板最终会被编译为渲染函数。h() 是 createElement 的简写,它接受三个参数:
h(
type, // 标签名、组件对象或异步组件
props, // 属性、事件、class、style 等
children // 子节点:字符串、VNode 数组或插槽对象
)
来看一个对照示例。模板写法:
<template>
<div class="file-icon" :title="fileName">
<img v-if="isImage" :src="thumbnailUrl" :alt="fileName" />
<span v-else class="file-icon__ext">{{ extension }}</span>
</div>
</template>
等价的渲染函数写法:
import { h } from 'vue'
export default {
props: {
fileName: String,
isImage: Boolean,
thumbnailUrl: String,
extension: String
},
setup(props) {
return () => {
const child = props.isImage
? h('img', { src: props.thumbnailUrl, alt: props.fileName })
: h('span', { class: 'file-icon__ext' }, props.extension)
return h('div', { class: 'file-icon', title: props.fileName }, [child])
}
}
}
2.2 何时使用渲染函数替代模板
渲染函数绝非模板的替代品,它是模板的补充。以下四种场景适合使用渲染函数:
场景一:高度动态的组件结构。 例如一个文件属性面板,需要根据后端返回的字段配置动态渲染不同的表单控件:
import { h, reactive, resolveComponent } from 'vue'
export default {
props: {
schema: { type: Array, required: true }
// schema 示例: [{ field: 'name', widget: 'input' }, { field: 'tags', widget: 'tag-select' }]
},
emits: ['field-change'],
setup(props, { emit }) {
const widgetMap = {
input: resolveComponent('ElInput'),
select: resolveComponent('ElSelect'),
'tag-select': resolveComponent('TagSelector'),
datepicker: resolveComponent('ElDatePicker')
}
// 用本地状态管理表单值,避免直接修改 props(单向数据流原则)
const formValues = reactive(
Object.fromEntries(props.schema.map(item => [item.field, item.value]))
)
return () => {
const fields = props.schema.map(item => {
const Widget = widgetMap[item.widget]
if (!Widget) return null
return h('div', { class: 'property-field', key: item.field }, [
h('label', item.label),
h(Widget, {
modelValue: formValues[item.field],
'onUpdate:modelValue': val => {
formValues[item.field] = val
emit('field-change', { field: item.field, value: val })
},
...item.widgetProps
})
])
})
return h('form', { class: 'property-panel' }, fields)
}
}
}
用模板实现这段逻辑,需要大量的 v-if / v-else-if 链,且每新增一种控件就要修改模板。渲染函数通过 widgetMap 查表,天然支持扩展。
场景二:需要程序化操作 VNode。 比如一个文件列表表格组件,允许使用者通过 render 函数自定义某一列的单元格内容:
// 使用者定义列配置
// 注意:Vue 3 中 h 需从 vue 导入,不再作为参数自动传入
import { h } from 'vue'
const columns = [
{ title: '文件名', key: 'name' },
{ title: '大小', key: 'size', render: ({ row }) => h('span', formatBytes(row.size)) },
{
title: '操作',
render: ({ row }) => h('button', {
onClick: () => downloadFile(row.id)
}, '下载')
}
]
场景三:编写需要兼容 SSR 的动态实例。 如果组件需要通过编程方式创建和挂载(如全局通知、确认对话框),使用渲染函数可以避免模板编译的兼容性问题。
场景四:在 JSX 中获得更好的开发体验。 Vue 3 对 JSX 的支持已经相当成熟,如果团队中有成员熟悉 React 风格,JSX 可以作为模板和纯渲染函数之间的折中方案:
// FileStatusBadge.jsx
import { defineComponent } from 'vue'
export default defineComponent({
props: {
status: { type: String, required: true }
},
setup(props) {
const statusConfig = {
synced: { label: '已同步', color: '#52c41a' },
syncing: { label: '同步中', color: '#1890ff' },
conflict: { label: '冲突', color: '#ff4d4f' },
offline: { label: '离线', color: '#999' }
}
return () => {
const config = statusConfig[props.status] || statusConfig.offline
return (
<span class="status-badge" style={{ color: config.color }}>
<i class="status-dot" style={{ backgroundColor: config.color }} />
{config.label}
</span>
)
}
}
})
三、函数式组件——轻量级的渲染单元
3.1 什么是函数式组件
在 Vue 3 中,函数式组件是一个普通函数,接收 props 和 context(含 slots、attrs、emit),返回 VNode。它没有自身的状态、没有生命周期钩子、没有组件实例——就像一个纯函数,输入确定则输出确定。
// FileIcon.js —— 函数式组件
import { h } from 'vue'
const iconMap = {
pdf: '📄',
image: '🖼️',
video: '🎬',
audio: '🎵',
code: '💻',
archive: '📦',
folder: '📁',
unknown: '📎'
}
function FileTypeIcon(props) {
const icon = iconMap[props.type] || iconMap.unknown
const size = props.size || 24
return h('span', {
class: 'file-type-icon',
style: { fontSize: size + 'px' },
role: 'img',
'aria-label': props.type
}, icon)
}
FileTypeIcon.props = {
type: { type: String, default: 'unknown' },
size: { type: Number, default: 24 }
}
export default FileTypeIcon
3.2 性能优势与适用场景
在 Vue 2 中,函数式组件由于跳过实例创建有明显的性能优势。但在 Vue 3 中,由于编译器优化和有状态组件自身性能的大幅提升,两者的性能差距已经可以忽略不计(Vue 官方迁移指南明确将其标注为 “negligible”)。
因此在 Vue 3 中,选择函数式组件更多是出于语义表达——明确告诉代码阅读者”这个组件是无状态的纯渲染单元”,而非出于性能考量。
函数式组件适合以下场景:
- 纯展示型组件:如图标、徽标、分隔线等不需要状态的 UI 原子。
- 高阶渲染代理:将 render prop 传递给子组件的中间层。
- 大批量渲染的列表项:如文件管理器中可能同时展示数百个文件图标。
来看一个渲染代理的实际用例——在文件表格中,允许使用者通过 render 函数自定义单元格:
// CellRenderer.js —— 函数式渲染代理
function CellRenderer(props) {
return props.renderFn(props.rowData, props.columnKey)
}
CellRenderer.props = {
renderFn: { type: Function, required: true },
rowData: { type: Object, required: true },
columnKey: { type: String, required: true }
}
export default CellRenderer
在表格组件内部使用:
<template>
<td v-for="col in columns" :key="col.key">
<CellRenderer
v-if="col.render"
:render-fn="col.render"
:row-data="row"
:column-key="col.key"
/>
<span v-else>{{ row[col.key] }}</span>
</td>
</template>
四、递归组件与动态组件——结构的无限可能
4.1 递归组件:渲染树形结构
文件管理器中最典型的递归结构就是目录树。一个文件夹下可以有子文件夹,子文件夹下还可以有子文件夹,层级不确定。这正是递归组件的用武之地。
实现递归组件的两个必要条件:
- 组件需要一个可引用的名称。在
<script setup>的 SFC 中(Vue 3.2.34+),编译器会自动从文件名推断组件名(如DirectoryTreeNode.vue自动获得名称DirectoryTreeNode),无需手动声明。如果需要自定义名称,可以使用defineOptions({ name: '...' })。 - 必须有明确的递归终止条件,否则会产生无限循环导致栈溢出。
<!-- DirectoryTreeNode.vue —— 递归组件 -->
<template>
<li class="tree-node">
<div
:class="['tree-node__label', { 'tree-node__label--active': isSelected }]"
:style="{ paddingLeft: depth * 20 + 'px' }"
@click="handleClick"
>
<span
v-if="node.children && node.children.length"
class="tree-node__arrow"
:class="{ 'tree-node__arrow--open': isExpanded }"
@click.stop="toggleExpand"
>
▶
</span>
<span v-else class="tree-node__arrow tree-node__arrow--leaf" />
<span class="tree-node__icon">{{ node.type === 'folder' ? '📁' : '📄' }}</span>
<span class="tree-node__text">{{ node.name }}</span>
</div>
<!-- 递归终止条件:仅当节点有子节点且处于展开状态时才递归 -->
<ul v-if="isExpanded && node.children && node.children.length" class="tree-node__children">
<DirectoryTreeNode
v-for="child in node.children"
:key="child.id"
:node="child"
:depth="depth + 1"
:selected-path="selectedPath"
@select="(path) => $emit('select', path)"
/>
</ul>
</li>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
node: { type: Object, required: true },
depth: { type: Number, default: 0 },
selectedPath: { type: String, default: '' }
})
const emit = defineEmits(['select'])
const isExpanded = ref(props.depth < 1) // 默认展开第一���
const isSelected = computed(() => props.selectedPath === props.node.path)
function toggleExpand() {
isExpanded.value = !isExpanded.value
}
function handleClick() {
emit('select', props.node.path)
}
</script>
外层容器的使用方式:
<!-- DirectoryTree.vue -->
<template>
<nav class="directory-tree" aria-label="文件目录">
<ul>
<DirectoryTreeNode
v-for="rootNode in treeData"
:key="rootNode.id"
:node="rootNode"
:selected-path="selectedPath"
@select="handleSelect"
/>
</ul>
</nav>
</template>
<script setup>
import DirectoryTreeNode from './DirectoryTreeNode.vue'
const props = defineProps({
treeData: { type: Array, required: true },
selectedPath: { type: String, default: '' }
})
const emit = defineEmits(['select'])
function handleSelect(path) {
emit('select', path)
}
</script>
数据结构示例:
const treeData = [
{
id: '1', name: '项目文档', path: '/项目文档', type: 'folder',
children: [
{
id: '1-1', name: '需求规格', path: '/项目文档/需求规格', type: 'folder',
children: [
{ id: '1-1-1', name: 'v2.0需求.pdf', path: '/项目文档/需求规格/v2.0需求.pdf', type: 'file', children: [] }
]
},
{ id: '1-2', name: '会议纪要.docx', path: '/项目文档/会议纪要.docx', type: 'file', children: [] }
]
},
{
id: '2', name: '设计资源', path: '/设计资源', type: 'folder',
children: []
}
]
4.2 递归组件的注意事项
在实际开发中,递归组件有几个容易踩的坑需要提前了解:
性能控制: 如果目录树有大量节点(数千个文件夹),一次性全部渲染会导致页面卡顿。解决方案是采用「懒加载」策略——初始只渲染已展开的节点,用户点击展开时再加载子节点数据并递归渲染。上面的示例中 v-if="isExpanded" 已经实现了这种按需渲染的效果。
事件冒泡的处理: 递归组件的事件传播链可能很长。比如在第五层的节点触发了 select 事件,它需要依次经过第四层、第三层……一直冒泡到顶层容器。上面的代码通过 @select="(path) => $emit('select', path)" 逐层转发事件。如果层级非常深,也可以考虑使用 provide/inject 将回调函数直接注入到子孙节点,避免逐层传递:
// DirectoryTree.vue(顶层容器)中
import { provide } from 'vue'
const handleSelect = (path) => { emit('select', path) }
provide('treeSelectHandler', handleSelect)
// DirectoryTreeNode.vue(递归节点)中
import { inject } from 'vue'
const treeSelectHandler = inject('treeSelectHandler')
function handleClick() {
treeSelectHandler(props.node.path)
}
这样无论嵌套多深,事件都能直达顶层,省去了中间层的逐级转发。
唯一 key 的重要性: 递归列表中的 :key 必须保证全局唯一,不能仅用数组索引。因为不同层级的索引可能重复,会导致 Vue 的 diff 算法错误复用 DOM,出现渲染异常。建议使用节点的唯一 ID 或完整路径作为 key。
4.3 动态组件:component :is 的妙用
文件预览是动态组件的经典场景——不同类型的文件需要完全不同的预览方式:图片需要缩放查看器,PDF 需要翻页阅读器,代码文件需要语法高亮,视频需要播放器控件。
<!-- FilePreviewPanel.vue -->
<template>
<div class="preview-panel">
<div class="preview-panel__header">
<h3>{{ file.name }}</h3>
<button @click="$emit('close')">关闭</button>
</div>
<div class="preview-panel__body">
<component
:is="previewComponent"
:file="file"
v-bind="previewProps"
/>
</div>
</div>
</template>
<script setup>
import { computed, defineAsyncComponent } from 'vue'
// 异步加载各预览组件,避免首屏加载所有预览器
const ImageViewer = defineAsyncComponent(() => import('./viewers/ImageViewer.vue'))
const PdfReader = defineAsyncComponent(() => import('./viewers/PdfReader.vue'))
const CodeHighlighter = defineAsyncComponent(() => import('./viewers/CodeHighlighter.vue'))
const VideoPlayer = defineAsyncComponent(() => import('./viewers/VideoPlayer.vue'))
const AudioPlayer = defineAsyncComponent(() => import('./viewers/AudioPlayer.vue'))
const PlainTextViewer = defineAsyncComponent(() => import('./viewers/PlainTextViewer.vue'))
const UnsupportedNotice = defineAsyncComponent(() => import('./viewers/UnsupportedNotice.vue'))
const props = defineProps({
file: { type: Object, required: true }
})
defineEmits(['close'])
const viewerRegistry = {
image: { component: ImageViewer, props: { zoomable: true } },
pdf: { component: PdfReader, props: { showPageNav: true } },
code: { component: CodeHighlighter, props: { showLineNumbers: true } },
video: { component: VideoPlayer, props: { autoplay: false } },
audio: { component: AudioPlayer, props: {} },
text: { component: PlainTextViewer, props: {} }
}
const previewComponent = computed(() => {
const entry = viewerRegistry[props.file.type]
return entry ? entry.component : UnsupportedNotice
})
const previewProps = computed(() => {
const entry = viewerRegistry[props.file.type]
return entry ? entry.props : { message: '暂不支持预览此类文件' }
})
</script>
这段代码的关键设计决策:
defineAsyncComponent实现按需加载:用户打开图片时不会加载视频播放器的代码,每个预览器独立分包。- 注册表模式取代 if-else 链:新增一种文件类型的支持,只需在
viewerRegistry添加一行配置。 component :is绑定组件对象::is可以接收组件对象引用(如上所示),也可以接收已注册的组件名字符串。
结合 <keep-alive> 还能缓存已打开的预览器,在切换预览文件时避免重复渲染:
<keep-alive :max="5">
<component :is="previewComponent" :file="file" :key="file.id" />
</keep-alive>
keep-alive 的 max 属性限制最多缓存 5 个预览实例,超出时自动销毁最久未使用的实例,防止内存泄漏。
五、高阶组件与组合模式——逻辑的复用与增强
5.1 用 Composable 替代传统高阶组件
在 Vue 2 时代,高阶组件(HOC)和 Mixin 是逻辑复用的主要手段。但它们都有显著缺陷:命名冲突、数据来源不透明、调试困难。Vue 3 的组合式 API(Composable)从根本上解决了这些问题。
假设文件管理器中多个组件都需要「权限检查」逻辑——文件列表需要检查能否删除,目录树需要检查能否新建子目录,预览面板需要检查能否下载。
// composables/useFilePermission.js
import { computed } from 'vue'
import { usePermissionStore } from '@/stores/permissionStore'
export function useFilePermission(filePath) {
const permissionStore = usePermissionStore()
const canRead = computed(() => permissionStore.check(filePath.value, 'read'))
const canWrite = computed(() => permissionStore.check(filePath.value, 'write'))
const canDelete = computed(() => permissionStore.check(filePath.value, 'delete'))
const canShare = computed(() => permissionStore.check(filePath.value, 'share'))
function requestPermission(action) {
return permissionStore.requestAccess(filePath.value, action)
}
return { canRead, canWrite, canDelete, canShare, requestPermission }
}
在不同组件中使用:
<!-- FileActionBar.vue -->
<script setup>
import { toRef } from 'vue'
import { useFilePermission } from '@/composables/useFilePermission'
const props = defineProps({ filePath: String })
const { canWrite, canDelete, canShare } = useFilePermission(toRef(props, 'filePath'))
</script>
<template>
<div class="action-bar">
<button :disabled="!canWrite">编辑</button>
<button :disabled="!canDelete">删除</button>
<button :disabled="!canShare">分享</button>
</div>
</template>
5.2 组合多个 Composable 构建复杂逻辑
真实项目中,一个功能往往需要组合多个 Composable。文件搜索功能就是典型例子——需要搜索逻辑、防抖、加载状态、权限过滤:
// composables/useFileSearch.js
import { ref, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { usePermissionStore } from '@/stores/permissionStore'
export function useFileSearch(currentPath) {
const keyword = ref('')
const results = ref([])
const isSearching = ref(false)
const searchError = ref(null)
async function executeSearch(query) {
if (!query.trim()) {
results.value = []
return
}
isSearching.value = true
searchError.value = null
try {
const response = await fetch(
`/api/files/search?path=${currentPath.value}&q=${encodeURIComponent(query)}`
)
const data = await response.json()
// 过滤掉用户无权访问的文件
const permissionStore = usePermissionStore()
results.value = data.files.filter(file => {
return permissionStore.check(file.path, 'read')
})
} catch (err) {
searchError.value = '搜索失败,请重试'
results.value = []
} finally {
isSearching.value = false
}
}
const debouncedSearch = useDebounceFn(executeSearch, 300)
watch(keyword, (newVal) => {
debouncedSearch(newVal)
})
function clearSearch() {
keyword.value = ''
results.value = []
searchError.value = null
}
return { keyword, results, isSearching, searchError, clearSearch }
}
5.3 渲染函数实现的包装组件
虽然 Composable 覆盖了大多数逻辑复用场景,但有时我们需要在渲染层面增强组件——为现有组件添加统一的加载状态、错误边界或权限控制。这时可以用渲染函数编写包装组件:
// components/WithPermission.js
import { h, computed } from 'vue'
import { usePermissionStore } from '@/stores/permissionStore'
export default {
props: {
resource: { type: String, required: true },
action: { type: String, default: 'read' },
fallback: { type: String, default: '无权访问此内容' }
},
setup(props, { slots }) {
const permissionStore = usePermissionStore()
const hasPermission = computed(() =>
permissionStore.check(props.resource, props.action)
)
return () => {
if (hasPermission.value) {
return slots.default ? slots.default() : null
}
return slots.fallback
? slots.fallback()
: h('div', { class: 'permission-denied' }, props.fallback)
}
}
}
使用方式:
<WithPermission resource="/机密文档" action="read">
<FileListGrid :files="secretFiles" />
<template #fallback>
<PermissionRequestForm resource="/机密文档" />
</template>
</WithPermission>
六、组件库设计实践——可复用组件的工程规范
当你的文件管理器从单一项目发展为通用组件库时,组件的 API 设计就成为最关键的工程决策。一个好的组件 API 应该像一份清晰的契约——使用者不需要阅读源码就能正确使用。
6.1 Props 设计原则
原则一:单一职责,语义明确。 每个 prop 应该只做一件事,命名要让人一看就懂。
// 不好的设计:一个 config 对象包罗万象
defineProps({
config: Object
// 使用者:config 里到底能传什么?
})
// 好的设计:扁平、明确、有类型约束
defineProps({
columns: { type: Array, required: true, validator: validateColumns },
data: { type: Array, required: true },
rowKey: { type: [String, Function], default: 'id' },
selectable: { type: Boolean, default: false },
bordered: { type: Boolean, default: true },
emptyText: { type: String, default: '暂无数据' },
maxHeight: { type: [Number, String], default: null }
})
原则二:提供合理的默认值。 使用者应该能用最少的配置启动组件。
<!-- 零配置可用 -->
<FileTable :columns="cols" :data="files" />
<!-- 按需定制 -->
<FileTable
:columns="cols"
:data="files"
:selectable="true"
:bordered="false"
row-key="path"
empty-text="此文件夹中没有文件"
:max-height="400"
/>
原则三:用 validator 提前拦截错误。
defineProps({
viewMode: {
type: String,
default: 'grid',
validator: (value) => ['grid', 'list', 'detail'].includes(value)
},
sortOrder: {
type: String,
default: 'asc',
validator: (value) => ['asc', 'desc'].includes(value)
}
})
6.2 事件契约设计
事件是组件与外界通信的另一半契约。好的事件设计应遵循以下规范:
<script setup>
// 明确声明所有事件及其载荷类型
const emit = defineEmits({
// 选择文件时触发,载荷为文件对象数组
'select': (files) => Array.isArray(files),
// 双击打开文件时触发,载荷为单个文件对象
'open': (file) => file && typeof file.id === 'string',
// 排序变更时触发,载荷为 { field, order }
'sort-change': (payload) => payload.field && payload.order,
// 右键菜单时触发,载荷为 { file, event }
'context-menu': (payload) => payload.file && payload.event
})
</script>
设计事件时的几个准则:
- 命名用 kebab-case 动词短语:
select、open、sort-change、context-menu。 - 载荷结构保持稳定:同一事件的载荷格式不应随条件变化。
- 不要用事件替代 v-model:对于双向绑定场景,使用
modelValue+update:modelValue的标准 v-model 协议。
6.3 插槽设计——给使用者留出定制空间
一个成熟的组件库组件,通常在关键渲染点位预留插槽:
<!-- FileTable.vue 的插槽体系 -->
<template>
<div class="file-table">
<div class="file-table__toolbar">
<!-- 工具栏插槽:让使用者自定义批量操作按钮等 -->
<slot name="toolbar" :selected-count="selectedFiles.length">
<span>已选择 {{ selectedFiles.length }} 个文件</span>
</slot>
</div>
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col.key">
<!-- 表头插槽:支持自定义列标题 -->
<slot :name="`header-${col.key}`" :column="col">
{{ col.title }}
</slot>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in data" :key="getRowKey(row)">
<td v-for="col in columns" :key="col.key">
<!-- 单元格插槽:支持每列自定义渲染 -->
<slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]">
{{ row[col.key] }}
</slot>
</td>
</tr>
<tr v-if="data.length === 0">
<td :colspan="columns.length">
<!-- 空状态插槽 -->
<slot name="empty">
<div class="file-table__empty">{{ emptyText }}</div>
</slot>
</td>
</tr>
</tbody>
</table>
</div>
</template>
使用者可以精确定制任意部分:
<FileTable :columns="columns" :data="files">
<template #toolbar="{ selectedCount }">
<button v-if="selectedCount > 0" @click="batchDelete">
批量删除 ({{ selectedCount }})
</button>
</template>
<template #cell-name="{ row }">
<div class="custom-name-cell">
<FileTypeIcon :type="row.type" :size="16" />
<span>{{ row.name }}</span>
<span v-if="row.isNew" class="badge-new">新</span>
</div>
</template>
<template #cell-size="{ value }">
<span :class="{ 'size-large': value > 10485760 }">
{{ formatBytes(value) }}
</span>
</template>
<template #empty>
<EmptyState icon="folder-open" message="拖拽文件到此处上传" />
</template>
</FileTable>
6.4 组件 API 设计清单
在发布一个组件之前,对照以下清单自查:
| 检查项 | 说明 |
|---|---|
| Props 类型约束完整 | 每个 prop 都有 type,关键 prop 有 validator |
| 默认值合理 | 零配置即可使用基本功能 |
| 事件声明完整 | 使用 defineEmits 显式声明,载荷可验证 |
| 插槽预留充分 | 关键渲染点位有具名插槽,插槽带 scope 数据 |
| v-model 协议标准 | 双向绑定遵循 modelValue / update:modelValue |
| 无副作用 | 组件不直接修改外部状态,不在内部调用全局 API |
| 可访问性(a11y) | 关键元素有 aria 属性,支持键盘操作 |
6.5 编程式创建组件实例
在组件库开发中,有些组件不是通过模板标签使用的,而是通过 JavaScript 调用——比如全局确认对话框、文件上传进度通知等。Vue 3 提供了 createApp 来实现编程式的组件挂载:
// utils/createConfirmDialog.js
import { createApp, h } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
export function showConfirmDialog(options) {
return new Promise((resolve, reject) => {
const container = document.createElement('div')
document.body.appendChild(container)
const app = createApp({
render() {
return h(ConfirmDialog, {
title: options.title || '确认操作',
message: options.message,
confirmText: options.confirmText || '确认',
cancelText: options.cancelText || '取消',
onConfirm: () => {
resolve(true)
cleanup()
},
onCancel: () => {
resolve(false)
cleanup()
}
})
}
})
app.mount(container)
function cleanup() {
app.unmount()
document.body.removeChild(container)
}
})
}
在文件管理器中使用:
import { showConfirmDialog } from '@/utils/createConfirmDialog'
async function handleDeleteFile(file) {
const confirmed = await showConfirmDialog({
title: '删除文件',
message: `确定要删除「${file.name}」吗?此操作不可撤销。`,
confirmText: '删除',
cancelText: '保留'
})
if (confirmed) {
await fileStore.deleteFile(file.id)
}
}
这种模式的核心要点有三个:
- 使用
createApp创建独立的应用实例,它与主应用完全隔离,不会互相影响。 - 手动创建 DOM 容器并挂载,确保对话框渲染在
<body>下方,不受父组件样式和定位的干扰。 - 操作完成后必须调用
app.unmount()并移除 DOM 容器,防止内存泄漏。
如果需要让编程式创建的组件访问主应用的全局资源(如 Pinia Store、国际化插件),可以在创建时手动安装:
import { createApp, h } from 'vue'
import { createPinia } from 'pinia'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
export function showConfirmDialog(options) {
return new Promise((resolve) => {
const container = document.createElement('div')
document.body.appendChild(container)
const app = createApp({
render: () => h(ConfirmDialog, { /* props */ })
})
// ⚠️ 注意:createPinia() 会创建全新的空 Pinia 实例,无法共享主应用的 Store 数据。
// 如需共享主应用的 Pinia 实例,应将主应用的 pinia 实例导出并在此处复用:
// import { pinia } from '@/main'
// app.use(pinia)
app.use(createPinia())
app.mount(container)
// ... cleanup 逻辑同上
})
}
模式选择决策树
面对一个具体的组件设计需求,如何选择合适的模式?以下是一个实用的决策路径:
- 需要复用业务逻辑(非 UI)吗? → 用 Composable。
- 组件结构是否高度动态,模板难以表达? → 用渲染函数或 JSX。
- 组件是否无状态、纯渲染? → 考虑函数式组件。
- 数据结构是否存在自引用的树形嵌套? → 用递归组件。
- 需要在运行时切换不同组件? → 用
<component :is>+ 异步组件。 - 需要在渲染层面统一增强多个组件? → 用包装组件(渲染函数 + slots 透传)。
- 组件要给团队/社区使用? → 严格遵循组件库 API 设计规范。
🤔 思考题
-
容器与展示的边界并不总是清晰的。 假设
DirectoryTree组件需要在展开文件夹时懒加载子目录数据(调用 API),这时它还算展示组件吗?你会如何处理这个”灰色地带”? -
递归组件的性能隐患。 如果文件系统有上万个节点且层级很深,一次性全部渲染递归组件会怎样?你能设计一种「虚拟滚动 + 递归」的方案来优化吗?
-
Composable vs. Provide/Inject。
useFilePermission在每个组件中都要显式调用。如果在组件树的很深层级中也需要权限信息,用provide/inject是否更合适?两种方式各有什么取舍? -
组件库的版本兼容问题。 你发布了一个
FileTable组件,v1.0 的select事件载荷是单个文件对象,但 v2.0 需要改为文件数组以支持多选。如何做到向后兼容而不破坏已有使用者的代码?
📝 结尾自测
完成本章学习后,检验你的掌握程度:
-
请解释容器组件和展示组件的核心区别。如果一个组件既需要从 Store 获取数据,又包含复杂的 UI 渲染逻辑,你会怎么拆分?
-
写出一个
h()调用,渲染如下 HTML 结构:<div class="file-card" data-id="f001"> <img src="/icons/pdf.svg" alt="PDF" /> <span>报告.pdf</span> </div> -
递归组件
DirectoryTreeNode如果忘记设置递归终止条件会发生什么?请说明name选项在递归组件中的作用。 -
<component :is>的is属性可以绑定哪两种类型的值?结合<keep-alive>使用时,被缓存的组件不会触发哪些生命周期钩子? -
你正在设计一个通用的
SearchFilter组件供多个项目使用。请列出你会如何设计它的 props、events 和 slots,使其满足”零配置可用、按需深度定制”的目标。
掌握了组件设计模式之后,下一章我们将学习 Vue 3 的内置组件与高级特性——Transition、KeepAlive、Teleport、Suspense、自定义指令和插件系统,让你从”能写出来”进化到”写得专业”。
购买课程解锁全部内容
渐进式到全面掌控:12 章系统精通 Vue 3
¥29.90