React 极简组件(Presentational):解耦 UI 展示与业务逻辑以提升组件复用度的实践

React 极简组件:别让 UI 和逻辑在同一个房间里吵架

各位同学,大家好!欢迎来到今天的“代码重构与架构艺术”专场。

我是你们的老朋友,一个在 React 代码里摸爬滚打多年,头发比发际线退得还慢的技术老鸟。今天我们不聊虚的,也不搞那些花里胡哨的架构图,我们就来聊聊一个极其接地气,但又让无数初级工程师(甚至部分中级工程师)抓耳挠腮的问题——如何把你的“面条代码”变成“意大利面”,然后再变成“精致的意式料理”

核心话题只有一个:React 极简组件(Presentational Components)。听起来很高大上,其实说白了就是:把 UI 摆在一边,把逻辑关进笼子,让它们互不干扰,各自安好。

为什么?因为如果你不这么做,你的组件就会变成那种“喝多了酒之后写的代码”——逻辑和 UI 混在一起,全是乱码,谁看了都想报警。


第一章:UI 是脸,逻辑是脑子,别把脑子缝在脸上

首先,我们来做个思想实验。

想象一下,你是个装修工。你有一块木板,你想把它变成一个衣柜。为了实现这个功能,你需要锯子、钉子、螺丝,还需要计算怎么切割才能最大化利用空间,还要考虑承重。这所有的计算、决策、工具使用,就是业务逻辑

然后,你把这块木板刷成白色,装上把手,钉上合页,变成了一个好看的衣柜。这就是UI 展示

现在,如果有人要求你把那个“计算承重”和“计算切割角度”的算法直接写在那块木板里,你会答应吗?你肯定会说:“神经病啊,那木板怎么放得下算法?而且我以后想把衣柜做成红色的,难道要把算法也涂成红色吗?”

在 React 里,很多初学者就是这么干的。他们写了一个组件,里面既有 fetchData,又有 useState,最后还混进了一大堆 JSX。这就好比把脑子缝在脸上,你说这玩意儿还能看吗?

什么是 Presentational Component(展示层组件)?

它是那个“木板”。

  • 只负责: 渲染 HTML/CSS,接收 Props,处理用户交互(但只是交互本身,比如 onClick={() => handleClick()},而不是 onClick={apiCall})。
  • 不关心: 数据从哪来(它只知道有数据),数据怎么存(它只知道怎么画出来)。

什么是 Container Component(容器层组件)?

它是那个“装修工”。

  • 只负责: 管理数据状态,调用 API,处理复杂的业务逻辑,计算数据,然后把数据通过 Props 传给那个“木板”。
  • 不关心: 那个“木板”具体画的是什么颜色,是圆的还是方的,它只管把数据喂给它。

第二章:当你把“上帝”写进组件时,世界就乱了

让我们先看看一个典型的“反面教材”。假设我们要做一个用户列表。

场景:一个包含所有功能的 UserList.js

// UserList.js —— 这是典型的“屎山”诞生地
import React, { useState, useEffect } from 'react';
import axios from 'axios';

const UserList = () => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [filterText, setFilterText] = useState('');

  // 1. 业务逻辑:获取数据
  useEffect(() => {
    const fetchUsers = async () => {
      try {
        setLoading(true);
        const response = await axios.get('https://api.example.com/users');
        setUsers(response.data);
      } catch (err) {
        setError('抓取数据失败,可能是网络挂了');
      } finally {
        setLoading(false);
      }
    };
    fetchUsers();
  }, []);

  // 2. 业务逻辑:筛选
  const handleFilterChange = (e) => {
    setFilterText(e.target.value);
  };

  // 3. UI 渲染:混杂在一起
  if (loading) return <div className="spinner">加载中...</div>;
  if (error) return <div className="error">{error}</div>;

  return (
    <div className="user-list-container">
      <input 
        type="text" 
        placeholder="输入名字筛选..." 
        onChange={handleFilterChange}
        className="filter-input"
      />
      <ul>
        {users
          .filter(user => user.name.toLowerCase().includes(filterText.toLowerCase()))
          .map(user => (
            <li key={user.id} className="user-item">
              {/* 这里的样式和结构非常脆弱 */}
              <img src={user.avatar} alt={user.name} />
              <div>
                <h3>{user.name}</h3>
                <p>{user.email}</p>
              </div>
            </li>
          ))}
      </ul>
    </div>
  );
};

export default UserList;

点评:
看这段代码,是不是很眼熟?它完美地展示了“职责不清”的后果。

  1. 复用性极差: 假设老板突然说:“UserList 不行,我要一个 UserCard 组件,放在首页里。”
  2. 你想怎么做? 你得把 useEffectaxiosfilter 逻辑全删掉,只留下 map 循环和 JSX。这太痛苦了!
  3. 维护噩梦: 如果 API 返回格式变了,或者筛选逻辑变复杂了,你不仅要改逻辑,还得担心会不会误伤了 UI 样式。

这就是我们今天要解决的问题:解耦


第三章:Hooks —— 现代解耦的瑞士军刀

React Hooks 的诞生,简直就是给“逻辑复用”和“UI 解耦”打了一针强心剂。我们要做的第一件事,就是从组件里把“脑子”抠出来。

步骤一:提取业务逻辑

我们创建一个文件 useUsers.js

// useUsers.js —— 这是纯粹的“大脑”
import { useState, useEffect } from 'react';
import axios from 'axios';

export const useUsers = () => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [filterText, setFilterText] = useState('');

  useEffect(() => {
    const fetchUsers = async () => {
      try {
        setLoading(true);
        const response = await axios.get('https://api.example.com/users');
        setUsers(response.data);
      } catch (err) {
        setError('抓取数据失败,可能是网络挂了');
      } finally {
        setLoading(false);
      }
    };
    fetchUsers();
  }, []);

  const handleFilterChange = (e) => {
    setFilterText(e.target.value);
  };

  // 返回筛选后的数据,而不是原始数据
  const filteredUsers = users.filter(user => 
    user.name.toLowerCase().includes(filterText.toLowerCase())
  );

  return {
    users: filteredUsers,
    loading,
    error,
    setFilterText, // 暴露 setter 以便外部控制
    handleFilterChange
  };
};

看! 这个 Hook 里面没有 JSX,没有 className,没有 <div>。它就是一个纯函数,一个纯逻辑单元。它只关心数据流。

步骤二:极简组件登场

现在,原来的 UserList 组件变得非常“极简”了。

// UserList.js —— 现在它只是一个“脸面”
import React from 'react';
import { useUsers } from './useUsers';

const UserList = () => {
  // 1. 只管拿数据,不管数据怎么来的
  const { users, loading, error, setFilterText } = useUsers();

  // 2. 只管处理交互,把结果传给 UI
  const handleFilterChange = (e) => {
    setFilterText(e.target.value);
  };

  // 3. 只管渲染
  if (loading) return <div className="spinner">加载中...</div>;
  if (error) return <div className="error">{error}</div>;

  return (
    <div className="user-list-container">
      <input 
        type="text" 
        placeholder="输入名字筛选..." 
        onChange={handleFilterChange}
        className="filter-input"
      />
      <ul>
        {users.map(user => (
          <UserItem key={user.id} user={user} />
        ))}
      </ul>
    </div>
  );
};

// 进一步拆分:UserItem 也是展示层组件
const UserItem = ({ user }) => (
  <li className="user-item">
    <img src={user.avatar} alt={user.name} />
    <div>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  </li>
);

export default UserList;

神奇的效果:

  1. 逻辑复用: 现在你可以把这个 useUsers Hook 用在 UserCard(首页卡片)、UserTable(后台表格)或者 UserChart(数据图表)里。不管 UI 是什么样,逻辑都是那一套。
  2. UI 复用: UserItem 组件现在非常干净,它只接受 user 这个 prop。你可以把它放在任何地方,甚至用 React Native 写个 UserItem,逻辑完全不用动。

第四章:Props 传递的艺术 —— 别让数据“迷路”

在解耦的过程中,Props 是我们最亲密的战友,也是最尴尬的累赘。为什么尴尬?因为“Props Drilling”(属性钻透)。

想象一下,你的逻辑层在 App.js,UI 层在 ComponentA -> ComponentB -> ComponentC。逻辑层想把 onSubmit 函数传给 ComponentC,你得像接力赛一样,一层层往下传。

// App.js
const App = () => {
  const handleSubmit = () => { console.log('提交了'); };
  return (
    <Container>
      <ComponentA handleSubmit={handleSubmit} />
    </Container>
  );
};

// ComponentA.js
const ComponentA = ({ handleSubmit }) => {
  return <ComponentB handleSubmit={handleSubmit} />;
};

// ComponentB.js
const ComponentB = ({ handleSubmit }) => {
  return <ComponentC handleSubmit={handleSubmit} />;
};

// ComponentC.js
const ComponentC = ({ handleSubmit }) => {
  return <button onClick={handleSubmit}>提交</button>;
};

这简直是代码界的“传声筒游戏”,而且传错了没人负责。

极简组件的解耦之道:

展示层组件(Presentational)不应该关心数据是怎么来的,它只需要知道“我有这个数据”和“我有这个操作方法”。

最佳实践:

  1. UI Props: 把那些控制 UI 行为的函数(如 onClickonChange)传给展示层。展示层只负责“触发”。
  2. Data Props: 把数据传给展示层。展示层只负责“渲染”。

代码示例:

// Presentation 组件:它只知道“我有一个按钮,点击它”
const SubmitButton = ({ onClick, disabled, text }) => (
  <button 
    onClick={onClick} 
    disabled={disabled}
    className="primary-button"
  >
    {text || '提交'}
  </button>
);

// Container 组件:它知道逻辑,并决定何时点击
const UserForm = () => {
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = async () => {
    setIsSubmitting(true);
    // 调用 API...
    await fakeApiCall();
    setIsSubmitting(false);
  };

  return (
    <form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
      <InputField label="用户名" />
      <InputField label="密码" type="password" />

      {/* 把逻辑层的状态(isSubmitting)传给 UI 层,控制 UI 状态 */}
      <SubmitButton 
        onClick={handleSubmit}
        disabled={isSubmitting}
        text={isSubmitting ? "提交中..." : "注册"}
      />
    </form>
  );
};

在这里,SubmitButton 是一个极简组件。它不在乎你是在注册还是在登录,也不在乎 API 是什么。它只是根据你传进来的 disabledtext,乖乖地显示一个按钮。


第五章:HOC(高阶组件) —— 老派的解耦魔法

虽然 Hooks 很好,但在某些场景下,HOC 依然是一把利器。HOC 本质上就是一个函数,接收一个组件,返回一个新组件。

HOC 的经典用途是“增强”。比如,我们有很多组件都需要“加载中”的状态,难道每个组件都要写一遍 useEffect 吗?不,我们用一个 HOC 包一层。

示例:withLoading HOC

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

// 这是一个通用的“加载中”包装器
export const withLoading = (WrappedComponent, LoadingFallback) => {
  return class extends React.Component {
    render() {
      const { isLoading, ...props } = this.props;

      if (isLoading) {
        // 如果加载中,展示 LoadingFallback
        return <LoadingFallback />;
      }

      // 否则,展示被包装的组件,并透传所有 props
      return <WrappedComponent {...props} />;
    }
  };
};

使用 HOC 解耦

// UserProfile.js (纯展示层)
const UserProfile = ({ user }) => (
  <div className="profile-card">
    <h1>{user.name}</h1>
    <p>{user.bio}</p>
  </div>
);

// useUserProfile.js (纯逻辑层)
const useUserProfile = (userId) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchUser(userId).then(setUser).finally(() => setLoading(false));
  }, [userId]);

  return { user, loading };
};

// App.js (组合层)
const App = () => {
  const { user, loading } = useUserProfile(123);

  // 把逻辑层的数据和状态传给 HOC
  const UserProfileWithLoader = withLoading(UserProfile, <div>Loading...</div>);

  return <UserProfileWithLoader user={user} isLoading={loading} />;
};

为什么这样做?
你看,UserProfile 组件完全不知道 loading 是怎么回事,它也不需要知道。它只管渲染 user。所有的状态管理、数据获取逻辑都藏在 useUserProfilewithLoading 里。

这就是解耦的精髓:逻辑层负责“我有”,HOC 负责“我能不能显示”,UI 层负责“怎么显示”。


第六章:组件组合 —— UI 的乐高积木

极简组件的终极形态,是利用组合

不要试图在一个组件里把所有样式、所有功能都写死。要像搭乐高积木一样。

场景:构建一个复杂的表单组件

假设我们要做一个“编辑用户”页面。我们需要名字、邮箱、年龄、性别。

糟糕的做法:
写一个 EditUserForm 组件,里面写满了 <input>,写满了 useState,写满了校验逻辑。想换个布局?重新写。

极简的做法:

  1. 拆分输入框:

    • TextInput:处理文本输入。
    • NumberInput:处理数字输入。
    • SelectInput:处理下拉选择。
    • CheckboxInput:处理复选框。
    • 这些组件只负责 UI,不负责校验逻辑,只负责把值传出来。
  2. 组合:

    const EditUserForm = () => {
      const [formData, setFormData] = useState({ name: '', age: 0, gender: 'male' });
    
      const handleChange = (field, value) => {
        setFormData(prev => ({ ...prev, [field]: value }));
      };
    
      return (
        <form className="edit-form">
          <TextInput 
            label="姓名" 
            value={formData.name} 
            onChange={(val) => handleChange('name', val)} 
          />
    
          <div className="form-row">
            <NumberInput 
              label="年龄" 
              value={formData.age} 
              onChange={(val) => handleChange('age', val)} 
            />
    
            <SelectInput 
              label="性别" 
              value={formData.gender} 
              options={['male', 'female', 'other']} 
              onChange={(val) => handleChange('gender', val)} 
            />
          </div>
    
          <SubmitButton onClick={() => console.log(formData)}>保存</SubmitButton>
        </form>
      );
    };

在这个例子中,TextInputNumberInput 都是极简组件。你可以把 TextInput 拿去用在“搜索框”里,或者用在“评论框”里。只要传入不同的 labelonChange,它就能胜任不同的工作。

关键点:
极简组件应该无状态(Stateless)。如果它需要保存数据,那它就不再是“展示”了,它就变成了“容器”或“逻辑组件”。展示层组件只接收 Props,渲染 DOM。


第七章:业务逻辑的边界 —— 什么时候该抽离?

很多同学在抽离逻辑时犹豫不决:“这个逻辑是不是太简单了,没必要抽成 Hook?”

我的建议: 只要这个逻辑涉及到状态的变化,或者副作用(API 调用、定时器、订阅),就把它抽出来。

让我们看一个稍微复杂点的例子:分页逻辑

假设你有一个列表组件。它需要知道:

  1. 当前页码。
  2. 每页显示多少条。
  3. 总数据量。
  4. 翻页函数。

不要写死在组件里:

// Bad Example
const ProductList = () => {
  const [page, setPage] = useState(1);
  const [pageSize] = useState(10);

  // 每次翻页都要重新请求 API,逻辑很重
  useEffect(() => {
    fetchProducts(page, pageSize).then(...);
  }, [page, pageSize]);

  return (
    <div>
      <Pagination 
        current={page} 
        onChange={setPage} // 传递 setter
        total={100} 
      />
      <ProductGrid products={products} />
    </div>
  );
};

抽离逻辑:

// usePagination.js
export const usePagination = (totalItems) => {
  const [currentPage, setCurrentPage] = useState(1);
  const pageSize = 10;

  // 计算分页数据
  const totalPages = Math.ceil(totalItems / pageSize);
  const startIndex = (currentPage - 1) * pageSize;
  const endIndex = startIndex + pageSize;

  const goToPage = (page) => setCurrentPage(page);

  return {
    pagination: {
      current: currentPage,
      pageSize,
      total: totalPages,
      onChange: goToPage
    },
    range: {
      start: startIndex,
      end: endIndex
    }
  };
};

现在,ProductList 变得非常干净:

const ProductList = () => {
  const { pagination, range } = usePagination(100);
  const products = fetchProducts(range.start, range.end); // 只传索引,不传页码

  return (
    <div>
      <Pagination {...pagination} /> {/* 传递整个对象,解耦更彻底 */}
      <ProductGrid products={products} />
    </div>
  );
};

为什么要这样做?
因为 usePagination 这个 Hook 现在可以用于任何分页场景:文章列表、商品列表、日志列表。它不关心数据长什么样,它只关心“怎么切分”。


第八章:极致复用 —— 让组件去流浪

当我们把 UI 和逻辑解耦到极致,会发生什么?

会发生组件的“流浪”

假设你开发了一个漂亮的 SearchBar 组件。它只是一个输入框,带个搜索图标。

场景 A: 在 Web 端,你把它渲染成 <input type="text" />
场景 B: 你要把这个项目移植到 React Native,或者做一个微信小程序版本。
场景 C: 你想把它做成一个通用的 UI 库,供其他团队使用。

如果逻辑和 UI 紧耦合,你需要重写 90% 的代码。

但如果它们是解耦的:

// SearchBar.js (纯展示层)
const SearchBar = ({ value, onChange, placeholder, icon }) => (
  <div className="search-bar">
    <span className="search-icon">{icon}</span>
    <input 
      type="text" 
      value={value} 
      onChange={onChange} 
      placeholder={placeholder} 
    />
  </div>
);

这个组件现在非常轻量。你可以把它放在任何地方。你甚至不需要知道它是怎么被调用的。

自定义 Hooks 的复用性

这是 Hooks 带来的最大红利。你可以写一个 useDebounce Hook。

// useDebounce.js
export const useDebounce = (value, delay) => {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);

  return debouncedValue;
};

现在,你在任何地方需要防抖输入,直接调用它。

const SearchInput = () => {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

  useEffect(() => {
    // 只有当 debouncedQuery 变化时才执行搜索
    if (debouncedQuery) {
      searchApi(debouncedQuery);
    }
  }, [debouncedQuery]);

  return (
    <input 
      value={query} 
      onChange={(e) => setQuery(e.target.value)} 
    />
  );
};

注意看,SearchInput 组件里并没有处理“防抖”的 UI 逻辑(比如显示“正在输入…”的 Loading),它只是调用了 Hook。逻辑完全被隔离了。


第九章:CSS 与样式 —— 别让样式污染了逻辑

UI 展示不仅仅是 HTML 结构。样式也是展示层的一部分。

极简组件应该尽可能少地依赖外部样式,或者使用 CSS Modules、Styled Components 这种方式,确保样式作用域隔离。

反模式: 在组件内部写 CSS,或者使用全局类名。

// 糟糕的例子
const MyComponent = () => {
  return (
    <div style={{ background: 'red', padding: '10px' }}>...</div>
  );
};

好的做法:

  1. CSS Modules: import styles from './MyComponent.css'。组件只负责 className={styles.container}
  2. Styled Components: const Container = styled.div。逻辑和样式在同一个文件里,但依然保持了结构清晰。

为什么?
因为样式通常是视觉层面的。如果逻辑层组件负责渲染样式,那它就变成了“视觉层”。当 UI 设计师要求改颜色时,改的是样式文件,而不是逻辑文件。


第十章:实战演练 —— 从“面条”到“拉面”

让我们回到最开始那个 UserList,做一次彻底的手术。

现状: 500 行代码,逻辑、样式、状态全混在一起。

目标: 拆分成 10 个小文件,逻辑复用,样式复用。

重构计划:

  1. hooks/useUsers.js:管理用户数据、筛选、加载状态。
  2. components/UserItem.js:渲染单个用户卡片。
  3. components/UserList.js:渲染列表容器,组合 useUsersUserItem
  4. components/UserFilter.js:渲染筛选框(可以复用)。
  5. styles/UserList.css:列表样式。
  6. styles/UserItem.css:单项样式。

最终效果代码:

// hooks/useUsers.js (逻辑核心)
export const useUsers = () => {
  const [data, setData] = useState([]);
  const [filter, setFilter] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetchUsers().then(setData).catch(setError).finally(() => setLoading(false));
  }, []);

  const filteredData = data.filter(u => u.name.includes(filter));

  return { data: filteredData, filter, setFilter, loading, error };
};

// components/UserItem.js (极简展示)
const UserItem = ({ user }) => (
  <div className="user-item">
    <img src={user.avatar} alt={user.name} />
    <div>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  </div>
);
export default UserItem;

// components/UserList.js (容器与组装)
import React from 'react';
import { useUsers } from '../hooks/useUsers';
import UserItem from './UserItem';
import UserFilter from './UserFilter';

const UserList = () => {
  const { data, filter, setFilter, loading } = useUsers();

  if (loading) return <div className="loading">Loading...</div>;

  return (
    <div className="user-list">
      <UserFilter value={filter} onChange={setFilter} />
      {data.map(user => <UserItem key={user.id} user={user} />)}
    </div>
  );
};
export default UserList;

看! 是不是清爽多了?

  • UserList 只关心“怎么显示列表”和“怎么筛选”。
  • UserItem 只关心“怎么显示一个人”。
  • useUsers 只关心“怎么获取数据”。
  • 如果以后你要把 UserList 换成 UserTable,你只需要改 UserList 组件的 map 部分,逻辑层 (useUsers) 和展示层 (UserItem) 一点都不用动。

总结

同学们,今天我们聊了这么多,其实核心思想就一句话:单一职责原则

  • UI 组件(Presentational): 只负责“画”。它像是一个画师,手里拿着画笔(Props),在画布(DOM)上画画。它不关心画的是什么,只关心怎么画得好看。
  • 容器/逻辑组件: 只负责“想”。它像是一个策划,决定画什么,画在哪个位置,怎么根据用户的反应调整画笔。

当你把 UI 和逻辑解耦后,你会发现代码变得像乐高积木一样好玩。你可以随意组合,随意替换,甚至可以把一个组件移植到另一个项目里。

记住,不要试图在一个组件里解决所有问题。那个组件会累死的,而且会变得非常丑陋。

把逻辑藏进 Hooks,把 UI 摆在组件里,把样式交给 CSS。 这就是 React 极简组件的艺术。

现在,拿起你的键盘,去把你那堆“屎山”代码拆开吧!祝你好运,别把脑子缝在脸上!

发表回复

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