05. 容器与展示组件

概述

容器与展示组件模式是一种将逻辑与 UI 分离的设计模式。容器组件负责数据获取和状态管理,展示组件负责 UI 渲染。这种模式提高了代码的可测试性和复用性。

维度内容
What容器组件负责逻辑和数据,展示组件负责 UI 渲染
Why分离关注点,提高可测试性和复用性
When组件有复杂逻辑和 UI 时
Where组件分层设计
Who需要组件逻辑复用的开发者
HowContainerComponent → 获取数据 → PresentationalComponent 渲染

1. 什么是容器与展示组件

1.1 基本概念

  • 容器组件:负责数据获取、状态管理、业务逻辑
  • 展示组件:负责 UI 渲染,通过 props 接收数据
// 展示组件:只负责 UI 渲染
function UserList({ users, loading, error, onRetry }) {
  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误: {error.message} <button onClick={onRetry}>重试</button></div>;
  
  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

// 容器组件:负责数据获取和逻辑
function UserListContainer() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const fetchUsers = async () => {
    try {
      setLoading(true);
      const response = await fetch('/api/users');
      const data = await response.json();
      setUsers(data);
    } catch (err) {
      setError(err);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchUsers();
  }, []);

  return (
    <UserList
      users={users}
      loading={loading}
      error={error}
      onRetry={fetchUsers}
    />
  );
}

1.2 为什么需要分离

// ❌ 耦合在一起:难以测试和复用
function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(setUsers)
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <div>加载中...</div>;
  return <ul>{users.map(user => <li key={user.id}>{user.name}</li>)}</ul>;
}

2. 展示组件

2.1 展示组件的特点

  • 只关心 UI 渲染
  • 通过 props 接收数据
  • 不依赖外部 API
  • 易于测试
  • 易于复用
// 展示组件示例
function Button({ children, onClick, disabled, variant = 'primary' }) {
  const variants = {
    primary: 'bg-blue-500 text-white',
    secondary: 'bg-gray-500 text-white',
    danger: 'bg-red-500 text-white',
  };

  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`px-4 py-2 rounded ${variants[variant]} ${disabled ? 'opacity-50' : ''}`}
    >
      {children}
    </button>
  );
}

function Card({ title, children, footer }) {
  return (
    <div className="border rounded-lg shadow">
      {title && <div className="border-b p-4 font-bold">{title}</div>}
      <div className="p-4">{children}</div>
      {footer && <div className="border-t p-4 text-gray-500">{footer}</div>}
    </div>
  );
}

function UserCard({ user, onEdit, onDelete }) {
  return (
    <Card title={user.name} footer={`邮箱: ${user.email}`}>
      <p>角色: {user.role}</p>
      <div className="flex gap-2 mt-4">
        <Button variant="secondary" onClick={() => onEdit(user)}>编辑</Button>
        <Button variant="danger" onClick={() => onDelete(user.id)}>删除</Button>
      </div>
    </Card>
  );
}

2.2 展示组件的测试

// 展示组件测试非常容易
import { render, screen, fireEvent } from '@testing-library/react';

test('UserCard 渲染用户信息', () => {
  const user = { id: 1, name: '张三', email: 'zhang@example.com', role: 'admin' };
  const onEdit = jest.fn();
  const onDelete = jest.fn();

  render(<UserCard user={user} onEdit={onEdit} onDelete={onDelete} />);

  expect(screen.getByText('张三')).toBeInTheDocument();
  expect(screen.getByText('zhang@example.com')).toBeInTheDocument();

  fireEvent.click(screen.getByText('编辑'));
  expect(onEdit).toHaveBeenCalledWith(user);
});

3. 容器组件

3.1 容器组件的特点

  • 负责数据获取
  • 负责状态管理
  • 负责业务逻辑
  • 将数据传递给展示组件
// 容器组件示例
function UserListContainer() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const fetchUsers = useCallback(async () => {
    try {
      setLoading(true);
      const response = await api.getUsers();
      setUsers(response.data);
    } catch (err) {
      setError(err);
    } finally {
      setLoading(false);
    }
  }, []);

  const handleEdit = useCallback(async (user) => {
    await api.updateUser(user.id, user);
    fetchUsers(); // 刷新列表
  }, [fetchUsers]);

  const handleDelete = useCallback(async (id) => {
    await api.deleteUser(id);
    fetchUsers();
  }, [fetchUsers]);

  useEffect(() => {
    fetchUsers();
  }, [fetchUsers]);

  return (
    <div>
      <h1>用户管理</h1>
      {loading ? (
        <Spinner />
      ) : error ? (
        <ErrorMessage error={error} onRetry={fetchUsers} />
      ) : (
        <div className="user-grid">
          {users.map(user => (
            <UserCard
              key={user.id}
              user={user}
              onEdit={handleEdit}
              onDelete={handleDelete}
            />
          ))}
        </div>
      )}
    </div>
  );
}

3.2 容器组件的测试

// 容器组件测试需要 mock API
import { render, screen, waitFor } from '@testing-library/react';
import { api } from './api';

jest.mock('./api');

test('UserListContainer 加载并显示用户', async () => {
  const mockUsers = [
    { id: 1, name: '张三', email: 'zhang@example.com', role: 'admin' },
  ];
  api.getUsers.mockResolvedValue({ data: mockUsers });

  render(<UserListContainer />);

  expect(screen.getByText('加载中...')).toBeInTheDocument();

  await waitFor(() => {
    expect(screen.getByText('张三')).toBeInTheDocument();
  });
});

4. 实际应用场景

4.1 数据获取场景

// 展示组件
function PostList({ posts, loading, error, onRetry }) {
  if (loading) return <PostSkeleton count={5} />;
  if (error) return <ErrorView error={error} onRetry={onRetry} />;
  
  return (
    <div className="posts">
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

// 容器组件
function PostListContainer() {
  const { data: posts, isLoading, error, refetch } = useQuery({
    queryKey: ['posts'],
    queryFn: () => fetch('/api/posts').then(res => res.json()),
  });

  return (
    <PostList
      posts={posts}
      loading={isLoading}
      error={error}
      onRetry={refetch}
    />
  );
}

4.2 表单场景

// 展示组件:表单 UI
function LoginForm({ values, errors, onChange, onSubmit, isLoading }) {
  return (
    <form onSubmit={onSubmit} className="login-form">
      <div>
        <input
          name="email"
          type="email"
          value={values.email}
          onChange={onChange}
          placeholder="邮箱"
          className={errors.email ? 'error' : ''}
        />
        {errors.email && <span className="error">{errors.email}</span>}
      </div>
      <div>
        <input
          name="password"
          type="password"
          value={values.password}
          onChange={onChange}
          placeholder="密码"
        />
      </div>
      <button type="submit" disabled={isLoading}>
        {isLoading ? '登录中...' : '登录'}
      </button>
    </form>
  );
}

// 容器组件:表单逻辑
function LoginFormContainer() {
  const [values, setValues] = useState({ email: '', password: '' });
  const [errors, setErrors] = useState({});
  const [isLoading, setIsLoading] = useState(false);

  const handleChange = (e) => {
    const { name, value } = e.target;
    setValues(prev => ({ ...prev, [name]: value }));
    
    // 实时验证
    if (name === 'email' && !value.includes('@')) {
      setErrors(prev => ({ ...prev, email: '邮箱格式错误' }));
    } else {
      setErrors(prev => ({ ...prev, email: '' }));
    }
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsLoading(true);
    try {
      await login(values.email, values.password);
      // 跳转到首页
    } catch (error) {
      setErrors(prev => ({ ...prev, form: error.message }));
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <LoginForm
      values={values}
      errors={errors}
      onChange={handleChange}
      onSubmit={handleSubmit}
      isLoading={isLoading}
    />
  );
}

5. 完整示例:商品管理系统

// ============ 展示组件 ============

// ProductCard 展示组件
function ProductCard({ product, onEdit, onDelete }) {
  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p className="price">¥{product.price}</p>
      <p className="stock">库存: {product.stock}</p>
      <div className="actions">
        <button onClick={() => onEdit(product)}>编辑</button>
        <button onClick={() => onDelete(product.id)}>删除</button>
      </div>
    </div>
  );
}

// ProductForm 展示组件
function ProductForm({ initialValues, onSubmit, isLoading }) {
  const [values, setValues] = useState(initialValues || { name: '', price: '', stock: '' });

  const handleSubmit = (e) => {
    e.preventDefault();
    onSubmit(values);
  };

  return (
    <form onSubmit={handleSubmit} className="product-form">
      <input
        value={values.name}
        onChange={(e) => setValues({ ...values, name: e.target.value })}
        placeholder="商品名称"
        required
      />
      <input
        type="number"
        value={values.price}
        onChange={(e) => setValues({ ...values, price: e.target.value })}
        placeholder="价格"
        required
      />
      <input
        type="number"
        value={values.stock}
        onChange={(e) => setValues({ ...values, stock: e.target.value })}
        placeholder="库存"
        required
      />
      <button type="submit" disabled={isLoading}>
        {isLoading ? '保存中...' : '保存'}
      </button>
    </form>
  );
}

// ProductList 展示组件
function ProductList({ products, loading, error, onRetry, onEdit, onDelete }) {
  if (loading) return <div>加载商品中...</div>;
  if (error) return (
    <div>
      错误: {error.message}
      <button onClick={onRetry}>重试</button>
    </div>
  );

  return (
    <div className="product-grid">
      {products.map(product => (
        <ProductCard
          key={product.id}
          product={product}
          onEdit={onEdit}
          onDelete={onDelete}
        />
      ))}
    </div>
  );
}

// ============ 容器组件 ============

function ProductManagement() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [showForm, setShowForm] = useState(false);
  const [editingProduct, setEditingProduct] = useState(null);
  const [saving, setSaving] = useState(false);

  const fetchProducts = useCallback(async () => {
    try {
      setLoading(true);
      const response = await fetch('/api/products');
      const data = await response.json();
      setProducts(data);
    } catch (err) {
      setError(err);
    } finally {
      setLoading(false);
    }
  }, []);

  const handleSave = async (product) => {
    setSaving(true);
    try {
      const url = editingProduct ? `/api/products/${editingProduct.id}` : '/api/products';
      const method = editingProduct ? 'PUT' : 'POST';
      
      await fetch(url, {
        method,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(product),
      });
      
      await fetchProducts();
      setShowForm(false);
      setEditingProduct(null);
    } catch (err) {
      console.error('保存失败:', err);
    } finally {
      setSaving(false);
    }
  };

  const handleEdit = (product) => {
    setEditingProduct(product);
    setShowForm(true);
  };

  const handleDelete = async (id) => {
    if (!confirm('确定删除吗?')) return;
    
    try {
      await fetch(`/api/products/${id}`, { method: 'DELETE' });
      await fetchProducts();
    } catch (err) {
      console.error('删除失败:', err);
    }
  };

  useEffect(() => {
    fetchProducts();
  }, [fetchProducts]);

  return (
    <div className="product-management">
      <div className="header">
        <h1>商品管理</h1>
        <button onClick={() => setShowForm(true)}>添加商品</button>
      </div>

      {showForm && (
        <div className="modal">
          <div className="modal-content">
            <h2>{editingProduct ? '编辑商品' : '添加商品'}</h2>
            <ProductForm
              initialValues={editingProduct}
              onSubmit={handleSave}
              isLoading={saving}
            />
            <button onClick={() => {
              setShowForm(false);
              setEditingProduct(null);
            }}>取消</button>
          </div>
        </div>
      )}

      <ProductList
        products={products}
        loading={loading}
        error={error}
        onRetry={fetchProducts}
        onEdit={handleEdit}
        onDelete={handleDelete}
      />
    </div>
  );
}

6. 总结

核心要点

类型职责特点
展示组件UI 渲染纯函数、易测试、易复用
容器组件逻辑状态有副作用、连接数据

优势

  • 关注点分离
  • 提高可测试性
  • 提高可复用性
  • 便于协作开发

记忆口诀

容器组件管逻辑,展示组件管 UI
数据 props 传下去,测试复用都容易


7. 相关资源


更多推荐