四、全新的 useInit 解决方案
1、避开依赖项声明
在第二章中提到过我不会将由用户事件触发的接口请求写到 useEffect 中。那如果遇到了一个组件的某个 props 确实会变化怎么办?比如 组件的 id 是会动态变化的,需要监听 id 的变化去重新请求项目详情信息。这种情况我会选择利用 key 让这个组件销毁然后重新创建一个新的组件实例:
// 每当 key 变化
// 原有的 ProjectInfo 组件实例就会被销毁
// 全新的 ProjectInfo 组件会从 componentDidMount 开始重启一个生命周期
const a = <ProjectInfo key={String(id)} id={id} />;
// 如果原本要监听多个状态,那就拼多个变量为字符串(虽然不太优雅,但是非常实用,感觉后续可以搞个 HOC 稍微封装一下模板字符串的逻辑)
const b = <ProjectInfo key={`${id}_${bizType}`} id={id} bizType={bizType} />;
官方对于这个特性的介绍:《 当 props 变化时重置所有 state 》和《 有 key 的非可控组件 》,这样每当遇到需要监听的 props 时,优先考虑能否使用 key 这个技巧让原组件销毁,不需要考虑属性变化之后组件内部要如何修改保证可以适应这种变化(重置一些状态等),因为组件一次生命周期内 id 和 bizType 是永远不会变化的。因此我平常很少写 useEffect 依赖,直到有一次参与一个研发周期比较长的重点项目重构,必须要用 addEventListener 设置很多监听器时:
const handleFooEvent = (event) => {
/* ...... */
};
/** 监听 foo 事件 */
useEffect(() => {
const eventName = 'foo-event';
window.addEventListener(eventName, handleFooEvent);
return () => window.removeEventListener(eventName, handleFooEvent);
}, [stateA, stateB]); // 🟡 __todo 上线前确认一遍依赖是否正确
const handleBarEvent = (event) => {
/* ...... */
};
/** 监听 bar 事件 */
useEffect(() => {
const eventName = 'bar-event';
window.addEventListener(eventName, handleBarEvent);
return () => window.removeEventListener(eventName, handleBarEvent);
}, [stateC, stateD]); // 🟡 __todo 上线前确认一遍依赖是否正确
/** 监听 xxx 事件 */
/** 监听 yyy 事件 */
/** 监听 zzz 事件 */
为了避免闭包问题,必须要在 useEffect 中声明依赖,因为我日常开发完全不会参考 react-hooks/exhaustive-deps 这个 ESLint 校验规则(就是这么倔...),所以只能人工判断在依赖中声明必要的 state 和 props,因为开发周期比较长,我知道后续事件响应函数的实现逻辑肯定会变化,依赖也必定会再次变化,所以我保留了几个 todo 的注释提醒自己。直到项目后期要开始逐渐清理项目中的 todo 时,我发现这几个确认依赖的 todo 最保险的还是在上线当前再清理,因为你不知道上线前会不会有 bug 要改动事件响应函数里的逻辑,所以我想了一个办法来解决这个问题:
const handleFooEvent = (event) => {
/* ......*/
};
/**
* 监听 foo 事件
* 🟡 维护依赖太麻烦了,而且后续迭代增减 state 状态之后非常容易出错,所以每次组件更新时都把最新的事件处理函数同步到 ref 中
*/
const handleFooEventFnRef = useRef(null);
handleFooEventFnRef.current = handleFooEvent;
useEffect(() => {
const eventName = 'foo-event';
const handler = (event) => handleFooEventFnRef.current(event);
window.addEventListener(eventName, handler);
return () => window.removeEventListener(eventName, handler);
}, []);
const handleBarEvent = (event) => {
/* ......*/
};
/**
* 监听 bar 事件
* 🟡 维护依赖太麻烦了,而且后续迭代增减 state 状态之后非常容易出错,所以每次组件更新时都把最新的事件处理函数同步到 ref 中
*/
const handleBarEventFnRef = useRef(null);
handleBarEventFnRef.current = handleBarEvent;
useEffect(() => {
const eventName = 'bar-event';
const handler = (event) => handleBarEventFnRef.current(event);
window.addEventListener(eventName, handler);
return () => window.removeEventListener(eventName, handler);
}, []);
我用 ref 来规避了闭包问题,既然已经规避了闭包问题,那就再也不需要声明依赖,因为这个代码没有很好的可读性(无法自说明),所以我给每个 useEffect 都加了很多一样的注释。
再往后就是基于这个模式进行的深入思考(同时要解决本文第三章提出的所有问题)和自定义 Hook 设计讨论,我们最终的解决方案就是使用两个新的自定义 Hook 来完全替代 useEffect,即 useInit 和 useWatch。这两个 Hook 实现上非常简单,源码已上传 GitHub - ZCY-FE/fool-proofing-hooks。
这个解决方案其实是一套全新的 React Hook 编码范式,完全抛弃了副作用的概念,更像是回归了生命周期的理念并加以额外约束。
2、“重新学习 React Hook”
在开始介绍这两个 Hook 之前,请大家先忘掉副作用的概念,也忘掉 React Hook,更不知道 useEffect。让时间重新回到 2018 年,你是一个有一定类组件开发经验的前端工程师,你看到 React 正式发布了 React Hook,你开始跟着 官方文档 学习:
- 先看了 Hook 简介 和 Hook 概览
- 然后学习了 State Hook,也就是 useState 的使用
- 从这里开始打住,在这个“平行世界”里你并没有学习 useEffect 而是学习了 useInit
“平行世界”的官方文档:当你想在 componentDidMount 和 componentWillUnmount 这两个生命周期中编写逻辑时,你可以使用 useInit 这个等价于以上两个生命周期的 Hook。
useInit 基础示例
const Page = (props) => {
/**
* -----------------------------------------------------------
* useInit 基础示例一
* -----------------------------------------------------------
*/
// 回调函数等价于 componentDidMount
useInit(() => {
// mount callback
console.log('componentDidMount');
});
// 回调函数中返回的回调函数等价于 componentWillUnmount
useInit(() => {
// mount callback
return () => {
// unmount callback
console.log('componentWillUnmount');
};
});
// 两个回调函数配合使用,便于在组件销毁时执行一些清理动作,避免内存泄露问题
useInit(() => {
// mount callback
const intervalId = setInterval(() => {
console.log('now:', Date.now());
}, 1000);
return () => {
// unmount callback
clearInterval(intervalId);
};
});
/**
* -----------------------------------------------------------
* useInit 基础示例二
* 使用多个 useInit 实现关注点分离,即一个 Hook 只做一件事
* -----------------------------------------------------------
*/
// 初始化逻辑
useInit(() => {
initPage();
});
// 新手指引逻辑
useInit(() => {
const { destroy } = Modal.info({
title: '新手指引',
content: '......',
});
return () => {
destroy();
};
});
/**
* -----------------------------------------------------------
* useInit 基础示例三
* 当不需要用到 unmount 回调时,useInit 直接支持 async 语法
* -----------------------------------------------------------
*/
// useInit 第一个入参支持 async 语法的函数(即返回值为 Promise 实例)
useInit(async () => {
// mount callback
await sleep(1000); // 模拟异步任务
console.log('组件初始化逻辑执行成功');
// 当前逻辑不需要在 unmount 时执行任何清理动作
// return () => {
//
// };
});
// 但是要注意下,用了 async 语法就不能再返回一个 unmount 回调函数
useInit(async () => {
await sleep(1000);
// 使用 async 语法时若返回了一个函数,ts 类型会报错,并且运行时 console 里会异步抛出一个不影响后续逻辑的异常
// Uncaught Error: If callback of 「useInit」 need return a cleanup callback, don't use async syntax.
return () => {
// 这个函数也不会被执行
console.log('unmount callback');
};
});
return <div>......</div>;
};
export default Page;
useInit 进阶示例
const Page = (props) => {
/**
* -----------------------------------------------------------
* useInit 进阶示例一
* 使用 useInit 的第二个参数规避闭包问题
* -----------------------------------------------------------
*/
// 需要实现的功能:
// 定义一个 number 类型的 state 命名为 inputCount 并和页面中的一个 input 的值绑定
// 定义一个 number 类型的 state 命名为 randomCount 并展示在页面上
// 每隔 1 秒生成为一个 0 ~ 9 的随机数 X
// 若该随机数 X 不等于当前的 inputCount 或 randomCount,则把 randomCount 设置为 X
// 若该随机数 X 等于当前的 inputCount 或 randomCount,则忽略本次随机出的 X
const [inputCount, setInputCount] = useState('0');
const [randomCount, setRandomCount] = useState(0);
const handleInputChange = (e) => {
setInputCount(e.target.value);
};
/**
* 定时器处理函数
* 函数中的逻辑依赖了两个状态:inputCount 和 randomCount
* 但是我们并不需要在任何地方去额外指出我们依赖了这两个状态,因为 useInit 会自动帮我们处理闭包问题
* 因此我们实现函数的时候,包括在后续迭代的时候,都不需要考虑闭包问题,可以直接访问到 state 和 props 的最新值
*/
const handleInterval = () => {
console.log('interval callback start');
// 生成 0 ~ 9 的随机数
const num = Math.floor(Math.random() * 10);
const blackList = [Number(inputCount), randomCount];
if (blackList.includes(num)) {
console.log(`skip setRandomCount: ${num} is in ${blackList}`);
} else {
setRandomCount(num);
}
};
// 设置定时器
useInit(
/**
* mount callback
*
* 若 useInit 传递了第二个参数 funcMap,则回调函数可以通过 self 对象拿到特殊处理过的 funcMap
* 所谓的特殊处理其实就只是将原本 funcMap 上的方法实时更新到 ref 中并固化函数的引用地址,从而解决闭包问题
*
* @param {typeof funcMap} self - 可以想象成类组件中的组件实例 this,从 self 上拿到的方法就像类组件中的原型方法是没有闭包问题的
* 🟡 self 上有哪些“原型方法”是通过 useInit 第二个参数 funcMap 定义的,只能在 self 中定义函数方法,不能定义字符串、数组等“原型属性”
* 🟡 但是不同 useInit 中的 self 以及其中的方法是独立的,从这个角度来说 funcMap 定义的其实是“实例方法”而非“原型方法”,self 也只是“局部作用域内的实例概念”
* 🟡 如果想要在整个组件层面使用实例概念去定义一些“实例属性”,并能在组件的所有地方都能实时访问和修改,请使用 useInstance(详见第六章),其实就是 useRef 的简单封装
* @returns {() => void} unmount callback
*/
(self) => {
// mount callback
const intervalId = setInterval(self.handleInterval, 1000);
return () => {
// unmount callback
clearInterval(intervalId);
};
},
/**
* 第二个参数 funcMap,即声明 self 上有什么方法可以调用,类似在 class 组件中定义原型方法
*/
{ handleInterval }
);
/**
* -----------------------------------------------------------
* useInit 进阶示例二
* 若 WebSocket 的回调中有依赖 state / props 的逻辑
* -----------------------------------------------------------
*/
// 需要实现的功能:
// 监听 WebSocket 消息,并在每次拿到消息时,实时对比消息中的 count 值
// 若大于上一个示例中的 randomCount 则保留该条消息,否则忽略该条消息
const [wsData, setWsData] = useState({});
// 处理 WebSocket 消息
const handleMessageEvent = (data) => {
const { count } = data || {};
if (count > randomCount) {
console.log('--- 符合条件,接收本次 WebSocket 数据 ---', data);
setWsData(data);
} else {
console.log('*** 不符合条件,舍弃本次 WebSocket 传输过来的数据 ***', data);
}
};
useInit(
(self) => {
// mount callback
const ws = new WS('/ws/message');
ws.onMessage = self.handleMessageEvent;
return () => {
// unmount callback
ws.close();
};
},
{ handleMessageEvent }
);
return (
<div>
<h4>inputCount by user input</h4>
<input type="number" value={inputCount} onChange={handleInputChange} />
<br />
<h4>randomCount by interval</h4>
<pre>{randomCount}</pre>
<br />
<h4>valid WebSocket data</h4>
<pre>{JSON.stringify(wsData, null, 4)}</pre>
</div>
);
};
export default Page;
如何理解 self 和第二个参数 funcMap
会被多次异步执行的业务逻辑,都应该封装成方法放到 funcMap 中,类似于在 class 组件中定义了一个单独的事件处理函数,然后 self 就能像 class 中的 this 实例一样去实时访问到没有闭包问题的事件处理函数。
声明了 funcMap,那么 useInit 回调中的逻辑就应该只剩下非常少量的代码:
- 设置各类监听器(addEventListener / WebSocket / setInterval 等等),并把 self 上的方法直接作为处理函数。
- 清理对应的监听器。
/** 泛指各类会被多次异步执行的处理函数 */
const handleFunc = (...args) => {
// 处理函数内访问 state 和 props 不会有闭包问题
};
// 场景一
useInit(
(self) => {
window.addEventListener('my-event', self.handleFunc);
return () => {
window.removeEventListener('my-event', self.handleFunc);
};
},
{ handleFunc }
);
// 场景二
useInit(
(self) => {
const id = setInterval(self.handleFunc, 1000);
return () => {
clearInterval(id);
};
},
{ handleFunc }
);
funcMap 上 90% 的情况中都只需要声明一个方法,因为代码要做到关注点分离,即一个 hook 只做一件事,而一件事则只应该只有一个入口函数。
另外 10% 的场景是考虑到可能部分监听器有多种回调需要处理,或者有时候同一类的外部事件适合批量设置监听器。
/**
* 某些监听器可能会有很多个回调需要处理
*/
const handleWebSocketConnected = (...args) => {
// 处理函数内访问 state 和 props 不会有闭包问题
};
const handleWebSocketMessage = (...args) => {
// 处理函数内访问 state 和 props 不会有闭包问题
};
const handleWebSocketDisconnected = (...args) => {
// 处理函数内访问 state 和 props 不会有闭包问题
};
useInit(
(self) => {
const ws = new MyWebSocket({
url: '/ws/api',
onConnected: self.handleWebSocketConnected,
onMessage: self.handleWebSocketMessage,
OnDisconnected: self.handleWebSocketDisconnected,
});
return () => {
ws.close();
};
},
{
handleWebSocketConnected,
handleWebSocketMessage,
handleWebSocketDisconnected,
}
);
/**
* 一次 hook 中一次性监听多个相似类型的事件
*/
const handleEventA = (...args) => {
// 处理函数内访问 state 和 props 不会有闭包问题
};
const handleEventB = (...args) => {
// 处理函数内访问 state 和 props 不会有闭包问题
};
const handleEventC = (...args) => {
// 处理函数内访问 state 和 props 不会有闭包问题
};
useInit(
(self) => {
window.addEventListener('event-a', self.handleEventA);
window.addEventListener('event-b', self.handleEventB);
window.addEventListener('event-c', self.handleEventC);
return () => {
window.removeEventListener('event-a', self.handleEventA);
window.removeEventListener('event-b', self.handleEventB);
window.removeEventListener('event-c', self.handleEventC);
};
},
{
handleEventA,
handleEventB,
handleEventC,
}
);
唯一的陷阱
如果完全按照上面的说明来定义 funcMap 和使用 self,则不会遇到闭包问题。但是给出最常见的错误示例,也是很重要的:
const WrongDemo = (props) => {
const {
dataId, // dataId 在当前组件整个生命周期中不会变化
} = props;
const [foo, setFoo] = useState(false);
// ...... 组件的其他逻辑中会调用 setFoo 修改 foo 的值 ......
const handleMyEvent = (...args) => {
// 处理函数内访问 state 和 props 不会有闭包问题
};
// 命名声明了 funcMap 并使用了 self 获取事件处理函数,为什么还是遇到闭包问题了?
useInit(
(self) => {
// 基于 handleMyEvent 封装了一层
const handler = () => {
if (foo) {
// 🔴 基于 foo 的 if 逻辑不属于设置监听器,不应该出现在 useInit 的回调中
// 问题说明:在 handler 被异步执行时,foo 永远是初始值 false
self.handleMyEvent();
}
};
const eventName = `my-event-${dataId}`; // 因为这个场景中 dataId 不会变化,所以不用考虑闭包问题
window.addEventListener(eventName, handler);
return () => {
window.removeEventListener(eventName, handler);
};
},
{ handleMyEvent }
);
return <div>......</div>;
};
在 useInit 的设置监听器逻辑中,已经将如何处理自定义事件这件事抽象在了 handleMyEvent 这个子函数内,那么和处理自定义事件相关的所有逻辑(细节实现、子函数、高阶函数等等)都不应该再出现在 handleMyEvent 中,所以这个功能正确的实现方式是:
const RightDemo = (props) => {
const {
dataId, // dataId 在当前组件整个生命周期中不会变化
} = props;
const [foo, setFoo] = useState(false);
// ...... 组件的其他逻辑中会调用 setFoo 修改 foo 的值 ......
/**
* 再次强调:只有将 state 和 props 写在异步事件处理函数内,且放到 funcMap 中时才能避免闭包问题
*/
const handleMyEvent = (...args) => {
// 🟢 基于 foo 的 if 逻辑应该放到 handleMyEvent 中
if (foo) {
// ......
}
};
/**
* 当使用了 useInit 的第二个参数 funcMap 时,回调中的逻辑就应该只剩下非常少量的两类代码:
* 1、设置各类监听器(addEventListener / WebSocket / setInterval 等等),并把 self 上的方法直接作为处理函数
* 2、清理对应的监听器
*/
useInit(
(self) => {
// 设置监听器
const eventName = `my-event-${dataId}`; // 因为这个场景中 dataId 不会变化,所以不用考虑闭包问题
window.addEventListener(eventName, self.handleMyEvent);
return () => {
// 清理监听器
window.removeEventListener(eventName, self.handleMyEvent);
};
},
{ handleMyEvent }
);
return <div>......</div>;
};
用 useWatch 填补最后的空白
让我们继续给上面的例子增加复杂度,假设组件 props 接受一个 mode 参数,根据 mode 来决定具体监听什么类型的事件:
enum ControlModeEnum {
Pending = 'pending', // 模式待定
EventA = 'event-a', // 监听 event-a 事件控制数据变化
EventB = 'event-b', // 监听 event-a 事件控制数据变化
}
const DataComponent = (props) => {
const {
dataId, // dataId 在当前组件整个生命周期中不会变化
mode, // 组件首次渲染时,mode 为 Pending,由父组件异步决定最终的控制模式
} = props;
const [data, setData] = useState<any>(null);
const handleEventA = (...args) => {
// 根据 event-a 事件修改 data
};
const handleEventB = (...args) => {
// 根据 event-b 事件修改 data
};
useInit(
(self) => {
const eventAName = `event-a-${dataId}`;
const eventBName = `event-b-${dataId}`;
// 🔴 在 didMount 时,mode 的值肯定是 pending
if (mode === ControlModeEnum.EventA) {
window.addEventListener(eventAName, self.handleEventA);
} else if (mode === ControlModeEnum.EventB) {
window.addEventListener(eventBName, self.handleEventB);
} else {
// do nothing
}
return () => {
if (mode === ControlModeEnum.EventA) {
window.removeEventListener(eventAName, self.handleEventA);
} else if (mode === ControlModeEnum.EventB) {
window.removeEventListener(eventBName, self.handleEventB);
} else {
// do nothing
}
};
},
{ handleEventA, handleEventB }
);
useInit(async () => {
const initData = await fetchInitialData(dataId);
setData(initData);
});
return <pre>{JSON.stringify(data, null, 4)}</pre>;
};
DataComponent 组件要根据 mode 决定监听事件 A 还是事件 B,而 mode 又是一个会变化的 props,且在这个场景中还有另一个 useInit 在 mode 初始化之前会先执行 fetchInitialData 这个动作,若在父组件中等到 mode 异步确定后再渲染 <DataComponent /> 组件,则会导致 fetchInitialData 的执行时机被延后(当然如果业务能接受这点性能差异,那这么做是最简单的)。
基于 mode 的 if 逻辑明显属于设置监听器逻辑的一部分,所以这个 if 逻辑就应该实现在 useInit 中,但是当执行 useInit 回调的时候,mode 的值肯定是 pending,出问题了。
其实我们可以先想象一下如果用类组件这个功能该如何实现。既然 mode 是会被父组件异步初始化的,那必然不能将设置事件监听器的逻辑写在 componentDidMount 中,必须要用到 componentDidUpdate 这个生命周期:
class MyComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
data: null,
};
// props.dataId 在当前组件整个生命周期中不会变化
this.eventAName = `event-a-${props.dataId}`;
this.eventBName = `event-b-${props.dataId}`;
}
componentDidMount() {
this.fetchInitialData(this.props.dataId).then((initData) => this.setState({ data: initData }));
}
handleEventA = (event) => {
// 根据 event-a 事件修改 data
};
handleEventB = (event) => {
// 根据 event-b 事件修改 data
};
componentDidUpdate(prevProps) {
const { mode } = this.props;
const { mode: prevMode } = prevProps;
// 我们假设 mode 只会从 Pending 变为 EventA 或 EventB,并不会在 EventA 和 EventB 之间切换
// 否则用类组件来实现这个功能会略微有点麻烦,这也不是我们要讨论的重点
if (mode !== prevMode) {
if (mode === ControlModeEnum.EventA) {
window.addEventListener(this.eventAName, this.handleEventA);
} else if (mode === ControlModeEnum.EventB) {
window.addEventListener(this.eventBName, this.handleEventB);
} else {
// do nothing
}
}
}
componentWillUnmount() {
const { mode } = this.props;
if (mode === ControlModeEnum.EventA) {
window.removeEventListener(this.eventAName, this.handleEventA);
} else if (mode === ControlModeEnum.EventB) {
window.removeEventListener(this.eventBName, this.handleEventB);
} else {
// do nothing
}
}
render() {
return <pre>{JSON.stringify(this.state.data, null, 4)}</pre>;
}
}
当一个功能我们在类组件中只能通过在 componentDidUpdate 中判断属性或状态的变化来实现的时候,在函数组件中,我们则可以使用 Watch Hook 提供的 props / state 监听能力来实现:
const DataComponent = (props) => {
const {
dataId, // dataId 在当前组件整个生命周期中不会变化
mode, // 组件首次渲染时,mode 为 Pending,由父组件异步决定最终的控制模式
} = props;
const [data, setData] = useState<any>(null);
const handleEventA = (...args) => {
// 根据 event-a 事件修改 data
};
const handleEventB = (...args) => {
// 根据 event-b 事件修改 data
};
// Watch Hook
useWatch(
/**
* deps
*
* 既然要监听 props / state 的变化,那监听什么就是最关键的,所以我们把监听数组作为第一个参数
*/
[mode], // 🟡 监听什么那些响应式值(如 state 和 props)需要人工判断
/**
* watch callback
*
* 每当监听数组中的任意一个依赖项发生了变化,就会执行该回调函数(在 componentDidMount 时也会自动执行一次,此时 prevDeps 中的所有项都是 undefined)
*
* @param {typeof funcMap} self - 和 useInit 一样,self 是用于解决闭包问题的
* @param {typeof deps} prevDeps - 数组中记录了被监听的依赖项在上一次渲染时的旧值
* 当监听了多个依赖项时,可以通过依次对比新旧的值来确定监听回调是因为哪个依赖项变化而触发的
* @returns {() => void} cleanup callback - 用于执行清理动作的回调
* 当前 watch callback 返回的 cleanup callback 会下一次被监听依赖项发生变动后,下次 watch callback 执行前被执行
* 最后一次 watch callback 返回的 cleanup callback 会在 componentWillUnmount 时被执行
*/
(self, prevDeps) => {
// watch callback
const eventAName = `event-a-${dataId}`;
const eventBName = `event-b-${dataId}`;
if (mode === ControlModeEnum.EventA) {
window.addEventListener(eventAName, self.handleEventA);
} else if (mode === ControlModeEnum.EventB) {
window.addEventListener(eventBName, self.handleEventB);
} else {
// do nothing
}
return () => {
// cleanup callback
// 在这个例子中,因为 mode 从 Pending 变为 EventA 或 EventB 后不会再发生改变
// 所以事件监听器 A / B 将会在 componentWillUnmount 时解绑
if (mode === ControlModeEnum.EventA) {
window.removeEventListener(eventAName, self.handleEventA);
} else if (mode === ControlModeEnum.EventB) {
window.removeEventListener(eventBName, self.handleEventB);
} else {
// do nothing
}
};
},
/**
* 第三个参数 funcMap,即声明 self 上有什么,类似在 class 组件中定义原型方法
*/
{ handleEventA, handleEventB }
);
useInit(async () => {
const initData = await fetchInitialData(dataId);
setData(initData);
});
return <pre>{JSON.stringify(data, null, 4)}</pre>;
};
谨慎使用 useWatch
但是要注意 useWatch 是一个 danger 的用于实现监听类逻辑的 Hook,真正适用的场景其实比较少(绝大部分场景有更适合的方案)。当你想用 useWatch 实现某个功能,或者在代码中看到 useWatch 时,请优先确认能否用以下方案实现这个功能:
- 如果要监听的依赖项是自身 state:
-
- 优先让当前组件在 setState 的时候通过函数调用直接触发对应逻辑。
- 如果要监听的依赖项是 props:
-
- 如果这个 props 在整个组件生命周期中都是不会变化的,那么请使用 useInit 来实现这个逻辑。
- 如果这个 props 只是需要进行异步初始化,则可以在父组件中等异步初始化完成后再渲染当前组件。
- 如果这个 props 确实是会不断变化,则:
-
-
-
优先考虑在父组件中给当前组件设置 key 实现组件在依赖项变化时自动销毁并创建新组件,具体参考《 当 props 变化时重置所有 state 》和《 有 key 的非可控组件 》。在一些需要异步初始化的业务组件中,可能会出现的 UI 界面抖动问题,可以使用骨架图、CSS Transitions 等方式解决。
-
可以考虑将依赖项变化时要执行的逻辑抽离成独立的函数方法,通过 useImperativeHandle 将函数方法挂载到 ref 上,从而让父组件直接调用该方法,例如一些弹框/抽屉类组件可以通过 ref 向外暴露一些 showModal/showDrawer 方法避免在这些组件内部监听 visible 属性执行部分 state 的重置逻辑。但若 props 透传层级过深,或抽离的函数方法不适合被父组件调用(比如父组件不应该关心这个子组件内部过程),则不适用这个方式。
-
-
如果以上方案都不能很好地实现你的功能,那么你才可能需要使用 useWatch。在使用过程中,请注意以下几个点:
/**
* 🟡 useWatch 和 unInit 一样,可以不声明 funcMap 参数,可以不返回 cleanup callback
*/
useWatch([foo, bar], () => {
console.log('watch:', foo, bar);
});
/**
* 🟡 使用 useWatch 和在 componentDidUpdate 判断依赖有一个小差异
* 即 useWatch 在首次渲染之后也会执行,此时的 prevDeps 数组中的每一项都是 undefined
*/
useWatch([foo, bar], (_, prevDeps) => {
/**
* 实际打印:
* prevDeps: [undefined, undefined]
* prevDeps: [..., ...]
* prevDeps: [..., ...]
* prevDeps: [..., ...]
*/
console.log('prevDeps:', prevDeps);
});
/**
* 🟡 useWatch 监听哪些依赖和回调函数中会用到哪些 props / state 是没有任何关联的,请自行确监听目标声明正确
*/
useWatch([lastName, firstName], () => {
// 仅在姓名变化的时候进行打印,监听了 2 个状态,用到了 3 个状态
console.log(`The name was changed to ${firstName} ${lastName} at ${timestamp}.`);
});
最后在用到 useWatch 的场景中,也建议主动找靠谱的同事进行 Code Review 相互确认一下,以及确保有合适的注释能让代码有较高的可读性。
至此,现在这个“平行世界”的 React Hook 方案已经介绍完了,在编写平时的业务代码时,相比于原本的 useEffect 方案:
-
你几乎不用写依赖(除了极少数场景需要必须要用到 useWatch 时,要明确要监听的数据是什么)
-
****很少会感知到闭包问题
再配合第一章中对于 useCallback 和 useMemo 的观点:PureComponent 式的优化方案在迭代中是极其脆弱的,应该优先用其他方式解决性能问题:
- ****你完全不用写 useCallback,是的我们顺便也消除了 100% 的 useCallback
- 你几乎不会用到 useMemo(仅在基于 state / props 进行昂贵的衍生计算时需要使用)
不过在写一些需要封装到 npm 包中的非常基础的业务 Hook 时,因为考虑到 npm 包引用方的环境不可控(比如除了修复问题之外不再进行功能迭代的历史项目工程),在确保代码质量的前提下,在向外暴露变量时还是要尽量用一下 useMemo 和 useCallback 正确包裹。
回顾第三章中提到的所有 useEffect 的问题,我们的新方案基本都很好地解决或者规避了。
Perfect ! 立马在团队内找几个前端工程试行这套新的 React Hook 编码范式,以后新的需求对应的实现代码不允许再使用 useEffect 和 useCallback。
3、自定义 Hook 设计问题
依赖异步数据的初始化逻辑
在试行这套新的方案时,很快我们遇到了一个问题,useInit 和很多原有的自定义 Hook 不太兼容,例如我们原本封装了一个公共的自定义 Hook 叫做 useProjectInfo:
/** 某个前端工程中公共的“查询项目信息 Hook” */
const useProjectInfo = () => {
const [info, setInfo] = useState(null);
const fetchInfo = async () => {
const { projectId } = qs.parse(location.search.slice(1));
const query = qs.stringify({ projectId });
const response = await fetch(`/project/detail?${query}`);
const data = await response.json();
setInfo(data);
};
useEffect(() => {
fetchInfo();
}, []);
return {
projectInfo: info,
};
};
/** 页面组件 */
const Page = (props) => {
const { projectInfo } = useProjectInfo();
if (!projectInfo) {
return null;
}
return <pre>{JSON.stringify(projectInfo, null, 4)}</pre>;
};
当某次迭代中,服务端在接口返回中增加了一些配置信息或者需要将 projectInfo 中的部分数据单独作为 state 时,原本用 useEffect 我们可能会这么写:
/** 页面组件 */
const Page = (props) => {
const { projectInfo } = useProjectInfo();
const [itemList, setItemList] = useState([]);
const handleAddItemBtnClick = () => {
setItemList([
...itemList,
{
name: '商品名称',
createTime: Date.now(),
},
]);
};
const handleDeleteItemBtnClick = () => {
// ......
};
const handleSaveBtnClick = async () => {
await saveProjectInfo();
await message.success('保存成功,页面将自动刷新。');
window.location.reload();
};
useEffect(() => {
if (!projectInfo) {
return;
}
// 获取到项目级信息后,执行以下额外逻辑:
// 读取某些配置,给予用户提示
if (projectInfo.configuration.needTip) {
Modal.info({
title: '提醒',
content: '请注意......',
});
}
// 将部分数据单独设为 state,因为页面中新增了商品新增、删除类功能(保存之前不生效)
// 而 projectInfo 中的 itemList 则视为服务端目前实际存储的商品数据
setItemList(_.clone(projectInfo.itemList || []));
}, [projectInfo]);
if (!projectInfo) {
return null;
}
return (
<div>
<div>......</div>
<pre>{JSON.stringify(projectInfo, null, 4)}</pre>
</div>
);
};
这种代码 useEffect 用法在我们团队中是比较常见的,我们用 useWatch 也能实现:
const { projectInfo } = useProjectInfo();
// ......
useWatch([projectInfo], () => {
if (!projectInfo) {
return;
}
// ...... 剩余代码和 useEffect 完全相同
});
但是如果这么玩,useWatch 的使用也会在工程中泛滥,这种用法根本不在我们 useWatch 的设计预期内,因为这些逻辑本质上还是属于组件初始化阶段的一次性逻辑,所以我们尝试过新增了一个 Hook 支持这种依赖异步数据的组件初始化逻辑(目前该 Hook 已被废弃):
const { projectInfo } = useProjectInfo();
// ......
// 🟡 useInitWhenReady 内部会自动监听第一个参数,等到第一个参数为真值时,再执行初始化逻辑
useInitWhenReady(Boolean(projectInfo), () => {
// 获取到项目级信息后,执行以下额外逻辑:
// 读取某些配置,给予用户提示
if (projectInfo.configuration.needTip) {
Modal.info({
title: '提醒',
content: '请注意......',
});
}
// 将部分数据单独设为 state,因为页面中新增了商品新增、删除类功能(保存之前不生效)
// 而 projectInfo 中的 itemList 则视为服务端目前实际存储的商品数据
setItemList(_.clone(projectInfo.itemList || []));
});
相比于 useEffect 和 useWatch 语义化确实更强了,也能复用各种已有的业务 Hook,而且还可以同时处理多个异步依赖:
const { projectInfo } = useProjectInfo();
const { articleInfo } = useArticleInfo();
const { announcementInfo } = useAnnouncementInfo();
// ......
useInitWhenReady(
Boolean(projectInfo) && Boolean(articleInfo) && Boolean(announcementInfo),
() => {
// 异步获取到各种信息后,再执行初始化逻辑
},
);
看起起来很不错,除了 isReady 的判断有点丑,但如果某个页面在迭代后 articleInfo 和 announcementInfo 的请求入参中需要额外传入 projectInfo 中的部分数据(这里不讨论服务端接口合并,这是两个话题),我们不得不调整自定义业务 Hook 中的逻辑,让他们支持类似 ahooks 中的 useRequest 手动触发模式:
// useProjectInfo 逻辑不用修改
const useProjectInfo = () => {
/* ...... */
};
// 修改 useArticleInfo 逻辑使其支持手动触发接口请求
const useArticleInfo = (options) => {
const {
manual = false, // 🟡 因为 useArticleInfo 作为通用业务 Hook 被引用的地方很多,所以通过增加配置项的方式来实现该功能以兼容其他场景
} = options | {};
const [info, setInfo] = useState(null);
const fetchInfo = async (extraParams = {}) => {
const { projectId } = qs.parse(location.search.slice(1));
const params = {
projectId,
...(manual ? extraParams : {}),
};
const query = qs.stringify(params);
const response = await fetch(`/article/detail?${query}`);
const data = await response.json();
setInfo(data);
return data;
};
useEffect(() => {
// 🟡 手动模式时,不自动触发请求
if (!manual) {
fetchInfo();
}
}, []);
return {
articleInfo: info,
fetchArticleInfo: fetchInfo,
};
};
// 修改 useAnnouncementInfo 逻辑使其支持手动触发接口请求
const useAnnouncementInfo = (params, options) => {
/* ...... 代码修改方式同 useArticleInfo ...... */
};
/** 页面组件 */
const Page = (props) => {
const { projectInfo } = useProjectInfo();
const { articleInfo, fetchArticleInfo } = useArticleInfo({ manual: true });
const { announcementInfo, fetchAnnouncementInfo } = useAnnouncementInfo({ manual: true });
useInitWhenReady(Boolean(projectInfo), () => {
fetchArticleInfo({
bizId: projectInfo.bizId, //
});
fetchAnnouncementInfo({
type: projectInfo.announcementType,
});
});
if (!projectInfo) {
return null;
}
return (
<div>
<div>......</div>
<pre>{JSON.stringify(projectInfo, null, 4)}</pre>
<pre>{JSON.stringify(articleInfo, null, 4)}</pre>
<pre>{JSON.stringify(announcementInfo, null, 4)}</pre>
</div>
);
};
或者我们干脆也让 useProjectInfo 支持 manual 模式,这样我们也就可以用回 useInit 了:
useInit(async () => {
const projectInfo = await fetchProjectInfo();
fetchArticleInfo({
bizId: projectInfo.bizId,
});
fetchAnnouncementInfo({
type: projectInfo.announcementType,
});
});
关于自定义 Hook 的额外规范
例子说完了,分析以上场景,感觉 useInitWhenReady 在一些场景中确实有必要,那我们为什么还是把这个 Hook 废弃了呢?其实主要是回到了我们第一章中的观点:“代码意图的正确传达”和“保持代码结构一致性”。
在最后示例中,使用 useInit 的代码语义化明显好于 useInitWhenReady,因为只看这十行左右的代码,你无法知道 projectInfo 是从哪来的。其实很多时候如何判断语义化是否优秀,可以把代码当作自然语言(中文 / 英语)来阅读,越通顺越好,这样才能更好地确保代码意图的正确传达。
在上面的几次迭代中,因为多了一个 Hook 之后实现方式的选择变多,导致我们的代码结构在不断地变化:
-
引入 useInitWhenReady
-
修改部分自定义业务 Hook 额外支持 manual 手动模式
-
修改全部自定义业务 Hook 额外支持 manual 手动模式
-
删除 useInitWhenReady 改回 useInit
-
......
而且我们完全有理由去猜测,开放 useInitWhenReady 以后会出现一些不在设计意图内的错误用法,比如下面这种错误的骚操作:
所以最后的结论是弃用 useInitWhenReady。并且在我们全新的 React Hook 编码范式中,对所有的业务自定义 Hook 设计增加了一些额外的约定:
-
封装了业务接口请求的自定义 Hook 只能包含状态和函数的定义,不能在自定义 Hook 内包含会自动执行的逻辑,即不能包含 useInit 和 useWatch 这两个 Hook。
-
不过可以将 useInit 和 useWatch 其封装到绑定监听器类的自定义 Hook 中(例如 useOnlineStatus),因为绑定监听器的逻辑一般不会有执行顺序要求,而且监听回调本质上也不是由组件内部触发的。
那么所有常见的数据请求类的自定义 Hook 都应该是类似这样的,有点类似只支持 manual 手动模式的 useRequest,但和 useRequset 不同的是,无论是手动触发请求的函数还是 Hook 本身,都会返回 state,一个用于 UI 渲染,一个用于 JS 逻辑:
/**
* 自定义 Hook 中只能包含 useState、useRef 以及常规函数、变量的定义
*/
const useXxxInfo = () => {
const [info, setInfo] = useState(null);
const fetchInfo = async (params = {}) => {
const query = qs.stringify(params);
const response = await fetch(`/project/detail?${query}`);
const data = await response.json();
// 🟡 注意下面两行代码
setInfo(data); // 请求成功后自动更新 state 用于 UI 渲染
return data; // 同时异步向外暴露最新的 state 值用于 JS 逻辑
};
// 向外暴露 state 和对应的方法,需要在外部手动调用这些方法来触发 Hook 中封装的逻辑
return {
projectInfo: info,
fetchXxxInfo: fetchInfo,
};
};
这种封装方式既保证了良好的可读性,只看所有的 useInit 就可以清晰地了解初始化逻辑。也确保了在不断的需求迭代中,代码整体结构尽量不发生大的变化,因为所有的初始化逻辑调整都将收敛在 useInit 中:
const { projectInfo, fetchProjectInfo } = useProjectInfo();
const { articleInfo, fetchArticleInfo } = useArticleInfo();
const { announcementInfo, fetchAnnouncementInfo } = useAnnouncementInfo();
// 场景一:请求没有先后顺序
useInit(async () => {
fetchProjectInfo();
fetchArticleInfo();
fetchAnnouncementInfo();
});
// 场景二:其中一个请求要前置(被另外所有接口依赖)
useInit(async () => {
const pInfo = await fetchProjectInfo();
fetchArticleInfo({
bizId: pInfo.bizId,
});
fetchAnnouncementInfo({
type: pInfo.announcementType,
});
});
// 场景三:其中一个请求要前置(被另外一个接口依赖)
useInit(async () => {
fetchArticleInfo();
const pInfo = await fetchProjectInfo();
fetchAnnouncementInfo({
type: pInfo.announcementType,
});
});
// 场景四:其中多个请求要前置(被另一个接口依赖)
useInit(async () => {
const [pInfo, aInfo] = await Promise.all([fetchProjectInfo(), fetchArticleInfo()]);
fetchAnnouncementInfo({
type: pInfo.announcementType,
articleId: aInfo.id,
});
});
不过这样自定义 Hook 设计规范后期是无法实现目前社区中 SWR 类的缓存优化的(例如 useSWR)。不过在这个 useSWR 的入门示例 中,我倒认为 用受控组件形式去接收 user 对象也没啥大问题,如果在使用了 useSWR 后当这个 Navbar 组件需要跨业务场景复用时,你反而会发现这个组件和具体数据接口耦合了。还有一些确实要在短时间内减少网络接口请求的场景中(比如移动端弱网场景),其实可以先让服务端先用传统的 HTTP 缓存策略进行优化,这样的缓存策略也不会入侵到前端代码,且哪些接口能缓存、可以缓存多久本来就是服务端要评估和维护的。
至此,对于我们新的 React Hook 编码范式的介绍已结束!
五、Effect 和“同步”
在梳理完本文第三章中描述的那些问题时,我首先想到的就是用一个新的方案去代替 useEffect,但是当我们要推广和介绍 useInit 方案前,真正理解 Effect Hook 是很有必要的,所以本章将较为深入且辩证地讨论 Effect Hook 的设计哲学,并涉及到了几个关键的问题:
-
为什么 React Hook 要引入副作用(Side Effects) 这个概念,以及 React 中的 Effect 表示什么?
-
React 官方文档为什么没有强调不应该用 Effect 处理用户事件(包括获取数据) ,而只是委婉地提了一句,甚至举了很多充满迷惑性的示例。
-
支持了 React Hook 函数组件为什么选择了同步的心智模型(useEffect),而不是继续沿用生命周期的心智模型,否则当初官方完全可以搞一套类似 useMountAndUnmount / useUpdate 这样的 Hook(也支持清理函数、关注点分离等特性),前者到底好在哪?
1、Side Effects
函数式编程
首先,副作用(Side Effects)的概念应该是来源于函数式编程,因为我其实并不了解函数式编程,所以问了下 DeepSeek,以下是 DeepSeek 回答的部分内容:
不可变数据 很好理解,比如 JavaScript 中通过字面量形式创建的对象、数组等引用类型数据,都是可变的数据,变量定义好了之后可以在任意能访问到这个变量的地方去修改对象、数组。我们可以通过 Object.freeze 或者借助其他第三方库创建不可变的数据。
那什么是 纯函数 呢?继续问:
OK,现在关于 函数式编程、纯函数、副作用 的概念很清晰了。
useEffect 的 Effect
再来看 React 对于 Effect 的定义:
从 React 官方介绍中可以看出,在 React 中有两个地方可以触发副作用(改变程序状态或外部系统的行为):
- 由用户事件触发的副作用,比如用户点击事件的事件响应函数中触发了一个 HTTP 接口调用,或读取了 localStorage 中的值等。
- 渲染过程自身引发的副作用,比如根据 title 这个 state 去修改 document.title,或根据 URL 中的 query 参数去查询数据等。
只有前者才被定义为 Effect,所以我们完全可以为 useEffect 是 useSideEffectNotTriggeredByUserEvent 的简称,因为在这个定义中由用户事件触发的副作用完全不属于 Effect !
果然只是我们用错了 useEffect,这么看来如果正确使用 useEffect,那么根本就不会遇到本文第三章中“过度的响应式编程”里提到的问题。然而我 Google 简单搜了下,在 2023 年 6 月(新版官方文档发布)之前,绝大部分关于 Hook 的讨论都没有提到这个观点,我只搜到 《 为什么你不应该在 React 中直接使用 useEffect 从 API 获取数据 》 这一篇技术文章提到了一句“绝大部分触发网络请求的原因都是用户操作,应该在 Event Handler 中发送网络请求”。
所以即使官方文档这么定义了,由用户事件触发的副作用不属于 Effect 并不是开发者的共识(可能因为官方举的文字示例都是由用户事件引发的提交类 POST 接口,但是查询类 GET 接口一样属于副作用)。
React 中的副作用
当我继续认真对比 函数式编程 和 React 中副作用的概念时,我还发现了一个问题:如果改变函数外部状态的行为都属于副作用,那么 React 函数组件中的 setState 是不是也应该属于副作用,而且函数组件执行过程中的入参是 props,在整个组件的生命周期中,即使 props 不变,函数组件返回的 JSX 是不固定的,因为每次 update 函数组件都从函数外部读取了可能会变化的 state 状态。
但是看官方对于 Effect 的各种示例中从来没有提到过 setState,也从来没有说过 setState 不是副作用,所以我只能调戏 DeepSeek 了,我新建了三个会话(避免在大模型一个会话里根据上下文出现墙头草行为)分别问了以下三个问题:
-
为什么 React 函数组件中的 setState 属于副作用?
-
为什么 React 函数组件中的 setState 不属于副作用?
-
React 函数组件中的 setState 是否属于副作用?
从 DeepSeek 的回答中,我也感受到了正反两种观点,不过第 3 个问题的回答是认为 setState 不属于副作用的。在这几个问题的回答中,最能说服我的可能是 DeepSeek 提到的以下这部分观点:
React 只是借鉴了函数式编程的思想,特别是将视角限定在函数组件的单次渲染时,setState 并没有修改因闭包而形成的时间切片中 state 的值(在这个比喻中类组件的 this 和函数组件的 ref 就像是可以穿越到未来的时光机)。
2、重新认识 useEffect
在知道了由用户事件触发的副作用不属于 Effect 后,我就在想本文第三章提到的这些问题会不会都是错误使用 useEffect 造成的?所以我决定重新仔仔细细地看一遍 React 官方文档。又恰好 2023 年 6 月上线了新的 React 官方文档《 介绍 react.dev 》,在新版官方文档的教程中已经完全抛弃了类组件和生命周期的概念,因此我也可以体验一下从零开始的 React Hook 学习路径。
脱围机制 vs 基础 Hook
首先通过观察新版官方教程中 《 脱围机制(Escape Hatches) 》 左侧的目录设计,你会发现 ref 和 Effect 等的介绍都归属于“脱围机制”,官方文档也指出了这是一个高级的功能,并且和“脱围机制”这个名字表示的意义一样,官方也说了大多数应用不应该用到这些功能。
然而这和我们日常的感知是截然相反的,我从来没见过哪个用到了 React Hook 的前端页面可以不使用 useEffect 来实现业务功能。为什么 useEfect 不像 useState 一样是被作为一个必要且基础的 Hook 来介绍,而是当作一个貌似 React 初学者可以先不用学习的高级 Hook 来介绍,我不理解。
陡峭的 useEffect 认知曲线
我们继续看官方对于 useEffect 的介绍过程,总共分了五章
-
《 使用 Effect 进行同步 》介绍了 useEffect 正确的使用方法,如何确定 useEffect 依赖项。
-
《 你可能不需要 Effect 》介绍了一些不应该使用 useEffect 的场景。
-
《 响应式 Effect 的生命周期 》分析了 useEffect 回调函数的触发逻辑。
-
《 将事件从 Effect 中分开 》这个标题很怪,其实是介绍了在一类特殊的场景中,如果用原来官方标准的判断 useEffect 依赖的方式去实现,则会遇到功能性问题,为了应对这个场景而介绍了一个还没有发布的实验性 Hook 及其对应的局限性。
-
《 移除 Effect 依赖 》强调了依赖项必须和回调中实际的引用情况保持一致,以及保持了一致却还是出现问题时要如何调整代码和依赖项。
我后来统计了一下官方文档的中文字符数,介绍 useEffect 这一个 Hook 的 5 篇官方中文教程(除去两侧的目录和每篇教程底部的挑战习题)加起来大概有 2.9w 个中文字符。
作为对比本文全文也有大概 3w 个中文字符(快逃!),但是其中介绍 useEffect 替代方案的第四章中文字符还不到 7k,不到官方文档的四分之一,而且还介绍了两个 Hook,平均一个 3.5k,简直物超所值!
当我把自己想象成一个初学者时,我认为前两章还可以接受,但是再加上后三章,我不得不说相比于老版官方文档,新版文档对于 useEffect 的介绍实在是太详细了,面面俱到。然而如此之多的章节和示例,真的能让大部分开发者学明白吗?
大部分开发者用不好 useEffect 不是因为学习曲线陡峭,相反随便找个官网之外的教程学个 15 分钟就能到业务代码中“大展身手”了。问题在于认知曲线太陡峭,副作用的概念其实一点都没有深入人心,每个人都可以基于自己的理解来使用 useEffect,这就导致很多人手中的 useEffect 反而成了构建屎山的一把利器。
下面这个图真的不只是搞笑的:
用户事件触发的接口请求
React 官方文档中从用途上把 HTTP 接口请求的概念分为了“数据请求”(应该指 GET 请求)和“POST 请求”。
阅读 《 你可能不需要 Effect 》 这个章节时,我对于其中关于“由用户事件触发的接口请求”的描述和示例产生了极大的困惑。其中 如何移除不必要的 Effect、发送 POST 请求、获取数据 这三个小节都提到了相关描述和示例:
对于 **如果使用 Effect 来处理用户事件,当一个 Effect 运行时,你无法知道用户做了什么(例如,点击了哪个按钮) ** 这个说法,我非常非常认同,也就是本文在第三章中“过度的响应式编程”里提到的,使用 useEffect 时会丢失“从用户行为到接口请求”之间的函数调用关系,特别是当页面逻辑变得复杂时会让这个问题成倍地放大。
然而在 获取数据 中,却说分页请求的逻辑放在 useEffect 中没有问题,但是我们之前看过 React 对于 Effect 的定义其实是 side effects that are not triggered by user events,而这里乍一眼看上去就是用户点击下一页等交互操作触发的接口请求:
按照示例中对代码逻辑的分析,我理解完整的代码应该是 query 在父组件中并不是一个 state 状态,用户每次修改关键词后事件处理函数会将其自动填充到当前的 URL 中,路由更新让父组件重新渲染,父组件从 URL 中读取 query 参数并传递给子组件:
const getUrlParams = () => qs.parse(window.location.search.slice(1));
/**
* 假设 SearchPage 是 SearchResults 的父组件
*/
const SearchPage = (props) => {
const { query = '' } = getUrlParams();
const [searchInputValue, setSearchInputValue] = useState('');
const handleInputSearchChange = (e) => {
const value = e.target.value;
setSearchInputValue(value);
};
const handleSearchBtnClick = () => {
if (!searchInputValue) {
return;
}
const queryString = qs.stringify({
query: searchInputValue,
});
// 🟢 点击搜索时触发路由变化,因为只是更新 query 参数,所以只是触发当前 SearchPage 组件的更新,更新后 getUrlParams 方法读取到新的 query 值
props.history.push(`/search-page?${queryString}`);
};
return (
<>
<div>
<input value={searchInputValue} onChange={handleInputSearchChange} />
<button onClick={handleSearchBtnClick}>Search</button>
</div>
<SearchResults query={query} />
</>
);
};
基于这个设定确实 query 的变化不是由用户事件直接触发的,所以文中 query 的来源不重要 这句话勉强说得通。但是从 handleNextPageClick 触发的翻页逻辑中看出,表示页码的 page 状态并不需要填充到 URL 中,是一个很常规的用户事件直接触发接口请求的逻辑,那么文中 page 的来源不重要 这句话就是完全错误的,因此点击下一页按钮触发的数据请求,就应该放在事件响应函数中,所以代码应该这么写:
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
const fetchData = (queryStr, pageNo) => {
fetchResults(queryStr, pageNo).then((json) => {
setResults(json);
});
};
useEffect(() => {
fetchData(query, page);
}, [query]); // 只监听来源于 URL 中的 query 变量变化
function handleNextPageClick() {
const targetPage = page + 1;
setPage(targetPage);
fetchData(query, targetPage);
}
// ...
}
调整过后的代码的功能和原来完全一样,但是你会发现 useEffect 中的依赖项数组中只有 query,而回调函数中却依赖了 query 和 page 两个状态! 所以我怀疑是因为这样写会导致依赖项和 react-hooks/exhaustive-deps 这个 ESLint 规则相冲突,所以官方的这个示例中就把所有的副作用逻辑都实现在了 useEffect 中,因为和 ESLint 规则冲突本质上是和 useEffect 的设计理念相违背了,参考《 使用 Effect 进行同步 》和《 useEffect 》中关于开发者应该如何书写依赖项的描述:
不过如果从监听思维来解读修改后的代码是很好理解的:我们要监听 URL 中 query 的变化,一旦 query 变化后,就执行请求逻辑,请求逻辑会用到哪些状态和监听什么并没有直接关系。
所以我个人认为,当你不需要把状态同步到 URL 中时,我们是不应该使用 useEffect 的,补充说明《 你可能不需要 Effect 》中开头说那句话就是:
绝大多数情况下,你不必使用 Effect 来处理用户事件(包括 GET 请求),请优先将用户事件相关的副作用逻辑写在事件处理函数中!
绝大多数情况下,你不必使用 Effect 来处理用户事件(包括 GET 请求),请优先 将 用户事件相关的副作用逻辑写在事件处理函数中!
绝大多数情况下,你不必使用 Effect 来处理用户事件(包括 GET 请求),请优先将用户事件相关的副作用逻辑写在事件处理函数中!
然而我发现这么重要的事情,其实并不是 React 开发者们的共同认知,因为在 2018 年 React Hook 发布时的老版的官方文档中也没有提到不应该用 Effect 来处理用户事件,2023 年新版官方文档提出这个观点时,也只是配了这么一个充满迷惑性的 获取数据 示例。
我真的觉得这个事情的重要程度完全值得将 useEffect 改名为我前面所说的 useSideEffectNotTriggeredByUserEvent,因为 useEffect 这个名字就像有魔法一样,不断地在吸引开发者把所有的副作用逻辑尽可能地放到里面。
更迷惑的是,官方教程还基于这个例子教你把 useEffect 进一步封装成自定义 Hook,并不是这个例子错了,而是这样会进一步引导开发者认为所有的 GET 请求都推荐这么封装:
但是实际业务代码中,很大部分的由用户事件触发的接口请求,并不需要把接口入参同步到 URL 中,更不应该封装成这样的自定义 Hook,因为这样会进一步加大阅读事件响应函数中代码逻辑的难度,并且修改 useEffect 中的代码时难以评估影响面。
所以为什么官方教程不在教程最显眼的位置,直接大大方方用一个代码示例告诉我们,这么做是不推荐的,比如:
// 如果 query 和 page 都是单纯的 React State
// 🔴 那么下面的示例是不推荐的 !
// 🔴 那么下面的示例是不推荐的 !
// 🔴 那么下面的示例是不推荐的 !
function SearchResults() {
const [results, setResults] = useState([]);
const [query, setQuery] = useState('React');
const [page, setPage] = useState(1);
useEffect(() => {
// 🔴 避免:因为 query 和 page 都是单纯的 React State,所以副作用逻辑不应该用 useEffect 来处理
fetchResults(query, page).then((json) => {
setResults(json);
});
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
function handleQueryInputChange(e) {
const value = e.target.value;
setQuery(value);
}
// ...
}
// 🟡 并且很容易出问题 !
// 🟡 并且很容易出问题 !
// 🟡 并且很容易出问题 !
function SearchResults() {
const [results, setResults] = useState([]);
const [query, setQuery] = useState('React');
const [page, setPage] = useState(1);
const theme = useContext(ThemeContext);
// 如果在请求结束后要用到其他状态,参考 useEffect 官方的依赖项声明规则,需要把这些状态也声明为依赖项
useEffect(() => {
fetchResults(query, page).then((json) => {
setResults(json);
showNotification('Data fetched!', theme);
});
}, [query, page, theme]); // 🔴 BUG:仅 theme 变动时也触发了不必要的接口请求和用户通知
// 解决方案:移除 theme 依赖项,并忽略这个 ESLint 依赖校验
// 但这样会导致后续请求增加状态入参时非常容易遗漏依赖
// eslint-disable-next-line react-hooks/exhaustive-deps
// }, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
function handleQueryInputChange(e) {
const value = e.target.value;
setQuery(value);
}
// ...
}
// 🟢 推荐示例,将首次之外的接口请求,都实现在用户事件中,因为它们都是由用户事件触发的
// 🟢 推荐示例,将首次之外的接口请求,都实现在用户事件中,因为它们都是由用户事件触发的
// 🟢 推荐示例,将首次之外的接口请求,都实现在用户事件中,因为它们都是由用户事件触发的
function SearchResults() {
const [results, setResults] = useState([]);
const [query, setQuery] = useState('React');
const [page, setPage] = useState(1);
const theme = useContext(ThemeContext);
const fetchList = (queryText, pageNo) => {
fetchResults(queryText, pageNo).then((json) => {
setResults(json);
showNotification('Data fetched!', theme);
});
};
const handleNextPageClick = () => {
const targetPage = page + 1;
setPage(targetPage);
// 🟢 查询下一页行为是一个事件,因为它是由特定的交互引起的。
fetchList(query, targetPage);
};
const handleQueryInputChange = (e) => {
const value = e.target.value;
setQuery(value);
// 🟢 输入行为是一个事件,因为它是由特定的交互引起的。
fetchList(value, page);
};
useEffect(() => {
fetchList(query, page);
// 🟡 初始请求很确定只需要在 didMount 时触发,所以这里只能忽略 ESLint 依赖校验
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ...
}
我觉得 React 官方是知道这件事情的,却刻意没有使用代码示例来强调说明。如果前端开发者们能在 2018 年就被明确地告知不应该用 Effect 处理用户事件,估计也不会有今天这篇文章。但 2025 年的今天,仅靠开发规范的约束已经完全无法让开发者们达成 useEffect 使用方式上的共识了(更别提本文第三章提到的各种其他问题) 。
为此我又往前看了看官方文档《 使用 Effect 进行同步 》中的另一个 获取数据 的官方示例,这个示例中 useEffect 的依赖项是 userId,可以猜测这确实不是由用户触发的副作用。但是紧接其后的 深入探讨 中,提到了一篇作者为 Robin Wieruch 的博客《 How to fetch data with React Hooks 》,这篇博客就是“完美地”用 useEffect 来处理了由用户事件引起的副作用,最后甚至还额外加了一个 UI 渲染时用不到名为 activeSearch 的 state 作为 useEffect 的依赖项实现点击搜索按钮触发接口请求:
我认为这简直是魔怔了,首先在 UI 渲染中用不到的变量,不应该定义为 state,其次 Effect 的定义是 由渲染自身引起的副作用,而 activeSearch 的变化并不会改变 UI 视图,所以这个 useEffect 其实是用监听思维写出来的!
但是官方认为这个示例的问题不在于用 useEffect 来处理了由用户事件引起的副作用,也不在于用监听思维使用 useEffect,而在于直接使用 useEffect 无法支持 SSR、可能会产生网络瀑布、无法进行缓存优化、需要手动处理竞态条件等等:
官方文档推荐了一些支持缓存的请求库,比如 React Query 和 useSWR,然而在将业务中所有的获取数据逻辑都用这些类库来实现时,本质上其实也都是在用 useEffect 来处理由用户事件引起的副作用:
被推荐的方案还有例如 Next.js 等的框架,这些框架支持了 和流式服务器渲染(Streaming Server Rendering)等能力,对服务端渲染的支持上非常好,体现了 React 强大的生态。
不过以上这些优化方案都是有适用场景的,当我们要构建一些有复杂交互逻辑的页面时,往往不需要用到以上的优化手段,我们需要的是先把代码逻辑写得足够清楚!
所以我认为 React 没有在官方文档中着重强调“不应该用 useEffect 来处理由用户事件引起的获取数据类副作用逻辑”,是因为解决缓存、服务端渲染等问题的各种优化方案,非常适合用封装了 useEffect 的自定义 Hook 来实现,因此很自然地也顺带将所有的获取数据逻辑(不管是否由用户事件触发)都放到了 Effect 中,即便这和 Effect 的设计理念相冲突。
强烈建议所有开发者评估一下对应的业务形态,如果前端交互逻辑是比较复杂的,并且不需要用到以上这些优化方案,请谨用 useEffect,更建议完整看完本文后考虑下是否要替换 useEffect 这个 Hook,否则随着产品功能的不断叠加,基于 useEffect 构建的页面会大大降低研发效率。
在请求方法中处理竞态条件
获取数据 中也提到了 竞态条件 问题,但是这种场景下肯定是要优先使用防抖 debounce 来处理高频的用户输入事件,一般情况下防抖处理后出现竞态问题的几率就很低了,这也是为什么大多数情况下我们并没有感知到竞态问题的原因。
但如果网络环境长期波动,或者服务端处理请求的时间有较大波动,我们还是要想办法解决竞态问题。同时我们依旧要避免将由用户事件触发的副作用逻辑实现在 Effect 中,所以解决方案就是直接在请求方法中处理竞态条件:
// 方案 1
// 🟢 给每个请求设定标识符
function SearchResults() {
const requestIdRef = useRef(0);
const fetchList = useCallback(async (queryText, pageNo) => {
const currentRequestId = ++requestIdRef.current;
const json = await fetchResults(queryText, pageNo);
// 只处理最后一次请求的响应
if (currentRequestId === requestIdRef.current) {
setResults(json);
}
}, []);
useEffect(() => {
return () => {
// 组件卸载时修改标识符,处理接口请求在组件销毁后才拿到响应的场景
requestIdRef.current++;
};
}, []);
}
// 方案 2
// 🟢 利用 AbortController 取消过期请求
function SearchResults() {
const abortControllerRef = useRef(null);
const fetchList = useCallback(async (queryText, pageNo) => {
// 取消之前的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const controller = new AbortController();
abortControllerRef.current = controller;
try {
const json = await fetchResults(queryText, pageNo, { signal: controller.signal });
setResults(json);
} catch (err) {
if (err.name !== 'AbortError') {
// 处理真实错误
}
}
}, []);
useEffect(() => {
return () => {
// 组件卸载时取消未完成的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
};
}, []);
// ...
}
牵强的 useEffectEvent
《 将事件从 Effect 中分开 》这个章节的标题很绕,这一章其实是介绍了一类特殊的场景,如果用原来官方标准的判断 useEffect 依赖的方式去实现,则会遇到功能性问题。为了解决这个问题,官方介绍了一个还没有发布的实验性 API useEffectEvent。
这一类特殊的场景,如果我们用监听思维去使用 useEffect 时,就很好描述:useEffect 要监听的依赖项和 useEffect 回调中实际要使用的依赖项不一致(其实就是本文第三章的“不应该的 WebSocket 性能问题”和“找不出问题的错误用法”中提及过的问题)。
在截图的示例中,开发者的“本意”是只想监听 roomId,但是 useEffect 回调中依赖了一个会不断变化的 props.theme:
-
如果不把 theme 放到 useEffect 依赖中,则会遇到闭包问题,notification 提示中的 theme 可能会是旧的值。
-
如果把 theme 放到 useEffect 依赖中,每次 roomId 没变,只是 theme 变化时也会重新断连和建连并通过 notification 通知用户。
这让 useEffect 依赖声明规则陷入了两难的境地。所以官方引入了一个实验性 API useEffectEvent 来解决这个问题:
从官方文档的介绍来看,我认为 useEffectEvent 的实际功能和 ahooks 中的 useMemoizedFn 没有任何区别,分析 useMemoizedFn 的 源码 可以发现,其实 useMemoizedFn 也是利用了 ref 来保持引用地址不变并在每次组件 update 时更新函数内部实现,从而绕过了闭包问题,即用 useEffectEvent / useMemoizedFn 包裹后函数可以一直访问到最新的 state / props。不过 ahooks 中对 useMemoizedFn 的定位是性能优化,解决闭包问题则推荐用 useLatest 包裹单个的 state 或 props,但是我个人觉得 useMemoizedFn 来解决闭包问题比 useLatest 更合适。
而 useEffectEvent 和 useMemoizedFn 唯一的不同可以通过下一个官方示例可以感受到:
再次引用《 使用 Effect 进行同步 》和《 useEffect 》中提到的关于开发者应该如何书写依赖项的描述:响应式值包括 props、state 以及所有直接在组件内部声明的变量和函数 。在这个示例中 onVisit 是一个在组件内部声明的变量,却不需要写到 useEffect 依赖中,所以 useEffectEvent 和 useMemoizedFn 唯一的不同就是官方的 ESLint 代码校验会自动忽略由 useEffectEvent 导出的函数,这是一个特例。
在《 将事件从 Effect 中分开 》中还写了一篇注意事项来补充说明了另一个问题(截图中的红字是我个人的解读):
不过我感觉 setTimeout 这个例子很牵强,把 url 和 setTimeout 都放到 useEffectEvent 中不就好了:
const onVisit = useEffectEvent( => {
setTimeout(() => {
logVisit(url, numberOfItems);
}, 5000); // 延迟记录访问
});
// 依赖不一致,但是功能依旧没问题
useEffect(() => {
onVisit();
}, [url]);
官方文档中提到的 抑制依赖项检查是可行的吗?也说明了我们平常忽略 ESLIint 代码提示可能会造成的问题(不过这个例子也恰恰体现了本文提出的 useInit 解决方案的优势),并给出了最重要的目的:**等 useEffectEvent 成为 React 稳定部分后,我们会推荐 永远不要抑制代码检查工具。 **
在 Effect Event 的局限性 中还提到了两条使用上的约定:
- 只在 Effect 内部调用他们。
- 永远不要把他们传给其他的组件或者 Hook。
虾仁猪心的时候到了,如果你是从头阅读本文的,你可能会感受到:useEffectEvent 的最核心本质不是一个新的 API(单纯要解决闭包问题可以直接使用 useMemoizedFn 这类 Hook),而是我在第三章“难以自动化”的最后提到过的,如果要在构建时自动化计算出 useEffect 依赖,需要所有开发者对现有代码做一个排查并进行一些“标记”,标记哪些变量是不需要进行 ESLint 依赖项自动检查的。因为自动化的前提就是有一个固定的规则可以让开发者无脑遵循,之前 **“响应式值包括 props、state 以及所有直接在组件内部声明的变量和函数” **这个官方规则在某些场景下是有漏洞的, 而 useEffectEvent 则填补了这个漏洞。
不过你应该也能明显地感受到,用 useEffectEvent 来填补这个漏洞非常牵强,因为按照官网说的如何使用 useEffectEvent 这个 Hook 本身就并不无脑,相反你要十分小心地去确保每一个 useEffect 中的依赖项(排除 useEffectEvent 导出的特殊函数)和官方 ESLint 校验规则一致。整件事情让我觉得不是官方 ESLint 插件在帮助开发者提升编码效率,而是在让开发者帮助官方 ESLint 插件提高依赖检查的正确率,可能这也是为什么 useEffectEvent 这个 API 这么久了还一直处于实验性 API 的阶段。
至于 《 将事件从 Effect 中分开 》 这个奇怪的标题,其实就是想要通过“响应式的 Effect”和“非响应式的用户事件”这两个基本概念衍生,从设计思想上引导开发者如何正确地使用 useEffectEvent。
但其实 React 在 useEffect 这个 API 上就从来没有做到过统一开发者思想:从 2018 到 2025 年,所有的官方文档在介绍 useEffect 时都没有用到“监听依赖项”这类词汇(并且从 useEffect 入参把回调函数作为第一个参数而不是依赖项作为第一个参数),一直用的是“副作用”、“与外部系统同步”等等的概念。然而每次当我们讨论和 useEffect 有关的代码时,绝大部分人说的都是“监听了 xxx 请求得到 yyy”,而不是“yyy 通过网络和 xxx 进行同步”。
所以我认为这是 React Hook 设计非常失败的地方,因为大部分开发者一直习惯用监听思维理解 useEffect,因此导致各种错误的使用方式层出不穷。而 React 官方的解决方案是继续堆叠这类和普通人习惯性认知相冲突的各种概念,妄想通过不断地“说教”来改变开发者的思维方式。
最后额外提一句,useEffectEvent 或 useMemoizedFn 这类 Hook 导出的函数若作为 render props 传递给子组件时,可能会出现子组件无法及时更新的问题:
import React, { useState, useEffect } from "react";
import { useMemoizedFn } from "ahooks";
const MemoizedPanel = React.memo((props) => {
return (
<>
<h1>
{typeof props.header === "function" ? props.header() : props.header}
</h1>
<p>{props.children}</p>
</>
);
});
const Page = () => {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
const renderHeader = useMemoizedFn(() => {
return `header count: ${count}`;
});
return (
<>
<p>real count: {count}</p>
<br />
<MemoizedPanel header={renderHeader}>
Panel 的内容
</MemoizedPanel>
</>
);
};
export default Page;
关键是这类问题是非常隐性的,如果 Panel 组件不用 React.memo 包裹或者 Page 组件写成下面这样这个问题就被掩盖了:
const Page = () => {
// ...
return (
<>
<p>real count: {count}</p>
<br />
<MemoizedPanel header={renderHeader}>
<span>Panel 的内容</span>
</MemoizedPanel>
</>
);
};
export default Page;
在类组件中如果将类方法直接作为 render props 传递给 PureComponet 类型的子组件,也会出现这种“渲染优化过度”的情况。不过在函数组件中因为每次渲染时,在组件内定义的函数都是新的引用,其实原本不会出现这类问题。所以使用 useEffectEvent 或 useMemoizedFn时还要注意不要用于 render props 这种场景。其实类组件中定义的所有原型方法如果作为 render props 也会有这个问题,因为原型方法的引用地址也是固定的,所以这也是函数组件优于类组件的一个方面。
声明依赖项增加了复杂度
《 移除 Effect 依赖 》这一章节中强调了依赖项必须和回调中实际的引用情况保持一致,并列举了一些保持一致后还是出问题了的示例及其对应的解决方案,其中部分示例其实在官方文档前序章节都有介绍了。看完这个章节,我除了觉得声明依赖项又复杂又繁琐之外,还想对其中的一个示例提出异议:
这种场景非常非常多,因此忽略 linter 校验的开发者也非常多,而 React 还是一直坚持倡导将 roomId 写到 useEffect 依赖中,我认为将 roomId 写到 useEffect 中反而会让代码丢失开发者的设计意图,即你无法知道这个 roomId 在组件的单次生命周期中到底是会变的还是不会变的。
当然如果 useEffect 保持空依赖 [],当 ChatRoom 这个组件作为一个需要编写详细文档的基础组件时,需要指出 props.roomId 是不是响应式的,即开发者在首次渲染 时就必须确定 roomId 的值并确保在整个 ChatRoom 组件的生命周期中不会变化。这种情况其实是很常见的,只是大多数此类 Component / Hook 的文档并没有显式地标记出这些属性,比如 ahooks 中的用于生成防抖函数的 useDebounceFn 方法,这个方法中的 wait 等参数就不是响应式的:
import React, { useState, useEffect } from "react";
import { useDebounceFn } from "ahooks";
const DebounceDemo = () => {
const [debounceWait, setDebounceWait] = useState(100);
useEffect(() => {
setTimeout(() => {
setDebounceWait(1000);
}, 5000);
}, []);
const { run: handleSubmitBtnClick } = useDebounceFn(
() => {
console.log("btn clicked.");
},
{
wait: debounceWait, // 🔴 实际 debounce 的 wait 不会从 100 变为 1000,因为 useDebounceFn 内部只会读取 wait 的初始值
}
);
return <button onClick={handleSubmitBtnClick}>Submit</button>;
};
export default DebounceDemo;
我们在项目中可能还看到过直接使用 lodash debounce 的代码,但是在函数组件中直接使用 debounce 的代码其实都是不健壮的,因为这样实现的防抖功能在迭代中是非常脆弱的。比如将下面这个示例中的 ButtonA -> ButtonB -> ButtonC 想象成的功能的持续迭代过程:
import React, { useState, useCallback, useMemo } from "react";
import { debounce } from "lodash";
const ButtonA = () => {
const handleBtnClick = debounce(
() => {
console.log("Button A clicked."); // 🟢 防抖成功:用户频繁点击时,确实也只 log 了一次
},
500,
{ leading: true, trailing: false } // 用 debounce 防止用户频繁点击时,一般只需响应第一次用户点击即可
);
return (
<div>
<button onClick={handleBtnClick}>Button A</button>
</div>
);
};
const ButtonB = () => {
const [count, setCount] = useState(0);
// 🔴 防抖失效:每次点击触发函数组件更新,导致每次都生成了新的 handleBtn1Click 防抖函数
const handleBtn1Click = debounce(
() => {
setCount(count + 1);
console.log("Button B1 clicked.");
},
500,
{ leading: true, trailing: false }
);
// 🟢 解决方案:这个场景可以使用 state 更新函数 和 useMemo 避免反复创建新的防抖函数
// React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead.
const handleBtn2Click = useMemo(
() =>
debounce(
() => {
setCount((c) => c + 1); // 改用 state 更新函数
console.log("Button B2 clicked.");
},
500,
{ leading: true, trailing: false }
),
[]
);
// 🟡 为什么不用 useCallback ?
// 因为 react-hooks/exhaustive-deps 的校验规则貌似不支持 debounce 这种高阶函数,会报如下的错误
/*
const handleBtn2Click = useCallback( // React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead.
debounce(
() => {
setCount((c) => c + 1); // 改用 state 更新函数
console.log("Button B2 clicked.");
},
500,
{ leading: true, trailing: false }
),
[]
);
*/
return (
<div>
<button onClick={handleBtn1Click}>Button B1</button>
<button onClick={handleBtn2Click}>Button B2</button>
<span>clicked {count} times</span>
</div>
);
};
const ButtonC = () => {
const [count, setCount] = useState(0);
// 🔴 防抖再次失效:当 handleBtnClick 内的逻辑必须依赖 count 时,useMemo 也无法阻止防抖再次失效
const handleBtnClick = useMemo(
() =>
debounce(
() => {
setCount((c) => c + 1);
if (count < 10) {
console.log("Button C clicked.");
} else {
console.log("Button C was clicked too many times.");
}
},
500,
{ leading: true, trailing: false }
),
[count]
);
return (
<div>
<button onClick={handleBtnClick}>Button C</button>
<span>clicked {count} times</span>
</div>
);
};
const DebounceButtons = () => {
return (
<div>
<ButtonA />
<ButtonB />
<ButtonC />
</div>
);
};
export default DebounceButtons;
所以每当需要在函数组件中创建防抖函数时,我们应该在一开始就使用 useDebounceFn 这类封装好的 Hook 工具。回到 ahooks 的 useDebounceFn 方法,我们来看一下 源码:
下面是我尝试在 useDebounceFn 的源码基础上支持所有 options 参数响应式后的满血版 useDebounceFn(代码并没有经过严格的测试,请不要直接用于生产环境):
import React, { useState, useEffect, useMemo, useRef } from "react";
import _ from "lodash";
import { useLatest, useUnmount, useMemoizedFn } from "ahooks";
type noop = (...args: any[]) => any;
interface DebounceOptions {
wait?: number;
leading?: boolean;
trailing?: boolean;
maxWait?: number;
}
/**
* “满血版” useDebounceFn
*/
function useDebounceFn<T extends noop>(fn: T, options?: DebounceOptions) {
// ...
const fnRef = useLatest(fn);
const wait = options?.wait ?? 1000;
// 🟡 因为 options 在外部必定是字面量形式定义的,导致每次外部组件 update 时 options 的引用地址都变化
// 所以不能直接把 options 放到 useMemo 的依赖项中,需要在 useMemo 外面把所有属性在从 options 解构出来
const { maxWait, leading, trailing } = options || {};
const previousDebouncedFn = useRef<any>(null);
const debouncedFn = useMemo(() => {
const debounced = _.debounce(
(...args: Parameters<T>): ReturnType<T> => {
return fnRef.current(...args);
},
wait,
// 🟡 这里踩了个坑:_.debounce 方法的 options 中各个选项不支持传入 undefined,需要过滤一下
_.omitBy(
{
maxWait,
leading,
trailing,
},
_.isNil
)
);
// 🟡 每当重新生成新的防抖函数时,都先去取消之前的防抖函数的在途延迟任务(这个逻辑应该抽成配置项让用户选择是否开启)
previousDebouncedFn.current?.cancel?.();
previousDebouncedFn.current = debounced;
return debounced;
}, [fnRef, wait, maxWait, leading, trailing]);
useUnmount(() => {
debouncedFn.cancel();
});
const memoizedDebouncedFn = useMemoizedFn(debouncedFn);
const memoizedDebouncedFnCancel = useMemoizedFn(debouncedFn.cancel);
const memoizedDebouncedFnFlush = useMemoizedFn(debouncedFn.flush);
return {
run: memoizedDebouncedFn,
cancel: memoizedDebouncedFnCancel,
flush: memoizedDebouncedFnFlush,
};
}
const DebounceDemo = () => {
const [debounceWait, setDebounceWait] = useState(100);
useEffect(() => {
setTimeout(() => {
setDebounceWait(1000);
}, 5000);
}, []);
const { run: handleSubmitBtnClick } = useDebounceFn(
() => {
console.log("btn clicked."); // 🟢 实际 debounce 的 wait 支持从 100 变为 1000 啦
},
{
wait: debounceWait,
}
);
return <button onClick={handleSubmitBtnClick}>Submit</button>;
};
export default DebounceDemo;
不过这就说明原本 ahooks 官方的 useDebounceFn 这个方法有缺陷吗?我认为完全不是,因为在绝大部分防抖的场景中,wait、maxWait、leading、trailing 这几个 debounce 的参数是不需要变化的(即不需要支持响应式),仅读取初始值完全够用了。在我实现 “满血版” useDebounceFn 的过程中,并不只是无脑让 useMemo 的依赖项和 linter 保持一致即可,还需要额外固定防抖函数的引用地址,以及在 options 参数变化时取消先前的防抖函数对应的在途延迟任务,并且这个代码还没有经过严格的测试,不知道有没有其他 bug。
众所周知,占用相同的测试资源时,系统的稳定性和复杂度必定是负相关的。当一个功能已经可以满足绝大多数场景时,为了兼容极少部分场景而大大增加系统复杂度是需要进行认真评估的,因为增加了复杂度之后,要保证原本的稳定性,需要投入更多的测试成本,所以我们应该优先考虑能否优化或者调整这些少数场景的实现方式,从而降低整个系统的复杂度。
在阅读完所有官网文档中关于 useEffect 章节后,我个人更加确信围绕依赖项的各类问题(如何确定依赖项、使用户事件响应逻辑变得难以阅读、忽略依赖检查引发问题、修改依赖困难、代码噪音等等),就是从类组件过渡到函数组件后引入的最大问题。
3、A Complete Guide to useEffect
那么到底为什么 React Hook 当初要设计 useEffect 这么一个难用的 Hook 呢?并且我也很好奇,难道别的团队也一直没有感受到这些问题吗?所以我又问了 DeepSeek:前端技术社区里有没有批判 useEffect 的观点或者文章等内容?
DeepSeek 提到了 Dan Abramov 的这篇写于 2019 年的文章《 A Complete Guide to useEffect 》,所以我又尝试从这篇文章中寻找 useEffect 设计的由来:
解释闭包特性
文章的前三个章节 Each Render Has Its Own Xxx 中解释了在函数组件中,因为闭包特性,其实每次渲染中所有的变量(props、state、事件处理程序)都是一次渲染的快照,以及每次渲染过程中都伴随着一次 effect 的执行(当不传递 useEffect 的第二个参数时)。
在快照中访问未来的状态
Each Render Has Its Own… Everything 这个章节中提到了类组件的 setTimeout 中的 this.state.count 会一直取到最新值的问题,并且原文也给出了如何取到点击时的值的解决方案:
类组件中这类代码确实比较脆弱,建议添加上注释表明代码意图,否则在迭代中稍作调整就会意外地读取到最新值/点击时的值:
class ComponentA extends React.Component {
state = { count: 0 };
handleClick = () => {
setTimeout(() => {
// 🟡 输出的是当前最新的 state.count,而非点击时的值
console.log(this.state.count);
}, 1000);
this.setState({ count: this.state.count + 1 });
};
// ...
}
class ComponentB extends React.Component {
state = { count: 0 };
handleClick = () => {
// 解决方案:利用闭包
const count = this.state.count;
setTimeout(() => {
// 🟡 输出的永远是点击时的 state.count,而不会突变成最新的值
console.log(count);
}, 1000);
this.setState({ count: count + 1 });
};
// ...
}
而函数组件中,默认只能拿到点击时的值,但是当你想要拿到最新值时,你必须不断地将 count 状态额外同步到 ref 中:
const ComponentA = () => {
const [count, setCount] = useState(0);
const handleClick = () => {
setTimeout(() => {
console.log(count); // 🟡 输出的是点击时的值,而非最新值
}, 1000);
setCount(count + 1);
};
return <button onClick={handleClick}>Click {count}</button>;
};
const ComponentB = () => {
const [count, setCount] = useState(0);
const countRef = useRef(count);
countRef.current = count; // 同步最新值到 ref
const handleClick = () => {
setTimeout(() => {
console.log(countRef.current); // 🟡 总是输出最新值
}, 1000);
const c = ;
setCount(count + 1);
};
return <button onClick={handleClick}>Click {count}</button>;
};
所以考虑到代码在迭代中的稳定性,虽然函数组件读取最新值代码会更冗余一点,但是这也将两种模式明显地区分开来了,函数组件确实是更优的设计。
不过文章中还表达了一个观点:**在过去的渲染快照中试图访问未来的状态,是一种逆势而为(Swimming Against the Tide) **。但我认为这两种情况完全没有好坏之分,要拿到最新值还是点击时的值,完全取决于需求目的,两者只是实现功能时不同的选择。而且若按照这个观点去评价的话,那后来提出的 useEffectEvent 这个 API 是不是可以说是官方 逆势而为 的方法了,因为这个 API 本质上也是从历史的渲染快照中访问到未来的状态。
最后,这个话题其实只和闭包有关,与 Effect 关系不大,所以继续看……
同步思维的由来
Synchronization, Not Lifecycle 这一章中介绍了同步概念的由来:
原文提到,相比于 jQuery 需要关注 DOM 操作过程,React 生来就只需要关注渲染结果,即在 React 中重要的是目的地,而不是过程。而之前类组件中只有 UI 渲染是关注目的地(没有 mount 和 update 的区别,都是直接执行 render 函数),而生命周期这一套则是面向过程的,因为开发者要感知到 mount / update / unmount 这个心智模型。
所以在支持了 Hook 的函数组件中不仅仅是 UI 渲染,所有的事物都将只关注目的地。为此函数组件中 UI 渲染之外的所有逻辑(现在被称为副作用),就像 UI 渲染是 根据 props 和 state “同步” DOM,剩余的逻辑也需要开发者改成 用根据 props 和 state “同步” React 组件树之外的事物 的心智模型(而不是原本 mount / update / unmount 的心智模型)进行开发。
感觉是统一了 React 组件内所有事物的心智模型:关注目的地、同步。讲实话还是挺抽象的,充满哲学意味,但是这么做本身的目的是什么呢?只是为了统一中心思想?这样做相比于在函数组件中也搞一套类似 useMountUnmount / useUpdate 的生命周期 Hook 有什么额外的优势呢? 不知道,继续看……
setInterval 频繁创建问题
Decoupling Updates from Actions 和 Why useReducer Is the Cheat Mode of Hooks 这两章中介绍了如何使用 useReducer 去解决 setInterval 被频繁创建的问题。
不过我觉得这个方法太绕了,真还不如用 ref 绕过闭包问题,或者使用 useEffectEvent 来得直接,正常人想不到的(对不起,还是忍不住吐槽了)。而且利用 useReducer 的这个方法本质上是把多个状态强行合并成了一个状态,然后继续用 setState 的函数式更新来绕过闭包问题,但是很多业务场景中这些状态就应该是独立的,并不适合合并状态。
把 reducer 定义在函数组件内部来解决 props 的闭包问题,emm...... 确实有种官方大佬下场教你后门骚操作的感觉。
不过这个话题依旧只和闭包有关,与 Effect 关系也不大,还是继续看……
Just let it throw !
在 Moving Functions Inside Effects 这章中,我感觉我终于找到这个问题的答案了!
即支持了 React Hook 函数组件为什么选择了同步的心智模型(useEffect),而不是继续沿用生命周期的心智模型,否则当初官方完全可以搞一套类似 useMountAndUnmount / useUpdate 这样的 Hook(也支持清理函数、关注点分离等特性),前者到底好在哪?使得 React Hook 当初要设计 useEffect 这么一个难用的 Hook。
因为看到这里之前我的观点和 Dan 截然相反,我认为大部分情况下 We’re just “appeasing React” ,也就是我在本文第三章的 大量的冗余代码 中提到的感觉:不是 React 在给我们提效,而是我们在给 React 当牛马。但是 Dan 提到了一个很重要的观点:
The design of useEffect forces you to notice the change in our data flow and choose how our effects should synchronize it — instead of ignoring it until our product users hit a bug.
useEffect 的设计迫使我们要注意到数据流中的变化,并思考我们的 Effect 应该如何同步它,而不是忽略它,否则我们产品的真实用户可能会遇到因此产生的 Bug。
我大概 get 到 Dan 的意思了:如果我们严格遵守 useEfect 的依赖项声明规则,那么我们的系统在应对外部变量的变化时,将会变得非常健壮,因为这能让整个 React 系统都能根据 useEffect 依赖项进行完美的“同步”。我想到一个不恰当的比喻:用 useEffect 这套新的 React 哲学构建出来的高楼,默认能抗八级地震,非常可靠!
然后我顺着这个思路继续想,那我们业务中有没有遇到过类似的因为外部变量变化导致的真线问题呢(因为我们团队显然没有严格遵守 useEfect 的依赖项声明规则,空数组 [] 的依赖项声明随处可见)?我突然想到了一个实际遇到过的问题,背景是有一个项目页面,不同页面状态复用同一个页面级组件,即匹配了多个路由:
// --------------- ProjectPage.jsx ---------------
import React, { Component } from "react";
// ...
class ProjectPage extends Component {
constructor(props) {
super(props);
this.state = {
// ...
};
}
componentDidMount() {
const { action } = this.props?.match?.params || {};
// 🟡 根据 action 执行不同的初始化逻辑
if (action === "create") {
// ...
} else if (action === "edit") {
// ...
} else if (action === "audit") {
// ...
} else if (action === "detail") {
// ...
}
}
// ... 1000 多行其他逻辑,以及各种子组件引用(部分是函数组件)
}
export default ProjectPage;
// --------------- router.js ---------------
import ProjectPage from "./ProjectPage.jsx";
export default [
{
/**
* 项目 创建/编辑/审核/详情 页面
*
* action 操作类型:create | edit | audit | detail
*/
url: "/project/:action",
component: ProjectPage,
},
];
问题出在同一个项目在不同 action 状态之间跳转的时候:
// 原本代码中的跳转都是通过刷新浏览器页面的形式进行跳转的
window.location.href = `/base-route/project/${targetAction}`;
// 团队来了一个新成员,认为这是项目内部跳转,应该使用 react-router 进行跳转
props.history.push(`/project/${targetAction}`);
新成员说得很对,但很显然这个 class 组件只在 componentDidMount 中判断了 action,如果使用 react-router 的 History 路由进行跳转,React 会沿用原来的组件实例,导致 targetAction 对应的初始化逻辑不会被执行。当时我们的解决方案是通过定义多个路由和多个页面级组件的方式让 React 无法复用组件:
// --------------- ProjectPage.jsx ---------------
import React, { Component } from "react";
// ...
class ProjectPage extends Component {
// ...
componentDidMount() {
// const { action } = this.props?.match?.params || {};
const { action } = this.props || {}; // 直接从 props 中读取 action
// ...
}
// ...
}
const withAction = (Comp, action) => {
return (props) => <ProjectPage {...props} action={action} />;
};
// 🟢 利用 HOC 高阶组件创建了四个不同的套壳 Page 组件
export const ProjectCreatePage = withAction(ProjectPage, "create");
export const ProjectEditPage = withAction(ProjectPage, "edit");
export const ProjectAuditPage = withAction(ProjectPage, "audit");
export const ProjectDetailPage = withAction(ProjectPage, "detail");
// --------------- router.js ---------------
import {
ProjectCreatePage,
ProjectEditPage,
ProjectAuditPage,
ProjectDetailPage,
} from "./ProjectPage.jsx";
export default [
{
/**
* 项目创建页面
*/
url: "/project/create",
component: ProjectCreatePage,
},
{
/**
* 项目编辑页面
*/
url: "/project/edit",
component: ProjectEditPage,
},
{
/**
* 项目审核页面
*/
url: "/project/audit",
component: ProjectAuditPage,
},
{
/**
* 项目详情页面
*/
url: "/project/detail",
component: ProjectDetailPage,
},
];
后来我们还发现,react-router 只要路由不同,即使组件相同,也不会复用组件实例,所以还有一个更简单的骚操作,组件源码完全不用改,定义多个路由且依旧保持 action 为动态路由参数,但是每个路由仅限定一个 action 枚举值:
// --------------- ProjectPage.jsx ---------------
import React, { Component } from "react";
// ...
class ProjectPage extends Component {
// ... 不做任何修改
}
export default ProjectPage;
// --------------- router.js ---------------
import ProjectPage from "./ProjectPage.jsx";
export default [
{
/**
* 项目创建页面
*/
url: "/project/:action(create)", // 🟢 一个路由仅允许一个 action 枚举值
component: ProjectPage,
},
{
/**
* 项目编辑页面
*/
url: "/project/:action(edit)",
component: ProjectPage,
},
{
/**
* 项目审核页面
*/
url: "/project/:action(audit)",
component: ProjectPage,
},
{
/**
* 项目详情页面
*/
url: "/project/:action(detail)",
component: ProjectPage,
},
];
当时在处理这个问题时,我们根本没有考虑过让 ProjectPage 组件及其所有的子组件都监听 action 的变化,使其支持在整个页面的生命周期内支持 action 的变化,原因也非常明显:支持了组件销毁之后的 History 路由切换在体验上也已经比直接修改 location.href 触发整个页面资源的重新加载好了很多。进一步让整个页面完全支持 action 动态变化能够带来的性能受益完全取决于不同 action 之间页面的差异化逻辑有多少,差异化逻辑越多,受益越小。况且这是一个复杂的历史页面,综合考虑下来,这么做的 ROI(投入产出比) 实在是太低了。
但是如果这个页面及其所有子组件都是由一个完全遵守了 useEffect 依赖项声明规则的开发者来实现的,就能完全避免这个问题了!因为整个页面可以完美地根据 action 进行“同步”:
const { action } = props?.match?.params || {}
useEffect(() => {
if (action === "create") {
// ...
} else if (action === "edit") {
// ...
} else if (action === "audit") {
// ...
} else if (action === "detail") {
// ...
}
// }, []); // 🟡 原本的类组件只在 didMount 时才执行的逻辑,相当于欺骗了 Effect 没有依赖项
}, [action, /* ... */]); // 🟢 依赖项声明完全遵守 react-hooks/exhaustive-deps 给出的提示
useEffect(() => {
// ...
}, [/* ... */]); // 🟢 依赖项声明完全遵守 react-hooks/exhaustive-deps 给出的提示
useEffect(() => {
// ...
}, [/* ... */]); // 🟢 依赖项声明完全遵守 react-hooks/exhaustive-deps 给出的提示
// ...
在我 get 到这个意思的时候,我确实是被震撼到了,感觉 useEffect 是非常超前的设计,甚至有几分 上工治未病 的感觉。
但是当我冷静下来仔细思考时,我发现了一个很大的漏洞:有些时候即使我们将某些组件中原本刻意声明了空依赖 [] 的 useEffect 改成真实的依赖项,也无法得到一个“完美的响应式组件”(可以兼容任意 props 在中途变化的组件),因为我们忘了要重置 state 状态。
还是举我们常见的中后台列表查询页的例子,再额外加上一个列表数据批量选择后删除的功能,假设 URL 中的 projectType 在一个页面的生命周期中是不会变化的,代码如下:
/**
* projectType 从 URL 中的 query 中读取
*
* pageSize 从高阶组件 withPageSize 中获取
*/
const ProjectListPage = (props) => {
const { projectType, pageSize } = props;
const [pageNo, setPageNo] = useState(1);
const [projectList, setProjectList] = useState([]);
const [selectedProjectIds, setSelectedProjectIds] = useState([]);
const fetchProjectList = useCallback(
async (params = {}) => {
const query = qs.stringify({
projectType,
pageSize,
pageNo: params.pageNo ?? 1,
});
const response = await fetch(`/project/list?${query}`);
const json = await response.json();
setProjectList(json);
},
[projectType, pageSize] // 🟢 依赖项声明完全遵守 react-hooks/exhaustive-deps 给出的提示
);
/** 点击每条项目的 checkbox */
const handleProjectItemSelect = (item) => {
const { id } = item || {};
if (selectedProjectIds.includes(id)) {
setSelectedProjectIds(selectedProjectIds.filter((pId) => pId !== id));
} else {
setSelectedProjectIds([...selectedProjectIds, id]);
}
};
/** 点击「批量删除」按钮 */
const handleBatchDeleteBtnClick = () => {
if (selectedProjectIds.length === 0) {
message.warn('请先选择要删除的项目');
return;
}
// 根据 selectedProjectIds 调用批量删除接口
};
const handlePageNoBtnClick = (targetPageNo) => {
setPageNo(targetPageNo);
fetchProjectList({ pageNo: targetPageNo });
};
const handleNextPageBtnClick = () => {
/* ... */
};
const handlePrevPageBtnClick = () => {
/* ... */
};
useEffect(() => {
fetchProjectList();
}, [fetchProjectList]); // 🟢 依赖项声明完全遵守 react-hooks/exhaustive-deps 给出的提示
return <div>{/* ... */}</div>;
};
export default withPageSize(ProjectListPage, 20);
系统正常运行完全没有问题,假设在一个意想不到的场景中,projectType 中途变化了,但是你会发现 pageNo 和 selectedProjectIds 这两个状态并没有重置:
const ProjectListPage = (props) => {
const { projectType, pageSize } = props;
const [pageNo, setPageNo] = useState(1);
const [selectedProjectIds, setSelectedProjectIds] = useState([]);
/** 点击「批量删除」按钮 */
const handleBatchDeleteBtnClick = () => {
if (selectedProjectIds.length === 0) {
message.warn('请先选择要删除的项目');
return;
}
// 根据 selectedProjectIds 调用批量删除接口
};
// ...
useEffect(() => {
// 如果 URL 中的 projectType 变化了,fetchProjectList 确实重新会重新执行请求到第一页的数据
// 🔴 但页面会有如下问题:
// 1、最严重的就是 selectedProjectIds 没有清空,可能导致用户错误删除页面中没有显示出来的项目
// 2、页面上展示的是新的 projectType 的第 1 页的数据,但是页面上的翻页器中可能展示的并不是第 1 页
fetchProjectList();
}, [fetchProjectList]);
return <div>{/* ... */}</div>;
};
其中 selectedProjectIds 没有清空的问题甚至让我觉得还不如 useEffect 的依赖中写个空数组,因为那样虽然 projectType 变化之后列表数据完全不会变化,但是至少不会导致用户误删数据。所以我感觉用 useEffect 这套新的 React 哲学构建出来的高楼,其实并抗不住八级地震,反而更像是一座非常昂贵(需要开发者维护好所有依赖项)但却一碰就碎的水晶琉璃塔(纯粹个人观点)。
然后当我试图在这个代码的基础上修复这两个问题时,发生了一个更诡异的事情,我不自觉地写出了一个完全错误的 useEffect 示例:
/**
* projectType 从 URL 中的 query 中读取
*
* pageSize 从高阶组件 withPageSize 中获取
*/
const ProjectListPage = (props) => {
const { projectType, pageSize } = props;
const [pageNo, setPageNo] = useState(1);
const [projectList, setProjectList] = useState([]);
const [selectedProjectIds, setSelectedProjectIds] = useState([]);
const fetchProjectList = useCallback(
async (params = {}) => {
const query = qs.stringify({
projectType,
pageSize,
pageNo: params.pageNo ?? 1,
});
const response = await fetch(`/project/list?${query}`);
const json = await response.json();
setProjectList(json);
},
[projectType, pageSize]
);
// ...
useEffect(() => {
// 🟡 不能这样修复,因为每当 projectType 或 pageSize 变化时会触发这个 Effect,而 pageSize 变化时不需要重置 state 状态
// PS: 这个示例中的 pageSize 可能不那么贴合业务场景,我想表达的其实是很多组件中这类原本只需在 didMout 时执行的 Effect 其实会有很多依赖项
// setPageNo(1);
// setProjectList([]);
// selectedProjectIds([]);
fetchProjectList();
}, [fetchProjectList]);
useEffect(() => {
// 所以我们必须明确只在 projectType 变化时进行 state 状态重置
setPageNo(1);
setProjectList([]);
selectedProjectIds([]);
}, [projectType]); // 🔴 但是这么做的话依赖项声明就完全不符合 useEffect 依赖项声明规则,根据 Effect 中的代码,应该声明空数组 [] 依赖
return <div>{/* ... */}</div>;
};
export default withPageSize(ProjectListPage, 20);
你可能还会发现,我在第二个 useEffect 中实现的其实并不是副作用逻辑,setState 类的方法是完全属于 UI 渲染的逻辑,因为第二个 useEffect 是我用监听思维写出来的!但是问题确实是被我修复了。
这貌似形成了一个悖论,当我想通过完全遵守 useEffect 依赖项声明规则来得到一个“完美的响应式组件”时,我却依赖了一个完全不遵守依赖项声明规则的 useEffect……我认为这是 Effect “同步”心智模型中非常大的漏洞。
抛开这个悖论,当我们再来分析一下做完全做到遵守 useEffect 依赖项声明规则这个事情,其实是非常难的,并且非常脆弱的,综合考量下来的 ROI 也是非常低的:
-
整个前端工程,包括所有依赖的二方包、三方包中的所有代码都要遵守 useEffect 依赖项声明规则,首先涉及的代码范围非常广,三方包甚至是不可控的,其次在每个组件中去关注所有 Effect 中的数据流变化,是非常耗费开发人员心智的,极端的例子就是前面提到过的“满血版” useDebounceFn,常见的例子就是这里的列表批量删除。
-
目前的正式版 React 对应的 useEffect 依赖项声明规则是有漏洞的,但若真的在正式版本中引入了 useEffectEvent 这个 API,对开发者的心智负担又会大大增加,加上原本就有很多人在用监听思维使用 useEffect,可想而知 eslint-disable-next-line 的情况依旧会很多,这是典型的破窗效应。
-
Dan 在后续的 Are Functions Part of the Data Flow? 这一章中也说了,如果整个工程中存在类组件,类组件的原型方法因为引用地址是固定的(我们前面提到过的 render props 问题也是这个原因),将类方法传递给子组件的 useEffect 依赖项时,是对这种数据流的一种破坏,即类组件会让这个模式在迭代中变得非常脆弱,因此整个前端工程包括所有依赖的二方包、三方包最好不包含任何类组件。
再回到刚才的示例,事实上:
-
若 projectType 变化时,要重置组件内的所有 state,那我们应该在父组件中(这里虽然没有父组件但也可以设计一个例如 withProjectType 的高阶组件)给当前组件设置 key={projectType} 使 ProjectListPage 组件在 projectType 变化时自动销毁并创建新组件(具体参考《 当 props 变化时重置所有 state 》和《 有 key 的非可控组件 》)。
-
若 projectType 变化时,只需要组件内的部分 state,那你只能写一个上方示例中的监听思维的 useEffect 去重置部分 state,或者重构 ProjectListPage 组件,将需要重置的 state 和不需要重置的 state 分别收敛到不同的子组件中,并给中一个子组件设置 key={projectType} 实现部分 state 的重置。
然而不管是哪种情况,是否遵守 useEffect 的依赖项声明规则并没有什么差别,或者说遵守了 useEffect 的依赖项声明规则并不能提前预防问题的发生,甚至可能帮倒忙(在上面的示例中,增加了用户误删数据的可能性)。
在我们的 useInit 方案中,如果设计上不需要过考虑 projectType 的变化,那就不用特意去关注什么数据流的变化:
const ProjectListPage = (props) => {
// ...
// 🟢 最简单的函数定义,永远不需要 useCallback,永远不用关心函数实现中的依赖项
const fetchProjectList = async (params = {}) => {
const query = qs.stringify({
projectType,
pageSize,
pageNo: params.pageNo ?? 1,
});
const response = await fetch(`/project/list?${query}`);
const json = await response.json();
setProjectList(json);
};
// ...
// 🟢 代码可以“自说明”:仅需在初始化的时候自动执行一次 fetchProjectList 即可,依旧不必关心什么依赖项
useInit(() => {
fetchProjectList();
});
return <div>{/* ... */}</div>;
};
所以我理想中的研发流程是这样的:
-
首先在平时开发时,对于一个组件的 props 属性(以及其他外部系统的变量),哪些是会变的,哪些是不会变的,我们要有明确的预期(根据产品需求制定技术方案的时候要确定),根据这个预期去写代码:若 props 已明确不会变化,那么对应的逻辑则只需要事先在 didMount 中;若 props 是明确会变化的,则需要用合适的方法去兼容这种变化。
-
如果遇到原本预期不会变化的 props 出现了变化,则需要分两种情况:若分析后认为该 props 确实应该变化,说明技术方案制定得有问题,需要重新执行 1 的流程;若认为该 props 不应该变化,则应该调整代码让这个 props 不再变化。
至于如何兼容 props 属性(以及其他外部系统的变量)的变化,在本文第四章的“谨慎使用 useWatch”中已经有详细的说明,监听思维(即列表查询示例中用于重置状态的 useEffect)应该是我们最后的手段。
这其实就是原本的类组件的研发习惯,因为这很符合开发者的直觉,原本的类组件研发中我们并没有感知到这个点是非常严重的痛点问题,类组件的问题主要还是在于逻辑复用困难以及围绕 this 出现的一堆“麻烦事”,所以在 React Hook 中花费这么多精力去维护依赖项是很没有道理的,这不仅没有带来什么收益,反而引来了另一堆围绕依赖项的“麻烦事”。
最后再次明确我的观点: 当我们已经在正向研发环节做好了代码设计这个环节的工作,并不需要提前去关注所有组件的数据流变化,而应该基于实际需求去关注数据流变化,不要过于担心因为数据流变化而引发意想不到的问题。万一问题真的发生了:Just let it throw !
竞态条件
在 Speaking of Race Conditions 这一章中提到了 useEffect 如何处理竞态条件,但是这个问题在类组件中也是一样可以处理的。前面我们已经介绍了在请求方法中使用 标识符 和 AbortController 这两种实现方式处理竞态条件,在类组件中只需将 ref 换成 this 即可。所以这一点上也并不能体现 Effect 的“同步”模型优于生命周期模型。
“更上一层楼”
看到 Raising the Bar 这一章时,我感觉虽然这一章没有出现任何代码,但是这里说的全部都是重点中的重点!也说明了 React 核心团队成员们其实也很清楚我前面所说的这些关于 useEffect 的情况。
-
试图使用 useEffect 对应的“同步”思维去处理所有理论上的边缘情况,这个前期成本非常高(需要耗费开发者非常多的额外精力),并且相比于那种只在 didMount 时执行一次的副作用逻辑,要困难得多。
-
官方对 useEffect 的定位其实是一个底层的 API,在 React Hook 发展的初期可能在各种教程中频繁出现,并且开发者平时也会用到,但是随着社区生态的发展,对于大多数人而言 useEffect 应该是一个使用频率非常非常低 Hook,业务代码中应该使用进一步封装过后的更高级的 Hook 来实现各种功能。
-
目前为止开发者们使用 useEffect 最多的场景还是声明空数组 [] 依赖项,然后在回调中进行数据接口的请求,这是完全违背“同步”理念的。
在空数组 [] 依赖项的 useEffect 中获取接口数据的情况,直到现在 2025 年也依旧还是很常见,所以有没有一种可能不是 React 用户有问题,而是这个“同步”的设计理念本身就是在 逆势而为 ?
依靠社区生态的发展逐渐降低 useEffect 使用频率的这个观点,让我想起了 React 对自己的定位,从始至终 React 都认为自己只是一个用于构建 UI 界面的库,而不是一个框架:
所以副作用除了作为函数式编程中的一个概念,还可以认为 React 这个库本身并不应该负责 UI 渲染之外的所有逻辑,useEffect 只是 React 干的“副业”。但是说 React 是一个普通的库也不对,负责 UI 渲染的库是整个前端技术栈选型的基础,只要选择了 React,在 UI 渲染之外的其他技术需求,也只能在 React 生态中选择合适的三方库,因为不同的 UI 渲染库对于副作用的支持形式是不一样的(在 React 中原来是组件生命周期,现在是 useEffect)。所以 useEffect 更像是一些大公司的开放平台给各种 ISV 公司提供的 API,并不是应该让用户直接使用的能力……
那么使用 useInit / useWatch 完全替代 useEffect 也是非常合理了,只不过我们还把 Effect 副作用和“同步”的理念也一锅端了,有点倒反天罡、欺师灭祖的味了……
六、useInit 方案的设计由来
首先这两个替代 Hook 的设计思路,肯定是站在了 useEffect 这个巨人的肩膀上,例如清理函数(cleanup callback)的设计让两个 callback(setup 和 cleanup)天然就有了共享的私有作用域,非常巧妙。
在此之外,本章会简单讲述一下我们新的 React Hook 编码范式的其他设计由来,帮助大家更好的理解用 useInit 和少量 useWatch 替代 useEffect 的这套范式。
1、忘不掉的 Class 模型
当我们在学习 React Hook 的时候,有一个非常重要的观点:我们要先学会忘记类组件和生命周期,因为之前的学习经验会阻碍你进一步学习。我们要重新用同步思维取代生命周期的概念,告诉 React 如何对比 Effects,以及不要对 Dependencies 撒谎……
“Unlearn what you have learned.” — Yoda
我们可以尝试忘记类组件、忘记生命周期,但是 React Hook 必须要确保当我们已经在用新的思维写代码时,在大部分场景下实现相同的功能,都应该优于类组件和生命周期。可就我观察到的事实,并非如此,所以我认为 Class 这个模型,包括生命周期等的概念本身是非常适合用在 UI 组件这个场景中的,不能因为 this 和复用代码的问题,就忘记了它的优点。
“If you don’t know where you’re from, you’ll never know where you’re going.” — 鲁迅
言归正传,比如当我们设计 state 的时候,有一个原则是只把渲染过程需要用到的变量作为 state,那么有时候一些渲染过程用不到的,不需要响应式的变量定义在哪里呢?在类组件中,我们可以直接定义在实例上,我觉得这非常合理,比如之前的定时器示例:
class Demo extends React.Component {
constructor(props) {
super(props);
// 🟢 直接在 this 实例上定义一些非响应式的值
this.renderCount = 0;
};
}
在函数组件中,我们需要用 ref 来做类似的事情,比如在项目列表页点击某一条数据的“分派”按钮,先唤起一个通用弹框进行分派目标的选择,然后调用接口进行分派:
const projectInfoRef = useRef(null);
const handleAssignBtnClick = (row) => {
projectInfoRef.current = row;
setCommonSelectModalVisible(true);
};
const handleCommonSelectModalOk = async (personInfo) => {
setCommonSelectModalVisible(false);
const projectInfo = projectInfoRef.current;
// ......(根据 projectInfo 和 personInfo 发送分派接口请求)
};
const handleCommonSelectModalCancel = () => {
setCommonSelectModalVisible(false);
};
我认为 projectInfoRef 这个语义是不好的,因为 ref 的原本表示的是一个组件的引用,只看命名可能会觉得这是 组件的引用。而且每次对其赋值和读取的时候都要用到 current 这一层没有任何语义的“命名空间”。所以我写了一个自定义 Hook 来模仿类组件中的 this 实现更好的语义化:
const useInstance = <T extends {}>(defaultParams: T): T => {
const ref = useRef(defaultParams);
return ref.current;
};
改写上述例子之后代码结构没有变化,语义会更好一些:
// 在这个用法中 instance 就等同于类组件中的 this 可以挂载自定义实例属性
const instance = useInstance({ projectInfo: null });
const handleAssignBtnClick = (row) => {
instance.projectInfo = row;
setCommonSelectModalVisible(true);
};
const handleCommonSelectModalOk = async (personInfo) => {
setCommonSelectModalVisible(false);
const projectInfo = instance.projectInfo;
// ......(根据 projectInfo 和 personInfo 发送分派接口请求)
};
const handleCommonSelectModalCancel = () => {
setCommonSelectModalVisible(false);
};
并且当你深入了解过社区里的各种函数组件闭包解决方案时,你会发现所有的方案其实都离不开 useRef,在这些场景中 ref 并不是指原生组件或者自定义组件的引用(reference),而是更像 class 组件中不需要触发响应式更新且没有闭包问题的实例属性。
2、类组件 vs 函数组件
在团队内试行这套新的编码范式之前,我详细对比并分享过类组件和函数组件的优缺点:
很明显,支持了 Hook 之后的函数组件在解决了类组件的问题之后,却带来了新的问题。导致很多原来在类组件中我们可以直接写原生代码就能快速实现的功能,在函数组件中变得十分复杂,以至于我们需要依赖一些三方 Hook 工具来协助完成这些功能。
比如防抖功能在类组件中可以直接使用 loadsh 的 debounce 方法实现,但是在函数组件中我们必须使用 useDebounceFn 这类工具 Hook 来完成。拥抱函数组件后,闭包问题等从根本上是无法避免的,所以我们应该设计一套能让开发者能尽可能避开这些问题的基础 Hook,而 useEffect 设计不仅没能让我们避开这些问题,还引入了更恶心的依赖项管理问题。
所以我们的设计思路就是把两者的优点都结合起来,把缺点都规避掉,扬长避短。
3、繁简一致的编码思路
在使用 useEffect 时,我需要不断地和新加入我们团队的成员沟通如何用好 useEffect,然而在 useInit 方案中,我根本不再需要花费精力去做沟通这么基础的事情。新的 useInit 方案也只是在原本的 React Hook 基础上去掉副作用的概念,只是保留了组件实例的心智模型,以及初始化和监听的概念(勉强算简化后的生命周期)。
在习惯了这个方式写代码时,除了第四章中提到的可以极大地避免依赖项声明、闭包问题、不必要的 useCallback 和 useMemo,还有一个关键的优势:不论遇到什么场景,写代码的思路都是一样的。
当遇到《 将事件从 Effect 中分开 》中提到的场景(类似第四章中的“useEffect 解决不了的问题”)时,用我们的方案去写代码,和其他任何常规的场景没有差别,更不需要引入一个新的 API,就像在类组件中这些场景原本就不是什么特殊情况:
function ChatRoom({ roomId, theme }) {
const handleConnectedEvent = () => {
showNotification('Connected!', theme);
};
/**
* 和 useInit 一样,当使用了 useWatch 的第三个参数 funcMap 时,回调中的逻辑就应该只剩下非常少量的两类代码:
* 1、设置各类监听器(addEventListener / WebSocket / setInterval 等等),并把 self 上的方法直接作为处理函数
* 2、清理对应的监听器
*/
useWatch(
[roomId],
(self) => {
// 设置监听器
const connection = createConnection(serverUrl, roomId);
connection.on('connected', self.handleConnectedEvent);
connection.connect();
return () => {
// 清理监听器
connection.disconnect();
};
},
{
handleConnectedEvent,
}
);
return <h1>Welcome to the {roomId} room!</h1>;
}
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
useWatch([url], () => {
logVisit(url, numberOfItems);
});
// ...
}
不过还是要再次提醒,即使这个代码实现起来很简单,但是在使用 useWatch 前,还是要参考第四章中“谨慎使用 useWatch” 里提到的方式去判断能否优先采用其他实现方案,因为其他实现方案相比于监听方案是更易维护的。
并且我们的方案也没有类似 Effect Event 的局限性 中提到的问题,因为 useInit 和 useWatch 在用 ref 解决闭包问题时,将“闭包消除魔法”的生效范围很好地控制在了 useInit 和 useWatch 中。
4、防呆设计
在我们团队对 useEffect 最初的讨论中,就认为 useEffect 太灵活了,看似门槛很低,其实非常容易玩脱,即 useEffect 大大提高了写出好代码的门槛,也正因为其灵活性,导致无法做到本文开头说的两个点:保持代码结构一致性 和 代码意图的正确传达。如果让我对 useEffect 的问题做一个抽象的总结,我会这么说:
useEffect 看似很简单,然而要用好 useEffect 则对开发者的要求很高。就像你完全可以把自动挡汽车当作碰碰车开上路,但是如果每个人都只会油门刹车却不懂交通规则、不懂防御性驾驶,整个城市的交通会迅速陷入瘫痪。并且很多开发者其实并没有注意到使用 useEffect 还要注意这么多规则,这很可能是因为大多数时候这些额外的规则并没有带来对应的额外收益。
前面提到过 Dan 认为 useEffect 的设计迫使我们要注意到数据流中的变化,从而能提前针对数据变化建立防御性措施,这其实属于系统稳定性的话题。即使这么做真的能带来稳定性收益,很多开发者依旧都没有完全遵守官方 ESlint 插件来写代码,我认为这其中有一个重要的设计原因,就是这个 useEffect 的设计中没有任何防呆(Fool-proofing) 措施:首先 react-hooks/exhaustive-deps 这个 ESLint 校验规则不防呆,不仅仅是因为类组件生命周期的概念其实依旧深入人心,导致大多数开发者都是靠自己的感觉来决定要不要参考这个校验规则,也因为本身这套校验规则就有漏洞,而且对应的 useEffectEvent 解决方案也还只是实验性 API;其次 useEffect 到底应该用在哪些场景,不应该用在哪些场景,这也非常不防呆,你需要阅读完官网 5 篇总计 3w 字的教程才可能搞明白……
在工业生产中、在生活中,各种领域都有很多优秀的防呆设计:
-
电脑的固态、内存条上都有防止反向错误安装而设计的非对称缺口。
-
所有手机的关机都需要长按或二次确认以防误触。
-
各种场景中的显著标识:红色表示紧急,绿色表示通行等等。
-
……
防呆设计通过一些限制手段或者显著标识来降低一个行为对于操作人精力、经验与专业知识的要求,从而达到预防错误的目的。不论新手、老手;不论你头脑清醒,还是疲惫不堪;不论是繁忙的并行工作,还是安静的专注工作,都能依靠防呆设计正确完成任务。
回到 useInit 的设计,就像 initialization 这个单词表示的意思一样,你只能应该放一些组件初始化时的逻辑,并且因为就像 componentDidMount 一样,useInit 的 callback 只会执行一次,任谁也玩不出花来。而 useWatch 就是最后的兜底手段,用监听思维去实现一些逻辑,不过遗憾的是我们也只能通过一些显著的约定来告诉开发者,这是一个 danger 的 Hook,要谨慎使用 useWatch 。