思维重构:为什么 Class Component 逐渐式微,而 Function Component 成为主流

引言

React 16.8 发布 Hooks 以来, class 关键字在新建的项目中逐渐销声匿迹,取而代之的是更加简洁、轻量的 Function Components

很多人认为这只是一次语法层面的"升级"------把 render 函数拆出来,把 this.state 换成 useState,一切就万事大吉了。

但事实并非如此。

如果你带着写 Class 组件的惯性思维(Lifecycle Thinking)去写 Hooks,你很快就会撞上一堵墙:无限循环的请求、过期的闭包变量(Stale Closures)、以及那个越写越长、让人望而生畏的 useEffect 依赖数组。

ClassFunction,本质上不是语法的改变,而是心智模型(Mental Model)的重构:我们正在从"面向对象与生命周期"转向"函数式与逻辑聚合"。

在这篇文章中,我们将通过一个具体的 WindowTracker 案例,对比展示两种模式的本质差异;并且讨论如何避免 useEffect 陷阱。

1. 范式转移------从"生命周期"到"逻辑聚合"

Class 组件时代,我们的代码组织方式是被 React生命周期方法(Lifecycle Methods) 强行切割的。为了说明这一点,我们来看一个经典的案例:WindowTracker(一个显示当前窗口宽度,并修改 document.title 的组件)。

1.1 Class Component:被割裂的逻辑

我们先看看在Class组件中,这个需求是如何实现的

js 复制代码
class WindowTracker extends React.Component {
  constructor(props) {
    super(props);
    this.state = { width: window.innerWidth };
    this.handleResize = this.handleResize.bind(this);
  }

  componentDidMount() {
    // 🔴 逻辑 A 的开始:订阅事件
    window.addEventListener('resize', this.handleResize);
    // 🔵 逻辑 B 的开始:更新标题 --- 首次挂载
    document.title = `Width: ${this.state.width}`;
  }

  // 🔵 逻辑 B 的重复:更新标题 --- state更新
  componentDidUpdate() {
    document.title = `Width: ${this.state.width}`;
  }

  // 🔴 逻辑 A 的结束:取消订阅
  componentWillUnmount() {
    window.removeEventListener('resize', this.handleResize);
  }

  handleResize() {
    this.setState({ width: window.innerWidth });
  }

  render() {
    return <div>Current Width: {this.state.width}px</div>;
  }
}

观察代码,你会发现相关的逻辑被强行拆分了 ,如果你想修改"更新标题"的逻辑,你必须同时修改 DidMountDidUpdate 的代码。

这种 "关注点分离"(Scattered Concerns) 是导致大型 Class 组件难以维护的元凶。

1.2 Function Component: 逻辑的自然聚合 (Co-location)

Hooks 的出现,让我们得以按照代码的用途,而不是代码执行的时间点来组织逻辑。

js 复制代码
function WindowTracker() {
  const [width, setWidth] = useState(window.innerWidth);

  // 🔴 逻辑 A:完整的窗口订阅逻辑
  // 订阅和取消订阅在一起,像一个独立的单元
  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  // 🔵 逻辑 B:完整的标题同步逻辑
  // 只要 width 变了,我就执行,不需要关心是 Mount 还是 Update
  useEffect(() => {
    document.title = `Width: ${width}`;
  }, [width]);

  return <div>Current Width: {width}px</div>;
}

核心优势: 这就是 Co-location(同地协作)。我们不再思考"这个组件挂载了吗?",我们思考的是"这个副作用依赖什么数据?"

1.3 自定义 Hook :逻辑复用

既然逻辑 A 已经聚合在一起了,我们就可以把提取一个自定义hooks出来以供服用:

js 复制代码
// hooks/useWindowWidth.js
export function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);
  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);
  return width;
}

// 组件代码缩减为:
function WindowTracker() {
  const width = useWindowWidth(); // ✅ 逻辑复用,极度简洁
  useEffect(() => { document.title = `Width: ${width}`; }, [width]);
  return <div>Current Width: {width}px</div>;
}

2. 谨防Effect陷阱

当你开始享受 Function Component 的简洁时,很快就会遇到新的挑战:useEffect 并不是 componentDidMount 的简单替代品。如果你还带着命令式的思维,你一定会掉进坑里。

2.1 陷阱一:闭包陷阱 (Stale Closures)

新手最容易犯的错误,就是以为 Effect 里的变量会自动更新。

❌ 错误示范:

js 复制代码
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // 永远打印 0!
      setCount(count + 1); // 永远重置为 1
    }, 1000);
    return () => clearInterval(id);
  }, []); // 依赖为空,Effect 只运行一次
}

JavaScript 的闭包机制导致 setInterval 内部引用的 count 永远是第一次渲染时的那个值 (0)。它被"冻结"在过去的时间里了。

✅ 修正方案:函数式更新 我们不需要依赖当前的 count,而是告诉 React 如何计算下一个状态:

js 复制代码
setCount(prevCount => prevCount + 1); // ✅ 不需要依赖外部的 count 变量

这不仅仅是 React 的特性。 如果你用过 Jotai、Zustand 或 Redux,你会发现这种控制反转 (Inversion of Control)的设计模式无处不在。

在 Jotai 中,更新 Atom 时也是 set(atom, prev => prev + 1)。

在 Redux 中,Reducer 的 (state, action) => newState 也是同样的道理。

2.2 陷阱二:依赖地狱 (Dependency Hell)

随着业务逻辑变复杂,你的依赖数组可能会变得非常长:[user, settings, history, socket, theme...]。任何一个变量微小的变动,都会导致整个 Effect 重新执行。

面对"依赖地狱",我们需要用三大原则进行逻辑拆解:

原则 A:按职责拆分 (Split by Concern)

不要把所有逻辑塞进一个 Effect

如果一个 Effect 既负责"埋点上报"又负责"连接 WebSocket",请把它们拆成两个 useEffect

原则 B:区分"事件"与"副作用" (Events vs Effects)

这是最重要的原则。不要为了获取最新值,就把逻辑塞进 Effect

错误: 用户点击按钮提交,但我需要在 Effect 里监听 isSubmitting 状态来发送请求,导致不得不把所有表单数据加入依赖。

js 复制代码
function LoginForm() {
  const [text, setText] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);

  // ❌ 错误:滥用 Effect 处理交互
  useEffect(() => {
    // 只有当开关被打开时,才发送请求
    if (isSubmitting) {
      async function postData() {
        await api.login(text);
        setIsSubmitting(false); // 请求完把开关关掉
      }
      postData();
    }
  }, [isSubmitting, text]); // sos灾难:不得不把 text 也加入依赖!

  const handleSubmit = () => {
    // 点击按钮时不直接发送,而是去拨动开关
    setIsSubmitting(true);
  };

  return (
    <form>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={handleSubmit}>登录</button>
    </form>
  );
}

正确: 回到最朴素的编程思维-"用户点击时,发送请求"。在 onClick 事件处理函数中直接读取 State 发送请求。Effect 应该只用于同步(比如根据 ID 获取数据),而不是用于处理交互。

js 复制代码
function LoginForm() {
  const [text, setText] = useState('');
  // 不需要 isSubmitting 这个状态来做触发器(虽然可以用它来控制 Loading UI)

  // ✅ 正确:事件处理函数包含所有逻辑
  const handleSubmit = async () => {
    // 1. 直接在这里读取最新的 state
    // 这里读取 text 不需要经过依赖数组,它是直接可用的
    console.log('正在提交:', text);
    
    try {
      await api.login(text);
      console.log('成功');
    } catch (e) {
      console.error(e);
    }
  };

  return (
    <form>
      <input value={text} onChange={e => setText(e.target.value)} />
      {/* 直接绑定事件处理函数 */}
      <button onClick={handleSubmit}>登录</button>
    </form>
  );
}

原则 C:使用 Ref 保持"沉默" (Escape Hatch)

有时你确实需要在 Effect 中读取一个最新值,但不希望这个值的变化触发 Effect 重新执行。

js 复制代码
// 场景:聊天室连接后,记录当前的 theme,但切换 theme 不应该导致重连
const themeRef = useRef(theme);

// 保持 Ref 最新
useEffect(() => {
  themeRef.current = theme;
});

useEffect(() => {
  const connection = createConnection();
  connection.on('connected', () => {
    // ✅ 读取最新 theme,但不需要将 theme 加入依赖数组
    console.log('Connected with theme:', themeRef.current);
  });
  return () => connection.disconnect();
}, []); // 只有挂载时连接一次

结语:构建以"依赖"为核心的心智模型

Class ComponentFunction Component 的迁移,表面上是一次语法的精简,实则是一场心智模型的彻底重构。

Class 时代,我们习惯于命令式地控制时间:

"在组件挂载时(DidMount)做这件事,在更新时(DidUpdate)做那件事。"

而在 Hooks 时代,React 强迫我们转向声明式的数据流:

"在这个副作用中,我依赖了这些数据(Dependencies)。每当数据变化,副作用自然随之流转。"

Hooks 的代价:手动管理"闭包的新鲜度"

Class 组件中,this.state 像是一个永远指向最新值的全局指针,你随时去读,它都是实时的。

但在 Function 组件中,由于闭包的存在,每一次渲染都是一次独立的快照,每个 Effect 内部持有的数据都可能是"过期"的。

React 无法在运行时通过静态分析知道你的闭包里引用了哪些外部变量。因此,依赖数组(Dependency Array) 本质上是你与 React 之间签署的一份 "缓存失效协议"。

你必须显式告诉 React:"当 userId 变化时,上一次的 Effect 闭包已经失效了(因为它引用的是旧的 userId),请销毁它,并运行本次渲染产生的新 Effect。"

这种机制虽然增加了心智负担,但它强迫我们将副作用与数据状态进行严格的同步绑定,从根本上消除了 Class 组件中常见的"数据已变但逻辑未响应"的一致性 Bug

获得的巨大回报

当你适应了这种 "数据驱动依赖" 的思维方式后,你会发现一个全新的世界:

  1. 逻辑高度内聚: 相关的业务代码终于可以摆脱生命周期的撕裂,聚合在一起(Co-location)

  2. 复用的极致: 提取一个 useWindowWidthuseForm 变得像提取一个普通函数一样简单,逻辑复用不再需要高阶组件的嵌套。

  3. 原子化的思维: 正如我们在对比 Jotai 时所发现的,通过函数式更新(prev => prev + 1),我们将状态管理的控制权交还给了框架,代码变得更加稳健和可预测。

  4. React 的进化方向很明确:UI 只是数据的投影,而副作用只是数据变化的涟漪。

你的下一步 (Call to Action)

不要试图一次性重构整个项目。

下次当你需要修改一个复杂的 Class 组件时,试着不只是"修补"它,而是用 Hooks "重写" 它。

相关推荐
晴栀ay8 小时前
React性能优化三剑客:useMemo、memo与useCallback
前端·javascript·react.js
Bigger9 小时前
Tauri (25)——消除搜索列表默认选中的 UI 闪动
前端·react.js·weui
hongkid9 小时前
React Native 如何打包正式apk
javascript·react native·react.js
aka_tombcato9 小时前
【开源自荐】 AI Selector:一款通用 AI 配置组件,让你的应用快速接入 20+ LLM AI厂商
前端·vue.js·人工智能·react.js·开源·ai编程
光影少年9 小时前
前端如何虚拟列表优化?
前端·react native·react.js
hxjhnct10 小时前
React 为什么不采用(VUE)绑定数据?
javascript·vue.js·react.js
time_rg1 天前
react fiber与事件循环
前端·react.js
前端不太难1 天前
用一张“状态扩散图”,定位 RN 列表性能风险
react.js·harmonyos
fe小陈1 天前
react-nil 逻辑渲染器
react.js
ahhdfjfdf1 天前
前端实现带滚动区域的 DOM 长截图导出
前端·javascript·react.js