
2026-09-06 · 阅读 14
一次讲透 React 渲染与性能优化:三件套、闭包陷阱、SSR/SSG
一次讲透 React 渲染与性能优化:三件套、闭包陷阱、SSR/SSG
本文是《一次讲透 XSS 与 CSRF》《一次讲透 JWT 与双 Token 机制》《一次讲透 Redis》的续篇。React 的性能优化是我在第一次面试里答得最狼狈的部分——被问到组件优化时基本胡言乱语。这次把渲染机制、记忆化三件套、闭包陷阱、渲染模式与 SEO 一条线讲透。
0. 铁律:父组件渲染,子组件无条件跟着渲染
一切优化的起点是这条铁律:
父组件重新渲染时,所有子组件无条件跟着重新执行——不管 props 变没变。
先分清两个容易混淆的词:
- 渲染(render)= 重新执行你的函数组件,产出新的虚拟 DOM;
- DOM 更新 = React 拿新旧虚拟 DOM 做 diff,发现差异才去改真实页面。
所以"子组件白渲染"的浪费 = 函数重新执行的成本 + diff 的成本,最后 diff 完发现啥也没变,白干。
类比:老板(父组件)改了周报里一个错别字,整层楼 200 个员工(子组件)全部重写一遍周报,秘书 diff 了 199 份说"和上周一模一样"——199 人白写。React 默认就是这么干活的。
我的 CloudStore 真实场景:文件列表页,搜索框每敲一个字,父组件 state 变化 → 500 个 FileItem 全部重新执行。文件越多,打字越卡。
1. 记忆化三件套:各记一层
| 工具 | 记什么 | 一句话 |
|---|---|---|
| React.memo | 组件 | props 没变(浅比较)就跳过这个组件的渲染 |
| useMemo | 值 | 依赖没变就返回上次的计算结果(跳过计算) |
| useCallback | 函数 | 依赖没变就返回同一个函数引用(跳过新建函数) |
1.1 React.memo:给子组件发"免死金牌"
const FileItem = React.memo(function FileItem({ file, onDelete }) {
return <div>{file.name}</div>;
});
原理:渲染前拿新旧 props 做浅比较(只比第一层 ===),全等 → 跳过渲染。
1.2 天坑:引用类型每次渲染都是"新的"
浅比较用 ===,而 JS 里内联函数、对象字面量、数组每次渲染都产生新引用:
<FileItem
onDelete={() => deleteFile(file.id)} // ❌ 每次渲染新建一个函数
style={{ color: 'red' }} // ❌ 每次渲染新建一个对象
/>
=== 比较的不是代码内容,是内存地址。验证只要三行:
const a = () => {};
const b = () => {};
a === b; // false —— 代码一模一样,也是两个对象
类比:两次渲染 = 造了一对双胞胎,长得一模一样但身份证号不同。React.memo 是门卫,只查身份证号,不认脸——查得快(O(1)),但"长得一样"不算"同一个人"。
1.3 解药:useCallback 焊死引用、useMemo 缓存值
const handleDelete = useCallback((id) => deleteFile(id), []); // 同一个函数引用
const filtered = useMemo(() => files.filter(f => f.name.includes(keyword)), [files, keyword]);
三件套的关系一句话:useCallback(fn, deps) 就是 useMemo(() => fn, deps) 的语法糖——一个缓存函数,一个缓存值。它们最常见的存在意义是让 React.memo 的浅比较能通过,三件套经常配套出现,不是三个独立工具。
1.4 同一个病根的第二个症状:useEffect 的连锁反应
引用不稳定不只坑 memo,还坑 effect:
const options = { sort: 'name' }; // 每次渲染新对象
useEffect(() => { loadFiles(options); }, [options]); // ❌ 每次渲染都误判"依赖变了"
连锁反应链条:新引用 → effect 误判依赖变了 → 重复执行副作用(重复请求)→ 若副作用里 setState → 再渲染 → 又新引用 → 无限循环,直到 React 报 Maximum update depth exceeded。
两条修法:①useMemo/useCallback 把地址固定住;②依赖只放原始值(字符串/数字比的是值本身,天生稳定)。
一句话串联:
引用类型每次渲染 = 新地址 → memo 浅比较失败(白包)+ useEffect 误判(重复执行/死循环)→ 解药:固定地址,或依赖拆成原始值。
1.5 反面认知:记忆化不是免费的
记忆化本身有成本(存上次结果 + 每次渲染比较依赖)。只有两种情况值得用:
- 计算成本高:大数组 filter/sort、复杂统计——跳过计算省下的远大于缓存成本;
- 引用稳定是刚需:值/函数要传给 memo 子组件,或出现在 useEffect 依赖里。
给显示用户名的小组件包 memo、给简单函数包 useCallback = 负优化。正确顺序:先用 React DevTools Profiler 测量,再优化。
1.6 项目串讲(30 秒话术)
"我的 CloudStore 文件列表页,文件多时搜索框打字会卡。用 Profiler 定位:父组件每敲一个字重新渲染,500 个 FileItem 跟着白渲染。优化是三件套配套:FileItem 用 React.memo 包住;删除回调用 useCallback 固定引用——不然内联箭头函数每次都是新的,memo 直接失效;搜索过滤用 useMemo,只在关键词或列表变化时重新 filter。优化后打字时列表项渲染次数从 500 降到 0。"
2. Hooks 深水区:三个高频雷区
2.1 为什么 hooks 不能写在 if / for 里?
函数组件每次渲染都是重新调用函数,局部变量本该重置——那 useState 的值凭什么能记住?因为状态不存在函数里,存在 React 外部按组件划分的存储位里,而 React 靠调用顺序对应存储位:
第一次渲染:useState(A) → 存储位0 useState(B) → 存储位1
第二次渲染:useState(A) → 取存储位0 useState(B) → 取存储位1
写进 if,条件一变,后面的 hook 全部错位拿别人的状态,数据错乱崩溃。规则:只在顶层调用,保证每次渲染的调用顺序一致。
2.2 闭包陷阱:setInterval 永远显示 1
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1); // ❌ 页面永远显示 1
}, 1000);
return () => clearInterval(timer);
}, []);
原理:空依赖 → effect 只在第一次渲染后执行一次 → 回调捕获的是第一次渲染时 count=0 的闭包快照 → 每秒实际执行 setCount(0 + 1) → 永远是 1。
闭包的本质:每次渲染都是一次独立的函数调用,那次调用里的变量被回调函数"拍照留念"——之后 state 怎么变,照片里的都不变。
首选修法:函数式更新 setCount(c => c + 1)——不依赖闭包快照,让 React 把最新值递进来。(备选:把 count 写进依赖,但每次都会拆掉旧定时器重建,笨重。)
2.3 连招:一次点击三连 setCount,显示几?
setCount(count + 1);
setCount(count + 1);
setCount(count + 1); // count 从 0 开始,最终显示 1,不是 3
原理:state 是渲染的快照,setState 是排队不是立刻改值。同一次事件里 count 从头到尾是 0,三次都是 setCount(0+1);且三次 setState 会被 React 批处理合并成一次渲染。改成函数式更新 c => c + 1 才能拿到排队中的最新值,显示 3。
这两道题是同一个知识点的两件马甲:闭包捕获快照,setState 异步排队。
2.4 useEffect 依赖三档 + 清理函数
| 写法 | 执行时机 |
|---|---|
| 不传依赖 | 每次渲染后都执行 |
[] | 只在挂载后执行一次(+ 卸载时跑清理) |
[a, b] | 挂载后 + a 或 b 变化时执行 |
清理函数 return () => {...} 的执行时机:下一次 effect 执行之前 + 组件卸载时。不清掉旧的定时器/订阅 = 内存泄漏 + 重复触发。
3. 渲染模式:CSR 的两宗罪与 SSR/SSG/ISR
3.1 病根:CSR 把"生产 HTML"留到浏览器现场干
传统 React SPA 是 CSR:服务器返回的 HTML 几乎是空壳——一个 <div id="root"> 加一堆 <script>。
首屏慢的链条:用户访问 → 拿到空壳 HTML → 下载 JS bundle(大,慢)→ 执行 JS → 发数据请求(网络往返)→ 才有内容。白屏时间 = 全部加起来。
SEO 差:爬虫拿到的也是那个空 div——文章内容在 JS 执行之前根本不存在于 HTML 里。
一句话:首屏慢和 SEO 差是同一个决定的两份账单。
3.2 爬虫经济学:为什么 Google 勉强执行、百度不执行
不是性能差距,是成本经济学:普通抓取 = 一个 HTTP 请求,几乎零成本;执行 JS = 每个页面起一个完整的无头 Chrome,成本是百倍量级——乘以万亿页面规模,谁都要算账。
- Google:付得起一部分 → "两波抓取":第一波抓原始 HTML 建索引(CSR 页面抓到空壳),第二波排进渲染队列用无头 Chrome 执行 JS 补索引。代价:排队几天到几周(慢),第一波索引时是空的(降权);
- 百度:基本不付——爬虫预算远小于 Google,资源优先给自家生态。中文互联网大量传统 SSR 站点 + 百度自家产品本来就够喂结果页,它定规矩"内容要在 HTML 里就有",站长去适配它,而不是它迁就站长。
所以 SEO 结论是硬性的:"内容必须在第一波 HTML 里就有"不是优化建议,是准入门槛。
3.3 四种模式:HTML 何时何地生成
| 模式 | HTML 何时生成 | 适合 |
|---|---|---|
| CSR | 用户浏览器里实时 | 后台管理系统(不要 SEO) |
| SSR | 服务器上,每次请求时 | 内容实时变化 + 要 SEO |
| SSG | 构建时(build)预生成 | 内容不常变 + 要 SEO |
| ISR | 构建时 + 定时增量再生 | 静态为主、偶尔更新 |
- SSG 的死穴:内容一变就得全站重新 build + 重新部署,几千篇文章改一处全量重建太重;
- ISR 让页面带过期时间,过期后只增量再生那一个页面,改动不用全量 rebuild。(和缓存"逻辑过期"是同一个思想。)
Next.js 的核心卖点不是"全站 SSR",而是按页面混用,我的博客:
app/blog/[slug] → SSG/ISR (文章页:要 SEO、内容不常变、量大)
app/ → SSR (首页:最新文章列表,要实时 + 要 SEO)
app/admin/ → CSR (管理后台:登录才能看,SEO 无所谓)
3.4 SEO 四件套
- 渲染模式:文章页 SSG/ISR,让爬虫直接读到内容(治本);
- metadata:Next.js 的
generateMetadata按文章动态生成 title / description / OG 标签; - 语义化标签:一页一个
h1(放文章标题)、article/nav/header/footer各司其职,帮爬虫理解结构; - sitemap.xml + robots.txt:主动告诉搜索引擎有哪些页面、哪些别爬。
3.5 项目串讲(30 秒话术)
"博客上线后发现首屏白屏久、搜索引擎抓不到内容。根因是纯 CSR:浏览器拿到空壳 HTML,要下载执行完 JS 再请求数据才有内容,爬虫看到的也是空壳。所以我把文章页改成 SSG 构建时预生成、配 ISR 增量更新,首页用 SSR 保实时,管理页保持 CSR;再补上每篇文章的 metadata、语义化标签和 sitemap。改完爬虫能直接读到完整内容,首屏不用等 JS 和数据请求了。"
4. 总结记忆卡
- 渲染铁律:父渲染 → 子无条件跟着渲染;渲染 ≠ DOM 更新(中间有 diff)
- 三件套:memo 跳组件渲染 / useMemo 跳计算 / useCallback 跳新建函数
- 关系:useCallback(fn, deps) = useMemo(() => fn, deps);它们让 memo 的浅比较能通过
- 引用天坑:浅比较只认地址不认内容——内联函数/对象每次渲染都是新地址,memo 白包
- 连锁反应:不稳定的引用进 useEffect 依赖 → 误判"变了" → 重复执行 → setState → 死循环
- 记忆化不是免费的:计算贵 or 引用稳定刚需才用;先 Profiler 测量再优化
- hooks 不进 if:React 靠调用顺序对应状态存储位,条件跳过 = 后面全部错位
- 闭包陷阱:回调捕获的是渲染快照;修法首选函数式更新
setCount(c => c+1) - 三连 setCount 显示 1:快照 + 批处理合并一次渲染
- CSR 两宗罪:首屏慢 + SEO 差 = 同一个决定(HTML 留到浏览器现场生产)的两份账单
- 爬虫经济学:执行 JS = 每页起浏览器,百倍成本;Google 排队渲染,百度基本不执行——内容必须第一波 HTML 里就有
- 混用才是 Next.js 的正确姿势:文章 SSG/ISR、首页 SSR、后台 CSR
- ISR 一句话:解决 SSG"一改全站重 build",过期只增量再生单页
本文基于个人项目实战(CloudStore 文件列表性能优化 + 博客 SEO 改造)与面试复盘整理,如有疏漏欢迎指出。系列前篇:《一次讲透 XSS 与 CSRF》《一次讲透 JWT 与双 Token 机制》《一次讲透 Redis:为什么快、缓存三兄弟、从单机到集群》。
评论 (0)
还没有评论,来抢沙发吧~