前端项目中最让人头疼的 bug,往往不是逻辑错误,而是"状态被意料之外地修改了"。你传了一个对象给某个函数,之后发现原对象的值变了;你调用了一个工具方法,页面突然白屏,排查半天才找到是因为它修改了全局变量。这些问题的根源,都在于函数不够"纯"。纯函数是函数式编程中最基础也最重要的概念,它就像代码质量的"守门员"------一旦你开始写纯函数,很多难以追踪的 bug 就自动消失了。

一、什么是纯函数?两个硬条件
一个函数要被称为"纯函数",必须同时满足两个条件:
- 相同的输入,永远得到相同的输出。不依赖任何外部可变状态。
- 没有副作用。不会修改传入的参数,不会改变全局变量,不会操作 DOM,不会发起网络请求。
javascript
// 非纯函数:依赖外部变量 total
let total = 0;
function addToTotal(num) {
total += num; // 修改了外部变量
return total;
}
// 纯函数:所有依赖都通过参数传入
function add(a, b) {
return a + b;
}
javascript
// 非纯函数:修改了传入的数组
function addUser(users, user) {
users.push(user); // 副作用!影响了原数组
return users;
}
// 纯函数:返回新数组,原数组不变
function addUser(users, user) {
return [...users, user];
}
二、副作用:躲在暗处的 bug 源
前端开发中,副作用无处不在。以下是最常见的几类:
| 副作用类型 | 示例 |
|---|---|
| 修改传入的对象/数组 | obj.name = 'new'、arr.push(item) |
| 修改全局变量 | window.config = {}、localStorage.setItem() |
| 操作 DOM | document.querySelector('#app').innerHTML = '' |
| 发起网络请求 | fetch()、axios.get() |
修改 this 或外部状态 |
在 React 类组件中直接修改 this.state |
| console.log | 严格意义上也是副作用(写入输出流) |
副作用本身不是"坏的"------一个完全没有副作用的程序什么也做不了,因为它不能显示内容、不能发请求、不能响应用户操作。关键在于控制副作用的范围:把副作用集中到组件层或特定的模块中,让核心的数据处理和业务逻辑保持纯粹。
React 的 useEffect 和 Vue 的 watch 就是典型的副作用容器------框架把副作用"关"在特定的钩子里,其余部分尽量保持纯函数。
三、纯函数带来的三个实打实的好处
1. 可缓存(Memoization)
纯函数的输入输出关系是确定的,所以可以安全地缓存结果。当同样的输入再次出现时,直接返回缓存值,跳过重复计算。
javascript
function memoize(fn) {
const cache = new Map();
return function(arg) {
if (cache.has(arg)) return cache.get(arg);
const result = fn(arg);
cache.set(arg, result);
return result;
};
}
// 一个昂贵的计算函数
function heavyComputation(n) {
// ... 大量计算
}
const memoizedHeavy = memoize(heavyComputation);
React 的 useMemo 和 useCallback 本质上也是基于这个原理------只有当依赖变化时才重新计算。
2. 可测试
测试纯函数不需要任何 mock、setup、teardown。给定输入,断言输出,一次搞定。
javascript
// 纯函数,测试极度简单
function formatPrice(price) {
return `¥${price.toFixed(2)}`;
}
test('formatPrice formats correctly', () => {
expect(formatPrice(10)).toBe('¥10.00');
expect(formatPrice(0)).toBe('¥0.00');
expect(formatPrice(99.99)).toBe('¥99.99');
});
不用 mock 网络请求,不用准备全局状态,不用清理测试环境。纯函数越多,测试覆盖率越高,回归测试越可靠。
3. 可预测
纯函数不会偷偷修改你的数据,也不会因为调用顺序不同而产生不同的结果。当一个复杂的业务逻辑被拆解成多个纯函数后,你可以像搭积木一样组合它们,每一步都是确定的、可推理的。这对排查 bug 特别友好------你只需要检查每个函数的输入和输出,而不必追踪全局状态的变迁。
四、如何把现有代码改造成纯函数?
实际项目中,业务逻辑往往和副作用纠缠在一起。改造的核心策略是抽离:把纯的计算逻辑提取出来,让副作用留在外层。
改造前:逻辑和副作用混在一起
javascript
// 从接口获取数据,处理后渲染
async function loadAndRender() {
const res = await fetch('/api/orders');
const data = await res.json();
const filtered = data.filter(o => o.status === 'paid');
const sorted = filtered.sort((a, b) => b.amount - a.amount);
document.querySelector('#app').innerHTML = sorted.map(o => `<div>${o.id}</div>`).join('');
}
这个函数做了四件事:请求数据、过滤、排序、渲染。后三步都是副作用或依赖副作用。
改造后:纯计算 + 副作用外壳
javascript
// 纯函数:数据处理逻辑
function getTopOrders(orders) {
return orders
.filter(o => o.status === 'paid')
.sort((a, b) => b.amount - a.amount);
}
// 纯函数:生成 HTML
function renderOrders(orders) {
return orders.map(o => `<div>${o.id}</div>`).join('');
}
// 副作用集中在组件/入口中
async function init() {
const res = await fetch('/api/orders');
const data = await res.json();
const topOrders = getTopOrders(data);
document.querySelector('#app').innerHTML = renderOrders(topOrders);
}
现在 getTopOrders 和 renderOrders 是纯函数,可以独立测试、缓存、复用。副作用只存在于 init 这一层。
依赖注入:让依赖变成参数
javascript
// 依赖全局变量
const API_URL = '/api/users';
async function fetchUsers() {
return fetch(API_URL).then(r => r.json());
}
// 通过参数注入依赖
async function fetchUsers(apiUrl) {
return fetch(apiUrl).then(r => r.json());
}
// 或封装成可配置的函数
const createUserFetcher = (baseUrl) => () =>
fetch(`${baseUrl}/users`).then(r => r.json());
依赖注入让函数的行为完全由参数决定,不再隐藏地依赖上下文。
五、什么时候不追求纯函数?
纯函数不是万能的。以下场景不需要强求"纯":
- 事件处理函数 :
onClick、onChange本身就依赖外部状态,它们的职责就是触发副作用。 - API 请求模块:封装网络请求的函数天然有副作用,应该单独抽离,而不是试图变成纯函数。
- DOM 操作:任何对页面的增删改都是副作用,这是前端的天职。
- 过度纯函数化:如果为了纯函数而传递大量参数,或者强行用柯里化让代码变得难读,那就舍本逐末了。
原则:纯函数应该成为代码的"主要成分",但不是"唯一成分"。把副作用集中在少数几个明确的位置,让大部分代码保持纯净------这就是函数式编程在前端中的最佳实践。
六、关于纯函数与副作用的开发建议
核心思想:把"计算"和"动作"分开
- 纯函数(计算) :只负责根据输入返回输出,不依赖、不改变外部环境。易于测试、缓存、推理。
- 副作用(动作) :网络请求、DOM 操作、修改全局变量、订阅事件等,它们会影响外部世界,必须被明确隔离。
代码分层原则 :
越靠近 UI 层(组件、事件处理),副作用越多;越远离 UI 层(工具函数、数据转换),纯函数越多。让核心业务逻辑纯函数化,副作用只出现在特定的"外壳"中。
组件开发习惯
- 把事件处理函数做成"副作用调度中心"
事件处理函数本身必然是副作用的(因为它要响应 DOM 事件),但它应该只负责调度,不包含具体的数据处理逻辑。
javascript
javascript
// no 事件处理中混合数据转换
const handleClick = () => {
const filtered = items.filter(i => i.price > 100); // 纯计算
const sorted = filtered.sort((a, b) => b.price - a.price); // 纯计算
setDisplayItems(sorted); // 副作用
};
// yes 抽离纯函数
const getExpensiveSorted = (items) => {
return items
.filter(i => i.price > 100)
.sort((a, b) => b.price - a.price);
};
const handleClick = () => {
setDisplayItems(getExpensiveSorted(items)); // 只保留副作用
};
- 状态更新必须保持"不可变性"
永远不要直接修改 state,而是返回新对象 / 新数组,让框架通过引用变化决定是否更新。
javascript
ini
// no 直接修改
setUser(u => { u.age++; return u; });
// yes 返回新对象(纯函数式更新)
setUser(prev => ({ ...prev, age: prev.age + 1 }));
- 用自定义 Hook 封装副作用逻辑
网络请求、定时器、事件监听等副作用应封装在自定义 Hook(React)或 Composable(Vue)中,组件本身只负责组装。
javascript
scss
// 自定义 Hook:把副作用关起来
function useSuggest(query) {
const [results, setResults] = useState([]);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/suggest?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(setResults);
return () => controller.abort();
}, [query]);
return results;
}
// 组件中干净使用
function SearchBox() {
const [query, setQuery] = useState('');
const results = useSuggest(query);
// ...
}
命名与代码审查习惯
- 命名上区分副作用:如果一个函数是纯的,名字通常为名词或计算性动词(
formatPrice,filterActive);如果含有副作用,名字中常带handle*,load*,fetch*,save*,便于识别。 - Code Review 时,一旦看到函数内部有
fetch、localStorage、document.xxx、this.setState、直接修改入参,就要警惕并建议抽离。
七、总结
纯函数与副作用的分离是现代前端可维护性的基石,是一种让代码变得可预测、可测试、可复用的工程方法。当你习惯把大部分逻辑写成纯函数,把副作用集中在少数明确的角落时,bug 更少、重构更安全、测试更简单。