React 组件的逻辑纯粹性:探究副作用分离(Side Effect Separation)对 React 可维护性的底层支撑

各位同学,大家好!

欢迎来到今天的技术讲座。今天我们不讲怎么写一个“Hello World”,也不教大家怎么用 map 把数组变成列表。今天我们要探讨的是 React 的灵魂——或者说,是 React 能够屹立不倒、成为前端界“泰坦尼克号”的底层逻辑。

我们要聊的话题有点枯燥,甚至有点反直觉:“纯粹性”

你们可能听过很多次“纯函数”、“不可变性”这样的词,但很少有人告诉你们,为什么 React 非要死磕这个“纯粹性”,以及为什么我们必须把“副作用”像扔垃圾一样扔出去,才能写出可维护的代码。

准备好了吗?让我们把键盘敲得响一点,这会是一场关于代码洁癖的洗礼。


第一章:纯函数的诱惑与 React 的“不纯”

首先,我们得聊聊数学家最喜欢的东西——纯函数

什么是纯函数?简单来说,就是输入确定,输出确定,且不改变外部世界

// 这是一个纯函数
const add = (a, b) => a + b;

console.log(add(1, 2)); // 3
console.log(add(1, 2)); // 还是 3
console.log(add(1, 2)); // 依然是 3

// 它不依赖外部变量,不修改全局变量,不读取文件,不发送网络请求。
// 它就像一个完美的瑞士钟表,给多少齿轮,它就给你多少时间。

纯函数是可预测的。如果你给一个纯函数同样的输入,它永远给你同样的输出。这对于测试来说简直是天堂:你不需要 mock 数据,不需要造假的网络请求,只需要把参数传进去,跑一下,看结果是不是你想要的。

但是,React 组件不是纯函数。

为什么?因为 React 组件的核心职责是渲染 UI。UI 是什么?UI 是用户能看到的、摸到的、感知到的现实世界。现实世界充满了变量,充满了时间、网络、DOM 操作。

当一个组件开始工作时,它在做的事情是:

  1. 读取状态(State)。
  2. 读取 Props。
  3. 读取外部 API。
  4. 修改 DOM(把 div 画在屏幕上)。
  5. 更新状态(触发下一次渲染)。

在这个过程中,组件不仅依赖了输入,还改变了世界(修改了 DOM)。所以,React 组件天生就是“不纯”的。

但是,我们人类的大脑喜欢秩序。我们讨厌混乱。我们希望我们的代码里,有一部分是像数学公式一样优雅的,另一部分是专门处理那些乱七八糟的外部世界的。

于是,React 的发明者们提出了一个天才的方案:副作用分离


第二章:当逻辑遇上副作用,就像让猫去开飞机

在 React 出现之前,或者说在 Hooks 出现之前,我们是怎么写的?

通常是这样的:在一个 render 函数里,我们既写业务逻辑,又写 API 调用,还写 DOM 操作。这就像是你让一只猫去开飞机。猫负责喵喵叫(渲染 UI),飞机负责飞上天(处理数据),结果猫在驾驶舱里睡着了,飞机一头栽进了太平洋。

看看这个经典的“上帝组件”:

class OrderComponent extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      orders: [],
      loading: false,
      error: null
    };
  }

  componentDidMount() {
    // 1. 立即开始获取数据
    this.fetchOrders();
  }

  fetchOrders = () => {
    this.setState({ loading: true });
    fetch('/api/orders')
      .then(res => res.json())
      .then(data => {
        // 2. 数据来了,更新状态
        this.setState({ orders: data, loading: false });
      })
      .catch(err => {
        // 3. 出错了,记录一下
        this.setState({ error: err.message, loading: false });
      });
  };

  handleDelete = (id) => {
    // 4. 业务逻辑:删除订单
    const newOrders = this.state.orders.filter(o => o.id !== id);
    this.setState({ orders: newOrders });

    // 5. 副作用:删除成功后,通知服务器
    fetch(`/api/orders/${id}`, { method: 'DELETE' });
  };

  render() {
    // 6. UI 渲染:根据数据画界面
    if (this.state.loading) return <div>Loading...</div>;
    if (this.state.error) return <div>Error: {this.state.error}</div>;

    return (
      <ul>
        {this.state.orders.map(order => (
          <li key={order.id}>
            {order.name}
            <button onClick={() => this.handleDelete(order.id)}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    );
  }
}

看到这段代码,我不仅感到头痛,我还感到一种深深的焦虑。

在这个 OrderComponent 里,render 函数(或者说渲染阶段)被污染了。它不仅要负责画图,还要关心数据是怎么来的,怎么删的,怎么删完还要发个请求。

这带来了什么问题?

  1. 可读性极差:你一眼看过去,不知道这个组件到底是干嘛的。它是个数据获取器?是个表单处理器?还是个 DOM 操作员?
  2. 逻辑复用困难:如果你想把这个“获取数据”的逻辑提取到一个单独的文件里,或者想在其他组件里复用,你发现它死死地嵌在 render 逻辑里,或者嵌在 componentDidMount 里,根本无法独立测试。
  3. 难以调试:当 fetchOrders 失败了,是因为网络问题?还是因为状态更新逻辑有问题?因为逻辑和副作用混在一起,你很难一眼看出来。

这就是副作用分离要解决的问题。

我们要把“数据获取”、“DOM 修改”、“订阅事件”这些副作用,从“计算 UI”的纯逻辑中剥离出来。


第三章:useEffect —— 你的“以后再说”占位符

React 16 引入了 useEffect Hook。它就像是你在渲染逻辑里写的一个占位符,它的签名是:

useEffect(setup, dependencies?)

它的核心哲学是:“渲染完了,别管了,这个 Effect 以后再说。”

让我们看看,把上面的 OrderComponent 重构一下,变得多么清爽:

function OrderComponent() {
  const [orders, setOrders] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  // 纯逻辑:只是定义了怎么获取数据
  const fetchOrders = useCallback(() => {
    setLoading(true);
    setError(null);
    fetch('/api/orders')
      .then(res => res.json())
      .then(data => {
        setOrders(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, []); // 空依赖数组,意味着这个函数只在组件挂载时创建一次

  // 副作用:在渲染完成后执行
  useEffect(() => {
    fetchOrders();
  }, [fetchOrders]); // 依赖数组:告诉 React,“只有当 fetchOrders 变了,我才重新跑一遍这个 Effect”

  const handleDelete = (id) => {
    // 纯逻辑:过滤数组
    const newOrders = orders.filter(o => o.id !== id);
    setOrders(newOrders);

    // 副作用:发个请求
    fetch(`/api/orders/${id}`, { method: 'DELETE' });
  };

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <ul>
      {orders.map(order => (
        <li key={order.id}>
          {order.name}
          <button onClick={() => handleDelete(order.id)}>
            Delete
          </button>
        </li>
      ))}
    </ul>
  );
}

看!现在 render 函数非常干净。它只负责根据 orders 数组画出列表。至于数据是怎么来的,怎么删的,那是 useEffect 的事。

这就是分离。渲染逻辑是静态的、声明式的;副作用逻辑是动态的、命令式的。


第四章:渲染阶段 vs. 提交阶段 —— React 的分身术

为了理解为什么 useEffect 这么重要,我们必须深入 React 的内部机制。React 有两个核心阶段:渲染阶段提交阶段

  1. 渲染阶段:这是 React 计算下一步 UI 的过程。这个过程是同步的,也是可中断的。在这个过程中,React 会调用你的组件函数。因为组件函数在这里调用,所以绝对不能做任何副作用(比如发网络请求、修改 DOM)。如果在这里做副作用,一旦 React 中断了计算,你的副作用就执行了一半,数据就乱了。更重要的是,如果在这里做副作用,React 的优化机制(比如 Fiber 树的 Diff 算法)就会失效,因为你的代码在“计算 UI”的同时还在“改变世界”。
  2. 提交阶段:这是 React 把计算好的 UI 挂载到 DOM 上的过程。这个过程是同步的。useEffect 就是在这个阶段执行的。此时,DOM 已经更新了(浏览器已经画出来了),React 才跑你的 useEffect 代码。

这就是 React 的分身术

React 让你的组件函数在渲染阶段表现得像一个“纯函数”,让你在里面只管算出新的 UI 是什么样;而把所有“脏活累活”(副作用)推后到渲染完成后,在 useEffect 里统一处理。

这不仅仅是性能优化,这是逻辑的隔离。


第五章:依赖数组 —— 信任,但要验证

useEffect 的第二个参数是依赖数组。这是 React 逻辑纯粹性中最容易被滥用,也最容易让人抓狂的地方。

useEffect(() => {
  console.log('我的组件挂载了');
}, []); // 空数组:只在挂载时跑一次

useEffect(() => {
  console.log('我的组件挂载了或者 count 变了');
}, [count]); // [count]:只要 count 变了,我就跑一次

依赖数组告诉 React:“嘿,如果下面这些变量没变,你就别动我。”

这看起来很简单,但这里隐藏着一个巨大的陷阱:闭包陷阱

让我们来看一个经典的例子。我们要写一个聊天组件,当用户发送消息时,把消息显示在列表里,并且滚动到底部。

function ChatComponent() {
  const [messages, setMessages] = useState([]);
  const [inputValue, setInputValue] = useState('');

  const sendMessage = () => {
    setMessages(prev => [...prev, inputValue]);
    setInputValue(''); // 发送后清空输入框
  };

  useEffect(() => {
    const messagesEnd = document.getElementById('chat-box');
    if (messagesEnd) {
      messagesEnd.scrollIntoView({ behavior: 'smooth' });
    }
  }, [messages]); // 依赖 messages

  return (
    <div>
      <div id="chat-box">
        {messages.map(m => <div key={m}>{m}</div>)}
        <div ref={el => messagesEnd = el} /> 
      </div>
      <input 
        value={inputValue} 
        onChange={e => setInputValue(e.target.value)} 
      />
      <button onClick={sendMessage}>Send</button>
    </div>
  );
}

这个代码看起来没问题吧?每次 messages 变化,我们就滚动到底部。

但是,如果我们在 sendMessage 函数里有一个 console.log 呢?

const sendMessage = () => {
  console.log('当前消息列表长度:', messages.length); // 这里打印的是 0
  setMessages(prev => [...prev, inputValue]);
  setInputValue('');
};

你可能会惊讶地发现,每次点击发送,控制台打印的都是 0,而不是你期望的 12

为什么?

因为在 sendMessage 函数的定义中,它捕获了 messages 的值。虽然 sendMessage 是在组件顶层定义的(所以它只会在组件挂载时创建一次),但它内部的闭包引用了当时的 messages

更糟糕的是,如果你把 sendMessage 放进 useEffect 里面,或者把 sendMessage 作为 useEffect 的依赖,那情况会更混乱。

这就是副作用分离带来的挑战: 我们把逻辑分开了,但它们之间还是通过闭包在通信。React 告诉你“不要依赖未列出的值”,这是为了防止数据过时。

如何解决这个问题?

  1. 函数式更新:在 sendMessage 里,使用 setMessages(prev => ...),而不是直接读取 messages。这保证了你拿到的永远是最新状态。
  2. 添加依赖:如果 sendMessage 必须依赖 messages,那你必须把它放进依赖数组里。
useEffect(() => {
  // ... 滚动逻辑
}, [messages, sendMessage]); // 把 sendMessage 加进去

// 但是,如果 sendMessage 依赖 messages,这就成了死循环!
// 因为 sendMessage 变了 -> 触发 useEffect -> sendMessage 变了 -> 触发 useEffect ...

这时候,我们需要使用 useCallback 来稳定 sendMessage 的引用,或者使用 useRef 来保存最新的消息列表。

function ChatComponent() {
  const [messages, setMessages] = useState([]);
  const messagesEndRef = useRef(null);

  // 使用 ref 来保存最新的消息列表,避免闭包陷阱
  const messagesRef = useRef(messages);
  messagesRef.current = messages;

  const sendMessage = () => {
    setMessages(prev => [...prev, messagesRef.current[messagesRef.current.length - 1] || 'default']);
  };

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]); // 只依赖 messages
}

这就是“逻辑纯粹性”在实战中的复杂性。我们追求纯粹,是为了让代码可预测;但为了维持这种纯粹,我们需要小心地处理变量之间的依赖关系。


第六章:清理函数 —— 优雅的退出

useEffect 的强大之处不仅在于它能“做事”,还在于它能“收尾”。

useEffect 的返回值中,你可以定义一个清理函数。这就像是你在进入一个房间前把灯打开,离开时把灯关掉。

useEffect(() => {
  console.log('订阅了 WebSocket 连接');
  const connection = new WebSocket('ws://example.com');

  connection.onmessage = (event) => {
    console.log('收到消息:', event.data);
  };

  // 返回清理函数
  return () => {
    console.log('断开连接,清理资源');
    connection.close();
  };
}, []);

为什么这很重要?

  1. 防止内存泄漏:如果你在 useEffect 里创建了一个定时器,但组件在定时器触发前就被卸载了,清理函数会确保定时器被清除,否则内存泄漏了都不知道。
  2. 防止状态不一致:如果你在 useEffect 里订阅了一个事件,组件卸载时你必须取消订阅。否则,组件卸载了,但事件监听还在,一旦事件触发,React 还会尝试更新已经不存在的组件状态,导致报错。

副作用分离的终极形态,就是确保每一次副作用都有明确的开始和明确的结束。


第七章:useLayoutEffect —— 想要同步,就要付出代价

既然 useEffect 是在渲染完成后(浏览器绘制之后)才执行的,那有没有办法在渲染之前、浏览器绘制之前执行代码呢?

有,那就是 useLayoutEffect

useLayoutEffect 的签名和 useEffect 完全一样,但它会在所有的 DOM 变更之后、浏览器绘制之前同步调用。

什么时候用 useLayoutEffect

通常用于需要读取 DOM 尺寸,或者在 DOM 更新后立即执行某些逻辑,以避免出现“闪烁”的情况。

useLayoutEffect(() => {
  const element = document.getElementById('box');
  console.log(element.clientWidth); // 这是一个同步操作
}, []);

警告: useLayoutEffect 是同步的,它会阻塞浏览器的绘制。如果你的 Effect 计算量很大(比如处理几万条数据),或者涉及复杂的 DOM 操作,会导致页面卡顿,用户体验极差。

所以,原则是:能用 useEffect 就用 useEffect,除非你遇到了浏览器绘制的闪烁问题。


第八章:useMemo 和 useCallback —— 不仅仅是性能优化

除了 useEffect,还有两个 Hook 经常和副作用联系在一起:useMemouseCallback

很多人误以为这两个只是性能优化工具。其实,它们也是副作用分离的一部分。

useMemouseCallback 返回的都是缓存的值。它们的本质是:“我不希望这个函数/计算结果因为父组件的重新渲染而频繁地重新计算。”

这为什么重要?

假设你有一个父组件 Parent,它有一个 heavyComputation 函数。子组件 Child 接收这个函数作为 prop。

function Parent() {
  const [count, setCount] = useState(0);

  // 每次父组件渲染,这个函数都会重新创建
  const heavyComputation = (data) => {
    // ... 一堆复杂的计算
    return result;
  };

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Click</button>
      <Child data={count} compute={heavyComputation} />
    </div>
  );
}

function Child({ data, compute }) {
  useEffect(() => {
    console.log('Child 组件挂载了');
  }, [compute]); // 如果 compute 每次都变,这个 Effect 就会疯狂触发

  return <div>Child: {data}</div>;
}

如果不加 useCallback,每次父组件的 count 变化,heavyComputation 就会重新创建一个新的函数引用。子组件收到新的 prop,useEffect 检测到依赖变化,就会执行。

这就导致了不必要的副作用执行

使用 useCallback

const heavyComputation = useCallback((data) => {
  // ... 计算逻辑
  return result;
}, []); // 空依赖,函数引用永远不变

这样,无论父组件怎么渲染,传给子组件的 compute 函数引用都是同一个。子组件的 useEffect 也就不会触发。

总结一下:

  • useEffect 处理外部世界的副作用(API、DOM、事件监听)。
  • useMemouseCallback 处理内部引用的副作用(防止子组件重渲染、防止 Effect 重复触发)。

第九章:可维护性的底层支撑 —— 为什么要折腾?

讲了这么多技术点,大家可能会觉得:“不就是换个写法吗?以前也能写,为什么要搞得这么复杂?”

因为可维护性

当你的项目增长到一定程度,代码量达到几万行,几十个开发者协作时,逻辑纯粹性和副作用分离就是救命稻草。

  1. 单元测试的福音
    如果你的逻辑是纯函数,你可以写这样的测试:

    test('add 函数应该返回正确结果', () => {
      expect(add(1, 2)).toBe(3);
    });

    简单、直接、快速。如果逻辑和副作用混在一起,你需要 mock 浏览器环境,mock 网络,mock DOM,测试变得极其痛苦。

  2. 逻辑复用
    当你把数据获取逻辑从组件中剥离出来,它就变成了一个独立的函数或 Hook。

    // useOrders.js
    export function useOrders() {
      const [orders, setOrders] = useState([]);
      useEffect(() => {
        fetch('/api/orders').then(setOrders);
      }, []);
      return orders;
    }

    你可以在任何组件里调用这个 Hook,它不会污染组件的渲染逻辑。

  3. 重构的勇气
    如果你的代码里,渲染逻辑和副作用混在一起,你敢动吗?动一下可能整个页面就崩了。
    如果逻辑分离了,你就可以把业务逻辑移到一个单独的文件里,重构它,优化它,甚至用 TypeScript 重写它,而不用担心破坏 UI。


第十章:实战演练 —— 一个完整的“订单管理”重构

让我们来实战一下。我们有一个购物车组件,它需要:

  1. 显示商品列表。
  2. 计算总价。
  3. 当总价超过 1000 元时,自动应用优惠券。
  4. 发送数据到后端保存。

重构前(混乱版):

function ShoppingCart() {
  const [items, setItems] = useState([]);
  const [total, setTotal] = useState(0);
  const [couponApplied, setCouponApplied] = useState(false);

  // 1. 渲染时计算总价
  const calculateTotal = () => {
    let sum = 0;
    items.forEach(item => sum += item.price * item.quantity);
    setTotal(sum);
  };

  // 2. 添加商品
  const addItem = (item) => {
    setItems(prev => [...prev, item]);
  };

  // 3. 计算总价和优惠券逻辑混在一起
  const handleAddItem = (item) => {
    addItem(item);
    calculateTotal(); // 这里的副作用是计算总价,但它混在添加逻辑里
    if (total > 1000 && !couponApplied) {
      setCouponApplied(true);
      // 发送优惠券逻辑
      saveCoupon();
    }
  };

  // 4. 保存到后端
  const saveCoupon = () => {
    fetch('/api/cart', { method: 'POST', body: JSON.stringify({ items, total }) });
  };

  // 5. 删除商品
  const removeItem = (id) => {
    setItems(prev => prev.filter(i => i.id !== id));
    // 这里的副作用是计算总价和保存,逻辑很乱
    calculateTotal();
    saveCoupon();
  };

  // 6. 重新渲染时也要计算总价
  useEffect(() => {
    calculateTotal();
    saveCoupon();
  }, [items]);

  return (
    <div>
      {/* UI 代码 */}
    </div>
  );
}

重构后(纯粹版):

// 纯函数:计算总价
const calculateTotal = (items) => {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
};

// 纯函数:判断是否满足优惠券条件
const shouldApplyCoupon = (total) => total > 1000;

// 纯函数:应用优惠券
const applyCoupon = (total) => total > 1000 ? total * 0.9 : total;

function ShoppingCart() {
  const [items, setItems] = useState([]);
  const [couponApplied, setCouponApplied] = useState(false);

  // 副作用 1:监听 items 变化,自动计算总价并保存
  useEffect(() => {
    const total = calculateTotal(items);
    setTotal(total); // 这里的 setTotal 会在渲染阶段触发,但逻辑是清晰的

    if (shouldApplyCoupon(total) && !couponApplied) {
      setCouponApplied(true);
      // 副作用 2:发送保存请求
      saveCartToServer(items, total);
    }
  }, [items, couponApplied]);

  // 副作用 3:组件挂载时加载购物车
  useEffect(() => {
    loadCartFromServer();
  }, []);

  const handleAddItem = (item) => {
    setItems(prev => [...prev, item]);
  };

  const handleRemoveItem = (id) => {
    setItems(prev => prev.filter(i => i.id !== id));
  };

  return (
    <div>
      {/* 渲染逻辑非常纯粹,只负责画 UI */}
    </div>
  );
}

看,现在 handleAddItemhandleRemoveItem 变得多么简单。它们只负责修改数据。所有的计算逻辑、副作用逻辑都交给了 useEffect。即使你想把 calculateTotal 移到一个单独的文件里,或者想把它改成 TypeScript,也变得轻而易举。


结语:拥抱纯粹,拥抱秩序

同学们,React 的哲学核心在于声明式编程。声明式编程的核心在于描述“是什么”,而不是“怎么做”

副作用分离,就是把“怎么做”(副作用:发请求、改 DOM)从“是什么”(渲染 UI:返回 JSX)中剥离出去。

当你开始尝试在代码中分离副作用时,你会发现你的代码结构变得更加清晰了。你的组件变小了,职责变单一了。你不再是一个写“面条代码”的码农,你是一个构建秩序的工程师。

记住,纯粹的逻辑是可预测的,可测试的,可复用的。而混乱的逻辑是不可控的,脆弱的,难以维护的。

下次当你准备在 useEffect 里写一堆逻辑,或者在 render 函数里发个请求时,请停下来想一想:“这个逻辑是‘是什么’的一部分,还是‘怎么做’的一部分?”

如果是“怎么做”,请把它移出去。这是对代码的尊重,也是对你未来维护代码时发际线的尊重。

今天的讲座就到这里。下课!记得把你的 useEffect 依赖数组写对,别再被 React 报错骂了!

发表回复

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