Container/Presentational Pattern(容器/展示组件模式)是现代前端开发中一个重要的架构模式,它通过明确分离组件的职责来提高代码的可维护性和可重用性。本章将深入探讨这一模式的起源、核心概念、在 Vue 3 中的实现方式,以及如何与现代开发模式(如 Composables)相结合。
这个模式最初由 React 社区的 Dan Abramov 在 2015 年提出,虽然主要针对 React,但其核心理念已被广泛应用到包括 Vue 在内的各种前端框架中。随着 Vue 3 Composition API 的引入,这一模式也在不断演进和适应新的开发范式。
学习目标
通过本章的学习,您将能够:
-
深入理解 Container/Presentational Pattern 的设计理念和历史背景
-
熟练掌握 在 Vue 3 中实现容器组件和展示组件的方法
-
灵活运用 该模式解决复杂的组件架构问题
-
理解演进过程 从传统模式到 Composables 的转变
-
掌握最佳实践 在现代 Vue 应用中合理应用这一模式
-
学会权衡取舍 何时使用传统模式,何时使用 Composables
模式的历史背景与设计理念
起源与发展
2015年,React 核心团队成员 Dan Abramov 发表了一篇名为《Presentational and Container Components》的文章,这篇文章彻底改变了许多开发者对组件架构的思考方式。他提出了一种将组件分为两个明确类别的模式:
-
Presentational Components(展示组件/哑组件):关注"如何显示"
-
Container Components(容器组件/智能组件):关注"如何工作"
核心设计理念
这种分离的核心理念基于以下几个重要原则:
1. 关注点分离(Separation of Concerns)
-
展示组件专注于 UI 渲染和用户交互
-
容器组件专注于数据获取和业务逻辑
2. 单一职责原则(Single Responsibility Principle)
-
每个组件只负责一个明确的职责
-
避免组件承担过多的责任
3. 可重用性(Reusability)
-
展示组件可以在不同的上下文中重用
-
容器组件可以为不同的展示组件提供数据
4. 可测试性(Testability)
-
展示组件易于进行 UI 测试
-
容器组件易于进行逻辑测试
传统架构的问题
在这种模式出现之前,组件往往承担了过多的职责:
go
<!-- 传统的混合组件 - 不推荐 -->
<template>
<div class="dog-gallery">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else class="image-grid">
<img
v-for="(dog, index) in dogs"
:key="index"
:src="dog"
:alt="`Dog ${index + 1}`"
class="dog-image"
@click="selectDog(dog)"
/>
</div>
</div>
</template>
<script>
export default {
data() {
return {
dogs: [],
loading: false,
error: null,
selectedDog: null
}
},
async mounted() {
await this.fetchDogs()
},
methods: {
async fetchDogs() {
this.loading = true
this.error = null
try {
const response = await fetch('https://dog.ceo/api/breed/labrador/images/random/6')
const { message } = await response.json()
this.dogs = message
} catch (err) {
this.error = 'Failed to fetch dogs'
} finally {
this.loading = false
}
},
selectDog(dog) {
this.selectedDog = dog
// 更多业务逻辑...
}
}
}
</script>
<style scoped>
.dog-gallery {
padding: 20px;
}
.loading, .error {
text-align: center;
padding: 20px;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.dog-image {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s;
}
.dog-image:hover {
transform: scale(1.05);
}
</style>
这种传统方式的问题:
-
职责混乱:同一个组件既要处理数据获取,又要处理 UI 渲染
-
难以重用:UI 部分与数据逻辑紧耦合,无法独立重用
-
测试困难:需要同时测试 UI 和逻辑,增加了测试复杂度
-
维护困难:修改 UI 可能影响逻辑,修改逻辑可能影响 UI
Container/Presentational Pattern 详解
展示组件(Presentational Components)
展示组件是"哑"组件,它们专注于如何展示数据,具有以下特征:
核心特征
-
只关心外观:专注于 UI 渲染和样式
-
通过 props 接收数据:不直接获取或修改数据
-
无状态或只有 UI 状态:不包含业务逻辑状态
-
高度可重用:可以在不同场景中使用
实际示例
让我们创建一个展示狗狗图片的展示组件:
go
<!-- DogImages.vue - 展示组件 -->
<template>
<div class="dog-images">
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<p>Loading adorable dogs...</p>
</div>
<div v-else-if="error" class="error-state">
<div class="error-icon">⚠️</div>
<p>{{ error }}</p>
<button @click="$emit('retry')" class="retry-button">
Try Again
</button>
</div>
<div v-else-if="dogs.length === 0" class="empty-state">
<div class="empty-icon">🐕</div>
<p>No dogs found</p>
</div>
<div v-else class="image-grid">
<div
v-for="(dog, index) in dogs"
:key="index"
class="image-card"
@click="$emit('dogSelected', dog)"
>
<img
:src="dog"
:alt="`Dog ${index + 1}`"
class="dog-image"
@load="onImageLoad"
@error="onImageError"
/>
<div class="image-overlay">
<button class="favorite-button" @click.stop="$emit('toggleFavorite', dog)">
{{ favorites.includes(dog) ? '❤️' : '🤍' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
// Props 定义 - 只接收数据,不获取数据
const props = defineProps({
dogs: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
error: {
type: String,
default: null
},
favorites: {
type: Array,
default: () => []
}
})
// 事件定义 - 通过事件与父组件通信
const emit = defineEmits(['dogSelected', 'toggleFavorite', 'retry'])
// 只包含 UI 相关的方法
const onImageLoad = (event) => {
event.target.classList.add('loaded')
}
const onImageError = (event) => {
event.target.src = '/placeholder-dog.jpg' // 备用图片
}
</script>
<style scoped>
.dog-images {
padding: 20px;
}
.loading-state, .error-state, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
text-align: center;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-icon, .empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.retry-button {
margin-top: 16px;
padding: 8px 16px;
background-color: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.retry-button:hover {
background-color: #2980b9;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.image-card {
position: relative;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
}
.image-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
}
.dog-image {
width: 100%;
height: 200px;
object-fit: cover;
opacity: 0;
transition: opacity 0.3s;
}
.dog-image.loaded {
opacity: 1;
}
.image-overlay {
position: absolute;
top: 8px;
right: 8px;
opacity: 0;
transition: opacity 0.2s;
}
.image-card:hover .image-overlay {
opacity: 1;
}
.favorite-button {
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.2s;
}
.favorite-button:hover {
background: rgba(255, 255, 255, 1);
}
</style>
展示组件的优势:
-
纯粹性:只负责渲染,逻辑简单清晰
-
可重用性:可以在不同的容器中使用
-
易于测试:只需要测试 UI 渲染逻辑
-
样式独立:可以专注于视觉设计
容器组件(Container Components)
容器组件是"智能"组件,它们专注于如何获取和管理数据,具有以下特征:
核心特征
-
关心数据流:负责数据获取、状态管理
-
包含业务逻辑:处理复杂的应用逻辑
-
很少有 UI:通常只渲染展示组件
-
提供数据和回调:为展示组件提供所需的数据和方法