FDE·技术基础
基础目录
速查版 · 一屏看懂
①请求旅程 ②读懂monorepo ③数据库存东西 ④并发锁事务 ⑤前端为什么能跑 ⑥读懂AI代码
深入版 · 原理到能排障
①请求旅程 ②monorepo ③数据库 ④并发事务 ⑤前端渲染 ⑥读懂AI代码

深入 · 前端为什么能跑起来

深入版:速查版讲「是什么、怎么用」,这一篇讲「为什么浏览器要这么干、出错时怎么救」。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),爬虫拿到的是登录页。

这一篇的所有机制和命令都基于这几个真实站点的行为,没有编造浏览器行为。

01

DOM 到底是什么

DOM = Document Object Model,文档对象模型。大白话:浏览器把 HTML 文本变成一棵「可以伸手去改」的树。

为什么要有它?因为 HTML 文件是文本,文本本身没法被程序修改。但浏览器需要让 JS 能干一件事:把页面上的某个字改成别的。所以浏览器在解析 HTML 时,把每个标签、每段文字都变成一个内存里的对象,再把这些对象用「父子关系」串成树。这棵树就是 DOM。

一棵最小的 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 里就有它,爬虫能看到它,只是里面没有文字。

「往空壳里塞内容」这句话其实不严谨,说清这点很重要。JS 实际做的是「操作节点」,不是「往一个字符串里塞文字」。具体是四类动作:创建(造一个新节点)、修改(改已有节点的文字或属性)、删除(把节点从树上摘掉)、移动(把已有节点挪到别的位置)。

为什么这个区别要紧?因为「创建 / 删除 / 移动」会让整棵树的规模发生变化,浏览器可能要把布局重算一遍;而「只改一段文字」通常只影响那一小块的布局。这直接引出后面的性能问题——所以 React 的核心思路就是:尽量只做「修改」,少做「删除重建」。

一个实用推论:如果你在浏览器里看到「页面结构被清空又重建、闪了一下」,那基本就是代码里在做 innerHTML = ... 这类整块替换。这是性能上最差的一种改法,也是最容易和 React 抢屏幕的一种改法。
02

渲染流水线:Paint 之前还有一步 Layout

速查版讲了五步曲,但真正精确的流水线是五个阶段。第 4 步其实包含两件事:先算位置,再画像素。

阶段 1 · Parse
解析 HTML,生成 DOM 树
同时也在处理 CSS,生成 CSSOM 树(样式树)。DOM 树 + CSSOM 树合成渲染树——这是后面所有步骤的输入。
阶段 2 · Style
算样式(Style Calculation)
给渲染树里每个节点算上最终样式:颜色、字号。这一步是纯计算,还没碰几何。在这一步发现「这个 <a> 没设颜色,继承父级的」——就是它干的。
阶段 3 · Layout
算几何:每个元素该占多宽多高、放在哪
这一步最贵。因为一个元素的位置可能依赖它的父元素、父元素的父元素……要一路往上算。中文叫「重排 / reflow」。CSS 里凡是会改变元素尺寸或位置的属性(宽高、字号、margin、display),改一个都会触发它。
阶段 4 · Paint
画:把每个图层画成像素
浏览器把算好的几何,一个像素一个像素画出来:文字、边框、背景、阴影。注意:此时还没叠图层。
阶段 5 · Composite
叠图层:把几个图层按顺序合起来
浏览器把能独立画的部分(背景、内容、滚动条)分成几个图层,按正确顺序叠起来。所以 GPU 加速是给这一步用的。
为什么说「布局很贵」:因为 Layout 是级联的。你在 JS 里改一个元素的宽度,可能会引起整页重排——因为后面所有元素的位置都得跟着重算。如果每秒改几十次宽高(典型的动画、拖拽),浏览器就会不停重排,页面就卡了。所以动画要优先用只触发 Paint/Composite 的属性(比如 transform、opacity),别用会触发 Layout 的属性(比如 width、top)。

落到你项目上:<video> 必须写宽高

你在 /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)也是同一个道理:让浏览器提前知道尺寸。
03

JS 为什么会「卡住」页面:defer 与 async

速查版说了「遇到 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 解析到哪了❌ 顺序完全不保证互不依赖的第三方脚本(统计、埋点)
为什么 Vite 打包产物默认带 defer?因为打包后通常就只剩一两个大 JS 文件,它必须等 HTML 解析完再执行——否则一执行就报「找不到 #root」,因为那时候 HTML 还没解析到那个节点。defer 正好解决这个:等 DOM 都在了,再开始执行 React 挂载。
什么时候 defer 会不够用?如果你的 JS 不依赖解析完成的 DOM,而且体积小、要尽快执行(比如埋点、字体加载脚本),用 defer 就晚了半拍。但反过来,用普通阻塞 script 让整个页面等他下载,是更糟的选择。结论:默认用 defer,只有明确需要「不等 DOM 就要跑」才用 async,普通阻塞基本淘汰。
你项目里的实践:/fde/ 这套知识库完全不用 JS——这是最省的选择:没有 script 标签,就没有「JS 卡住 HTML」这回事。爬虫也完全不受影响。
04

客户端路由 vs 服务端路由(SPA 的核心,也是 84 个 URL 的真相)

这是整篇最重要的一节。它直接解释了「为什么你 84 个 URL 在服务器看来是同一个文件」。

两种路由,点导航时发生了什么

服务端路由(传统)客户端路由(SPA)
点导航时浏览器重新向服务器请求一个新 URL → 服务器返回另一个 HTML 文件 → 整个页面重新加载不重新下载 HTML。只是 JS 里的路由代码判断「哦,这是 /bt/」,然后把原来那块组件换成另一块组件。地址栏变了,页面内容换了,但一个字节的 HTML 都没重新下
服务器收到什么一个真实存在的文件(/bt/index.html)永远都是那一个 index.html
服务器知道你在哪个路由吗知道——因为它是不同的文件不知道。它只看到「有人来要 index.html」,完全不知道你要 /bt/ 还是 /ai/
好处简单、可缓存、可分享(URL 就是文件)切换快(不用重下 HTML)
坏处每次切换都要重新下载爬虫看到的是同一个空壳

所以 84 个 URL 到底是怎么回事

磁盘上真实存在的东西:
/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 里讲的那条规则,在这里得到了验证。
05

预渲染的实现原理(你正在用的那一种)

预渲染的目标只有一句:让爬虫拿到的那个文件里就有正文。做法有三种。

做法怎么做代价 / 限制
① 构建时用无头浏览器跑一遍抓下来
← 你用的就是这种
先正常构建 → 起一个本地服务 → 用无头浏览器(Playwright)逐个访问 URL → 等 JS 执行完 → 把最终 HTML存成静态文件 → 部署这份静态文件构建时间变长;依赖页面内容不依赖真实后端数据(否则抓到的是空数据)
② 用框架自带的 SSRNext.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
为什么第 2 步要用 dist 而不是开发服务器:dev 模式下模块是按需动态加载的、还带热更新逻辑,抓到的 HTML 和线上不一样。要抓就抓构建产物——构建产物才是你真正要部署的那份东西。
坑 1 · JS 里读 URL 参数时「还没渲染完」
如果页面 JS 里有const {id} = useParams() 这类逻辑,而渲染要等数据回来才出内容,你在 3 秒后抓,抓到的可能还是骨架。判据很直接:抓下来的文件里如果找不到该页面独有的中文文字,就是抓早了。对策:等一个明确的「内容已出现」标志,而不是死等固定秒数。
坑 2 · 动态内容要等
页面要靠接口拿数据才显示的(比如订单列表、报价结果),构建期那个接口是不存在的。抓下来的文件里那一块是空的。对策只有两条:①这部分的抓取单独处理(把真实数据喂进去);②接受它为空——只要核心正文(标题、说明、价格)是静态的就够爬虫收录了。
红线 · 抓到文件就以为成功
必须做字节数下限校验 + 正文采样校验。具体做法:给每个页面定一个「最小合理字节数」(比如主站空壳是 3120 字节,那么抓出来的页面至少要明显大于这个数),再 grep 一个只有正文里才有的中文关键词,两个都过了才算成功。只判断「文件存在」「返回 200」是最常见的假成功。LumenX 就是活例子:静态导出做得完全正确,但整站 401,爬虫拿到的是登录页——文件在,正文不在。
06

React 到底怎么更新屏幕:虚拟 DOM + diff

大白话:React 不是重画整个页面,而是先算清「哪几行文字要改」,再精准地只改那几行。

如果没有虚拟 DOM

设想你页面上有 1000 个商品卡片。现在要改第 500 个卡片的价格。朴素做法:把 1000 个卡片全部删掉重建一遍。这必然卡。

有了虚拟 DOM

第 1 步
先在内存里算出一棵「假页面树」
React 把「第 500 个卡片价格改成 99」这件事先在内存里演算一遍,得到一棵新的假树。这棵树不在屏幕上,只是一份计算草稿。
第 2 步
diff:新旧两棵树比一遍
diff = 找不同。React 逐个节点比对,算出「只有第 500 个卡片的那个文本节点变了」,其余 999 个一模一样。这个比对算法有专门的名字,叫 reconcile。
第 3 步
只把差异部分真正改到真实 DOM 上
浏览器真正收到的一次改动是:把第 500 个文本节点的内容改成「99」。一个节点,不是 1000 个。
类比:改一份 1000 行的文档要改第 500 行。笨办法是重打一遍 1000 行;diff 的办法是只把第 500 行替换掉。React 的价值就是自动帮你做那个「找出第 500 行」的动作。
为什么大列表 / 高频更新还是会卡?因为 diff 本身也有成本。它不是免费的魔法:
① 节点多,比对本身就慢。一个列表渲染 10 万个节点,diff 要跑 10 万次,页面直接卡死。
② 高频更新会把成本放大。每秒 setState 60 次,就是 diff 跑 60 遍。
所以真实项目里要上「虚拟化」(只渲染当前屏幕能看到的那几十行,滚动了再渲染下一批——你项目里没有图表库也没有大数据表格,所以暂时用不上)和 memo 记忆化(组件参数没变就跳过重算)。
记忆一句:虚拟 DOM 解决的是「改得多」的问题(从1000 个改成 1 个);它没有解决「节点本身太多」和「更新太频繁」的问题。所以列表长到几千行就要虚拟化,动画就要改用只触发 Paint 的属性。
07

状态放哪里:服务端状态 vs 客户端状态

你项目里有两套前端: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——见下面的红线
混用会出什么样的 bug(这个必须讲清)
症状:你把一份服务器数据先用 useState 存了下来。然后别人改了数据库,或者你自己在另一个页面改了同一条数据——你切回这个页面,它还是旧的。
原因:useState 里的东西只在这个组件活着的时候存在,组件重新挂载时它就回默认值了,它永远不会再问服务器要一次数据。
正确的做法:服务器数据就放在 tRPC / TanStack Query 手里,它自带缓存、自带失效(invalidation)、自带重新拉取的时机。你只要在「数据变了」的时候让它重新拉一次,页面就是对的。
一句话记法:useState 用来记「用户在这个页面干了什么」,不要用它记「服务器上现在是什么」。
为什么 tRPC 比手写 fetch 好用(正好对上这一节):它把「拉数据、缓存、什么时候该重新拉、类型对不对」这几件事统一管掉了。你不用自己写 useEffect 判断「我是不是该重新拉了」——这正是上面那个 bug 最容易写出来的地方。
你项目的一个反面参照:如果你在 React 19 项目里用 useState 存了一份 SKU 列表,然后又用 Pinia 那套思路去手动同步,就会出现两份真相。红线:一个数据只允许有一个「唯一的存放处」——服务器数据在 Query 里,界面状态在 useState 里,不要互相拷贝。
08

构建工具做了什么(Vite)

Vite 在开发时和构建时做的是完全不同的两件事。你的项目里 Vite 5 / 6 / 8 三个版本都在用,但它们做的事本质一样。

开发时 · dev
按需编译 + 改文件热更新
传统打包工具要先把整个项目打一遍才能启动。Vite 不打包——它启动时只做一个极简的壳,你访问哪个页面,它才临时把那个页面用到的模块转成浏览器能执行的代码。所以启动是秒级的。
改文件热更新(HMR):你保存一个 CSS,它只用网络推送告诉浏览器「这个 CSS 变了」,整页不刷新,JS 状态都还在。这就是你改 Tailwind 样式时页面不会跳回首页的原因。
构建时 · build
打包压缩 + 代码分割 + 文件名加 hash
① 打包压缩:把几十个模块合成少数几个文件,去掉空格和注释、缩短变量名 → 下载体积大幅下降。
② 代码分割(Code Splitting):把不常用的代码拆成单独的文件,用到才下载。
③ 文件名加 hash:产出 app.a3f9c2.js 这种带随机串的文件名。这是为了缓存策略服务的,下一节讲。
为什么文件名要加 hash?——这是缓存策略的关键
因为内容变了文件名就变了,所以浏览器可以放心地永久缓存这些带 hash 的文件:文件变了名字也变,浏览器自然会发现是「新文件」去重新下载;文件没变名字就没变,浏览器直接用缓存。
对应的服务器设置就是这套组合:
文件类型缓存策略为什么
index.html不缓存(Cache-Control: no-cache)它是「清单」——里面写着「本页要加载哪个 app.a3f9c2.js」。如果它被缓存住了,你发了新版本,浏览器还拿着旧清单去找旧文件,用户永远看不到更新
app.a3f9c2.js
app.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 个页面全都生效。
09

性能排查:可执行的三步

前端慢不要猜,有三个工具能给出确定的答案。按下面顺序做,不要跳。

1

先测:Lighthouse 看三个核心指标

Chrome 里按 F12 → Lighthouse → 跑一次。只看这三个数就够了,其他都是细节:

2

再看:Network 面板找「最大的那个文件」

F12 → Network → 刷新 → 按大小排序。你要回答的问题是:「首屏必须的文件里,哪个最大?能不能推迟加载它?」
典型发现:一个 900KB 的 JS 里藏着整个图表库/某个 SDK,而首屏根本用不到它——那就把它拆出去、延迟加载。

3

最后:对着三个数字找原因

LCP 大 → 慢的是「最大的那个内容元素」:图太大没压缩、视频没延迟、首屏卡在 JS。CLS 大 → 布局在跳:找没设宽高的图/视频/iframe。TBT 大 → 主线程被 JS 占死:找超大 JS 包和主线程里跑的同步计算。

三个指标各是什么意思(大白话版)

指标它在量什么大白话超标时先查什么
LCP
最大内容绘制
页面里最大的那个内容元素出现用了多久「用户从点开到看到主要内容等了多久」——这是体感到速度的核心首屏那个大图/大视频压了吗?index.html 和主JS 多大?有没有把内容锁在 JS 后面
CLS
累积布局偏移
页面加载过程中内容跳来跳去的总量「我正看着呢,页面突然往下蹿了一下」——最烦人的那种体验所有 img / video / iframe 有没有写宽高。你那个摩恩广告片就在这个清单里
TBT
总阻塞时间
主线程被 JS 长时间占住的总时间「页面看着有了,但点不动、滚不动」——JS 在阻塞你操作首屏那个巨大 JS 包;有没有在渲染时做重计算(比如同步排 10 万行)
你这一类站点的关键指标只有一个:LCP。因为 /fde/ 这套页面的正文是静态 HTML,LCP 天然就很好(不用等 JS)。而主站那种 SPA,LCP 被「等 JS 下载 + 执行」卡住——这就是预渲染要解决的核心指标问题。所以做不做预渲染,看的不只是收录,LCP 也实实在在变好了(真人用户同样在等那个白屏)。
一个额外的免费检查:F12 → Network → 勾上 Disable cache,然后看「文档」那一项的响应正文有多少字节。3120 字节这个数字就是这么来的——你不需要工具,直接看这一项就能判断「爬虫拿到的是正文还是空壳」。
10

常见坑 / 红线(深入版补充)

坑 5 · 用 innerHTML 整块替换 React 管的区域

这是性能最差、也最容易和 React 打架的一种改法——它会触发 Create + Delete 一整批节点,React 下一次 diff 时发现节点全没了,又会全部重建,两边来回抢。改法:让 React 管这块内容,你只改状态。

坑 6 · 用会触发 Layout 的属性做动画

按第 02 节的原理:改 width / height / top / margin 会让浏览器重排;改 transform / opacity 只走 Paint/Composite。做位移动画一律用 transform,别用 left/top。

坑 7 · index.html 被长缓存,用户永远看不到更新

第 08 节讲过。它是清单,被冻住就等于发布失效。你改了页面、也部署了,但用户浏览器里的旧 index.html 还在,它依然指向旧的 hash 文件名——用户永远看不到新版本,而且你会以为「部署没生效」,白排查半天。

坑 8 · 静态导出 + Basic Auth 的组合

LumenX 就是这个组合:静态导出做得完全正确,但整站 401挡着,爬虫拿到的是登录页。技术是对的,结果是错的。所以静态化做完一定要用真实浏览器 UA 实测一遍字节数和正文采样,不能只看构建成功。

红线 · 预渲染抓到空 HTML 却以为成功

这一条我在第 05 节讲过,但它是这一整套里唯一必须守住的红线:抓完必须做「字节数下限校验 + 正文关键词采样校验」,两个都过才算成功。只判断「文件生成了」「返回 200」是最常见的假成功——因为无头浏览器打开你的站点时,如果 JS 报错、或者数据接口 404,它照样会给你一个 200 的空页面,你保存下来,然后宣布预渲染完成。

✓

自查清单

  • ✓我能解释 DOM 是什么,并且知道「往空壳里塞内容」不严谨——实际是创建/修改/删除/移动节点
  • ✓我能背出渲染五阶段:Parse → Style → Layout → Paint → Composite
  • ✓我知道布局很贵,改宽度会引起整页重排;动画要改用 transform / opacity
  • ✓我知道所有 img / video 都要写宽高(CLS 的根因),/fde/ 里的摩恩广告片同理
  • ✓我认得三种 script 写法,知道 Vite 产物默认带 defer,也知道 async 不保证顺序
  • ✓我能解释客户端路由为什么让 84 个 URL 在服务器看来都是同一个 index.html
  • ✓我知道服务器不是不知道路径,是磁盘上真的没有那些文件,所以必须 try_files 兜底
  • ✓我知道预渲染要用构建产物 + 无头浏览器,不是 dev 服务器
  • ✓我能说出预渲染的三个坑:读 URL 参数时还没渲染完、动态数据抓不到、抓完必须校验字节数
  • ✓我知道虚拟 DOM+diff 解决的是「改得多」,解决不了「节点太多」和「更新太频繁」(所以要虚拟化 / memo)
  • ✓我能区分服务端状态(tRPC / TanStack Query)和客户端状态(useState / Pinia),并说出混用的 bug
  • ✓我知道 Vite dev 按需编译+热更新,build 打包+分割+加 hash
  • ✓我知道index.html 不能长缓存(它是清单),带 hash 的 JS/CSS 才能永久缓存
  • ✓我知道/fde/ 的 kb.css 不带 hash,改样式后有老用户看到旧版的真实风险
  • ✓我能解释 LCP / CLS / TBT 三个指标各自在量什么,并且知道超标时先查什么
  • ✓我守住的红线:预渲染必须做字节数下限校验 + 正文采样校验
面试怎么答

题目:讲讲前端是怎么把页面画出来的?为什么你的 SPA 收录差?预渲染具体怎么做的?

先说机制。浏览器渲染走五步: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 · 前端为什么能跑起来。