速查版:一屏看懂前端到底在干什么、浏览器拿到文件之后经历了什么、React 凭什么能把页面画出来,以及最关键的一件事——为什么你的主站爬虫只拿到 3120 字节空壳,而 /fde/ 静态页能被爬到。
这个对比是你全项目最值钱的一条前端事实:
主站 memberhandbook.cn:纯 React 单页应用(React 19 + TS + Vite + Tailwind),84 个 URL 给爬虫只返回 3120 字节空壳 HTML——内容全在 <div id="root"></div> 这个空壳里,靠 JS 执行后才有。
你自己这个 /fde/kb/ 知识库:纯静态 HTML,不依赖任何 JS 就有正文,所以能被爬到、能被收录。
同一个域名、同一个 nginx、同一套服务器,收录结果天差地别。差别不在 SEO 技巧,只在架构。这一篇就讲清这个差别是怎么来的。
顺便一个已经验证的正面案例:geo.memberhandbook.cn 和 /xiaosenlin/ 用「预渲染 + llms.txt」已经成功被收录,证明这条路在你的服务器上是走得通的。
前端就是「发一堆文件给浏览器,让它自己把页面画出来」。
没有「前端」这回事的时候,服务器给浏览器的是已经画好的成品——一张报纸。现在不是了:服务器给的是图纸(HTML 骨架 + CSS 样式 + JS 指令),浏览器在你这台机器上把图纸变成实物。
浏览器拿到 index.html 之后,不是「显示出来」这一下,而是五个阶段。每个阶段都在干一件事。
<script src="..."> 时,会暂停对 HTML 的继续解析,去下载那个 JS 文件,下载完立刻执行。JS 执行完,才继续往下解析 HTML。<div id="root"></div> 在 HTML 里是空的,React 这个 JS 执行之后,才一句一句把标题、段落、按钮创建出来、塞进那个空壳里。这套东西叫 DOM 操作。/fde/ 没有这个问题?因为它的正文就写在 HTML 里。第 1 步就已经有内容了,不依赖第 3、4 步的 JS。这就是「静态页 vs 单页应用」最实际的差别。你只管说页面长什么样,我负责同步到屏幕上。
这是 React 最准的一句话翻译。它的核心承诺是:你不用手动去改那个空壳,你只描述「现在页面应该是什么样子」,状态一变,它自己把屏幕同步过来。
| 词 | 大白话 | 在 /fde/ 这个知识库里对应什么 |
|---|---|---|
| 组件 Component | 可复用的积木。一块积木是一个组件,页面是很多积木拼起来的。积木之间可以互相嵌套 | 每个知识页里的 .card 卡片、每个 .sec-head 标题条,都可以看成一个「组件」在复用 |
| 状态 State | 会变的数据。「现在订单是空的」「用户已登录」「弹窗开着」——这些都是状态 | 页面上「已读/未读」的标记、当前展开的章节,都是状态 |
| 渲染 Render | 状态变了 → 自动把用到这块状态的地方重画一遍。你不用通知它 | 你点开一章,那一章的正文从折叠变成展开——不是你手动画的,是状态变了自己重画的 |
// 这就是 React 的全部核心思想
import { useState } from 'react'
function Counter() {
// 状态:初始值 0
const [n, setN] = useState(0)
return (
<button onClick={() => setN(n + 1)}>
点了 {n} 次
</button>
)
}
这一段里最值钱的是「状态变了会自动重画」这件事:你调 setN(n+1) 之后,没有一行代码手动去改按钮上的文字——但屏幕上就是从「点了 0 次」变成了「点了 1 次」。
document.getElementById(...).innerHTML去改React 管的区域,那就是两套系统在抢同一块屏幕——出 bug 的时候你会怀疑人生。这四个词被混用得厉害,但它们的差别只在一件事上:HTML 是什么时候被变出来的。
| 方式 | 什么时候生成内容 | 爬虫能不能读到正文 | 你项目里的例子 |
|---|---|---|---|
| CSR 纯客户端渲染 | 浏览器执行 JS 之后才生成 | ❌ 读不到(除非它肯等 JS) | 主站 React SPA——84 个 URL 给爬虫只返回 3120 字节空壳 |
| SSR 服务端渲染 | 服务器每次收到请求时现场生成 | ✅ 能 | 你项目里没有纯 SSR 的服务(这是事实,也说清它为什么不是你现在的选择) |
| 预渲染 | 构建时就生成好静态 HTML,服务器只递文件 | ✅ 能 | 你已经用预渲染让 geo.memberhandbook.cn 和 /xiaosenlin/ 成功收录了 |
| SSG 静态生成 | 构建时生成,每个 URL 一个真实 HTML 文件 | ✅ 能 | /fde/ 就是这个——你打开这个页面时,正文已经在文件里了 |
这不是「SEO 技巧没做好」,而是架构选择必然带来的结果。因果链只有三步:
<div id="root"></div> ——正文一个字都没有。它看到的是「这个页面上没有内容」,于是判定这个页面没有价值,收录就差。meta、改 title、加 keywords 来解决——那些都在空壳里,爬虫本来就能读到。真正能解决的只有一件事:把内容从 JS 里搬到 HTML 里。index.html,所以 nginx 必须靠 try_files $uri $uri/ /index.html 兜底——它不是不知道你在哪个路由,它是把同一个文件递给了所有 84 个 URL。geo.memberhandbook.cn 和 /xiaosenlin/ 就是这么成功收录的);② 直接写静态页——就像 /fde/ 这样。两条的本质是一样的:让爬虫一拿到文件就有正文。lumenx.memberhandbook.cn,但整站被 Basic Auth 挡住(401)。静态导出做得再对也没用——爬虫拿到的是登录页,不是正文。这就是为什么必须用真实浏览器 UA 去实测字节数和内容采样,返回 200 完全不等于内容对。前端搜索 = 把所有内容下载到浏览器,然后在本地查。不问服务器。
你 2026-07-05-task-72/geo-learning 用的就是这条路:React 19 + Vite 8 + Tailwind 4 + fuse.js + react-markdown + oxlint。fuse.js 是一个「模糊搜索」库——用户搜「洗浴」也能命中「淋浴」,因为它算的是相似程度而不是精确相等。
| 做法 | 数据在哪 | 搜索在哪算 | 适合 |
|---|---|---|---|
| 前端搜索 (fuse.js) | 打包进JS,全部下载到用户浏览器 | 浏览器本地,输入即出结果,零等待 | 几千条以下。你这个知识库、工具箱完全够用 |
| 后端搜索 | 在服务器数据库里 | 发请求到服务器,等返回 | 上万条,或者内容不能公开(比如订单、客户资料) |
geo-learning 用 fuse.js 是对的?因为它要搜的内容(几十到几百篇知识文档)本来就是公开的静态内容。全量下载一次之后每个用户都能瞬间搜,不需要服务器、不会因为搜索把服务器打挂、离线也能用。不写一堆自定义 CSS,直接在 HTML 里写类名(class="flex items-center"),样式由框架按需生成。
你的真实分布是:React 85 处、Vue 74 处、Vite 8(多版本)、Tailwind 3 和 4 都有——Tailwind 在你 15 个项目里都是主力样式方案。这不是个人偏好,是它对「一个人做很多项目」这个场景特别合适。
| 好处 | 具体是什么意思 |
|---|---|
| 改样式不用翻 CSS 文件 | 颜色、间距、字号就写在用到它的地方。想改成圆角,搜 rounded 就找到全部了。不存在「这个类在哪定义的」这种问题 |
| 不可能有类名冲突 | 普通 CSS 里你自己起 .header,别人起一个 .header,两个就撞了、样式互相覆盖。Tailwind 的类名自带约束语义(px-4 就是「上下左右各 1rem内边距」),两个同名类含义必然一样,不会打架 |
| 删组件能删干净 | 用普通 CSS 时,删掉一个组件,它的样式还留在文件里(没人敢删,怕别处在用)。Tailwind 的样式跟着组件一起被删走 |
| 不用维护设计系统 | 一个人做十几个项目,没时间先建一套设计规范再开工。Tailwind 相当于「先用起来,规范以后再说」 |
| 代价 | 说明 |
|---|---|
| 类名长 | class="flex items-center justify-between px-4 py-2 text-sm text-slate-700" —— 一行里塞了七八个类名,视觉上是「噪音」 |
| 初学者不直观 | 新同学问「这个 flex 是啥意思」,得答「它在传统 CSS 里叫 display:flex」。门槛比手写 CSS 高 |
| 没有语义化类名 | HTML 里全是 div + 一堆工具类名,从标签上看不出这块是导航还是正文。所以你的知识库才需要 .card .hero .note 这类自定义类来补语义 |
这是你主站 84 个 URL 只返回 3120 字节空壳的直接原因。判断方法很简单:把 JS 关掉,看这个页面还有没有内容?没有内容,爬虫就一定读不到。所有要对外被读到的文字,都必须在 HTML 里。
浏览器读到图片时还不知道它多宽多高,就先按「不知道」布局,等图片下载完才撑开——页面会突然往下移一截。这叫 CLS(布局偏移)。你在 /fde/ 页里嵌的摩恩广告视频就是典型:不写 width/height,它加载时页面就跳。对策很简单:所有 <img>、<video> 都写死宽高或写比例占位。
一个把所有页面打成一个巨大 JS 的包,用户为了看第一屏,要先下载整个大文件。代码分割就是「把不常用的拆出去,用到才下载」。反过来也提醒一句:分割不能拆太碎,几十个碎片来回请求反而更慢。
有些项目为了减少请求数,把 CSS 塞进 JS 里。如果 JS 加载失败或者被延后执行,样式就来不及生效——页面会短暂显示成「完全没样式」的样子。关键样式不要依赖 JS 执行才生效。
这是你交过学费的一条。返回 200 只说明「请求成功了」,不代表返回的是正文。三个真实的例子:①主站 200 + 3120 字节空壳;②LumenX 200 但整站 Basic Auth(401),爬虫拿到登录页;③预渲染 如果抓早了,抓到的也是空 HTML。所以验收必须做两件事:看字节数 + 采样正文里有没有你写的那些字。
geo.memberhandbook.cn 和 /xiaosenlin/ 上验证成功根因是架构,不是技巧。我的主站 memberhandbook.cn 是纯 React 单页应用,84 个 URL 给爬虫返回的都只是 3120 字节的空壳 HTML——所有正文都要等 JS 执行后才被创建到 <div id="root">
我做了三层对比验证:①主站 React SPA——84 URL / 3120 字节空壳,收录差;②预渲染的子站——geo.memberhandbook.cn 和 /xiaosenlin/,用构建时无头浏览器跑一遍把每个 URL 的结果抓下来存成 HTML,再配 llms.txt,已经成功被收录;③纯静态页——/fde/ 这个知识库,正文直接写在 HTML 里,不依赖任何JS,所以能被爬到。同一个域名、同一套 nginx,结果天差地别,说明差别只在架构。
方案上我选了预渲染/SSG,而不是 SSR。因为我的内容是固定的(工具页、产品页、知识页),不需要每次请求现算。预渲染拿到了 SSR 的全部好处(爬虫能读正文),却没有 SSR 的代价(每次请求占服务器 CPU、要多一套 Node 进程)。本质只有一个:把内容从 JS 里搬到 HTML 里。
我踩过的坑我也说清楚:预渲染不是抓下来就成功。有两个坑——一是JS 里读 URL 参数时可能还没渲染完,抓早了存下来还是空壳;二是抓完必须校验字节数下限,别以为抓到文件就等于抓到正文。所以我的验收标准是看字节数 + 采样正文里有没有那些关键文字,而不是只看返回 200。LumenX 就是反面例子:Next.js 静态导出做得再对,整站被 Basic Auth 挡住(401),爬虫拿到的是登录页。
另外我也清楚 SPA 的代价不只体现在收录上:首屏有一个白屏期,因为要等 JS 下载执行完才出内容;图片和视频不写宽高会造成布局跳(CLS)——/fde/ 里的摩恩广告视频就必须写 width/height。这些都不是玄学,是渲染流水线决定的。所以我的原则是:内容/收录/首屏必须在 JS 之前的,用静态或预渲染;必须靠交互才出来的(后台系统),才用单页应用。
上面是速查版,只讲「是什么、怎么用」。DOM 到底是什么、渲染流水线五阶段、JS 阻塞与 defer/async、客户端路由为什么让 84 个 URL 其实是同一个 index.html、预渲染的三个坑、虚拟 DOM + diff、状态该放哪里、Vite 构建做了什么、Lighthouse 怎么排查都在深入版: