这是该系列文章“React 开发者的网络性能”的第三篇文章。
在之前的几篇文章中,我们了解了初始加载性能指标的基础知识,什么是CSR(客户端渲染),它的流行原因,以及性能火焰图的记录、解读和理解方法。我们还了解到客户端渲染的两个主要缺点:对初始加载有负面影响,并且在没有JavaScript的环境中无法运行。
在这篇文章中,我们将通过引入另一种渲染模式——SSR(服务器端渲染)及其变体如预渲染和SSG(静态站点生成)——来解决这些不足之处。我们还将通过一个学习项目,借助它来实现一个简约而美观的网站,然后查看其成本及解决了什么问题,接着实现完整的SSR,评估其性能影响,并讨论SSR的成本,最后快速实现SSG,以应用到该网站。
就要来劲儿啦!
为什么没有JavaScript的环境这么重要
我们先从没有JavaScript的环境说起。这可能是个最让人摸不着头脑的问题。现在有谁会禁用浏览器里的JavaScript呢?没有它,几乎什么都玩不转。大多数人压根不知道JavaScript是啥,更别说去禁用了。对吧?
答案在于“人”这个词。更准确地说,在于真正的人并不只是能访问你的网站的唯一群体。在这一领域,两个主要参与者是:
- 搜索引擎爬虫,尤其是谷歌的爬虫。
- 各种社交媒体和即时通讯软件的“预览”功能,如消息预览和图片预览。
它们都以类似的方式工作。首先,它们以某种方式获取了你网站页面的链接。这通常发生在用户尝试在社交媒体上分享链接时,或者当搜索引擎爬虫无目的地在网上爬行,遍历无数公开页面时。这就是它们被称为爬虫的原因。顺便说一句,这就是为什么它们被称为“爬虫”的原因。
第二,机器人将请求发送到服务器并接收HTML,就像浏览器最初那样。
第三,从这个HTML中,他们提取并处理所需的信息。搜索引擎提取文本、链接、元标签等内容。基于此,他们创建搜索索引,使页面可被搜索引擎索引。社交媒体预览器则抓取元标签,生成我们熟悉的漂亮预览,通常包括大图片、标题,有时还有简短描述。
最后,第四……其实有时候根本没有第四点。仅此而已。只有纯HTML,没有JavaScript。因为用JavaScript正确渲染页面意味着机器人需要启动浏览器,等待页面生成。这在资源和时间上成本很高。因此,并非所有机器人能做到这一点。
在[研究项目]中查看它。下载它并安装所需的依赖项:
运行命令 `npm install` (安装依赖包)
然后构建并运行试试看。
运行构建命令, npm run build
, 启动应用程序, npm run start
.
你可以通过那里切换主页和设置。你会看到页面标题会随着导航改变。“学习项目:主页”是首页的标题,“学习项目:设置”是设置页面的标题。
这个标题是这样通过简单的代码如下通过React注入的。
useEffect(() => {
updateTitle('学习项目:主页');
}, []);
里面就是这样的:
export const updateTitle = (text: string) => {
document.title = text;
};
不过,你可能会注意到它在初始加载时会短暂地“一闪”——这是因为我在index.html
里设定的标题是“Vite + React + TS”,因此这也是服务器返回给我的标题。
现在,让网站对外公开,使用[ngrok](https://ngrok.com/)(或者你有的类似工具)。
使用 ngrok 将 http 3000 端口暴露出去
你可以将生成的 URL 分享到你选择的社交媒体上。在预览中,你会注意到原来的“Vite + React + TS”标题。没有加载任何 JavaScript。
尽管,这并不完全适用于某些爬虫。大多数流行的搜索引擎确实会对JavaScript进行解析。例如,Google 有一个两步过程,它会解析“纯”HTML,还会将页面加入‘渲染’队列,在那里实际启动一个浏览器,加载网站,等待JavaScript渲染完成,然后再次提取所有内容。
然而,这意呸意味着依赖于JavaScript的网站的索引可能会变得速度更慢且预算有限。详情请参阅这里。
那对于你的网站来说就是:
- 至关重要的是要尽快被尽可能多的搜索引擎发现。
- 能在社交媒体平台上分享,并且在分享时看起来也很棒。
然后对于服务器来说,在首次响应时返回“正确的”HTML,并确保其包含所有核心信息是非常重要的。例如:
- 以阅读为主的网站,即各种形式的博客、文档、知识库、论坛、问答网站、新闻媒体等。
- 各种形式的电子商务网站。
- 落地页。
- 等等——基本上你在万维网上能找到的所有东西。
这意味着传统的客户端渲染的SPA,在这里首次HTML响应只是一个空的div,不是一个好主意。
然而,这并不意味着我们需要生气地放弃React。我们还可以先试一试几个解决方法。
服务器预渲染为此,我们需要引入一个服务器。目前,在这个研究项目中,看起来像这样。
app.get('/*', async (c) => {
// 读取并返回index.html文件的内容
const html = fs
.readFileSync(path.join(dist, 'index.html'))
.toString();
// 将html内容返回给客户端
return c.html(html);
});
当服务器收到任何请求时,它只是读取预构建的 index.html
文件,将其转换成字符串后返回给请求者。这基本上就是所有支持单页面应用(SPA)的托管平台为你做的事儿。这并不是我们能直接控制或修改的部分,特别是在使用这类平台时。
然而,要解决“无JavaScript”的问题,我们现在需要修改服务器。幸运的是,改动不大,只需要对一个字符串进行修改。这大大简化了情况。因为我们可以在发送之前修改这个字符串。例如,我们可以将现有的标题替换为“学习项目”。
app.get('/*', async (c) => {
const html = fs
.readFileSync(path.join(dist, 'index.html'))
.toString();
const modifiedHTML = html.replace(
'<title>Vite + React + TS</title>',
`<title>学习项目</title>`,
);
return c.html(html);
});
这稍微好一些,但在实际情况中,标题应根据每一页的变化而变化:像这样固定不变是没有意义的。幸运的是,每个服务器都知道请求来自何处。对于我正在使用的框架(Hono),只需要从 c.req.path
获取它即可。
然后,我们可以根据这条路径生成各种不同的标题。
app.get('/*', async (c) => {
const html = fs
.readFileSync(path.join(dist, 'index.html'))
.toString();
const title = getTitleFromPath(pathname);
const modifiedHTML = html.replace(
'<title>Vite + React + TS</title>',
`<title>${title}</title>`,
);
return c.html(html);
});
在 getTitleFromPath
这个地方,我们可以这样做:
const getTitleFromPath = (pathname: string) => {
let title = '学习计划';
if (pathname.startsWith('/settings')) {
title = '学习计划:设置选项';
} else if (pathname === '/login') {
title = '学习计划:登录页面';
}
return title;
};
这可能需要与实际页面一起部署,甚至直接从页面代码中提取,否则它很快就会与页面不同步。但对于这个研究项目来说,这样已经足够好了。
最后一件事是让它更漂亮一点:在index.html
文件中,我们可以将原来的标题<title>Vite + React + TS</title>
替换为<title>{{title}}</title>
,比如说,并将其变成一个模板。
<html lang="en">
<head>
<title>{{ title }}</title>
</head>
...
</html>;
// 在服务器端这样做。
const modifiedHTML = html.replace('{{title}}', title);
未来,需要的话,我们可以根据需要将其转换成任何模板语言。
当然,我们不仅限于title
标签——我们可以预渲染<head>
中的所有信息,就像这样。这为我们提供了一种相对简单且低成本的解决方案,用于无JavaScript情况下的社交媒体预览功能。它们通常不需要更多的信息。大多数都依赖于Open Graph协议,这只是一个包含各种信息的<meta>
标签集合。
我们甚至可以预渲染整个页面,而不仅仅是元数据标签!但这个部分我们将在下面单独讲解SSR,那里还有好多其他东西可以学。
额外挑战
- 在
backend
目录中,将index
文件的内容替换为backend/pre-rendering-index.ts
文件的内容。 - 在
src/index.html
文件中,将title
标签的内容改为{{ title }}
。 - 将前后端代码重构,以支持社交媒体分享所需的元标签 (查看此列表)。
- 构建项目,并再次通过
ngrok
暴露项目。尝试将登录、主页和设置页面分享到你选择的社交媒体。此时预览应正常工作并显示每个页面的详细信息。 - 额外问题:你认为如何重构项目,以避免客户端和服务器之间重复元标签信息?
我在上面提到过,预渲染元标签相对来说成本较低。这句话具体指的是什么呢?特别是在引入之前的价格和成本相比,它到底有多便宜?
遗憾的是,相比完全静态的SPA,这里并没有任何好消息,情况更糟糕。通过添加一个简单的预渲染脚本,我除了显而易见的增加了复杂性之外,还遇到了两个新问题。
要部署在哪里?第一个问题就是,我现在该把应用部署到哪里呢?在变更之前,我可以将托管成本保持为零很长时间。现在,托管静态资源非常便宜。现在,我需要一台服务器。而且这些通常都挺贵的。
这里最常见的两种解决方案是。
我们可以使用托管提供商的无服务器功能来服务静态资源:Cloudflare 无服务器功能,Netlify 无服务器功能,Vercel 无服务器功能,Amazon Lambdas等。大多数静态资源托管提供商应该都有提供类似的功能。
这里的好处是我们仍然不需要考虑服务器及其维护,而是不需要担心。那些云函数就像是提供商为我们处理的小型服务器。我们的工作是编写代码,它就能神奇地正常工作。对于学习用的项目、一些小众项目、刚开始的项目以及那些没有内建病毒式传播特性的项目来说,云函数将是最佳选择。
云函数通常非常容易配置和部署起来,它们按使用量计费,使用量则是调用端点的实际使用情况。因此,无需担心因周末忘记关闭运行实例而产生意外费用。
不过有个缺点是“按使用量计费”的部分。网站越受欢迎,使用量超出限制的可能性就越大。我读过一些吓人的真实经历,某个项目在 HackerNews 或 TikTok 上突然爆红,从几百个访客突然增加到几百万,结果他一觉醒来发现账单竟然高达 5000 美元。因此,在使用无服务器解决方案时,设定支出上限并密切监控支出,并准备好应对这种情况至关重要。
如果不使用无服务器函数的话,你可以将它保持为一个实际的小节点或者其他类似的东西,并将其部署到任何云平台,比如AWS、Azure、Digital Ocean,或者其他你喜欢的托管服务提供商。
这个解决方案有它的优势。一切都由你说了算。从一种解决方案迁移到另一种解决方案时不需要更改代码,这与无服务器函数不同,后者会让你被某个供应商绑定。价格通常更可预测,更简单,而且随着使用量的增加,价格也不会太高。此外,你可以用任何你喜欢的技术栈,而无服务器函数的选择通常非常有限。
这些不足之处与优点完全相同。你需要监控CPU和内存的使用。要关注系统可观测性。要关注系统的扩展性。内存泄漏会让你夜不能寐。
你还需要考虑地理区域。这引出了在之前纯SPA应用中引入服务器所带来的第二个问题。
有服务器对性能的影响记得那篇关于初始加载性能的文章吗?延迟和内容分发网络(CDN)对初始加载性能的影响?通过引入一个仅仅预渲染元标签的简单服务器,我正在为每个初始加载请求强制增加一次无法缓存且不可避免的服务器交互,无论是新用户还是回访者。
我刚刚让一个单页应用(SPA)的初始加载性能稍微变得更差了,而它的初始加载性能本来就已经不是很好。变得更差的程度将主要取决于服务器部署的位置。
如果它作为无服务器函数部署,情况可能就不会太糟糕。一些提供商会让这些函数“在边缘运行”。也就是说,这些函数会在靠近用户的服务器上运行。这很像静态资源的CDN。在这种情况下,延迟和性能下降都会很小。
然而,如果我选择自行管理的服务器,就无法享受到分布式网络的优势。我需要把它部署到一个特定区域。因此,位于地球另一边的用户就可能真的感受到性能下降的影响。
如果这种性能影响至关重要,你必须采取某种措施来应对。准备好应对复杂的缓存策略,部署到不同区域等。它不再仅仅是一个简单的无服务器前端应用了。现在它更像一个全栈应用,甚至可以称之为后端优先的应用。
在 Vercel/Netlify 上的 Next.js你可能会马上想到一个问题:“我只是用 Next.js 写前端部分,然后部署到 Vercel 或 Netlify。我真的需要知道这些吗?”
这里的答案是,“很遗憾,确实是这样。”因为,那些主打Next.js的托管服务提供商默认就是这样做的:他们将你的应用转换成JavaScript文件和一堆小的无服务器函数。这一切都发生在你不知情且无法控制的情况下。
所以,除非你明确地设置了 Next.js 项目以“静态”方式导出,否则“服务器预渲染的费用”中的所有内容都适用。
额外的挑战
- 如果你有一个“原生支持”部署(即一键部署)到像 Vercel/Netlify 这样的无服务器平台上的 Next.js 应用,试着找出为其创建了多少个函数。
- 这些是“边缘”函数还是普通函数?你能找出它们的使用是如何计算的吗?在达到限制前,你的网站能承受多少访客?
- 如果你有一个既包含“普通”也包含“边缘”函数的应用——你能弄清楚每个函数的功能及其对部署项目的影响吗?
我们再来说说预渲染。在上面提到的,我们只预渲染了元标签,因为这只需用一个字符串替换另一个字符串,非常简单。但我们为什么不能在 <head>
标签之外也进行预渲染呢?让我们看看服务器发送的 HTML 页面中的 <body>
标签里有什么:
<body>标签包含了<div id="root"></div>元素和一个模块脚本,这个脚本的源文件是"./main.tsx"。
还记得客户端渲染是如何工作的吗?当脚本被下载和处理时,React 会将生成的 DOM 元素添加到“根”元素上。那么,如果我返回的不是空的 div,而是带有内容的 div 呢?让我们把它变成一个大红色的块:
<div id="root">
<div style="background:red;width:100px;height:100px;">
大红方块
</div>
</div>
试着把它加到 index.html
,构建项目并启动它,别忘了禁用缓存并降低 CPU 和网络的速度,这样更方便观察。
当你刷新页面时,你应该会看到大红块一闪而过,随后被正常的控制台页面取代。首先的好消息是大红块没有停留——显然,React 在插入内容前清空了“根”div。这在本文中并不重要。
第二个好消息是观察性能图表。现在记下来。结果应该差不多是这样的:
特别注意这里的顺序和时间安排。
最初,它看起来和我们之前看到的火焰图完全一样。首先,服务器返回 HTML,导致“main”部分出现一个蓝色的 HTML 解析块,,这触发了 CSS 和 JavaScript(在“Network”部分显示为黄色和紫色块)的下载。在“Network”部分,你可以看到 CSS 和 JavaScript 的下载分别以黄色和紫色块表示。
但 CSS 下载完成后,开始出现不同的情况。首先,我们看到一个较长的紫色“布局”块(与蓝色的 HTML 块在同一层级)。这之前从未出现过!几乎就在它完成后,首次内容绘制(First Contentful Paint)被触发了。但是顶部的 JavaScript 仍然在加载!之后,一切照常——JavaScript 完成加载并进行处理和绘制,随后触发 LCP(大内容绘制)。
如果你将鼠标悬停在屏幕快照显示的顶部区域,你会看到FCP和LCP之间的差距正好是我们的大红块出现在页面上的时间段。FCP和LCP之间的差距约为500毫秒,FCP约为800毫秒,LCP约为1.3秒。
看来这500毫秒基本上就是初始加载时客户端渲染的成本了。这太给力了!如果我设法在这500毫秒上减少一些时间,从而降低LCP的时间,那将是一个40%的提升!我可能会因此获得升职。
幸運的是,一切皆有可能。React 提供了几个方法,可以预渲染整个应用,我们理论上可以在这里使用它们。比如,有一个“renderToString”方法。它可以像文档中所述,将我们的应用渲染成字符串:
const App = () => <div>React app</div>;
// 在服务器上的某个地方
const html = renderToString(<App />); // 渲染结果将是 <div>React app</div>.
因为我们已经在服务器上处理字符串了,这看起来正合适。我只需要用该函数的输出替换空的“root”元素即可。就像我们处理 meta 标签那样。试试看?
进入 backend/index.ts
文件并清理我们在上面所做的任何修改。找到被注释掉的代码。
// 返回 c.html(预渲染的html)
取消注释。再次录制表演。最后的结果大概会是这样。
差异立竿见影:FCP 和 LCP 同时发生。在主要的 React 生成的 JavaScript 触发之前,甚至在 JavaScript 完全加载之前。这意味着内容预渲染已经生效!🎉 欢乐的时光 ☀️☺️。请将鼠标悬停在顶部的截图上以验证当时确实显示了漂亮的仪表板,而不是一些随机变化。
然而,不过有一个小问题——FCP触发的时间比预期的晚了一些。我原本预计在800毫秒看到它,但实际是在大约900毫秒触发。关于性能方面的经验教训:永远不要提前承诺具体数字 😅。那么这100毫秒究竟去了哪里呢?
首先,看看左上角的“网络”部分,这里有一个对服务器的初始请求。注意那里的实心蓝色线了吗?这条实心蓝色线表示我们在下载HTML内容。我们现在发送的内容远不止一个空的<div>
。如果将鼠标悬停在该区域,可以看到具体的数字——大约三分之一的100毫秒左右的时间是用来下载内容的。
此外,请注意“解析HTML”任务后的紫色“布局”块。它看起来要长得多,不是吗?再将鼠标悬停在它上面查看确切的数字——这就是你丢失的100毫秒中三分之二的部分。浏览器不仅要下载额外的HTML,还要计算更多元素的位置才能开始绘制。
所以这就是节省下来的时间。但这还是很值得的,不是吗?我从LCP时间中节省了400毫秒,并将初始加载性能提高了30%!还有一个很酷的部分:现在禁用JavaScript再刷新页面。仪表盘还在!链接也还能点,不过会触发全页面刷新。
这部分让SSR变得有意义。现在,所有搜索引擎和其他你想要授权的机器人都可以在不加载任何JavaScript的情况下看到所有内容。性能提升不仅是个额外的好处,而且这个提升还很不稳定。
SSR 可能使初始加载更糟糕性能不稳定,因为性能上没有快速解决方案。如果有人跟你说 SSR 能让你的 SPA 单页应用首次加载速度提升一倍,那么他错了。现在你知道了网络环境、客户端渲染和服务端渲染是如何工作的,你能想到一个场景,SSR 让 LCP 变得更差吗?
就这样吧。
关闭CPU节流功能,让机器再次变快。将网络模拟速度调到最慢。对我来说,默认的Chrome 3G设置就已经够用了,但你可能需要设置得更慢。这要视你的机器速度而定。取消勾选“禁用缓存”选项。我希望这些CSS/JS文件能从浏览器内存中加载。
现在,分别在有预渲染和没有预渲染的情况下测量LCP。
结果对我来说是这样的。在没有预渲染的情况下,‘SPA’模式下的LCP约为2.13秒。有了预渲染,‘SSR’模式下的LCP约为2.62秒。差不多多了500毫秒!
这些性能图表在这种情况下也非常有趣,尤其是“SPA”模式,看起来像这样。
首先,有一个很长的等待时间(2秒)来等待服务器的回应,在网络部分。这是慢速网络连接的延迟造成的。然后,几乎瞬间就可以访问JavaScript和CSS资源,因为它们来自浏览器缓存,不需要网络请求。此外,HTML内容几乎是瞬间下载完成的,因为它只是一个空的div。接下来,常规且相当快的JavaScript执行,因为CPU没有被拖慢。这就是React在生成页面。最后,页面变得可见了。
现在,启用SSR模式后,相同的网络和CPU条件如下:
最初的等待时间一样 — 延迟问题一直没解决。然后开始下载 HTML 文件。但现在 HTML 文件很大,下载时间变得很长。带宽很小,下载起来很慢。
然后是最有趣的部分:当内容正在下载时,我注意到主区域有活动峰值。放大并悬停在这些点上——它们大多是布局任务,比如。浏览器已从缓存中获取了CSS和JavaScript,因此一旦接收到少量内容,它就能立即开始绘制布局,确实如此。
你应该能看到页面上的界面是如何逐步构建起来的:先是侧边栏出现,接着是顶部导航,然后是顶部图表,最后是表格。这一切都是按照HTML代码慢慢加载的顺序出现的。如果不是这么酷,我真的不知道还有什么比这更酷的了。
虽然这个例子感觉像是一个奇怪的边缘情况,但实际上并非如此。对于出差的商务人士、野生动物摄影师、旅行博主或被派往偏远地区工作的工程师来说,网络慢、延迟高且使用快速笔记本电脑的情况经常发生。因此,如果你的应用主要针对这类特定用户群体,并且你的应用已经是SPA(单页面应用),那么尝试引入SSR反而可能让情况变得更糟。
当然也可能不会这样。这取决于下载的 HTML 大小、设备的实际运行速度,以及应用需要多少 JavaScript 来渲染。总的来说,这主要取决于两方面:了解你的用户并不断测量。
SSR与保持水分在急于尽早展示内容的兴奋中,我们忘了看看内容加载之后会发生什么。
还记得那个大红块的行为吗?React 加载并生成了元素之后,它完全替换了“root” div 及其内部的所有内容,包括大红块。但如果我没有发送那个奇怪的红色块,而是发送未来页面的实际 HTML 呢?
实际上并没有什么特别的。我并没有以任何方式告诉 React 这些内容很重要,所以它会以同样的方式处理:清空“root” div 中的所有内容并用新的内容替换掉。从 HTML 的角度来看,内容看起来完全一样,所以我们看不出有什么区别。
但我们确实可以在性能概况中看到这一点。降低CPU和网络的速度,使行为变得更加明显,然后重新录制SSR示例的性能。注意接收CSS和JavaScript后的情况。
在左上角的网络部分,资源已经全部下载完成。刚一接收到CSS,我便在下方看到了一个较大的紫色“布局”部分——那就是我们的SSR内容显示的时候。当左上角的JavaScript黄色块加载完成后,React就开始发挥作用了。稍长一些的任务(180毫秒)是React构建UI的过程。在右下角,我又看到了一个较小的布局块。
这是我们在客户端渲染,中多次见到的典型画面。这时,React 清空了“根” div 并注入它生成的新内容。这样做完全是多余的。React 已经有了所有的 DOM 元素,它本可以重用这些DOM元素。显然,这样应该会更快。
这时就是所谓的 ‘hydration’ 发挥作用的时候了。正如我上面提到的,hydration 正好实现了我所期待的效果,它向 React 表明页面上已经存在与即将生成的 HTML 完全相同的节点。因此,React 可以直接复用这些节点,为其添加事件监听器,并做好未来功能的准备,从而省去重新挂载组件的步骤。无需从零开始重新挂载组件!
在 React 中实现 hydration 实际上非常简单,只需要一步:只需要调用一下 这个函数。我们只需要将 createRoot
替换成这个函数即可。
初始化根元素,并使用严格的模式应用组件。hydrateRoot
函数将根元素与应用程序组件关联起来,其中 <StrictMode>
用于确保应用程序在严格模式下运行,而 <App />
表示应用程序的主要组件。
你可以在 src/main.tsx
这个文件中找到这段代码-注释掉 createRoot
这部分代码,并取消对 hydration 部分的注释。然后重新构建并启动项目:
运行构建命令 `npm run build`,然后启动 `npm run start`
再测一下性能。
在与 React 相关的 JavaScript 执行中,不再看到紫色的东西了。现在稍微快了一点——从 180 毫秒减少到 142 毫秒。特别是考虑到之前 LCP 已经触发过的情况,现在看起来也许没有那么多。但情况不会一直这样下去。
试一试,例如取消选中“禁用网络缓存”,并去掉网络限速,同时保持CPU较低。模拟有快速互联网但设备较慢的重复访客。对我来说,在没有启用hydration的情况下,它将FCP与LCP分开,并将LCP推到了最末端的JavaScript任务之后。在这种情况下,LCP大约是550毫秒左右。启用hydration后,LCP更接近FCP,并在JavaScript任务开始时徘徊在280毫秒左右。
还有一个问题是阻塞主线程并尽可能减少其影响,Hydration在这方面有所帮助。此外,Hydration不仅仅关于JavaScript监听器。它还允许获取并注入一些初始数据到应用中——因此我们可以避免加载指示器或内容闪现。关于这点,我们以后有机会再详谈。
我应该这样实现SSR吗?你觉得这样可以吗?现在看来,所谓的SSR对于某些情况可能非常有用,实现起来似乎很简单,这时你可能会想:我能直接用研究项目里的代码来实现自己的SSR吗?
对于这个博客来说,这个回应将是非常罕见的:绝对不是!可以用来观察在开关某个功能时,预渲染内容在不同视角下的表现,这种解决方案在学习目的上是没问题的。
但这其实一点都不简单。我隐藏了完成所需任务的一半,以使其运行。另外一半还没有实现。这是一个非常基础且几乎过时的后端版本,不支持最新的 React 特性。
首先,这里没有SSR支持,特别是对于开发服务器。因此,调试SSR只能通过不断重新构建项目来实现。这也是你经常需要重建项目来应用更改的原因之一。(另外,性能测试应该基于生产构建,因此我对此并不特别在意)。
如果你想拥有酷炫的功能,比如热重载,那就得自己实现。关于如何适当地将SSR与Vite集成,有一整套详细的指南。而对于Webpack来说,则完全不同,可能连官方文档都讲得不够清楚。对于更奇特的需求,我甚至不知道从哪里开始。
其次,我从React文档中展示的漂亮字符串const html = renderToString(<App />);
其实是个误解,这行代码实际上永远无法运行。问题在于这一部分 - <App />
。这是JSX,这是我们大多数时候编写React代码的方式,所以看起来很普通。但实际上它能工作的原因是你的构建系统中有一个转换步骤,这个步骤可能是由Babel提供支持的(也可能是其他工具),这取决于构建系统。“纯粹”的Node或任何其他服务器框架都不会理解这种语法。
查看 backend/pre-render.ts
文件,看看它是如何实现的。
首先,我从 Vite 中提取了 App
的转换代码:
const { default: App } = await vite.ssrLoadModule('@/App');
// 从 '@/App' 加载服务器端渲染模块,并将其赋值给常量 { default: App }
如果你使用 Webpack,你可能需要手动配置和注册 Babel 插件。因此,单是第一步,你就需要明白发生了什么,并知道如何为你的应用实现它。
接下来的步骤,实际的渲染成字符串
(renderToString
)。
const 生成的HTML = renderToString(React.createElement(App, { ssrPath: path }));
仍然看起来和文档描述的不一样——实际后端文件的 JSX 支持与从 Vite 提取的不相同。但是你会在文档中看到 renderToString
并不支持流式传输和等待数据。(详情见文档:不支持流式传输和等待数据)
所以,要真正实现SSR,你需要了解你的应用是否需要这些新特性。如果需要的话,你又该如何在后端实现这些特性呢?推荐的方法可以在这里找到相关文档,还有一些关于这个话题的讨论和讨论在GitHub上,至少这可以算作一个起点。
但这确实是一项艰巨的任务,提到的只是冰山一角。不知不觉间,项目已经落后了三个月,实际上,你就是在从头开始开发自己的 Next.js 版本。你觉得为什么它没有很多竞争对手呢?
所以,除非有非常合理的商业理由,并且在时间、资源和专业技能方面有充足的支撑,否则可能更简单地使用现有的SSR框架。尤其是考虑到这里的后端只是整个拼图的一部分。前端同样存在较高的复杂性。
SSR (服务端渲染) 和前端这取决于应用的大小以及其对SSR的优化情况,实际上可能比后端部分还要复杂得多。没错,你没听错,确实是这样,在实现SSR的过程中,还有一个隐藏的秘密没告诉你:我们还需要对前端代码进行一些修改。
浏览器 API (浏览器应用程序编程接口) 及 SSR (服务端渲染)还记得我怎么获取发送到浏览器的HTML吗?我只是生成了一个字符串,使用React的renderToString
,然后将这个字符串注入到另一个字符串中。这个过程中根本没有浏览器的存在,也永远不会有。
所以,你觉得那些我们一直使用的浏览器变量调用会怎么样?所有的 window.location
,window.history
,和 document.getElementById
?情况不会好。那些我们一直使用的浏览器变量会变成 undefined
。没有浏览器能在全局范围内提供这些变量。
所以当第二个 React 尝试调用一个函数(即渲染一个组件)时,这个函数试图直接访问这些变量,它会失败并抛出 window is not defined
的错误。整个应用将会完全崩溃。不仅客户端会崩溃,服务器部分也会崩溃,这更糟——前端无法捕获错误并显示一个写着“我们正在处理,这里有一块饼干”的友好页面。错误处理需要在服务器端进行,你还需要一个专门的“服务器错误”页面。
额外的考验
- 尝试在前端代码的任何部分添加一个简单的
console.info(window.location)
,例如在src/App.tsx
文件里。 - 使用
npm run build
重新构建应用,并重新启动它。 - 你应该会在屏幕上看到
Internal Server Error
这个字符串。 - 你能想到一种解决方法吗?
一个典型的修复方法是检查在访问之前全局变量 window
(以及其他所有全局变量)是否已经声明。
if (typeof window !== 'undefined') {
当 window 全局对象可用时做点什么
}
如果你查看 frontend/utils/use-client-router.tsx
文件中的代码,这就是我必须做的事情的内容。每次需要在运行时环境中访问 window
、document
或其他类似的东西时,我都必须这样做。
说到 use-client-router
文件。如果仔细看,你会发现我不需要在 useEffect
中进行 typeof window
的检查:
useEffect(() => {
// useEffect 用于在组件的生命周期内执行副作用操作,比如在组件更新后执行某些操作。
const handlePopState = () => {
// handlePopState 函数用于处理浏览器的历史状态改变事件,更新当前路径。
setPath(window.location.pathname);
};
window.addEventListener('popstate', handlePopState);
// 返回一个函数,用于在组件卸载时移除 popstate 事件监听器。
return () =>
window.removeEventListener('popstate', handlePopState);
}, []);
这是因为当在服务器上运行(例如通过 renderToString
及其相关方法)时,React 不会触发 useEffect
。同样的,useLayoutEffect
也不会被触发。这些钩子仅在客户端在完成 hydration 后才会运行。如果你想了解更多关于这种行为的原因,可以参考 这个简短的解释 以及 详细的讨论,后者包含 React 核心团队成员的观点。
所以这确实是一个需要记住的地方,如果你预计 useEffect
会带来一些 UI 变化,那么它们会在 JavaScript 加载时导致内容的“闪现”。
你的代码中某些部分可能过于依赖于浏览器 API,以至于你可能觉得在 SSR(服务器端渲染)模式下完全跳过这部分内容的渲染会更简单。因此,诱惑你去这样做,这样的操作。
const Component = () => {
// 在SSR模式下啥也不渲染
if (typeof window === "undefined") return null;
// 客户端模式启动时就渲染东西
// 渲染逻辑
}
不行。那不会起作用。换句话说,它会起作用,但会让React感到困惑——它会期望服务端代码生成的HTML与客户端代码生成的HTML一模一样。
它會因此退回到前端渲染模式——在這種模式下,它會清空“根” div 中的所有內容,並替換為新生成的元素。它將表現得hydration過程從未發生一樣,帶來所有由此產生的缺點。
额外的挑战来了:
- 在
frontend/pages/dashboard.tsx
(或其他你喜欢的地方)创建一个名为ClientOnlyButton
的组件,代码示例如下:
const ClientOnlyButton = () => {
// ClientOnlyButton 仅在客户端渲染的按钮
if (typeof window === 'undefined') return null;
return <button>按钮</button>;
};
- 在页面上找个地方渲染它。
- 像平常一样构建和启动项目。
- 记录性能表现。它应该显示我们还没实现hydration时的界面,里面有React JavaScript任务中的Layout块。
如果你很幸运!有时候它会引入一些非常奇怪的布局错误,从而使网站看起来完全出问题了。布局错误
正确的做法是依靠 React 的生命周期来“移除”那些不兼容 SSR 的代码块。为此,我们需要引入状态来跟踪组件是否已挂载:
class MyComponent extends React.Component {
constructor(props) {
super(props);
// 设置初始状态为未挂载
this.state = { isMounted: false };
}
componentDidMount() {
// 组件挂载后,将状态设置为已挂载
this.setState({ isMounted: true });
}
// 使用 this.state.isMounted 来判断是否已挂载
}
这种方法适用于类组件,并且可以适应功能组件的使用。
const Component = () => {
// 初始未挂载
const [isMounted, setIsMounted] = useState(false);
};
然后在组件安装完成或加载完毕后将状态改为 true
,比如在 useEffect
中:
const Component = () => {
// 初始未挂载
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
};
记得:useEffect
不会在服务器上运行,所以状态只有在网站的客户端版本被 React 完全初始化后才会变成 true
。
最后,渲染那些我们原先想要的效果,但这些效果不支持SSR。
const Component = () => {
// 初始并未挂载
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
// 在组件挂载后执行
setIsMounted(true);
}, []);
// 如果处于SSR模式则不渲染任何内容
if (!isMounted) return null;
// 当客户端模式启动时渲染内容
return 具体的渲染内容;
}
额外的挑战
- 将之前的练习中的
ClientOnlyButton
重新编写,使其能够正确地与 SSR 配合工作。 - 像往常一样重新构建并重新启动项目。
- 记录性能配置文件。它应该恢复成 SSR 的样子。
并不是所有的外部依赖都支持SSR。这总是对库来说是一场赌博,但你不能确定。对于其中一些,你可以使用上面的解决方案来选择退出SSR支持。有些会被打包工具拒绝,因此你需要在客户端JavaScript加载后动态导入它们。对于一些,你需要从项目中移除并替换为更支持SSR的库,比如替换为友好的库。
这将会特别难受,特别是在项目中使用了不兼容 SSR 的基础库,比如状态管理库或 CSS-in-JS 的实现。
例如,尝试在Study项目中的某个地方使用Material UI图标:
// 例如在 src/App.tsx 文件中
import { Star } from '@mui/icons-material';
function App() {
// 其余代码保持原样
return (
<>
...
<Star />
</>
);
}
重新建一下并启动它——你应该能看到SSR崩了
[vite] (ssr), 在评估 SSR 模块 @/App 时报错:deepmerge 不是函数
试着找出解决它的方法 😬
静态网站生成(SSG)好的,假设我们绝对需要有“正规”的服务器渲染页面,并准备好在前端处理由此带来的后果。例如,我们正在实现一个炫酷的“促销”网站。这类网站显然需要被所有可能的搜索引擎尽快索引,并且能够通过任何可以分享链接的方式来分享。这才是这类网站的重点。
让我们也假设网站上的所有信息都是“静态”的,也就是说,没有用户生成的内容,也不需要考虑权限问题,也没有复杂的动态生成的数据。这个网站只有一些介绍产品的一些页面,一些标准页面如“服务条款”,以及一个每周更新的博客。
这种情况很罕见,就好比说我们既能把蛋糕吃了,还能保存下来一样。我们知道在服务器上预渲染网站相对比较简单,只需调用我们应用的 React.renderToString
方法即可(差不多就是这个意思)。
所以这里的主要问题就是:是什么阻止我们在执行 npm run build
之后的构建阶段,运行 React.renderToString
呢?理论上,我们本就可以预渲染并发送一个完整的 HTML 页面到浏览器。而预渲染的内容总是固定的。我们完全可以提前做好这件事,将其保存为一堆实际的 HTML
文件,就像老式做法一样,从而省掉运行服务器的麻烦。不是吗?
完全没有东西阻止我们这么做,试试看运行这个。
npm run build:ssg
运行构建静态生成的命令
它将首先用 Vite 按常规方式构建我们的网站,然后运行一个非常简单的脚本(backend/generate-static-pages.ts
),该脚本将空的 <div id="root"></div>
替换为 renderToString
生成的内容。这与服务器所做的相同。现在我们不再需要服务器了,因为。
查看 dist
文件夹里的文件。现在你将会看到多出的两个文件:login.html
和 settings.html
。打开任意一个 HTML 文件,你会发现 <div id="root">
里面填上了内容。
这是我们的静态网站,我们可以用任何简单的服务器开始。
运行这条命令来启动dist目录的开发服务器:npx serve dist
或者将其上传到任何地方,就像任何前端渲染的应用程序一样。但这次它不会有任何前端渲染的缺点,所有搜索引擎都能立即正确地抓取和索引它,而且社交媒体分享也会变得非常顺畅。
静态网站好到甚至有了自己的三个字母缩写:SSG(静态站点生成)。当然,还有很多框架可以帮助你生成它们,无需手动劳作:Next.js 支持 SSG,Gatsby 依然很受欢迎,很多人喜欢 Docusaurus,Astro 声称拥有最佳性能,还有许多其他框架。
关于SSR还有很多可谈的,这些概念只是基础。但希望这对你有所帮助,下次当你需要做决定时,你会更有把握。下次当你需要决定我们是否应该在下一个网站上使用SSR时,你会更有信心。
_最初发布于https://www.developerway.com 。该网站还有更多类似的文章哦😉
看看这本《Advanced React》可以让你的 React 技能更上一层楼。
订阅 newsletter ,在 LinkedIn 上连接我 或关注我的推特 或关注我的 Bluesky 以便及时获得新文章的通知.
共同学习,写下你的评论
评论加载中...
作者其他优质文章