这些年一直在写业务代码,对"网页到底是怎么出现在屏幕上的"只有一个模糊印象。最近从零学 Next.js,用 6 天时间把这个问题亲手验证了一遍。这篇文章记录过程和实验数据——不是抄文档,每一步都有我可复现的证据。
文章按一次真实访问的顺序走:页面是什么时候做出来的 → 服务器发来的是哪几样东西 → 点链接为什么不用整页刷新。
第一幕:页面是提前"做"好的
以前我以为网页是"访问时才生成的"——敲下网址,服务器现算一份 HTML 发回来。实际在 Next.js 里,大多数页面是提前"预制"好的:构建时就把 HTML 生成好存着,用户来了直接给现成的,打开飞快。前提是这页内容不因人而异。
我动手做的第一实验:通过读取cookie信息,在页面上显示当前访客的"主题偏好",我在页面里加了一行读 cookie 的代码,然后 build。
结果不是编译通过,而是直接报错退出,原文如下:
Error: Route "/": Next.js encountered uncached or runtime data during prerendering.
Ways to fix this:
- [stream] Provide a placeholder with <Suspense fallback={...}> around the data access
- [cache] For uncached data: cache the access with "use cache"
- [block] Set export const instant = false to allow a blocking route
原因一想就通:预制的时候根本没有访客,cookie 从哪读?这页内容因人而异,没法预制了。
有意思的是,报错没替我做决定,而是给了三条路,每条都标了名字和做法:
| 报错给的路 | 一句话做法 | 代价 |
|---|---|---|
| stream(流式) | 页面架子照常预制好,cookie 那块留个"加载中"占位,数据到了再补进去 | 那一小块晚到一会儿 |
| cache(缓存) | 读一次存一份,一段时间内后面所有访客直接用存的 | 数据有一点点旧 |
| block(阻塞) | 放弃预制,每个访客来了现做 | 最慢,服务器开销最大 |
每条路具体怎么写,也就几行代码的事:
stream(流式)——把读 cookie 的那部分包进 Suspense,给它一个加载中的占位:
<Suspense fallback={<p>加载中…</p>}>
<ThemeLabel /> {/* 这个组件里读 cookie */}
</Suspense>
cache(缓存)——在读取数据的函数顶部加一行指令,告诉 Next 这结果可以存:
'use cache'
block(阻塞)——页面文件顶部导出一个常量,宣布"这页我放弃预制":
export const instant = false
三条路都不难写,就是选一个:想快一点的用流式,想新一点的用缓存,懒得折腾的直接阻塞。
第一次碰到框架用报错逼你做性能决策,不选清楚不让过。
第二幕:服务器发来的不是"一个网页",是三样东西
打开一个网页,服务器实际发来三份东西:
- HTML(约 4.3KB):页面本体,你看得见
- RSC payload(约 4KB):一份"组装说明书"。HTML 只记录页面长什么样;这份说明书记录"页面由哪些组件组成、每个组件的代码去哪个文件拿",藏在 HTML 里一起送到,给浏览器里的 React 用来组装页面
- JS chunks(约 600KB):交互代码。浏览器先拿到 HTML 把页面显示出来,然后才发现"还需要这些 JS",再回头去下载——所以它们总是最后到。下完之前,页面看得见、点不动。
为了验证"哪些代码真的发货了",我做了标记实验:在服务端组件和客户端组件里各写一句标记,然后分别在三份产物里搜。
先解释这两个词:服务端组件 = 只在服务器上运行的组件,默认就是这种;客户端组件 = 要在用户浏览器里运行的组件,文件顶部标一行 'use client' 才是。
| 标记写在哪 | HTML | 说明书 | JS chunks |
|---|---|---|---|
| 服务端组件 | ✅ | ✅ | ❌ 搜不到 |
| 客户端组件 | ✅ | ❌ | ✅ |
结论很干脆:服务端组件的代码一行都不进浏览器——这就是 RSC 省体积的机制本体。客户端组件的代码必须发货,因为它要在浏览器里执行(这个过程叫"水合":JS 加载完接管页面,按钮才开始响应点击)。
第二幕·续:亲手把代码搬出零件箱
知道"服务端组件代码不发货"之后,我立刻想:demo 里有没有"不用电却跟着发货"的代码?
还真有。我的聊天窗口整个标了 'use client'(它需要 state 管理消息列表,必须通电),但里面住着一堆不需要通电的东西:页面标题、布局外壳。它们跟着零件箱白白发货。
怎么判断哪些能搬?我总结了三问:
- 有 state / hooks 吗?
- 有事件吗(onClick、onChange)?
- 有浏览器 API 吗(window、document)?
三个都没有 → 可以下沉;有任何一个 → 动不了。
具体做法,就是把这段代码从要打包的文件搬到不打包的文件:
// 搬之前:标题和外壳住在 ChatWindow.tsx(要通电 → 代码发货)
return (
<div className="flex flex-col h-screen ...">
<h1>AI Chat Demo</h1>
...
// 搬之后:它们住进了 page.tsx(服务端组件,不打包)
// ChatWindow 只留消息列表和输入框
return (
<>
...
验证搬家成功,我用四个检查(缺一个都可能把"改坏"当"成功"):
① build 后页面还是 ○(静态性没丢)
② grep 零件包:搜不到标题了(不发货了)
③ grep page.tsx:标题还在项目里(区分"搬走"和"删了"的关键)
④ 打开页面:标题在、布局没塌
结果:零件包 612K → 604K。
老实说 8K 在这个小 demo 里微不足道——这个练习的价值不在省下的体积,在于跑通了"发现 → 搬走 → 验证"的完整流程。真实项目里,被误标 'use client' 的静态内容往往几十 KB 起步。
第三幕:点链接切页面,为什么这么快
传统网页点链接 = 整页刷新,白屏、全部重新下载。
Next.js 的做法是提前下载:链接一进屏幕,浏览器就在后台悄悄把目标页面的数据拉下来了,用户毫无感知。这个行为叫预取(prefetch)。
我开了 Network 面板实测:
- 链接出现在屏幕上(我什么都没点),浏览器就悄悄发出了对该页面的预取请求
- 预取还分段:一个请求拿页面外壳,一个拿页面内容(请求头
Next-Router-Segment-Prefetch里能看到这两个名字) - 我点击链接那一刻,新增网络请求数:0——拿缓存的数据,局部替换页面
还挖到一个文档和现实的差距:官方说"50 个商品链接共享页面外壳,只下载一次",但我在 16.4 上实测同屏两个链接,外壳照样各下载一次。用 curl 重放请求做 diff 后才发现:外壳响应只是一副"骨架"(几百字节的结构清单),真正的内容按内容指纹复用。请求次数不等于传输量——文档和实测各对了一半。