React StrictMode下无清理外部库致useEffect重复执行的解决方案探讨
问题场景与示例
以下是Next.js项目中的示例代码:
pages/index.js
import { useState, useRef, useEffect } from 'react' function complexExternalLibraryFunctionWithoutCleanup(elem, cb) { elem.addEventListener('click', () => { cb() }) } export default function IndexPage() { const [text, setText] = useState('') const [myint, setMyint] = useState(0) const ref = useRef(null) function incIt() { setMyint(i => i+1) } useEffect(() => { if (ref.current) { complexExternalLibraryFunctionWithoutCleanup(ref.current, incIt) } return () => { // 希望执行清理操作,但外部库未提供对应方法 // 手动清理需了解库内部实现,存在风险 //complexExternalLibraryFunctionWithoutCleanup_cleanup(ref.current) } }, []) return <div> <div><input value={text} onChange={e => setText(e.target.value)} placeholder='Type here' /></div> <div>You typed: {text}</div> <div><button ref={ref}>Click me after typing something! If unfixed, it will increment twice!</button></div> <div><button onClick={incIt}>Click me to increment!</button></div> <div>{myint}</div> </div> }
上述代码中,complexExternalLibraryFunctionWithoutCleanup代表一个无清理方法、不具备幂等性的外部JS库——重复调用会导致回调被多次注册,触发时执行多次,不符合预期。
在开发模式+StrictMode下,点击"Click me after typing something!"按钮后,回调会被注册两次,导致计数翻倍;但生产环境运行正常。
疑问与复现信息
该问题仅在Next.js项目中复现,纯React示例无法复现。
Next.js复现所需文件
package.json
{ "private": true, "scripts": { "dev": "next dev --turbo", "build": "next build", "start": "next start", "lint": "eslint ." }, "dependencies": { "next": "14.2.5", "react": "18.2.0", "react-dom": "18.2.0" }, "devDependencies": { "eslint": "7.24.0", "eslint-config-next": "14.2.5" } }
.eslintrc
{ "extends": "next", "root": true }
next.config.js
module.exports = { reactStrictMode: true, }
启动命令
- 开发模式:
npm install npm run dev
- 生产模式:
npm run build npm run start
纯React复现尝试(未成功)
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <script src="https://unpkg.com/react@18.3.1/umd/react.development.js"></script> <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js"></script> <script src="https://unpkg.com/@babel/standalone@7.14.7/babel.min.js"></script> </head> <body> <div id="root"></div> <script type="text/babel"> const { StrictMode, useState, useEffect, useRef } = React function complexExternalLibraryFunctionWithoutCleanup(elem, cb) { elem.addEventListener('click', () => { cb() }) } function Main(props) { const [text, setText] = useState('') const [myint, setMyint] = useState(0) const ref = useRef(null) function incIt() { setMyint(i => i+1) } useEffect(() => { if (ref.current) { complexExternalLibraryFunctionWithoutCleanup(ref.current, incIt) } }, []) return <div> <div><input value={text} onChange={e => setText(e.target.value)} placeholder='Type here' /></div> <div>You typed: {text}</div> <div><button ref={ref}>Click me after typing something! If unfixed, it will increment twice!</button></div> <div><button onClick={incIt}>Click me to increment!</button></div> <div>{myint}</div> </div> } ReactDOM.createRoot(document.getElementById('root')).render(<StrictMode><Main /></StrictMode>) </script> </body> </html>
核心问题
- 此类无清理、非幂等的外部库,是否根本无法安全用于React?(有评论指出生产环境中组件也可能多次运行,比如用户点击浏览器后退按钮时的卸载重挂载)
- 还是StrictMode开发模式过于严苛,在此场景下无法使用?
此外,常见的useRef标记初始化状态的解决方案,被指出在React 19中可能失效。
解决方案分析
关于库的可用性
这类库并非完全不能在React中安全使用,但需要手动处理幂等性和清理逻辑:
- 生产环境中组件确实可能因为路由跳转、状态更新导致的卸载重挂载,触发重复调用,StrictMode只是提前暴露了这个隐患,并非过于严苛。
可行解决方法
用ref标记初始化状态,避免重复调用
新增一个ref来记录是否已经执行过初始化,修改后的useEffect如下:useEffect(() => { const isInitialized = useRef(false); if (ref.current && !isInitialized.current) { complexExternalLibraryFunctionWithoutCleanup(ref.current, incIt); isInitialized.current = true; } // 如果能逆向实现清理逻辑,在这里补充 return () => { // 示例中可以手动移除点击监听 if (ref.current) { ref.current.removeEventListener('click', incIt); } }; }, []);注意:手动清理需要了解库的内部实现,存在一定风险,若库的逻辑复杂,可能无法完全模拟清理。
临时禁用StrictMode(不推荐)
可以在next.config.js中关闭reactStrictMode,但这会失去StrictMode提前发现隐患的能力,可能导致生产环境出现未知问题。封装库的调用逻辑
将外部库的调用封装成自定义Hook,在Hook内部处理幂等性和清理,确保组件中调用时不会重复执行。
React 19的兼容性问题
React 19中StrictMode的卸载重挂载模拟逻辑有调整,但核心思路依然是保证初始化逻辑只执行一次。可以改用useState配合依赖数组,或者使用React 19新增的useEffectEvent来分离回调逻辑,避免重复绑定。
内容的提问来源于stack exchange,提问作者Ciro Santilli OurBigBook.com

