React 容器模式:在大规模应用中划分 Smart 与 Dumb 组件的逻辑分层准则

各位好,欢迎来到今天的“React 大型应用架构重构”现场。

如果你是刚入坑的前端,或者是一个在代码堆里摸爬滚打了一两年的老兵,你一定经历过那种让人想撞墙的时刻:你的组件文件越来越长,从 50 行变成了 200 行,然后是 500 行,最后变成了一个让人绝望的 2000 行的“上帝组件”。

在这个组件里,你既处理数据获取,既写业务逻辑,既写样式,甚至连 Redux 的 dispatch 都直接写在渲染函数里。这就像是在厨房里,一个厨师既要杀鸡,又要切菜,还要炒菜,最后还要负责洗碗,并且还得保证上桌的菜色香味俱全。结果是什么?通常是一锅乱炖,不仅难吃,而且只要你想改一道菜,整个厨房都会炸。

今天,我们要聊的,就是如何用容器模式,把这种“乱炖”变成“米其林三星流水线”。我们要讲的是 React 中最经典,也是最容易被误解的分层策略:Smart(智能)组件与 Dumb(傻瓜)组件的博弈。

准备好了吗?让我们把代码从泥潭里拔出来。

第一章:上帝组件的陨落

首先,让我们看看“以前”的写法。这就是我们要抛弃的“意大利面条式代码”的典型代表。

假设我们要做一个“用户个人中心”。在不懂分层的时候,你会怎么做?

// UserProfile.jsx - 一个让人想哭的文件
import React, { useState, useEffect } from 'react';
import axios from 'axios';

const UserProfile = () => {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // 这里是数据获取
    axios.get('/api/user/123')
      .then(response => {
        setUser(response.data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, []);

  // 这里是业务逻辑
  const handleFollow = () => {
    if (!user) return;
    // 复杂的表单验证、权限检查、API 调用...
    axios.post('/api/follow', { userId: user.id })
      .then(() => setUser({ ...user, isFollowing: true }));
  };

  // 这里是样式... 等等,这里怎么还有 CSS-in-JS?
  if (loading) return <div className="spinner">Loading...</div>;
  if (error) return <div className="error">Error: {error}</div>;

  return (
    <div className="profile-container">
      <img src={user.avatar} alt="Avatar" />
      <h1>{user.name}</h1>
      <p>{user.bio}</p>

      <button onClick={handleFollow} className={user.isFollowing ? 'active' : ''}>
        {user.isFollowing ? 'Following' : 'Follow'}
      </button>

      {/* 甚至可能还有其他无关的代码... */}
      <div className="random-footer">广告位招租</div>
    </div>
  );
};

export default UserProfile;

看,这就是问题所在。这个组件既关心数据(从 API 获取),又关心逻辑(关注点分离),还关心展示(HTML 结构)。它就像一个超级英雄,试图独自对抗全宇宙的 bug。但是,当你需要把这个头像组件复用到另一个页面时,你就麻烦了:你不得不把 axios 请求也带过去,或者把整个组件复制粘贴。这就是耦合,它是软件工程中最糟糕的气味。

第二章:谁是“厨师”,谁是“服务员”?

为了解决这个问题,我们需要引入两个概念:Smart 组件(容器组件)Dumb 组件(展示组件)

为了让你彻底理解,我们来打个比方。

想象一家高端餐厅。

  1. Dumb 组件(展示组件)就像是“服务员”

    • 他不关心牛排是从哪头牛身上割下来的,也不关心厨师用了什么火候。
    • 他只知道把盘子端给顾客。
    • 他只负责:接收数据 -> 渲染 HTML -> 传递点击事件给上家。
    • 他不包含任何逻辑,不包含任何状态管理,甚至不知道 Redux 是什么。他非常“傻”,但他非常纯粹。
  2. Smart 组件(容器组件)就像是“主厨”

    • 他负责点菜(获取数据),切菜(处理数据),以及决定怎么烹饪(业务逻辑)。
    • 他知道菜单上有什么,知道顾客想要什么。
    • 他负责把数据准备好,然后扔给服务员。

核心原则:

  • Dumb 组件:只接收 props。只渲染。纯函数式思维(除了生命周期钩子外)。
  • Smart 组件:管理状态。获取数据。订阅 Redux。处理业务逻辑。

第三章:重构实战 – 从混乱到有序

让我们把刚才那个“上帝组件”拆开。我们将创建两个文件:UserProfile.js(Dumb)和 UserProfileContainer.js(Smart)。

3.1 创建 Dumb 组件

Dumb 组件是纯粹的。它不关心数据从哪来,它只关心数据长什么样。

// UserProfile.js
import React from 'react';

const UserProfile = ({ user, isLoading, error, onFollow }) => {
  // 渲染逻辑
  if (isLoading) {
    return <div className="profile-loading">正在加载您的灵魂...</div>;
  }

  if (error) {
    return <div className="profile-error">哎呀,服务器打了个喷嚏:{error}</div>;
  }

  return (
    <div className="user-profile-card">
      <div className="profile-header">
        <img src={user.avatar} alt={user.name} className="avatar" />
        <h2>{user.name}</h2>
        <p className="bio">{user.bio}</p>
      </div>

      <div className="profile-stats">
        <span>Posts: {user.postsCount}</span>
        <span>Followers: {user.followersCount}</span>
      </div>

      <button 
        className={`follow-btn ${user.isFollowing ? 'following' : ''}`} 
        onClick={onFollow}
      >
        {user.isFollowing ? '已关注' : '关注'}
      </button>
    </div>
  );
};

export default UserProfile;

你看,这代码干净得像刚洗过的盘子。如果你想把这个组件放到一个深蓝色的后台管理系统里,你只需要改 CSS 类名,完全不需要动逻辑。这就是可复用性

3.2 创建 Smart 组件

现在,逻辑去哪了?去 UserProfileContainer.js

// UserProfileContainer.js
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import UserProfile from './UserProfile';

const UserProfileContainer = () => {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  // 1. 数据获取逻辑
  useEffect(() => {
    const fetchUser = async () => {
      try {
        setIsLoading(true);
        const response = await axios.get('/api/user/123');
        setUser(response.data);
      } catch (err) {
        setError('无法获取用户信息');
        console.error(err);
      } finally {
        setIsLoading(false);
      }
    };

    fetchUser();
  }, []);

  // 2. 业务逻辑处理
  const handleFollow = async () => {
    if (!user) return;

    try {
      await axios.post('/api/follow', { userId: user.id });
      // 更新本地状态,触发重绘
      setUser({ ...user, isFollowing: true });
    } catch (err) {
      alert('关注失败,请重试');
    }
  };

  // 3. 将处理好的数据传给 Dumb 组件
  return (
    <UserProfile 
      user={user}
      isLoading={isLoading}
      error={error}
      onFollow={handleFollow}
    />
  );
};

export default UserProfileContainer;

现在,逻辑被隔离了。如果你想把这个组件放到 Redux 里,你只需要把 useStateuseEffect 换成 useSelectoruseDispatch,Dumb 组件一行代码都不用改。这就是解耦

第四章:进阶技巧 – Hooks 的崛起与容器模式的演变

上面的例子是 Redux 时代的标准写法。但在 React 16.8 引入 Hooks 之后,容器模式发生了一些微妙的变化。我们不再需要显式地写两个文件来区分 Smart 和 Dumb,我们可以用自定义 Hooks 来封装逻辑。

这就像是把“主厨”的帽子戴在了“服务员”头上,但服务员依然只负责端盘子,只是他背着一个写满了菜谱的小本本。

4.1 自定义 Hooks 作为容器逻辑的封装

让我们重构一下,把数据获取的逻辑抽离成一个 Hook。

// hooks/useUserProfile.js
import { useState, useEffect } from 'react';
import axios from 'axios';

export const useUserProfile = (userId) => {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchUser = async () => {
      // ... 省略网络请求代码
    };
    fetchUser();
  }, [userId]); // 依赖 userId

  return { user, isLoading, error };
};

现在,我们的 Smart 组件变得非常轻量,甚至可以直接写在 JSX 里,或者作为非常小的容器。

// SmartComponent.js
import { useUserProfile } from './hooks/useUserProfile';
import UserProfile from './UserProfile';

const SmartComponent = () => {
  const { user, isLoading, error } = useUserProfile(123);

  return (
    <UserProfile 
      user={user}
      isLoading={isLoading}
      error={error}
      onFollow={/* 处理逻辑 */}
    />
  );
};

这种写法更符合现代 React 的风格。但核心思想没变:数据获取和逻辑处理在“Smart”层,UI 渲染在“Dumb”层。

第五章:大规模应用中的“宏观战术”

当你的应用从几百行代码变成几万行、几十万行时,容器模式就不仅仅是“拆分文件”那么简单了。它变成了架构决策

5.1 上下文传递:不要把 Context 传给每个 Dumb 组件

在大规模应用中,我们经常使用 Context API 或 Redux 来管理全局状态(比如用户登录信息、主题配置)。

错误示范:

// UserProfile.js (Dumb)
import { ThemeContext } from './ThemeContext'; // Dumb 组件引入了 Context

const UserProfile = ({ user }) => {
  const theme = useContext(ThemeContext); // Dumb 组件居然在消费 Context!
  // ...
}

这是糟糕的。Dumb 组件不应该知道它在一个什么样的主题环境下运行,也不应该知道它是不是在一个 Redux store 里。

正确示范:

// UserProfileContainer.js (Smart)
const UserProfileContainer = () => {
  const user = useUser();
  const theme = useTheme(); // 在 Smart 层获取 Theme

  return (
    <UserProfile 
      user={user}
      theme={theme} // 把 Theme 作为 prop 传下去
    />
  );
};

// UserProfile.js (Dumb)
const UserProfile = ({ user, theme }) => {
  // 直接用 theme,不需要知道它从哪来
  return <div style={{ color: theme.primary }}>Hello {user.name}</div>;
};

这样,Dumb 组件是完全隔离的。如果你明天换成了 CSS Modules 或者 Styled Components,你只需要改 Smart 组件的传参方式,Dumb 组件连一行代码都不用动。

5.2 性能优化:React.memo 的位置

很多人会问:“Smart 组件和 Dumb 组件哪个应该用 React.memo?”

答案是:通常给 Dumb 组件用。

为什么?因为 Smart 组件本身就在管理状态。当状态更新时,Smart 组件会重新渲染,它会重新计算 props 并传递给 Dumb 组件。如果 Dumb 组件没有优化,它也会重新渲染。

// Dumb 组件
const UserProfile = React.memo(({ user, onFollow }) => {
  // 纯渲染逻辑
  // ...
});

但是! 这里有个坑。如果你把 onFollow 作为一个普通的函数传下去,每次父组件渲染,这个函数都会重新创建,导致 React.memo 失效(浅比较失败)。

解决方案:使用 useCallback

// Smart 组件
const SmartComponent = () => {
  const [user, setUser] = useState(null);

  // 确保这个函数引用是稳定的
  const handleFollow = useCallback(() => {
    setUser(prev => ({ ...prev, isFollowing: true }));
  }, []);

  return <UserProfile user={user} onFollow={handleFollow} />;
};

第六章:模式的高级形态 – 模板组件

在非常复杂的大型应用中,我们经常遇到一种情况:结构是固定的,但内容是动态的。

比如,一个通用的“列表页”容器。它负责处理分页、排序、筛选(Smart),但它不知道列表里到底显示的是“文章”、“用户”还是“商品”(Dumb)。

这就是模板组件模式。

// Smart: ListContainer.js
const ListContainer = ({ type, renderListItem }) => {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    // 获取数据逻辑...
    fetchItems(page, type).then(setItems);
  }, [page, type]);

  return (
    <div>
      <div className="list-header">
        <button onClick={() => setPage(p => p - 1)}>上一页</button>
        <button onClick={() => setPage(p => p + 1)}>下一页</button>
      </div>
      <ul>
        {items.map(item => (
          <li key={item.id}>
            {/* 把渲染逻辑委托给子组件 */}
            <renderListItem item={item} />
          </li>
        ))}
      </ul>
    </div>
  );
};
// Dumb: ArticleList.js
const ArticleList = () => {
  const ArticleItem = ({ item }) => (
    <article>
      <h3>{item.title}</h3>
      <p>{item.summary}</p>
    </article>
  );

  return <ListContainer type="articles" renderListItem={ArticleItem} />;
};

// Dumb: UserList.js
const UserList = () => {
  const UserItem = ({ item }) => (
    <div className="user-item">
      <img src={item.avatar} />
      <span>{item.name}</span>
    </div>
  );

  return <ListContainer type="users" renderListItem={UserItem} />;
};

在这个例子中,ListContainer 是一个超级 Smart 组件,它管理了所有的列表状态(分页、筛选)。而 ArticleListUserList 只是 Dumb 组件,它们只是定义了“一个条目长什么样”。这种分离极大地提高了代码的复用性,你可以轻松地加一个“视频列表”或者“评论列表”,而不需要重写分页逻辑。

第七章:反模式与陷阱 – 别把代码搞死了

虽然容器模式很好,但在大规模应用中,它很容易被滥用,导致代码变得极其复杂,甚至难以维护。这就是所谓的过度设计

7.1 不要为简单的展示创建容器

如果你有一个按钮,或者一个简单的文本段落,不要给它包一个容器。

// ❌ 错误示范
const ButtonContainer = () => {
  const handleClick = () => alert('Clicked');
  return <Button onClick={handleClick}>Click Me</Button>;
};
const Button = ({ onClick, children }) => <button onClick={onClick}>{children}</button>;

这完全是脱裤子放屁。这种情况下,直接用 <Button onClick={...}> 就好了。

7.2 不要在 Dumb 组件里放 Redux 链接

这是最常见的错误。Redux 的 connect 或者 Hooks 的 useSelector 是为了访问全局状态设计的。

// ❌ 错误示范
import { useSelector } from 'react-redux';

const UserProfile = () => {
  const user = useSelector(state => state.user); // Dumb 组件直接吃 Redux
  return <div>{user.name}</div>;
};

这会让你的组件变得难以测试。如果你想测试这个组件,你必须 mock Redux store。而且,如果有一天你想把这个组件放到一个没有 Redux 的页面,你就得重写整个组件。

7.3 容器组件的嵌套地狱

不要为了复用逻辑,把容器组件嵌套得很深。

<PageContainer>
  <HeaderContainer>
    <TitleContainer>My App</TitleContainer>
  </HeaderContainer>
  <ContentContainer>
    <UserListContainer>
      <UserItemContainer>
        <UserAvatar />
      </UserItemContainer>
    </UserListContainer>
  </ContentContainer>
</PageContainer>

这就像俄罗斯套娃,虽然每一层都很干净,但你要找到最底层的 UserAvatar,得穿过 5 层父组件。这增加了不必要的渲染开销(虽然 React 会优化,但心理负担很重)。

原则: 层级应该扁平。容器组件应该只包裹真正需要它的逻辑部分。

第八章:测试的艺术 – 如何证明你是对的

很多团队不愿意重构,是因为觉得“太麻烦了,还要写单元测试”。但有了容器模式,测试变得异常简单。

8.1 测试 Dumb 组件

Dumb 组件是纯函数式的(大部分情况下)。你可以用简单的快照测试或者手动测试。

import { render, screen } from '@testing-library/react';
import UserProfile from './UserProfile';

test('renders user name', () => {
  const user = { name: 'Alice', bio: 'Developer' };
  render(<UserProfile user={user} isLoading={false} error={null} onFollow={() => {}} />);
  expect(screen.getByText('Alice')).toBeInTheDocument();
});

你不需要 mock axios,不需要 mock Redux。你只需要传给它数据,然后看它渲染得对不对。

8.2 测试 Smart 组件

Smart 组件负责数据获取和逻辑。你需要 mock API 调用。

import { render, screen, waitFor } from '@testing-library/react';
import UserProfileContainer from './UserProfileContainer';
import axios from 'axios';

jest.mock('axios');

test('fetches user data', async () => {
  const mockUser = { name: 'Bob', bio: 'Designer' };
  axios.get.mockResolvedValue({ data: mockUser });

  render(<UserProfileContainer />);

  // 等待 loading 结束
  await waitFor(() => {
    expect(screen.getByText('Bob')).toBeInTheDocument();
  });
});

这样,你既测试了 UI,也测试了数据流。而且,如果以后你把 Redux 换成了 Context,或者把 axios 换成了 fetch,Dumb 组件的测试完全不需要改。这就是容器模式带来的巨大价值。

第九章:总结与展望

好了,今天的讲座要接近尾声了。

我们回顾一下:

  1. 混乱是敌人:一个文件几千行,逻辑、样式、数据混在一起,是代码腐烂的开始。
  2. 分离是解药:Smart 组件负责“大脑”(数据、逻辑),Dumb 组件负责“脸面”(UI、渲染)。
  3. Hooks 是工具:用自定义 Hooks 封装逻辑,让 Smart 组件更轻量。
  4. 性能是关键:用 React.memo 优化 Dumb 组件,用 useCallback 保持引用稳定。
  5. 测试是保障:Dumb 组件易于测试,Smart 组件逻辑易于模拟。

在实际的大规模应用开发中,容器模式不是一条死板的教条,而是一种思维习惯。

当你写下一行代码时,停下来问自己:“这段代码是决定‘长什么样’的,还是决定‘怎么长出来’的?”

如果是决定“怎么长出来”的(数据流、API 调用、复杂计算),把它放在容器里。
如果是决定“长什么样”的(HTML 结构、CSS 样式、事件绑定),把它放在展示组件里。

不要为了分层而分层。如果你的应用只有几十行代码,不要搞容器模式,直接写一个组件得了。但在几千行、几万行的代码面前,容器模式就是你拯救代码生命力的唯一稻草。

记住,优秀的代码不是写出来的,是重构出来的。从混乱到有序,从意大利面条到米其林,这就是我们作为前端工程师的浪漫。

好了,现在去把你的那个 2000 行的文件拆了吧。加油!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注