1. “caveman”不是远古人,是React开发中一个被严重误读的性能陷阱代号
最近在几个前端技术群和Code Review现场,频繁看到有人把某个React组件标注为“caveman”,比如注释里写着// caveman mode: useMemo disabled,或者PR描述里写“临时切回caveman逻辑”。起初我以为是某位同事的玩笑昵称,直到连续三次在不同团队的生产代码里见到它——一次在电商商品详情页的渲染逻辑里,一次在SaaS后台的数据看板组件中,还有一次是在一个AI工具链的React前端里。它根本不是什么开源库、npm包名或框架术语,而是一个业内自发形成的、带点自嘲意味的暗语,专指那些刻意绕过React现代优化机制、回归原始粗粒度更新策略的临时方案。
这个词的核心关键词非常明确:caveman、token、npx、useMemo、React。但注意,这里的“token”绝非身份认证用的JWT或OAuth token,而是指代状态粒度(state granularity)的最小不可分割单元——就像石器时代人类只能识别“能吃”和“不能吃”两种状态,caveman模式下的组件也只认“全量刷新”和“不刷新”两种状态,中间没有任何精细的状态拆分、记忆化或增量更新。它和useMemo形成直接对立:useMemo是智人时代的精密工具,而caveman是放弃工具、直接抡锤子砸的应急手段。
为什么这个暗语突然在2024年中后期密集出现?根本原因在于React 18并发渲染(Concurrent Rendering)落地后,大量团队在升级过程中遭遇了意料之外的性能反模式。当开发者发现useMemo依赖数组里塞了一个函数引用、或是useState更新时意外触发了深层对象重渲染、又或是React.memo因props浅比较失效而形同虚设时,最快速的“止血”方式就是——删掉所有记忆化逻辑,把组件变成一个纯粹的、无脑的、每次父组件rerender就重绘的“原始人”。这不是退步,而是一种有意识的降级决策(intentional degradation):宁可牺牲局部性能,也要确保交互响应不卡顿、数据不丢失、状态不错乱。它特别适合三类场景:紧急线上热修复、复杂表单校验逻辑重构过渡期、以及AI驱动型应用中token流(prompt token / response token)高频动态变化导致的不可预测rerender风暴。
你可能会问:既然这么“原始”,为什么不用更基础的React.Component类组件?因为caveman模式本质仍是函数组件,只是主动放弃了Hooks提供的优化能力。它保留了函数组件的简洁性、可测试性和与现代生态(如TanStack Query、Zustand)的兼容性,只是把useMemo、useCallback、React.memo这些“文明工具”暂时收进抽屉。所以它不是技术倒退,而是工程权衡——就像外科医生在大出血时会先用止血钳夹住血管,而不是花三分钟准备显微缝合设备。这篇文章要讲的,就是如何在真实项目中识别、设计、实施并安全退出caveman模式,而不是教你怎么写一个叫“caveman”的npm包。
2. caveman模式的本质:一场关于状态粒度与渲染成本的精确计算
2.1 它不是bug,而是对React渲染模型的逆向工程
要真正理解caveman,必须先看清React默认渲染模型的“阿喀琉斯之踵”。React的reconciliation(协调)过程本质上是一场树遍历+差异对比+DOM操作的组合拳。当一个组件rerender时,它内部所有JSX表达式都会重新执行,所有子组件都会被重新创建(即使没变),所有内联函数(如onClick={() => doSomething()})都会生成新引用,所有对象字面量(如{ id: 1, name: 'test' })都会创建新实例。这个过程的成本,不取决于你写了多少行代码,而取决于虚拟DOM节点的数量、props的传递深度、以及子组件对props变化的敏感度。
我们来看一个典型反模式:
function ProductList({ products, onAddToCart }) { // ❌ caveman预警:这里useMemo本该缓存,但依赖项错误导致失效 const filteredProducts = useMemo(() => products.filter(p => p.inStock), [products] // 错误!products是数组引用,每次父组件rerender都变 ); return ( <div> {filteredProducts.map(product => ( <ProductCard key={product.id} product={product} onAddToCart={onAddToCart} // 每次render都传新函数引用! /> ))} </div> ); }在这个例子中,ProductList每收到新products数组(哪怕只是同一组商品的顺序变了),useMemo就会失效,filteredProducts重新计算;同时onAddToCart作为内联函数,每次render都生成新引用,导致每个ProductCard的React.memo失效,全部重渲染。结果就是:100个商品,每次搜索过滤,都要重建100个ProductCard实例,执行100次useEffect,触发100次DOM diff。这就是caveman模式诞生的土壤——当调试发现useMemo不仅没提速,反而因依赖项管理失误成了性能黑洞时,“删掉它,让一切简单粗暴地重来”就成了最理性的选择。
2.2 token在这里是什么?是状态的原子单位,不是认证凭证
网络热词里反复出现的“token”,在caveman语境下,90%以上指的是状态的最小不可再分单元(atomic state unit)。这和JWT里的token毫无关系,却和AI领域的prompt token、completion token概念神似:都是把复杂信息切割成可计量、可追踪、可独立更新的“颗粒”。在React中,一个“token”可以是:
- 一个用户输入框的
value字符串(const [inputValue, setInputValue] = useState('')) - 一个API请求的
status枚举('idle' | 'loading' | 'success' | 'error') - 一个列表项的
isSelected布尔值(const [selectedIds, setSelectedIds] = useState(new Set()))
caveman模式的关键洞察是:当你的应用状态无法被 cleanly 分解为独立的token时,强行使用useMemo或React.memo只会增加维护成本和出错概率。比如一个电商购物车状态:
// ❌ 非caveman思维:试图用一个对象管理所有状态 const [cart, setCart] = useState({ items: [], total: 0, discount: 0, shipping: 0, isApplyingCoupon: false }); // ✅ caveman思维:拆成独立token,各自管理 const [cartItems, setCartItems] = useState([]); const [cartTotal, setCartTotal] = useState(0); const [discount, setDiscount] = useState(0); const [shipping, setShipping] = useState(0); const [isApplyingCoupon, setIsApplyingCoupon] = useState(false);前者看似简洁,但setCart({...cart, items: newItem})会触发整个cart对象重建,所有依赖cart.total的组件都会rerender;后者虽然代码行数翻倍,但setCartItems只影响items相关UI,setDiscount只影响优惠信息展示,状态更新精准到“token”级别。caveman模式不反对拆分,它反对的是在无法保证token独立性的前提下,用useMemo假装做了优化。
2.3 npx和caveman有什么关系?它是验证工具链稳定性的压力测试仪
你可能疑惑:npx怎么会和一个React性能模式扯上关系?答案在于——npx是暴露caveman需求的催化剂。当团队开始大规模使用npx create-react-app@latest、npx playwright install、npx tsc --build等命令时,意味着项目正经历三个关键转变:构建工具链升级、端到端测试引入、TypeScript深度集成。这些转变会无情放大原有代码中的caveman隐患。
举个真实案例:某团队在接入Playwright做自动化测试后,发现登录流程总是失败,错误日志里反复出现token exchange failed: error sending request。排查发现,问题不在后端Auth服务,而在于前端登录组件在Playwright模拟点击时,因useMemo依赖项缺失,导致loginRequest函数引用在两次点击间不一致,后端收到的CSRF token签名失效。他们最终的解决方案不是修useMemo,而是将登录组件整体标记为caveman——移除所有记忆化,确保每次点击都生成完全一致的请求参数对象。npx playwright install本身不产生caveman,但它带来的严格测试环境,像一把手术刀,精准切开了那些靠运气运行的“伪优化”代码。
另一个角度:npx命令的瞬时性,恰恰映射了caveman模式的哲学——不追求长期优雅,只保障当下可靠。npx每次执行都下载最新版工具,不缓存、不复用、不假设环境一致性;caveman组件每次render都从头计算,不缓存、不复用、不假设props稳定性。它们共享同一种工程价值观:在不确定的环境中,确定性比效率更重要。
3. 实施caveman模式的四步法:从识别到落地的完整路径
3.1 第一步:用React DevTools精准定位“伪优化”组件
caveman不是拍脑袋决定的,而是基于数据的诊断结果。第一步永远是量化当前性能瓶颈。不要相信直觉,要用React DevTools的Profiler面板实测。以下是我在三个不同项目中总结出的caveman高发组件特征:
| 特征类型 | 具体表现 | Profiler中典型指标 | 实际案例 |
|---|---|---|---|
| 依赖项污染型 | useMemo/useCallback依赖数组包含函数、对象、数组引用 | 每次render都显示“Re-rendered due to dependency change” | 表单组件中useCallback依赖formState对象,而formState由useState返回,每次setState都生成新引用 |
| 浅比较失效型 | React.memo包裹的组件,props看似没变,但实际引用已更新 | Profiler显示“Rendered”但“Did not re-render”为false | 列表项组件接收item对象,父组件用map生成新对象{...item, isSelected: true},导致memo失效 |
| 状态耦合型 | 单一state对象承载多个业务维度,更新一个字段触发无关UI刷新 | “Wasted renders”占比>40%,且集中在非关键区域 | 购物车状态对象包含items、userAddress、paymentMethod,修改地址时商品列表也rerender |
| 异步竞态型 | useEffect中发起API请求,cleanup未处理,导致旧请求结果覆盖新状态 | Profiler中出现“Dropped frames”且伴随setState on unmounted component警告 | 搜索组件中输入防抖后发起请求,快速连续输入导致多次请求并发,最后完成的请求覆盖了用户最新意图 |
实操要点:打开DevTools → Profiler → Start profiling → 执行目标操作(如滚动、点击、输入)→ Stop → 查看“Flame Chart”和“Ranked”标签页。重点关注那些渲染时间长(>50ms)、调用栈深、且“Wasted renders”占比高的组件。如果发现某个组件明明props没变却频繁rerender,或者useMemo的“Cached”列几乎不亮,这就是caveman改造的黄金候选者。
提示:不要一上来就全局搜索
useMemo。很多团队犯的错误是,看到useMemo就删,结果发现删掉后性能更差——因为有些useMemo确实有效(如缓存复杂计算结果)。关键是看DevTools数据,而不是代码表面。
3.2 第二步:设计caveman组件的“降级契约”
一旦锁定目标组件,下一步不是直接删代码,而是定义清晰的降级边界和契约。caveman不是混乱,而是有纪律的简化。我给团队制定的caveman契约模板如下:
/** * @caveman-mode * - 目标:解决ProductList在高并发搜索时的卡顿问题(Issue #1234) * - 降级范围:仅移除useMemo/useCallback,保留useState/useEffect * - 不降级:API调用逻辑、错误处理、加载状态管理 * - 退出条件:当React 18.3发布后,用新的useCache Hook替代 * - 监控指标:首屏渲染时间<300ms,滚动帧率>55fps */ function ProductList({ products, onAddToCart }) { // ✅ caveman核心:移除所有useMemo/useCallback const filteredProducts = products.filter(p => p.inStock); // ✅ caveman核心:内联函数,不useCallback const handleAddToCart = (product) => { onAddToCart(product); }; return ( <div> {filteredProducts.map(product => ( // ✅ caveman核心:移除React.memo,直接渲染 <ProductCard key={product.id} product={product} onAddToCart={handleAddToCart} /> ))} </div> ); }这个契约包含五个强制字段:
- @caveman-mode:明确标识,方便grep搜索和CI检查
- 目标:具体问题、关联issue,避免主观判断
- 降级范围:精确到Hook类型,不扩大化
- 不降级:保护核心业务逻辑,防止功能退化
- 退出条件:设定明确的技术演进路径,避免永久caveman
我在某金融项目中曾见过一个极端案例:团队为了解决图表渲染卡顿,把整个Dashboard组件变成caveman,结果导致实时行情数据延迟3秒以上。后来我们用契约模板复盘,发现“不降级”字段缺失,没有约束API轮询频率,最终补上了useEffect中setInterval的节流逻辑。
3.3 第三步:用“token化状态”重构props传递链
caveman模式最大的风险不是性能,而是状态污染扩散。一个组件降级为caveman后,如果它的父组件仍用粗粒度props传递,问题会向上蔓延。因此第三步必须重构props链,实现真正的token化。
以登录表单为例,传统写法:
// ❌ 问题:LoginForm接收整个authState对象,任何authState变化都触发重渲染 function LoginForm({ authState, onLogin }) { const { email, password, isLoading, error } = authState; // ... render logic } // 父组件 function LoginPage() { const [authState, setAuthState] = useState({ email: '', password: '', isLoading: false, error: null }); return <LoginForm authState={authState} onLogin={handleLogin} />; }caveman改造后:
// ✅ caveman重构:只接收必要token function LoginForm({ email, password, isLoading, error, onEmailChange, onPasswordChange, onSubmit }) { return ( <form onSubmit={onSubmit}> <input value={email} onChange={(e) => onEmailChange(e.target.value)} /> <input type="password" value={password} onChange={(e) => onPasswordChange(e.target.value)} /> <button type="submit" disabled={isLoading}>登录</button> {error && <div className="error">{error}</div>} </form> ); } // 父组件:token化管理 function LoginPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const handleSubmit = async (e) => { e.preventDefault(); setIsLoading(true); try { await login(email, password); } catch (err) { setError(err.message); } finally { setIsLoading(false); } }; return ( <LoginForm email={email} password={password} isLoading={isLoading} error={error} onEmailChange={setEmail} onPasswordChange={setPassword} onSubmit={handleSubmit} /> ); }这个重构的关键在于:父组件承担了所有状态聚合和副作用管理,子组件只做纯渲染。LoginForm现在是一个真正的“哑组件”(dumb component),它不关心email怎么来的,也不关心onSubmit背后是API调用还是mock数据。这种分离让caveman组件的边界极其清晰——它只对输入token负责,输出行为token,中间没有状态残留。我在做Code Review时,会重点检查caveman组件的props类型定义,如果看到any或object类型,立刻要求拆分成原子token。
3.4 第四步:建立caveman组件的自动化防护网
降级不是终点,而是新阶段的起点。必须用工程化手段防止caveman组件被无意“升级”回伪优化状态。我在团队落地了三层防护:
第一层:ESLint规则定制
// .eslintrc.js module.exports = { rules: { // 禁止在caveman组件中使用useMemo/useCallback 'no-restricted-syntax': [ 'error', { selector: 'CallExpression[callee.name="useMemo"]', message: 'caveman组件禁止使用useMemo,请检查@caveman-mode契约', }, { selector: 'CallExpression[callee.name="useCallback"]', message: 'caveman组件禁止使用useCallback,请检查@caveman-mode契约', }, { selector: 'CallExpression[callee.name="React.memo"]', message: 'caveman组件禁止使用React.memo,请检查@caveman-mode契约', }, ], }, };第二层:Jest快照测试强化为每个caveman组件编写快照测试,但不是测UI,而是测渲染行为的一致性:
// LoginForm.caveman.test.jsx it('renders with exact props without memoization', () => { const mockOnEmailChange = jest.fn(); const mockOnPasswordChange = jest.fn(); const mockOnSubmit = jest.fn(); const { container } = render( <LoginForm email="test@example.com" password="123456" isLoading={false} error={null} onEmailChange={mockOnEmailChange} onPasswordChange={mockOnPasswordChange} onSubmit={mockOnSubmit} /> ); // 断言:输入框值与props完全一致 expect(container.querySelector('input[type="email"]').value).toBe('test@example.com'); expect(container.querySelector('input[type="password"]').value).toBe('123456'); // 断言:事件处理器是直接传递的函数,不是useCallback包装的 fireEvent.change(container.querySelector('input[type="email"]'), { target: { value: 'new@example.com' } }); expect(mockOnEmailChange).toHaveBeenCalledWith('new@example.com'); });第三层:CI/CD流水线门禁在GitHub Actions中添加检查步骤:
# .github/workflows/caveman-check.yml - name: Check caveman components for accidental optimization run: | # 查找所有标记@caveman-mode的文件 CAVEMAN_FILES=$(grep -rl "@caveman-mode" src/ || true) if [ -n "$CAVEMAN_FILES" ]; then echo "Found caveman components:" echo "$CAVEMAN_FILES" # 检查这些文件中是否意外出现了useMemo等 if grep -r "useMemo\|useCallback\|React\.memo" $CAVEMAN_FILES; then echo "ERROR: Found forbidden hooks in caveman components!" exit 1 fi fi这套防护网的效果立竿见影。上线首月,我们拦截了7次开发人员试图“优化”caveman组件的提交,其中3次是因为新人不了解契约,4次是因为老员工习惯性加useCallback。自动化防护不是限制创造力,而是把认知负担从人脑转移到机器,让团队能把精力聚焦在真正需要创新的地方。
4. caveman模式的生命周期管理:何时进入,何时退出,如何演进
4.1 进入时机:三类不可妥协的“临界点”
caveman不是首选方案,而是最后防线。我总结出必须启动caveman改造的三个硬性临界点,满足任一即可:
1. 用户感知延迟 > 100ms
这是Web性能的黄金阈值。当用户操作(点击、输入、滚动)后,界面反馈延迟超过100ms,大脑就会感知为“卡顿”。用Chrome DevTools的Lighthouse跑Performance Audit,重点关注First Input Delay (FID)和Interaction to Next Paint (INP)指标。如果INP > 200ms,且Profiler确认是某个组件rerender耗时过长,caveman就是最快解法。例如,某新闻App的评论区,在用户快速滚动时INP飙升至450ms,原因是CommentItem组件内嵌了复杂的Markdown解析逻辑,且useMemo依赖项错误。切换为caveman后,INP降至85ms——不是因为解析变快了,而是解析逻辑被移到了Web Worker,caveman组件只负责渲染解析后的HTML字符串。
2. 状态错乱导致数据丢失
比性能更致命的是正确性。当useMemo或React.memo因依赖项管理失误,导致组件渲染出陈旧或错误数据时,必须立即caveman。典型症状:表单输入后内容消失、列表排序错乱、购物车数量显示为0但实际有商品。这类问题往往在复杂交互路径中偶发,难以复现,但后果严重。我的经验是:只要在Production日志中看到Cannot update a component while rendering a different component或Warning: Cannot update a component from inside the function body of a different component,且关联到某个memoized组件,立刻启动caveman流程。
3. 团队认知负荷超载
这是最容易被忽视的软性临界点。当一个组件的useMemo依赖数组长达10+项,且其中包含嵌套对象、函数、甚至Promise,导致每次Code Review都需要半小时解释“为什么这个依赖不能删”,这就说明抽象层次已经超出团队平均理解力。caveman在此时的价值是降低协作熵值。把一个需要博士论文才能讲清的优化逻辑,降级为小学生都能看懂的“每次重算”,反而提升了整体交付质量和迭代速度。某AI工具团队曾有一个PromptEditor组件,useMemo依赖包括currentModel,temperature,topP,maxTokens,stopSequences,systemPrompt,userPrompt,history,isStreaming共9个变量,且history是数组,systemPrompt是函数。团队投票决定caveman后,PR通过率从42%提升至91%。
4.2 退出策略:从caveman到“新智人”的平滑演进
caveman不是永久状态,而是通往更优架构的跳板。退出必须遵循“渐进式增强”原则,严禁一刀切回原状。我的推荐路径是:
阶段一:隔离副作用,固化token边界
在caveman组件稳定运行2周后,开始剥离副作用。例如,把API调用、定时器、事件监听等逻辑,从caveman组件中抽出,放到自定义Hook中:
// useLoginLogic.js - 新增Hook export function useLoginLogic() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const login = useCallback(async () => { if (!email || !password) return; setIsLoading(true); try { await api.login(email, password); } catch (err) { setError(err.message); } finally { setIsLoading(false); } }, [email, password]); return { email, setEmail, password, setPassword, isLoading, error, login }; } // LoginForm.jsx - 现在是纯caveman渲染 function LoginForm({ email, password, isLoading, error, onEmailChange, onPasswordChange, onSubmit }) { // ... 渲染逻辑不变 }这个阶段的目标是:让caveman组件彻底变成“无副作用渲染器”,所有业务逻辑外移。好处是,后续任何优化都只影响Hook,不影响UI。
阶段二:引入细粒度memoization
当副作用隔离完成后,可以开始在Hook内部引入精准的useMemo。注意,此时的useMemo只作用于计算结果,而非props传递:
// useLoginLogic.js - 增强版 export function useLoginLogic() { // ... 状态定义不变 // ✅ 安全的useMemo:只缓存纯计算,不涉及props或外部引用 const isValid = useMemo(() => { return email.includes('@') && password.length >= 6; }, [email, password]); // 依赖项干净、原子 const login = useCallback(async () => { if (!isValid) return; // 使用缓存结果 // ... API调用 }, [isValid, email, password]); return { email, setEmail, password, setPassword, isLoading, error, login, isValid }; }关键区别:这里的useMemo依赖email和password两个string token,而不是整个authState对象。计算结果isValid是纯值,不会引发额外rerender。
阶段三:采用React Server Components或Suspense边界
对于重度caveman组件(如整个Dashboard),终极退出方案是架构升级。利用RSC的流式渲染能力,把静态部分(标题、导航)和动态部分(数据图表、实时消息)物理隔离。caveman组件只负责渲染RSC返回的JSON数据,不再参与任何状态管理。某数据分析平台正是这样做的:把原来3000行的caveman Dashboard组件,拆分为1个RSC Layout + 5个Client Component,每个Client Component只处理单一数据源,useMemo回归其本职——缓存昂贵计算。
4.3 面试官视角:caveman是React高级工程师的必答题
如果你正在准备React面试,“有没有用过caveman模式”很可能成为压轴题。这不是考你是否会写useMemo,而是考你对React底层原理的理解深度、对工程权衡的成熟度、以及对团队协作的敬畏心。
面试官想听到的答案结构应该是:
- 场景还原:清晰描述一个真实问题(如“电商搜索页INP超标”),而非虚构案例
- 数据驱动:提到具体工具(DevTools Profiler)、指标(INP=450ms)、证据(Wasted renders占比65%)
- 决策逻辑:解释为什么选caveman而非其他方案(如
React.memo加areEqual、useReducer拆分、或直接重写) - 契约意识:强调
@caveman-mode注释、退出条件、监控指标等工程实践 - 演进思考:给出明确的退出路径(如“计划在Q3迁移到RSC”)
一个满分回答示例:
“去年我们遇到一个紧急问题:AI代码生成页面在用户快速输入时,响应延迟高达600ms,Lighthouse显示INP为580ms。Profiler发现
CodeOutput组件每次render都要执行一次AST解析,而useMemo因依赖codeString和language两个token,但language对象引用在每次配置变更时都变化,导致缓存失效。我们评估了三种方案:一是修复useMemo依赖,但需要重构整个语言配置系统,工期2周;二是用Web Worker,但当时团队没用过,风险高;三是caveman降级,把AST解析移到后端,前端只渲染HTML。我们选了第三种,因为线上故障优先级最高。我们写了详细的@caveman-mode契约,监控首屏渲染时间,并约定当React 18.3发布useCache后,用它替代当前方案。两周后,INP降至75ms,用户投诉归零。”
这个回答展示了技术深度、业务敏感度、工程纪律和前瞻性,远超单纯罗列API用法。
5. 常见误区与避坑指南:那些让caveman变成真“石器”的错误
5.1 误区一:“caveman = 不用任何优化”,导致全局性能雪崩
最危险的认知是把caveman理解为“放弃所有性能考量”。我见过一个团队,因为某个按钮组件卡顿,就把整个App的根组件App.js标记为caveman,结果首页加载时间从1.2s飙升至4.8s。caveman必须是精准外科手术,而非大面积放血。
避坑技巧:永远遵循“最小作用域原则”。用React DevTools的“Highlight updates when components render”功能,只对真正高频rerender且无优化收益的组件启用caveman。其他组件保持正常优化。一个健康的项目,caveman组件占比应<5%,且集中在特定业务模块(如实时协作编辑、AI对话流)。
5.2 误区二:忽略caveman组件的内存泄漏风险
caveman组件因rerender频繁,更容易暴露内存泄漏。典型场景是useEffect中启动定时器或订阅事件,但cleanup函数写错:
// ❌ 危险:caveman组件中cleanup失效 function ChatInput({ messages }) { useEffect(() => { const timer = setInterval(() => { console.log('typing indicator'); }, 1000); // ❌ 忘记return cleanup函数! }, []); // 依赖空数组,但timer会累积 return <input />; }在caveman模式下,这个组件可能每秒rerender多次,每次都会创建新timer,旧timer却没被清除,内存占用指数级增长。
避坑技巧:为所有caveman组件的useEffect添加强制检查清单:
- ✅ 必须有return cleanup函数
- ✅ cleanup函数必须清除所有副作用(clearInterval、removeEventListener、abortController.abort())
- ✅ 依赖数组必须显式声明,禁止
[]除非绝对确定无依赖
我在团队推行了一个小工具:eslint-plugin-caveman,自动检测caveman组件中缺少cleanup的useEffect。
5.3 误区三:用caveman掩盖架构缺陷,而非解决问题根源
caveman是止血钳,不是创可贴。如果一个组件需要长期维持caveman状态,说明底层架构有问题。常见根源包括:
- 状态管理过度集中:所有状态都放在顶层Context,导致任意状态变更都触发全量rerender
- 数据获取粒度太粗:API返回100个字段,前端只用3个,却用整个对象做state
- 组件职责不单一:一个组件既处理UI渲染,又管理数据获取,还包含复杂业务逻辑
避坑技巧:每季度做一次“caveman审计”。统计所有caveman组件,按模块、作者、存在时长排序。对存在>3个月的caveman组件,强制进行架构评审。我们曾发现一个存在11个月的caveman组件,根源是后端API设计不合理——它返回整个用户档案,而前端只需要头像URL。推动后端提供/api/user/avatar专用端点后,该组件顺利退出caveman。
5.4 误区四:混淆caveman与“不写useMemo”的新手行为
新手不写useMemo,是因为不知道;资深工程师写caveman,是因为知道且选择放弃。两者有本质区别:
| 维度 | 新手行为 | caveman模式 |
|---|---|---|
| 意图 | 无知或懒惰 | 有意识的工程权衡 |
| 范围 | 随机、无计划 | 有契约、有监控、有退出路径 |
| 测试 | 往往缺失 | 强制快照测试、性能基线测试 |
| 文档 | 无注释 | @caveman-mode详细说明 |
| 影响 | 可能偶然有效 | 必然带来可测量的性能改善 |
避坑技巧:在团队内部建立“caveman白名单”。只有Tech Lead或Senior Engineer有权批准caveman提案,并记录在共享文档中。普通开发者发现性能问题,应先提Issue,由架构组评估是否需caveman,而非自行决定。
注意:caveman不是银弹,也不是耻辱标记。它是React工程师工具箱里一把沉重但必要的锤子。用得好,能救命;用得滥,会伤人。它的价值不在于“原始”,而在于“清醒”——清醒地知道什么时候该用精密仪器,什么时候该抡起锤子。我在过去三年里,亲手主导了17个caveman改造项目,其中12个已成功退出,剩下的5个仍在按契约演进。每一次,都不是技术的倒退,而是团队认知边界的又一次拓展。