跳到主要内容
预计阅读 73 分钟

组件设计模式

在一个真实的文件管理系统中,你会遇到这样的挑战:文件树需要无限层级展开,文件预览面板要根据格式动态切换渲染方式,权限面板的逻辑在多个页面重复出现,搜索过滤组件的 UI 和数据逻辑纠缠不清。这些问题的本质,是组件设计决策的问题——用什么模式组织组件,才能让系统既灵活又可维护?

本章将以「文件管理器」作为贯穿场景,系统讲解 Vue 3 中六大核心组件设计模式。你将学会在不同工程场景下做出正确的组件架构决策。

📋 开篇自测

在正式学习前,试着回答以下三个问题,检验你对组件设计的现有认知:

  1. 在一个文件管理页面中,「获取文件列表数据」的逻辑应该放在哪类组件中?它和「渲染文件卡片」的组件应该是同一个吗?
  2. 如果需要根据文件类型(图片、视频、文档、代码)动态渲染不同的预览组件,你能想到几种实现方式?
  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 中,函数式组件是一个普通函数,接收 propscontext(含 slotsattrsemit),返回 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 递归组件:渲染树形结构

文件管理器中最典型的递归结构就是目录树。一个文件夹下可以有子文件夹,子文件夹下还可以有子文件夹,层级不确定。这正是递归组件的用武之地。

实现递归组件的两个必要条件:

  1. 组件需要一个可引用的名称。在 <script setup> 的 SFC 中(Vue 3.2.34+),编译器会自动从文件名推断组件名(如 DirectoryTreeNode.vue 自动获得名称 DirectoryTreeNode),无需手动声明。如果需要自定义名称,可以使用 defineOptions({ name: '...' })
  2. 必须有明确的递归终止条件,否则会产生无限循环导致栈溢出。
<!-- 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>

这段代码的关键设计决策:

  1. defineAsyncComponent 实现按需加载:用户打开图片时不会加载视频播放器的代码,每个预览器独立分包。
  2. 注册表模式取代 if-else 链:新增一种文件类型的支持,只需在 viewerRegistry 添加一行配置。
  3. component :is 绑定组件对象:is 可以接收组件对象引用(如上所示),也可以接收已注册的组件名字符串。

结合 <keep-alive> 还能缓存已打开的预览器,在切换预览文件时避免重复渲染:

<keep-alive :max="5">
  <component :is="previewComponent" :file="file" :key="file.id" />
</keep-alive>

keep-alivemax 属性限制最多缓存 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 动词短语selectopensort-changecontext-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 逻辑同上
  })
}

模式选择决策树

面对一个具体的组件设计需求,如何选择合适的模式?以下是一个实用的决策路径:

  1. 需要复用业务逻辑(非 UI)吗? → 用 Composable。
  2. 组件结构是否高度动态,模板难以表达? → 用渲染函数或 JSX。
  3. 组件是否无状态、纯渲染? → 考虑函数式组件。
  4. 数据结构是否存在自引用的树形嵌套? → 用递归组件。
  5. 需要在运行时切换不同组件? → 用 <component :is> + 异步组件。
  6. 需要在渲染层面统一增强多个组件? → 用包装组件(渲染函数 + slots 透传)。
  7. 组件要给团队/社区使用? → 严格遵循组件库 API 设计规范。

🤔 思考题

  1. 容器与展示的边界并不总是清晰的。 假设 DirectoryTree 组件需要在展开文件夹时懒加载子目录数据(调用 API),这时它还算展示组件吗?你会如何处理这个”灰色地带”?

  2. 递归组件的性能隐患。 如果文件系统有上万个节点且层级很深,一次性全部渲染递归组件会怎样?你能设计一种「虚拟滚动 + 递归」的方案来优化吗?

  3. Composable vs. Provide/Inject。 useFilePermission 在每个组件中都要显式调用。如果在组件树的很深层级中也需要权限信息,用 provide/inject 是否更合适?两种方式各有什么取舍?

  4. 组件库的版本兼容问题。 你发布了一个 FileTable 组件,v1.0 的 select 事件载荷是单个文件对象,但 v2.0 需要改为文件数组以支持多选。如何做到向后兼容而不破坏已有使用者的代码?


📝 结尾自测

完成本章学习后,检验你的掌握程度:

  1. 请解释容器组件和展示组件的核心区别。如果一个组件既需要从 Store 获取数据,又包含复杂的 UI 渲染逻辑,你会怎么拆分?

  2. 写出一个 h() 调用,渲染如下 HTML 结构:

    <div class="file-card" data-id="f001">
      <img src="/icons/pdf.svg" alt="PDF" />
      <span>报告.pdf</span>
    </div>
  3. 递归组件 DirectoryTreeNode 如果忘记设置递归终止条件会发生什么?请说明 name 选项在递归组件中的作用。

  4. <component :is>is 属性可以绑定哪两种类型的值?结合 <keep-alive> 使用时,被缓存的组件不会触发哪些生命周期钩子?

  5. 你正在设计一个通用的 SearchFilter 组件供多个项目使用。请列出你会如何设计它的 props、events 和 slots,使其满足”零配置可用、按需深度定制”的目标。


掌握了组件设计模式之后,下一章我们将学习 Vue 3 的内置组件与高级特性——Transition、KeepAlive、Teleport、Suspense、自定义指令和插件系统,让你从”能写出来”进化到”写得专业”。

购买课程解锁全部内容

渐进式到全面掌控:12 章系统精通 Vue 3

¥29.90