接手老项目的一个月,我重构了那个2000行的“祖传”React组件

1.1 那个让我失眠的周五下午

入职第一天,领导把一个"简单任务"丢给我:"这个后台系统你熟悉一下,下周加几个小功能。"

我打开项目,找到他说的那个"核心组件"。第一眼,我以为是压缩后的代码------一个文件,2000多行。第二眼,我确认了,这是人写的。第三眼,我想起了那个著名的笑话:这段代码,只有上帝和它的原作者看得懂,而现在,原作者跳槽了,上帝说他也看不懂。

那一刻,我闻到了屎山的味道。

1.2 "精彩片段"

typescript 复制代码
// 片段1:变量命名艺术
const data = props.data || [];
const data2 = localStorage.getItem('data2');
const data3 = this.props.data3; // 为什么从三个地方拿数据?不知道
const d = data.filter(...); // d是什么?大概是过滤后的数据吧

// 片段2:函数长度之最
const handleEverything = (type, value, callback, flag, ...args) => {
  // 这个函数有300行
  // 它处理表单提交、按钮点击、路由跳转、数据格式化...
  // 甚至还有一个setTimeout里面套着另一个setTimeout
  if (type === 'submit') { /* 100行 */ }
  if (type === 'click') { /* 80行 */ }
  if (flag === true) { /* 还有逻辑 */ }
  // 300行后...
  callback && callback();
}

// 片段3:神秘的魔法数字
<div style={{ marginTop: 17, paddingLeft: 13 }}> {/* 为什么是17和13?设计师的生日?*/}
  {status === 2 && <span>进行中</span>}  {/* 2代表什么?后面还有3、4、5... */}
  {status === 4 && <span>已完成</span>}
</div>

2.1 第一步:源码考古

我没有急着改代码。我知道,对于一个陌生的系统,直接动手等于自杀。我打开Xmind,开始给这段代码画"CT片"。

scss 复制代码
组件 (UserManage)
├── 初始化 (useEffect)
│   ├── 获取用户列表 (/api/users) → 存入state.users
│   ├── 获取部门列表 (/api/depts) → 存入state.depts
│   ├── 获取角色列表 (/api/roles) → 存入state.roles
│   └── 检查权限 (checkPermission) → 存入state.canEdit
├── 状态管理 (useState x 15个)
│   ├── users, depts, roles, permissions...
│   ├── loading, error, submitting, editing...
│   ├── modalVisible, modalType, modalData...
│   └── searchKeyword, searchResult, searchHistory...
├── 事件处理 (handleXxx x 20个)
│   ├── handleSearch, handleAdd, handleEdit, handleDelete
│   ├── handleModalOk, handleModalCancel, handleModalChange
│   ├── handleSelectRow, handleSelectAll, handleExport
│   └── handlePageChange, handlePageSizeChange
├── 渲染逻辑 (renderXxx x 8个)
│   ├── renderSearchBar, renderTable, renderPagination
│   ├── renderModal, renderDrawer, renderToolbar
│   ├── renderLoading, renderEmpty, renderError
│   └── renderActionButtons (里面还有权限判断)
└── 副作用 (useEffect x 6个)
    ├── 监听search变化 → 重新搜索
    ├── 监听page变化 → 重新加载
    ├── 监听selectedRows → 更新按钮状态
    └── 监听权限变化 → 隐藏/显示按钮

2.2 第二步:代码体检 ------ 给问题分类

根据逻辑整理和各种分类后,我整理出了这个组件的"体检报告":

问题类型 具体表现 严重程度
单一职责 一个组件同时负责:数据获取、状态管理、UI渲染、权限控制、路由跳转 ⭐⭐⭐⭐⭐
代码重复 相同的API调用逻辑出现在3个地方;相同的表单校验写了5遍 ⭐⭐⭐⭐
魔法数字/字符串 status用1/2/3/4表示,type用'A'/'B'/'C'表示 ⭐⭐⭐⭐
命名混乱 data, data1, data2, temp, temp2, res, result 满天飞 ⭐⭐⭐
副作用管理 6个useEffect互相触发,形成死循环,只能用setTimeout打断 ⭐⭐⭐⭐⭐
类型安全 没有TypeScript,props校验时有时无,经常传错参数 ⭐⭐⭐⭐

2.3 第三步:制定重构计划

有了体检报告,我制定了分阶段的重构计划。这不是一蹴而就的事情,我给自己定了一个月的"慢性治疗"时间。


第三部分:重构实战

3.1 技巧一:先拆后合 ------ 用"组件拆分"解决2000行的问题

面对2000行的庞然大物,我的第一个想法是:能不能把它拆成多个小文件?就像切蛋糕一样,找到自然的切分点。

步骤1:识别UI区块

观察render函数,找到可以独立出来的UI模块:

javascript 复制代码
// 重构前的render(简化版)
render() {
  return (
    <div className="user-manage">
      {/* 1. 搜索栏 */}
      <div className="search-bar">
        <input value={keyword} onChange={onSearch} />
        <button onClick={handleSearch}>搜索</button>
      </div>
      
      {/* 2. 操作工具栏 */}
      <div className="toolbar">
        <button onClick={handleAdd}>新增</button>
        <button onClick={handleExport}>导出</button>
        {canDelete && <button onClick={handleDelete}>删除</button>}
      </div>
      
      {/* 3. 表格 */}
      <table>
        {/* 200行的表格渲染逻辑 */}
        {data.map(item => (
          <tr key={item.id}>
            <td>{item.name}</td>
            {/* 还有很多列... */}
          </tr>
        ))}
      </table>
      
      {/* 4. 分页 */}
      <Pagination 
        current={page}
        total={total}
        onChange={handlePageChange}
      />
      
      {/* 5. 新增/编辑弹窗 */}
      {modalVisible && (
        <Modal title={modalType === 'add' ? '新增' : '编辑'}>
          {/* 100行的表单 */}
        </Modal>
      )}
    </div>
  )
}

步骤2:逐个提取子组件

创建第一个子组件:SearchBar.jsx

ini 复制代码
// components/SearchBar.jsx
const SearchBar = ({ keyword, onSearch, onKeywordChange }) => {
  return (
    <div className="search-bar">
      <Input
        value={keyword}
        onChange={onKeywordChange}
        placeholder="输入用户名/手机号搜索"
        allowClear
      />
      <Button type="primary" onClick={onSearch}>
        搜索
      </Button>
    </div>
  );
};

// 加上PropTypes校验
SearchBar.propTypes = {
  keyword: PropTypes.string,
  onSearch: PropTypes.func.isRequired,
  onKeywordChange: PropTypes.func.isRequired
};

export default SearchBar;

步骤3:递归拆分

按照同样的方式,继续提取:

  • UserTable.jsx:表格组件
  • Toolbar.jsx:操作栏组件
  • UserModal.jsx:弹窗组件
  • PaginationBar.jsx:分页组件

重构后的主组件

ini 复制代码
// UserManage.jsx 重构后
import SearchBar from './SearchBar';
import Toolbar from './Toolbar';
import UserTable from './UserTable';
import PaginationBar from './PaginationBar';
import UserModal from './UserModal';

const UserManage = () => {
  // 状态管理
  const [keyword, setKeyword] = useState('');
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(false);
  const [pagination, setPagination] = useState({ page: 1, pageSize: 10, total: 0 });
  const [modal, setModal] = useState({ visible: false, type: 'add', data: null });
  
  // 业务逻辑
  const handleSearch = () => { /* 搜索逻辑 */ };
  const handleAdd = () => { /* 新增逻辑 */ };
  const handleEdit = (user) => { /* 编辑逻辑 */ };
  const handleDelete = (ids) => { /* 删除逻辑 */ };
  
  return (
    <div className="user-manage">
      <SearchBar 
        keyword={keyword}
        onKeywordChange={setKeyword}
        onSearch={handleSearch}
      />
      
      <Toolbar 
        onAdd={handleAdd}
        onExport={handleExport}
        canDelete={hasPermission('delete')}
        selectedRows={selectedUsers}
      />
      
      <UserTable 
        data={users}
        loading={loading}
        onEdit={handleEdit}
        onDelete={handleDelete}
        onSelect={handleSelect}
      />
      
      <PaginationBar 
        {...pagination}
        onChange={handlePageChange}
      />
      
      <UserModal 
        visible={modal.visible}
        type={modal.type}
        data={modal.data}
        onOk={handleModalOk}
        onCancel={handleModalCancel}
      />
    </div>
  );
};

效果对比

  • 重构前:1个文件,2000行
  • 重构后:6个文件,每个200-400行
  • 可维护性:⭐⭐⭐⭐⭐(可以独立修改、测试每个组件)

3.2 技巧二:逻辑抽离 ------ 用自定义Hooks消灭"面条式代码"

拆完UI后发现,主组件虽然变短了,但里面的逻辑仍然很混乱。各种useState、useEffect、事件处理函数挤在一起,像一个没有整理的工具箱。

步骤1:识别逻辑分组

观察现有的逻辑,可以分为几类:

  • 用户数据逻辑:获取用户列表、搜索、分页
  • 弹窗逻辑:打开/关闭、编辑/新增模式
  • 权限逻辑:判断是否有操作权限
  • 选择逻辑:表格行选择、全选/取消

步骤2:创建第一个自定义Hook ------ useUsers

ini 复制代码
// hooks/useUsers.js
import { useState, useEffect } from 'react';
import { userService } from '../services/userService';

export const useUsers = (initialPagination) => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(false);
  const [pagination, setPagination] = useState(initialPagination);
  const [filters, setFilters] = useState({ keyword: '' });
  
  // 加载用户数据
  const fetchUsers = async () => {
    setLoading(true);
    try {
      const { page, pageSize } = pagination;
      const { keyword } = filters;
      
      const result = await userService.getUsers({
        page,
        pageSize,
        keyword
      });
      
      setUsers(result.list);
      setPagination(prev => ({
        ...prev,
        total: result.total
      }));
    } catch (error) {
      console.error('获取用户列表失败:', error);
    } finally {
      setLoading(false);
    }
  };
  
  // 当分页或搜索条件变化时,重新加载
  useEffect(() => {
    fetchUsers();
  }, [pagination.page, pagination.pageSize, filters.keyword]);
  
  // 搜索
  const search = (keyword) => {
    setFilters({ keyword });
    setPagination(prev => ({ ...prev, page: 1 })); // 重置到第一页
  };
  
  // 改变页码
  const changePage = (page, pageSize) => {
    setPagination(prev => ({
      ...prev,
      page,
      pageSize: pageSize || prev.pageSize
    }));
  };
  
  return {
    users,
    loading,
    pagination,
    search,
    changePage,
    refresh: fetchUsers
  };
};

步骤3:创建更多自定义Hook

ini 复制代码
// hooks/useModal.js
export const useModal = () => {
  const [visible, setVisible] = useState(false);
  const [type, setType] = useState('add'); // 'add' | 'edit' | 'view'
  const [data, setData] = useState(null);
  
  const openAdd = () => {
    setType('add');
    setData(null);
    setVisible(true);
  };
  
  const openEdit = (record) => {
    setType('edit');
    setData(record);
    setVisible(true);
  };
  
  const close = () => {
    setVisible(false);
    // 延迟清除数据,避免弹窗关闭动画期间数据消失
    setTimeout(() => {
      setData(null);
    }, 300);
  };
  
  return {
    modal: { visible, type, data },
    openAdd,
    openEdit,
    close
  };
};

// hooks/useSelection.js
export const useSelection = (dataSource, rowKey = 'id') => {
  const [selectedKeys, setSelectedKeys] = useState([]);
  const [selectedRows, setSelectedRows] = useState([]);
  
  const select = (keys, rows) => {
    setSelectedKeys(keys);
    setSelectedRows(rows);
  };
  
  const clearSelection = () => {
    setSelectedKeys([]);
    setSelectedRows([]);
  };
  
  const isSelected = (record) => {
    const key = record[rowKey];
    return selectedKeys.includes(key);
  };
  
  return {
    selectedKeys,
    selectedRows,
    select,
    clearSelection,
    isSelected
  };
};

步骤4:重构主组件,使用这些Hooks

ini 复制代码
// UserManage.jsx 最终版
import SearchBar from './SearchBar';
import Toolbar from './Toolbar';
import UserTable from './UserTable';
import PaginationBar from './PaginationBar';
import UserModal from './UserModal';
import { useUsers } from '../hooks/useUsers';
import { useModal } from '../hooks/useModal';
import { useSelection } from '../hooks/useSelection';

const UserManage = () => {
  // 使用自定义Hooks
  const {
    users,
    loading,
    pagination,
    search,
    changePage,
    refresh
  } = useUsers({ page: 1, pageSize: 10, total: 0 });
  
  const {
    modal,
    openAdd,
    openEdit,
    close
  } = useModal();
  
  const {
    selectedKeys,
    selectedRows,
    select,
    clearSelection
  } = useSelection(users);
  
  // 处理弹窗提交
  const handleModalOk = async (values) => {
    if (modal.type === 'add') {
      await userService.addUser(values);
    } else {
      await userService.updateUser(modal.data.id, values);
    }
    close();
    refresh(); // 刷新列表
    clearSelection();
  };
  
  return (
    <div className="user-manage">
      <SearchBar 
        onSearch={search}
      />
      
      <Toolbar 
        onAdd={openAdd}
        onExport={handleExport}
        canDelete={hasPermission('delete')}
        selectedRows={selectedRows}
      />
      
      <UserTable 
        data={users}
        loading={loading}
        onEdit={openEdit}
        onDelete={handleDelete}
        selectedKeys={selectedKeys}
        onSelect={select}
      />
      
      <PaginationBar 
        current={pagination.page}
        pageSize={pagination.pageSize}
        total={pagination.total}
        onChange={changePage}
      />
      
      <UserModal 
        visible={modal.visible}
        type={modal.type}
        data={modal.data}
        onOk={handleModalOk}
        onCancel={close}
      />
    </div>
  );
};

3.3 技巧三:类型安全 ------ 用TypeScript让代码"自文档化"

重构的最后一步,也是最关键的一步:引入TypeScript。这不仅是为了类型检查,更是为了让代码变成"自文档",让下一个接手的人不再迷茫。

步骤1:定义数据模型

ini 复制代码
// types/user.ts
export interface User {
  id: string;
  username: string;
  nickname: string;
  email: string;
  phone: string;
  avatar?: string;
  status: UserStatus;
  role: UserRole;
  department: Department;
  createdAt: string;
  updatedAt: string;
  lastLoginAt?: string;
}

export enum UserStatus {
  INACTIVE = 0,  // 未激活
  ACTIVE = 1,    // 正常
  LOCKED = 2,    // 锁定
  DELETED = -1   // 已删除
}

export enum UserRole {
  VISITOR = 'visitor',      // 访客
  MEMBER = 'member',        // 普通成员
  MANAGER = 'manager',      // 管理员
  SUPER_ADMIN = 'super_admin' // 超级管理员
}

export interface Department {
  id: string;
  name: string;
  parentId: string | null;
  path: string;
}

export interface PaginationParams {
  page: number;
  pageSize: number;
  keyword?: string;
  status?: UserStatus;
  departmentId?: string;
}

export interface PaginationResult<T> {
  list: T[];
  total: number;
  page: number;
  pageSize: number;
}

步骤2:给组件加上类型

typescript 复制代码
// components/UserTable.tsx
import React from 'react';
import { Table, Tag, Button, Space, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { User, UserStatus, UserRole } from '../types/user';

interface UserTableProps {
  data: User[];
  loading: boolean;
  onEdit: (user: User) => void;
  onDelete: (userId: string) => void;
  selectedKeys: string[];
  onSelect: (keys: string[], rows: User[]) => void;
}

const UserTable: React.FC<UserTableProps> = ({
  data,
  loading,
  onEdit,
  onDelete,
  selectedKeys,
  onSelect
}) => {
  // 状态标签的颜色映射
  const statusColorMap: Record<UserStatus, string> = {
    [UserStatus.ACTIVE]: 'green',
    [UserStatus.INACTIVE]: 'orange',
    [UserStatus.LOCKED]: 'red',
    [UserStatus.DELETED]: 'gray'
  };
  
  // 状态文本的映射
  const statusTextMap: Record<UserStatus, string> = {
    [UserStatus.ACTIVE]: '正常',
    [UserStatus.INACTIVE]: '未激活',
    [UserStatus.LOCKED]: '已锁定',
    [UserStatus.DELETED]: '已删除'
  };
  
  // 表格列定义
  const columns: ColumnsType<User> = [
    {
      title: '用户名',
      dataIndex: 'username',
      key: 'username',
      render: (text, record) => (
        <Space>
          {record.avatar && <Avatar src={record.avatar} size="small" />}
          <span>{text}</span>
        </Space>
      )
    },
    {
      title: '昵称',
      dataIndex: 'nickname',
      key: 'nickname'
    },
    {
      title: '状态',
      dataIndex: 'status',
      key: 'status',
      render: (status: UserStatus) => (
        <Tag color={statusColorMap[status]}>
          {statusTextMap[status]}
        </Tag>
      )
    },
    {
      title: '角色',
      dataIndex: 'role',
      key: 'role',
      render: (role: UserRole) => role.toUpperCase()
    },
    {
      title: '部门',
      dataIndex: ['department', 'name'],
      key: 'department'
    },
    {
      title: '最后登录',
      dataIndex: 'lastLoginAt',
      key: 'lastLoginAt',
      render: (date: string) => date ? new Date(date).toLocaleString() : '-'
    },
    {
      title: '操作',
      key: 'action',
      render: (_, record) => (
        <Space>
          <Button type="link" onClick={() => onEdit(record)}>
            编辑
          </Button>
          <Button 
            type="link" 
            danger 
            onClick={() => onDelete(record.id)}
          >
            删除
          </Button>
        </Space>
      )
    }
  ];
  
  return (
    <Table
      rowKey="id"
      columns={columns}
      dataSource={data}
      loading={loading}
      rowSelection={{
        selectedRowKeys: selectedKeys,
        onChange: onSelect
      }}
      pagination={false}
    />
  );
};

export default UserTable;

步骤3:给自定义Hooks加上类型

ini 复制代码
// hooks/useUsers.ts
import { useState, useEffect } from 'react';
import { userService } from '../services/userService';
import type { User, PaginationParams, PaginationResult } from '../types/user';

interface UseUsersOptions {
  initialPage?: number;
  initialPageSize?: number;
  autoLoad?: boolean;
}

interface UseUsersResult {
  users: User[];
  loading: boolean;
  pagination: {
    page: number;
    pageSize: number;
    total: number;
  };
  search: (keyword: string) => void;
  changePage: (page: number, pageSize?: number) => void;
  refresh: () => Promise<void>;
}

export const useUsers = (options: UseUsersOptions = {}): UseUsersResult => {
  const {
    initialPage = 1,
    initialPageSize = 10,
    autoLoad = true
  } = options;
  
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [pagination, setPagination] = useState({
    page: initialPage,
    pageSize: initialPageSize,
    total: 0
  });
  const [filters, setFilters] = useState<Partial<PaginationParams>>({});
  
  const fetchUsers = async () => {
    setLoading(true);
    try {
      const params: PaginationParams = {
        page: pagination.page,
        pageSize: pagination.pageSize,
        ...filters
      };
      
      const result: PaginationResult<User> = await userService.getUsers(params);
      
      setUsers(result.list);
      setPagination(prev => ({
        ...prev,
        total: result.total
      }));
    } catch (error) {
      console.error('获取用户列表失败:', error);
      // 可以在这里处理错误,比如显示错误提示
    } finally {
      setLoading(false);
    }
  };
  
  useEffect(() => {
    if (autoLoad) {
      fetchUsers();
    }
  }, [pagination.page, pagination.pageSize, filters]);
  
  const search = (keyword: string) => {
    setFilters({ keyword });
    setPagination(prev => ({ ...prev, page: 1 }));
  };
  
  const changePage = (page: number, pageSize?: number) => {
    setPagination(prev => ({
      ...prev,
      page,
      pageSize: pageSize || prev.pageSize
    }));
  };
  
  return {
    users,
    loading,
    pagination,
    search,
    changePage,
    refresh: fetchUsers
  };
};

第四部分:重构成果展示

4.1 量化指标对比

指标 重构前 重构后 改善
文件大小 2000行/1个文件 6个文件,平均300行 可维护性大幅提升
代码重复率 35% 5% -30%
圈复杂度 25 8 -68%
测试覆盖率 0% 85% +85%
新增功能耗时 3天 0.5天 -83%

4.2 新增功能演示

重构完成后的第二周,产品经理过来说:"我们要加一个功能:批量导出用户数据,按部门筛选。"

重构前的改动方式

  • 在2000行的组件里找哪里获取数据
  • 加一个按钮,加一个事件处理
  • 可能还要改分页逻辑
  • 测试要花2天,因为怕改坏其他地方

重构后的改动方式

ini 复制代码
// 1. 在Toolbar组件里加一个按钮
<Toolbar 
  onAdd={openAdd}
  onExport={handleExport}  // 新增
  onBatchExport={handleBatchExport}  // 新增批量导出
/>

// 2. 在useUsers里加一个导出方法
const exportUsers = async (filters) => {
  return await userService.exportUsers(filters);
};

// 3. 在UserManage里组合
const handleBatchExport = async () => {
  await exportUsers({ departmentId: selectedDept });
  message.success('导出成功,请到下载中心查看');
};

耗时:2小时完成,测试10分钟。

4.3 个人收获

这次重构让我深刻体会到:

  1. 好的代码是改出来的,不是写出来的。那个2000行的组件,可能最初也只有200行,只是随着需求迭代,慢慢长成了怪物。
  2. 重构不是重写。我保留了所有的业务逻辑,只是改变了组织形式。这让我在重构过程中,随时可以上线,不影响业务。
  3. 可读性 > 炫技。重构后的代码,用了很多"笨"方法------明确的命名、简单的逻辑、标准的模式。新人来了也能很快上手。

第五部分:结尾

5.1 给读者的建议

如果你也面临一个"祖传代码",不要慌,记住这三点:

  • 先看懂,再动手:用思维导图画出代码的地图
  • 小步快跑,持续集成:每次只改一点,随时可以回滚
  • 面向未来编程:让代码能轻松应对下一个需求

5.2 资源分享

这次重构过程中,我用到的工具和模板都整理好了:

  • GitHub\] 重构后的完整代码仓库(地址)

  • 思维导图\] 我画的CT图模板

5.3 互动话题

你遇到过最离谱的"祖传代码"是什么样的?欢迎在评论区分享你的"屎山历险记"!

相关推荐
用户83040713057012 小时前
路由传参刷新丢失问题:三种解决方案与最佳实践
前端
从文处安2 小时前
「前端何去何从」高效提示词(prompts):前端开发者的AI协作指南
前端·aigc
大时光2 小时前
gsap--《pink老师vivo官网实现》
前端
www_stdio2 小时前
全栈项目第五天:构建现代企业级 React 应用:从工程化到移动端实战的全链路指南
前端·react.js·typescript
my_styles2 小时前
window系统安装/配置Nginx
服务器·前端·spring boot·nginx
神奇的程序员2 小时前
不止高刷:明基 RD280UG 在编码场景下的表现如何
前端
Rabbit_QL2 小时前
【音频处理】从 AirPods 主动降噪到音频 Source Separation:同一个问题的两种工程解法
前端·人工智能·音视频
-孤存-2 小时前
Spring Bean作用域与生命周期全解析
java·开发语言·前端
QEasyCloud20223 小时前
WooCommerce 独立站系统集成技术方案
java·前端·数据库