深入版:速查版讲「是什么、怎么用」,这一篇讲「为什么浏览器要这么干、出错时怎么救」。DOM 到底是什么?布局为什么很贵?defer 和 async 差在哪?为什么你的 84 个 URL 其实都是同一个 index.html?预渲染抓到的空 HTML 怎么识别——全部讲透。
两个真实站点,同一台服务器(124.222.108.196),同一套 nginx:
主站 memberhandbook.cn:React 19 + TS + Vite + Tailwind 的单页应用,84 个 URL 给爬虫只返回 3120 字节空壳 HTML(正文在 <div id="root"></div> 里,要等 JS 执行)。
已验证成功收录的:geo.memberhandbook.cn 和 /xiaosenlin/——用预渲染 + llms.txt 让爬虫一拿到文件就有正文。
纯静态代表:/fde/——知识库这一套 12 个页面全是纯 HTML + assets/kb.css,零 JS 也能读全。
反面教材:LumenX 是 Next.js 静态导出,但整站 Basic Auth(401),爬虫拿到的是登录页。
这一篇的所有机制和命令都基于这几个真实站点的行为,没有编造浏览器行为。
DOM = Document Object Model,文档对象模型。大白话:浏览器把 HTML 文本变成一棵「可以伸手去改」的树。
为什么要有它?因为 HTML 文件是文本,文本本身没法被程序修改。但浏览器需要让 JS 能干一件事:把页面上的某个字改成别的。所以浏览器在解析 HTML 时,把每个标签、每段文字都变成一个内存里的对象,再把这些对象用「父子关系」串成树。这棵树就是 DOM。
<!doctype html>
<html>
<head><title>报价</title></head>
<body>
<div id="root"></div>
</body>
</html>
↓ 浏览器解析后,内存里变成这样:
html
├─ head
│ └─ title →"报价"
└─ body
└─ div#root ← 空壳!什么都没有,但它是一个真实的节点
注意 div#root:它是空壳,但它是真实存在的一个节点。主站的 3120 字节空壳 HTML 里就有它,爬虫能看到它,只是里面没有文字。
为什么这个区别要紧?因为「创建 / 删除 / 移动」会让整棵树的规模发生变化,浏览器可能要把布局重算一遍;而「只改一段文字」通常只影响那一小块的布局。这直接引出后面的性能问题——所以 React 的核心思路就是:尽量只做「修改」,少做「删除重建」。
innerHTML = ... 这类整块替换。这是性能上最差的一种改法,也是最容易和 React 抢屏幕的一种改法。速查版讲了五步曲,但真正精确的流水线是五个阶段。第 4 步其实包含两件事:先算位置,再画像素。
<a> 没设颜色,继承父级的」——就是它干的。margin、display),改一个都会触发它。transform、opacity),别用会触发 Layout 的属性(比如 width、top)。你在 /fde/ 这个知识库里嵌的摩恩广告片(<video>),就是一个会触发 Layout 变化的元素。浏览器读到它时,还不知道它多大,只能先按「未知」处理,等视频文件下载完才知道尺寸,于是把布局撑开——页面加载时会跳一下。这就是 CLS。
<!-- 错:会跳 -->
<video src="moen.mp4" autoplay muted loop></video>
<!-- 对:写死宽高,浏览器立刻知道占多大,页面不跳 -->
<video src="moen.mp4" width="720" height="405"
style="width:100%;height:auto;"
autoplay muted loop playsinline></video>
aspect-ratio: 16 / 9),视频进去之前位置就已经定死了。媒体查询那一套(<picture>、srcset)也是同一个道理:让浏览器提前知道尺寸。速查版说了「遇到 script 会停下来」,这里讲清为什么,以及三种加载方式的区别。
因为浏览器要保证页面按顺序出现。HTML 是一份有序文档,浏览器不能「先跳到后面去执行 JS,再回头补前面的内容」——那顺序就乱了。所以遇到 <script> 就必须停下解析,等它下载完、执行完,才继续往下走。这段时间里,用户看到的是「解析到一半的页面」。
| 写法 | 下载时机 | 执行时机 | 顺序保证 | 用在哪 |
|---|---|---|---|---|
<script src="a.js">普通(阻塞) | 立刻开始 | 下载完立刻执行,会卡住 HTML 解析 | 按标签出现顺序,多个 script 之间也是顺序的 | 极少数必须在最前面的老代码 |
<script src="a.js" defer>延迟 | 和 HTML 解析「同时」进行(并行,不抢带宽) | 整个 HTML 解析完之后才执行 | ✅ 多个 defer 之间仍然按顺序 | 绝大多数场景。Vite 打包产物的 script 默认就是它 |
<script src="a.js" async>异步 | 和解析并行,且下载完就可以执行 | 下载完立刻执行,不管 HTML 解析到哪了 | ❌ 顺序完全不保证 | 互不依赖的第三方脚本(统计、埋点) |
defer?因为打包后通常就只剩一两个大 JS 文件,它必须等 HTML 解析完再执行——否则一执行就报「找不到 #root」,因为那时候 HTML 还没解析到那个节点。defer 正好解决这个:等 DOM 都在了,再开始执行 React 挂载。defer 会不够用?如果你的 JS 不依赖解析完成的 DOM,而且体积小、要尽快执行(比如埋点、字体加载脚本),用 defer 就晚了半拍。但反过来,用普通阻塞 script 让整个页面等他下载,是更糟的选择。结论:默认用 defer,只有明确需要「不等 DOM 就要跑」才用 async,普通阻塞基本淘汰。/fde/ 这套知识库完全不用 JS——这是最省的选择:没有 script 标签,就没有「JS 卡住 HTML」这回事。爬虫也完全不受影响。这是整篇最重要的一节。它直接解释了「为什么你 84 个 URL 在服务器看来是同一个文件」。
| 服务端路由(传统) | 客户端路由(SPA) | |
|---|---|---|
| 点导航时 | 浏览器重新向服务器请求一个新 URL → 服务器返回另一个 HTML 文件 → 整个页面重新加载 | 不重新下载 HTML。只是 JS 里的路由代码判断「哦,这是 /bt/」,然后把原来那块组件换成另一块组件。地址栏变了,页面内容换了,但一个字节的 HTML 都没重新下 |
| 服务器收到什么 | 一个真实存在的文件(/bt/index.html) | 永远都是那一个 index.html |
| 服务器知道你在哪个路由吗 | 知道——因为它是不同的文件 | 不知道。它只看到「有人来要 index.html」,完全不知道你要 /bt/ 还是 /ai/ |
| 好处 | 简单、可缓存、可分享(URL 就是文件) | 切换快(不用重下 HTML) |
| 坏处 | 每次切换都要重新下载 | 爬虫看到的是同一个空壳 |
磁盘上真实存在的东西: /fde/index.html ← 真的文件 /fde/14-request.html← 真的文件 /fde/15-monorepo.html ← 真的文件 ... 主站磁盘上真实存在的东西: /index.html ← 就这一个文件! 磁盘上没有 /bt/index.html,也没有 /ai/index.html 浏览器访问 memberhandbook.cn/bt/ 时: ①nginx:磁盘上没有 /bt/ → 走try_files 兜底 → 返回 /index.html ② 浏览器下载 index.html(3120字节空壳)+ JS ③ JS 执行,根据地址栏上的 /bt/ 决定渲染哪个组件 ④ 爬虫停在第 ① 步:拿到的是空壳,后面的它不执行
try_files $uri $uri/ /index.html 这条兜底规则——不然所有前端路由都会 404。这条规则同时解释了「为什么主站必须预渲染」:nginx 只会递文件,它没法帮你把空壳变成有内容的页面。/fde/:为什么这 12 个页面能被爬到?因为它们是服务端路由——每个 URL 对应磁盘上一个真实文件,nginx 第一步就命中了,不需要走兜底、不需要 JS、正文在文件里。这就是 14-request 里讲的那条规则,在这里得到了验证。预渲染的目标只有一句:让爬虫拿到的那个文件里就有正文。做法有三种。
| 做法 | 怎么做 | 代价 / 限制 |
|---|---|---|
| ① 构建时用无头浏览器跑一遍抓下来 ← 你用的就是这种 | 先正常构建 → 起一个本地服务 → 用无头浏览器(Playwright)逐个访问 URL → 等 JS 执行完 → 把最终 HTML存成静态文件 → 部署这份静态文件 | 构建时间变长;依赖页面内容不依赖真实后端数据(否则抓到的是空数据) |
| ② 用框架自带的 SSR | Next.js / Nuxt 这类框架在服务器上直接把组件渲染成 HTML 再返回 | 要引入框架;要多一套 Node 进程;服务器每次请求都要现算 |
| ③ 静态化分支 | 框架里写「如果是静态内容就生成静态文件」的那条路(Next.js 的静态导出) | 要求内容能被框架静态推导;动态路由的动态段要单独配置 |
# 第1 步:正常构建,产出和线上一模一样的静态资源 npm run build # 第 2 步:用 dist 起一个本地静态服务(不能用 vite dev——dev 模式产物不一样) npx serve dist -l 4321 # 第 3 步:用无头浏览器逐个 URL 访问,等 JS 执行完再存 # 下面这段是示意骨架,参数按你的项目实际情况改 npx playwright screenshot \ --wait-for-timeout=3000 \ --full-page \ http://127.0.0.1:4321/bt/ out/bt.html # 第 4 步(最关键):验证抓到的是正文,不是空壳 wc -c out/bt.html grep -c "根容器里的中文关键词" out/bt.html
dist 而不是开发服务器:dev 模式下模块是按需动态加载的、还带热更新逻辑,抓到的 HTML 和线上不一样。要抓就抓构建产物——构建产物才是你真正要部署的那份东西。const {id} = useParams() 这类逻辑,而渲染要等数据回来才出内容,你在 3 秒后抓,抓到的可能还是骨架。判据很直接:抓下来的文件里如果找不到该页面独有的中文文字,就是抓早了。对策:等一个明确的「内容已出现」标志,而不是死等固定秒数。大白话:React 不是重画整个页面,而是先算清「哪几行文字要改」,再精准地只改那几行。
设想你页面上有 1000 个商品卡片。现在要改第 500 个卡片的价格。朴素做法:把 1000 个卡片全部删掉重建一遍。这必然卡。
reconcile。memo 记忆化(组件参数没变就跳过重算)。你项目里有两套前端:sales-ai 用了 Pinia(Vue 那套),delimao-system 的 React 19 走 tRPC + TanStack Query。它们看起来不一样,但背后是同一条判断规则:这个数据是「服务器来的」还是「界面自己的」。
| 服务端状态 Server State | 客户端状态 Client State | |
|---|---|---|
| 是什么 | 从服务器拿来的:订单列表、SKU 主数据、报价结果、客户资料 | 界面自己的临时状态:弹窗开着没、当前选中的 tab、表单填了没 |
| 特征 | 会过期、会被别人改、可能随时变。你不去重新拉,它就是旧的 | 只跟这一个用户在这一个页面的行为有关。跟服务器无关 |
| 该用啥 | tRPC / TanStack Query(你 delimao-system 的做法) | useState(React)/ Pinia(Vue,你 sales-ai 的做法) |
| 为什么不能混 | 混用会出 bug——见下面的红线 | |
useState 存了下来。然后别人改了数据库,或者你自己在另一个页面改了同一条数据——你切回这个页面,它还是旧的。useState 里的东西只在这个组件活着的时候存在,组件重新挂载时它就回默认值了,它永远不会再问服务器要一次数据。useEffect 判断「我是不是该重新拉了」——这正是上面那个 bug 最容易写出来的地方。useState 存了一份 SKU 列表,然后又用 Pinia 那套思路去手动同步,就会出现两份真相。红线:一个数据只允许有一个「唯一的存放处」——服务器数据在 Query 里,界面状态在 useState 里,不要互相拷贝。Vite 在开发时和构建时做的是完全不同的两件事。你的项目里 Vite 5 / 6 / 8 三个版本都在用,但它们做的事本质一样。
app.a3f9c2.js 这种带随机串的文件名。这是为了缓存策略服务的,下一节讲。| 文件类型 | 缓存策略 | 为什么 |
|---|---|---|
index.html | 不缓存(Cache-Control: no-cache) | 它是「清单」——里面写着「本页要加载哪个 app.a3f9c2.js」。如果它被缓存住了,你发了新版本,浏览器还拿着旧清单去找旧文件,用户永远看不到更新 |
app.a3f9c2.jsapp.7b1e04.css | 永久缓存(Cache-Control: max-age=31536000, immutable) | 内容变了文件名就变,缓存永远不会失效出错。所以可以放心缓存一年——第二次打开页面几乎瞬间加载完 |
assets/kb.css | 看它改不改 | 你这个知识库的 CSS 没有 hash(写死在 HTML 里引用),所以必须用短缓存或加版本号,否则你改了样式、用户浏览器里还是老的 |
/fde/ 这套页面的 assets/kb.css 文件名不带 hash。改了 CSS 之后,如果服务器给的是长缓存,老用户会一直看到旧样式。对策二选一:给 CSS 文件名加版本号(kb.css?v=2),或者把缓存时间设短一点。这条对 12 个页面全都生效。前端慢不要猜,有三个工具能给出确定的答案。按下面顺序做,不要跳。
Chrome 里按 F12 → Lighthouse → 跑一次。只看这三个数就够了,其他都是细节:
F12 → Network → 刷新 → 按大小排序。你要回答的问题是:「首屏必须的文件里,哪个最大?能不能推迟加载它?」
典型发现:一个 900KB 的 JS 里藏着整个图表库/某个 SDK,而首屏根本用不到它——那就把它拆出去、延迟加载。
LCP 大 → 慢的是「最大的那个内容元素」:图太大没压缩、视频没延迟、首屏卡在 JS。CLS 大 → 布局在跳:找没设宽高的图/视频/iframe。TBT 大 → 主线程被 JS 占死:找超大 JS 包和主线程里跑的同步计算。
| 指标 | 它在量什么 | 大白话 | 超标时先查什么 |
|---|---|---|---|
| LCP 最大内容绘制 | 页面里最大的那个内容元素出现用了多久 | 「用户从点开到看到主要内容等了多久」——这是体感到速度的核心 | 首屏那个大图/大视频压了吗?index.html 和主JS 多大?有没有把内容锁在 JS 后面 |
| CLS 累积布局偏移 | 页面加载过程中内容跳来跳去的总量 | 「我正看着呢,页面突然往下蹿了一下」——最烦人的那种体验 | 所有 img / video / iframe 有没有写宽高。你那个摩恩广告片就在这个清单里 |
| TBT 总阻塞时间 | 主线程被 JS 长时间占住的总时间 | 「页面看着有了,但点不动、滚不动」——JS 在阻塞你操作 | 首屏那个巨大 JS 包;有没有在渲染时做重计算(比如同步排 10 万行) |
/fde/ 这套页面的正文是静态 HTML,LCP 天然就很好(不用等 JS)。而主站那种 SPA,LCP 被「等 JS 下载 + 执行」卡住——这就是预渲染要解决的核心指标问题。所以做不做预渲染,看的不只是收录,LCP 也实实在在变好了(真人用户同样在等那个白屏)。Disable cache,然后看「文档」那一项的响应正文有多少字节。3120 字节这个数字就是这么来的——你不需要工具,直接看这一项就能判断「爬虫拿到的是正文还是空壳」。innerHTML 整块替换 React 管的区域这是性能最差、也最容易和 React 打架的一种改法——它会触发 Create + Delete 一整批节点,React 下一次 diff 时发现节点全没了,又会全部重建,两边来回抢。改法:让 React 管这块内容,你只改状态。
按第 02 节的原理:改 width / height / top / margin 会让浏览器重排;改 transform / opacity 只走 Paint/Composite。做位移动画一律用 transform,别用 left/top。
index.html 被长缓存,用户永远看不到更新第 08 节讲过。它是清单,被冻住就等于发布失效。你改了页面、也部署了,但用户浏览器里的旧 index.html 还在,它依然指向旧的 hash 文件名——用户永远看不到新版本,而且你会以为「部署没生效」,白排查半天。
LumenX 就是这个组合:静态导出做得完全正确,但整站 401挡着,爬虫拿到的是登录页。技术是对的,结果是错的。所以静态化做完一定要用真实浏览器 UA 实测一遍字节数和正文采样,不能只看构建成功。
这一条我在第 05 节讲过,但它是这一整套里唯一必须守住的红线:抓完必须做「字节数下限校验 + 正文关键词采样校验」,两个都过才算成功。只判断「文件生成了」「返回 200」是最常见的假成功——因为无头浏览器打开你的站点时,如果 JS 报错、或者数据接口 404,它照样会给你一个 200 的空页面,你保存下来,然后宣布预渲染完成。
transform / opacity/fde/ 里的摩恩广告片同理defer,也知道 async 不保证顺序index.htmltry_files 兜底index.html 不能长缓存(它是清单),带 hash 的 JS/CSS 才能永久缓存/fde/ 的 kb.css 不带 hash,改样式后有老用户看到旧版的真实风险先说机制。浏览器渲染走五步:Parse(HTML 解析成 DOM 树,同时 CSS 解析成 CSSOM 树,合成渲染树)→Style(给每个节点算最终样式)→ Layout(算每个元素多宽多高、放在哪,也就是重排 reflow)→ Paint(画成像素)→ Composite(叠图层,GPU 加速是给这一步的)。其中 Layout 最贵,因为它是级联的——改一个宽度可能引起整页重排。所以做动画要用 transform 和 opacity,这两个只走 Paint 和 Composite;用 width、top 会触发重排。
Layout 贵还有一个直接后果:所有媒体元素必须写宽高。浏览器读到 <img>/<video> 时还不知道它多大,只能先按未知布局,等文件下载完才撑开——页面加载时会跳一截,这就是 CLS。我在 /fde/ 里嵌的摩恩广告视频就写了 width、height 并用 aspect-ratio 占位,就是为了消除这一跳。
再说收录差的原因,是架构不是技巧。主站是纯 React 单页应用,84 个 URL 给爬虫返回的都只是 3120 字节的空壳 HTML,正文全在 <div id="root"> 里,要等 JS 下载并执行完才被创建。很多爬虫不执行 JS,或者不执行完就抓,抓到的就是没有内容的壳。再往深一层:这和客户端路由是同一件事的两面——因为是客户端路由,磁盘上真的只有那一个 index.html,服务器看到的永远是同一个文件,它完全不知道你在 /bt/ 还是 /ai/,所以 nginx 必须靠 try_files $uri $uri/ /index.html 兜底。服务器不是「不知道路径」,是那些文件根本不存在。
我的解法是预渲染,具体流程是:①正常 npm run build 产出静态资源;②用 dist 起一个本地静态服务(不能用 dev 服务器,dev 的产物和线上不一样);③用 Playwright 这类无头浏览器逐个访问 URL,等内容真的出现后,把最终 HTML 存成静态文件;④部署这份静态文件。关键在于构建时就生成好 HTML,服务器只递文件,行为和 SSG 一样。
但预渲染有三个坑,我都踩过:一是抓早了——JS 里读 URL 参数、渲染要等数据,3 秒后抓可能还是骨架;二是动态数据抓不到——构建期那个接口不存在,那一块是空的,但只要核心正文是静态的就够爬虫收录;三是最关键的,抓到文件不等于抓到正文。所以我给每个页面定了「字节数下限」并要求 grep 一个正文独有的中文关键词,两个都过才算成功。只看「文件生成了」「返回 200」是最常见的假成功——无头浏览器打开你站点时,JS 报错、接口 404,它照样给你一个 200 的空页面。
我验证过的结果:geo.memberhandbook.cn 和 /xiaosenlin/ 用预渲染 + llms.txt 已经成功收录;/fde/ 这个知识库是纯静态 HTML,12 个页面都能被爬到。反面教材是 LumenX:Next.js 静态导出做得完全正确,但整站被 Basic Auth 挡住,爬虫拿到的是登录页——静态化做完必须用真实浏览器 UA 实测字节数和正文采样。
另外两点我也会主动说,因为它们决定了真实用户体感和发布是否生效:①React 怎么更新屏幕——不是重画整页,而是虚拟 DOM 上跑 diff 算出「只有第 500 个文本变了」,再精准改那一个节点。但 diff 本身也有成本,所以大列表要虚拟化、高频更新要 memo 和只改 transform;②缓存策略——index.html 是清单,绝不能长缓存,否则用户永远看着旧清单找旧文件,你会以为部署没生效;带 hash 的 JS/CSS 才配永久缓存。顺带一个我自己的隐患:/fde/ 的 assets/kb.css 文件名不带 hash,如果服务器给长缓存,我改样式后老用户会一直看到旧版——这条我记在待办里。
想看一句话版本、浏览器五步曲、CSR/SSR/预渲染/SSG 对比表、fuse.js 选型、Tailwind 好处与代价、坑清单,回速查版 18 · 前端为什么能跑起来。