JavaScript 中的副作用:为什么纯函数更容易维护

2023/01/14

刚接触“副作用”时,我一度把它理解成代码里的坏事。后来才发现,副作用本身并不等于错误。前端需要请求接口、修改页面、记录日志,这些工作天然都会影响函数外部。真正需要注意的是:不要让副作用和数据计算混在一起,最后连函数会改动什么都看不清。

一、什么是副作用

如果一个函数除了返回结果,还读取或改变了函数外部的状态,就产生了副作用。修改全局变量、操作 DOM、发送网络请求、写入本地存储、启动定时器、注册事件监听,甚至打印日志,都属于常见副作用。

let count = 0;

function increment() {
  count += 1;
  return count;
}

increment 没有参数,但每次调用结果都可能不同,因为它依赖并修改了外部的 count。想知道它做了什么,不能只看输入和返回值,还要检查外部状态。

二、纯函数为什么更容易理解

纯函数有两个关键特点:相同输入总能得到相同输出,并且执行过程中不改变外部状态。比如:

function add(a, b) {
  return a + b;
}

function increase(count) {
  return count + 1;
}

这类函数的行为只由参数决定,测试时给出输入、检查输出就够了。它也更容易复用、缓存和重构。相比直接修改外部变量,increase 返回一个新值,把“如何计算”和“在哪里保存结果”分开了。

不过,读取外部可变状态同样会降低可预测性。一个函数即使没有修改全局变量,只要结果依赖当前时间、随机数或随时会变化的配置,也不能算严格意义上的纯函数。

三、前端项目离不开副作用

页面最终必须和外部世界交互,所以目标不是消灭副作用,而是识别并管理它。常见场景包括:

  1. 调用 fetch 获取或提交数据;
  2. 修改 DOM、滚动位置或浏览器标题;
  3. 读写 localStorage
  4. 创建定时器、订阅事件并在合适时机清理;
  5. 上报日志、埋点和错误信息。

React 把这类与外部系统同步的逻辑放进 useEffect;Vue 中的生命周期、watch、异步请求和 DOM 操作也承担类似职责。框架提供专门入口,是为了让副作用的执行时机和清理过程更明确,而不是因为 Effect 里的代码天然更安全。

四、把计算和操作分开

我更习惯先用纯函数完成数据转换,再由边界层执行请求或写入:

function buildOrderPayload(cart) {
  return {
    items: cart.map(({ id, quantity }) => ({ id, quantity })),
    totalQuantity: cart.reduce((sum, item) => sum + item.quantity, 0)
  };
}

async function submitOrder(cart) {
  const payload = buildOrderPayload(cart);

  return fetch('/api/orders', {
    method: 'POST',
    body: JSON.stringify(payload)
  });
}

buildOrderPayload 只负责计算,可以独立测试;submitOrder 明确负责网络请求。以后接口变化或计算规则调整时,两个部分也更容易分别修改。

五、副作用需要关注生命周期

副作用最容易出问题的地方,通常不是“发生了”,而是“什么时候发生”和“是否清理”。重复注册事件会造成多次响应,未清理的定时器可能继续执行,过期请求还可能覆盖最新数据。因此写副作用代码时,我会确认三件事:

  1. 它依赖哪些状态,依赖变化后是否需要重新执行;
  2. 多次执行会不会产生重复请求、监听或写入;
  3. 组件销毁或条件变化时,是否需要取消任务和释放资源。

副作用并不可怕,可怕的是副作用藏在普通计算里,调用者无法预料。把纯逻辑留在纯函数中,把不可避免的外部操作集中到明确的边界,再处理好执行时机和清理,代码通常会更稳定,也更容易测试。