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

215 阅读7分钟

引言

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组件中,这个需求是如何实现的

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 的出现,让我们得以按照代码的用途,而不是代码执行的时间点来组织逻辑。

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出来以供服用:

// 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 里的变量会自动更新。

❌ 错误示范:

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 如何计算下一个状态:

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 状态来发送请求,导致不得不把所有表单数据加入依赖。

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 获取数据),而不是用于处理交互。

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 重新执行。

// 场景:聊天室连接后,记录当前的 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 “重写” 它。