第04章 容器/展示组件模式

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》的文章,这篇文章彻底改变了许多开发者对组件架构的思考方式。他提出了一种将组件分为两个明确类别的模式:

  1. Presentational Components(展示组件/哑组件):关注"如何显示"

  2. 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>

这种传统方式的问题:

  1. 职责混乱:同一个组件既要处理数据获取,又要处理 UI 渲染

  2. 难以重用:UI 部分与数据逻辑紧耦合,无法独立重用

  3. 测试困难:需要同时测试 UI 和逻辑,增加了测试复杂度

  4. 维护困难:修改 UI 可能影响逻辑,修改逻辑可能影响 UI

Container/Presentational Pattern 详解

展示组件(Presentational Components)

展示组件是"哑"组件,它们专注于如何展示数据,具有以下特征:

核心特征
  1. 只关心外观:专注于 UI 渲染和样式

  2. 通过 props 接收数据:不直接获取或修改数据

  3. 无状态或只有 UI 状态:不包含业务逻辑状态

  4. 高度可重用:可以在不同场景中使用

实际示例

让我们创建一个展示狗狗图片的展示组件:

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>

展示组件的优势:

  1. 纯粹性:只负责渲染,逻辑简单清晰

  2. 可重用性:可以在不同的容器中使用

  3. 易于测试:只需要测试 UI 渲染逻辑

  4. 样式独立:可以专注于视觉设计

容器组件(Container Components)

容器组件是"智能"组件,它们专注于如何获取和管理数据,具有以下特征:

核心特征
  1. 关心数据流:负责数据获取、状态管理

  2. 包含业务逻辑:处理复杂的应用逻辑

  3. 很少有 UI:通常只渲染展示组件

  4. 提供数据和回调:为展示组件提供所需的数据和方法