React面试宝典【Part8-1:经典面试题】
项目 React 渲染卡顿:标准排查 + 逐阶段优化方案
一、第一步:先定位“哪里卡”
1. 打开浏览器 Performance 面板录制
- 看是否有 Long Task(长任务 > 50ms)
- 看 Scripting 耗时是否异常高
- 看是否有 频繁 Layout Shift / 大量重排重绘
2. 打开 React DevTools → Profiler
- 开启 Record
- 操作卡顿场景
看三件事:- 哪些组件渲染了
- 渲染耗时多少
- 为什么渲染(Why did this render?)
这一步直接定位:
- 某个组件疯狂重渲染
- 列表渲染过多
- 大计算阻塞主线程
3. 看控制台是否有大量重复 log
快速判断是否无限渲染 / 死循环渲染。
二、第二步:按优先级排查最常见 4 大原因
1)无效重渲染(最常见)
父组件一渲染,所有子组件全跟着跑
表现:Profiler 一片红,组件疯狂 render
排查点:
- 组件是否包
memo - 传递的 props 是否是对象/函数字面量
onClick={() => {}}style={{}}data={[...]}
- 状态是否放在过高层级(不该放 App 级的放了 App)
- Context 滥用,一个 value 变全组件刷新
2)长列表 / 大量 DOM
表现:滚动卡、切换卡
排查:
- 列表是否超过 50 条还直接全渲染
- 是否有图片、复杂组件在列表里
3)同步 heavy 计算
表现:点击后卡死几秒,JS 阻塞主线程
排查:
- 渲染时做了大量计算:filter、sort、deepClone、递归
- useEffect 里 heavy 计算
- 大量 JSON 解析、大列表处理
4)Effect 死循环 / 无限重渲染
表现:CPU 持续 100%,控制台 log 刷屏
原因:
- useEffect 依赖项写得不对
- setState → effect → setState 循环
三、第三步:对应逐一优化
1. 解决无效重渲染(最有效)
- 子组件包 React.memo
- 函数用 useCallback 包裹
- 对象/数组用 useMemo
- 不传递字面量对象/函数给 memo 组件
- 状态下放:谁用谁定义,不要全放顶层
- Context 拆分:避免一个状态变全应用刷新
- 状态管理改用细粒度方案:Zustand / Jotai
2. 长列表卡顿
- 虚拟列表(react-window / react-virtualized)
只渲染可视区域 10~20 条 - 列表项包 memo
- 图片懒加载
- 减少列表项内复杂计算与 DOM
3. Heavy 计算阻塞
- 计算逻辑用 useMemo 缓存
- 大计算放到 Web Worker
- 使用 React 并发特性
startTransitionuseDeferredValue
让渲染可中断,不阻塞输入/点击
4. 消除 Effect 循环
- 修复依赖项
- 不要在 useEffect 里无脑 setState
- 用 ref 跳过不必要更新
5. 其他通用优化
- 移除 dev 环境、严格模式,生产环境验证
- 大组件拆小
- 避免内联样式、频繁修改 style
- 避免频繁操作 DOM
四、第四步:验证优化效果
- 重新跑 Performance
- 重新跑 React Profiler
- Long Task 消失
- 组件渲染次数大幅下降
- 滚动/交互流畅
五、总结
我会先通过 Performance 定位长任务,再用 React Profiler 定位过度渲染组件,然后按优先级处理:
- 减少无效重渲染:memo、useCallback、useMemo、状态下放、拆分 Context
- 长列表使用虚拟滚动
- 耗时计算用 useMemo 或 Web Worker
- 用 startTransition 做并发调度
- 修复 effect 循环依赖
最后通过工具验证卡顿是否消失。
如何设计大型表单 + 性能优化
一、大型表单怎么设计组件?
1. 分层拆分(核心原则:单一职责)
- 表单容器组件:负责整体结构、提交、重置、校验、数据流转
- 区块组件:按业务拆分组(基础信息 / 联系人 / 账户 / 附件…)
- 表单项组件:Input、Select、Radio、Date 等封装成统一受控组件
- 公共组件:上传、富文本、级联选择、动态行(增删行)
结构示例:
FormPage(容器)
├── BasicBlock(基础区块)
│ ├── FormItem(姓名)
│ └── FormItem(性别)
├── ContactBlock(联系信息区块)
└── SubmitBar(提交/重置)
2. 统一数据结构
- 整个表单使用 单层/分层对象 管理
- 统一
name映射字段:user.name、user.phone - 方便回填、提交、重置
3. 统一受控逻辑
封装一个 useForm 核心 Hook,提供:
formDatasetFormItem修改单个字段setFields批量修改validate校验reset重置
4. 校验设计
- 字段校验:必填、格式、长度、异步校验(用户名重复)
- 区块校验:按区块分步提交/分步校验
- 提交前全量校验
- 错误信息统一展示
5. 交互体验
- 分步表单(Step Form),避免一屏超长
- 自动聚焦、tab 切换
- 失焦校验 / 输入防抖校验
- 草稿自动保存(localStorage / 接口)
- 错误滚动定位
二、大型表单性能优化
1. 阻止全表单重渲染(重点)
问题:改一个输入框 → 整个表单所有组件重新渲染
优化方案
- 表单区块组件用 React.memo 包裹
- 修改字段的方法用 useCallback 缓存
- 不直接传递整个 formData 给子组件
只传对应区块数据:basicData、contactData
// 父组件
const basicData = useMemo(() => ({
name: formData.name,
age: formData.age
}), [formData.name, formData.age])
<BasicBlock
data={basicData}
onChange={onFieldChange}
/>
2. 防抖输入(避免高频 setState)
搜索/输入联想/实时格式化:
- 使用
useDebounce延迟更新状态 - 只在停止输入 300ms 后触发 setState & 校验
3. 按需校验,不要全量校验
- 输入时:只校验当前字段
- 离开焦点时:校验当前字段
- 提交时:才全量校验
避免每次输入都跑一遍所有规则。
4. 动态表单项(增删行)优化
- 每行用
memo - 行内事件用
useCallback - 不使用索引作为 key
- 大量行使用虚拟列表
5. 避免内联对象/函数/样式
下面这些都会导致 memo 失效:
// 坏
<Input style={{ margin: 10 }} />
<Button onClick={() => submit()} />
// 好
const style = useMemo(() => ({ margin:10 }), [])
const handleSubmit = useCallback(() => submit(), [])
6. 分步加载 / 懒渲染
- Step 表单:只渲染当前步骤
- 折叠面板:收起的区块不渲染
- 复杂组件(富文本、上传)按需加载
React.lazy
7. 避免在 render 中做 heavy 计算
- 数据格式化、选项过滤用
useMemo - 级联选择预处理数据
- 不循环生成大量 DOM
8. 关闭不必要的重新渲染
- 使用 不可变更新,不修改原对象
- 使用 Jotai/Valtio/Zustand 做细粒度表单状态(改谁谁渲染)
- 自己实现原子化表单,每个字段独立更新,不扩散
9. 草稿自动保存优化
- 防抖保存(5s 一次)
- 只保存变化部分
- 卸载/关闭页面前一次保存
10. 生产环境检查
- 开发环境严格模式会导致重复渲染
- 打包后再评估真实性能
三、总结
大型表单先按业务区块做组件拆分,统一用
useForm管理数据与校验;
性能上核心是避免全表单重渲染,通过memo拆分区块、useCallback稳定方法、useMemo隔离数据,配合输入防抖、按需校验、分步渲染,即可保证超流畅体验。
如何实现组件缓存(类似 Vue keep-alive)?
React 没有内置 keep-alive,但可以通过三种方式实现组件缓存:
- CSS display: none / 隐藏(最简单)
- 状态控制挂载/卸载 + 组件实例保留
- 第三方库 react-keep-alive / react-activation(生产标准)
一、最简单实现:隐藏式缓存(display:none)
原理:组件不卸载,只隐藏,状态 & DOM 全部保留。
import { useState } from 'react';
function KeepAliveDemo() {
const [tab, setTab] = useState('A');
return (
<div>
<button onClick={() => setTab('A')}>A</button>
<button onClick={() => setTab('B')}>B</button>
<div style={{ display: tab === 'A' ? 'block' : 'none' }}>
<ComponentA /> {/* 切换走也不卸载 */}
</div>
<div style={{ display: tab === 'B' ? 'block' : 'none' }}>
<ComponentB />
</div>
</div>
);
}
function ComponentA() {
const [count, setCount] = useState(0);
console.log('A 渲染/挂载');
return <div>组件A count: {count} <button onClick={() => setCount(count+1)}>+</button></div>;
}
function ComponentB() {
console.log('B 渲染/挂载');
return <div>组件B</div>;
}
优点:超简单、状态完全保留
缺点:DOM 一直存在,多页面会越来越卡
二、标准路由级缓存(纯 React Router 标签页缓存)
思路:
- 组件不卸载,只根据 path 显示/隐藏
- 适合后台管理系统 Tab 页缓存
import { useState, useEffect } from 'react';
import {
HashRouter,
Routes,
Route,
Link,
useLocation,
Navigate
} from 'react-router-dom';
// 页面组件
function PageA() {
const [count, setCount] = useState(0);
console.log('✅ PageA 渲染');
return (
<div style={{ padding: 20 }}>
<h2>Page A</h2>
<p>计数:{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
function PageB() {
const [text, setText] = useState('');
console.log('✅ PageB 渲染');
return (
<div style={{ padding: 20 }}>
<h2>Page B</h2>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="输入测试缓存"
/>
</div>
);
}
// 缓存路由容器
function KeepAliveRouter() {
const location = useLocation();
const [activePath, setActivePath] = useState(location.pathname);
const [cachedPaths, setCachedPaths] = useState([location.pathname]);
// 监听路由变化,自动加入缓存
useEffect(() => {
const currentPath = location.pathname;
setActivePath(currentPath);
if (!cachedPaths.includes(currentPath)) {
setCachedPaths(prev => [...prev, currentPath]);
}
}, [location.pathname]);
return (
<div>
{/* 标签栏 */}
<div style={{ padding: '0 20px', background: '#f5f5f5' }}>
{cachedPaths.map((path) => (
<button
key={path}
onClick={() => setActivePath(path)}
style={{
padding: '8px 16px',
margin: '0 8px 0 0',
background: activePath === path ? '#1677ff' : '#fff',
color: activePath === path ? '#fff' : '#000',
border: '1px solid #ddd',
cursor: 'pointer'
}}
>
{path}
</button>
))}
</div>
{/* 缓存内容区域 */}
<div style={{ padding: 20 }}>
{cachedPaths.map((path) => (
<div
key={path}
style={{ display: activePath === path ? 'block' : 'none' }}
>
{path === '/' && <Navigate to="/a" />}
{path === '/a' && <PageA />}
{path === '/b' && <PageB />}
</div>
))}
</div>
</div>
);
}
// 入口
export default function App() {
return (
<HashRouter>
<div style={{ padding: 10 }}>
<Link to="/a" style={{ marginRight: 15 }}>Page A</Link>
<Link to="/b">Page B</Link>
</div>
<Routes>
<Route path="*" element={<KeepAliveRouter />} />
</Routes>
</HashRouter>
);
}
三、生产级方案:react-activation(最接近 Vue keep-alive)
最成熟、社区公认 React keep-alive 方案
1. 安装
这个依赖有严重的版本匹配问题,请参考以下配置进行
依赖版本(package.json 关键字段)
{
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"react-activation": "0.12.4",
"react-scripts": "5.0.1"
}
}
确保安装以上精确版本或兼容版本。
react-activation必须为0.12.4,因为其导出方式为export default KeepAlive。
src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
src/App.jsx — 根组件(路由与缓存配置)
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import KeepAlive from 'react-activation'; // 默认导出
import { AliveScope } from 'react-activation'; // 具名导出
import Home from './pages/Home';
import PageA from './pages/PageA';
import PageB from './pages/PageB';
export default function App() {
return (
<AliveScope>
<BrowserRouter>
<nav style={{ marginBottom: 20 }}>
<Link to="/">Home</Link> |{' '}
<Link to="/a">PageA (keep-alive)</Link> |{' '}
<Link to="/b">PageB (keep-alive)</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route
path="/a"
element={
<KeepAlive name="PageA" key="PageA">
<PageA />
</KeepAlive>
}
/>
<Route
path="/b"
element={
<KeepAlive name="PageB" key="PageB">
<PageB />
</KeepAlive>
}
/>
</Routes>
</BrowserRouter>
</AliveScope>
);
}
src/pages/Home.jsx — 普通页面(不缓存)
export default function Home() {
return <h2>首页 - 每次进入都会重新渲染</h2>;
}
src/pages/PageA.jsx — 被缓存的页面 A
import { useState } from 'react';
export default function PageA() {
const [count, setCount] = useState(0);
return (
<div style={{ border: '2px solid green', padding: 10 }}>
<h2>页面 A - 状态已缓存</h2>
<p>计数器:{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
src/pages/PageB.jsx — 被缓存的页面 B
import { useState } from 'react';
export default function PageB() {
const [count, setCount] = useState(0);
return (
<div style={{ border: '2px solid orange', padding: 10 }}>
<h2>页面 B - 状态已缓存</h2>
<p>计数器:{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
- Home 页面每次进入都会重新渲染。
- PageA 与 PageB 之间任意切换,各自的计数器状态都会被保留(类似 Vue 的
<keep-alive>)。 - 从 Home 切换到 PageA/PageB 时,若先前已有缓存,则直接恢复。
四、底层原理
keep-alive 本质是什么?
组件不执行 unmount,只隐藏,保留 Fiber 节点、state、DOM。
React 为什么没有内置?
- React 推崇单向数据流、显式控制
- 保留组件会导致:
- 内存占用升高
- 副作用堆积
- 数据不同步风险
- 官方认为缓存应该显式控制,而不是黑盒
实现核心
- 组件不卸载
- 控制显示/隐藏
- 保留组件实例 & 状态
- 提供生命周期钩子(activated / deactivated)
五、总结
React 没有内置 keep-alive,实现组件缓存主要有三种方式:
- 简单场景用 CSS display: none
组件不卸载,只隐藏,实现最简单,但 DOM 累积会影响性能。- 路由场景手动维护缓存列表
切换路由时不卸载组件,只控制显示隐藏,适合后台多标签页。- 生产环境使用 react-activation
功能最完整,和 Vue keep-alive 行为一致,支持缓存、激活、失活、手动销毁。
底层原理都是保留组件实例不卸载,只控制显示隐藏,从而保留状态与 DOM。