为什么无 JavaScript 环境如此重要
我们从无 JavaScript 环境开始讨论。这可能是最令人困惑的缺点。如今谁还会在浏览器中禁用 JavaScript?它几乎在所有地方都是默认启用的,没有它几乎都不会工作,而且大多数人甚至不知道 JavaScript 是什么,更不用说去禁用它了。对吧?
这里的答案在于 "人" 这个词,或者更准确地说,是实际访问你网站的不仅仅是人。两个主要参与者是:
- 搜索引擎机器人(爬虫)。
- 各种社交媒体和消息应用的 "预览" 功能。
它们的工作方式大致相似。首先,它们以某种方式获取你网站的 URL。
其次,机器人向服务器发送请求并接收 HTML。
第三,从 HTML 中提取信息并进行处理。搜索引擎提取诸如文本、链接、元标签等信息。基于这些信息,它们形成搜索索引。社交媒体预览功能抓取元标签并创建我们大家都见过的漂亮预览,带有图片、标题,有时还有简短描述。
下载项目并安装依赖项:
npm install
然后构建并启动:
arduino
npm run build
npm run start
在主页和设置页面之间。你会看到页面标题随导航变化。
jsx
useEffect(() => {
updateTitle('学习项目:主页');
}, []);
其中,内部代码如下:
jsx
export const updateTitle = (text: string) => {
document.title = text;
};
然而,你还会看到初始加载时标题会短暂 "闪烁"------因为默认标题是 "Vite + React + TS",这是 index.html
中的标题,也是从服务器接收的标题。
现在,使用 ngrok(或类似的工具)将网站暴露给外界:
yaml
ngrok http 3000
尝试在你选择的社交媒体上分享它生成的 URL。在生成的预览中,你会看到旧的 "Vite + React + TS" 标题。没有加载 JavaScript。
虽然,这并不完全适用于某些机器人。大多数流行的搜索引擎确实会等待 JavaScript 加载。例如,谷歌:它解析 "纯" HTML,还将页面放入 "渲染" 队列,在那里它实际上会启动浏览器,加载网站,等待 JavaScript 渲染,然后再次提取所有信息。
因此,如果你的网站:
- 尽可能快地被搜索引擎发现。
- 在社交媒体平台上分享。
那么服务器返回 "正确" 的 HTML,包含所有关键信息非常重要。典型的例子包括:
- 以阅读为主的网站,即各种形式的博客、文档、知识库、论坛、问答网站、新闻机构等。
- 各种形式的电子商务网站。
- 落地页。
- 几乎所有可以在万维网上搜索到的内容。
这并不意味着我们需要抛弃 React。有几个解决方案可以尝试。
服务器预渲染
在学习项目中,它看起来是这样的:
jsx
app.get('/*', async (c) => {
const html = fs
.readFileSync(path.join(dist, 'index.html'))
.toString();
return c.html(html);
});
当服务器收到任何请求时,它只是读取为我们提前生成的 index.html
文件,将其转换为字符串,并将其发送给请求者。
然而,为了解决 "无 JavaScript" 问题,我们现在需要修改代码。
例如,找到现有的标题并将其替换为 "学习项目":
jsx
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
即可提取它。
之后,我们可以根据该路径生成不同的标题:
jsx
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
中可以这样做:
jsx
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>
并将其变成模板。
jsx
<html lang="en">
<head>
<title>{{ title }}</title>
</head>
...
</html>;
然后在服务器上这样做:
jsx
const modifiedHTML = html.replace('{{title}}', title);
未来,如果需要,我们可以将其转换为任何模板语言。
当然,我们不仅限于 title
标签------我们可以通过这种方式预渲染 <head>
中的所有信息。这为我们解决社交媒体预览功能的问题提供了一种相对容易且廉价的方式。
我们甚至可以预渲染整个页面,而不仅仅是元标签!
服务器预渲染的成本
与完全静态的单页应用相比。通过添加一个简单的预渲染脚本,我引入了两个问题。
部署到哪里?
第一个问题是,我现在应该将应用部署到哪里?
现在,我需要一个服务器。
这里有两种最常见的解决方案。
我们可以使用托管提供商的 无服务器函数。
无服务器函数的缺点是 "按使用量计费" 部分。网站越受欢迎,使用量超过限制的可能性就越大。
如果你不选择无服务器函数,你可以使用一个实际的服务器 并将其部署到任何云平台。
这种解决方案有其优势。一切都在你的控制之下。从一个解决方案迁移到另一个解决方案不需要代码更改。价格通常更可预测,更简单,而且当使用量增加时更低。
拥有服务器的性能影响
还记得初始加载文章对初始加载性能的影响吗?
如果它作为无服务器函数部署,那么有可能并不算太糟。一些提供商可以在 "边缘" 运行这些函数。
然而,如果我选择了自行管理的服务器,我就没有分布式网络的优势。
在服务器上预渲染整个页面(SSR)
在上面的部分中,我们预渲染了元标签。让我们看看 HTML 页面中由服务器发送的 <body>
标签的内容:
jsx
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
还记得客户端渲染的工作原理吗?当脚本下载并处理后,React 获取 "root" 元素并将生成的 DOM 元素附加到其中。那么,如果我返回一个包含一些内容的 div 而不是一个空 div 会怎样?让我们把它变成一个大红块:
jsx
<div id="root">
<div style="background:red;width:100px;height:100px;">
大红块
</div>
</div>
将其添加到 index.html
中,构建项目,启动它,并禁用缓存,放慢 CPU 和网络以提高可见性。
当你刷新页面时,你应该会看到大红块的瞬间闪烁,然后被正常的页面替换。
幸运的是。React 提供了一些方法可以预渲染整个应用。例如,有一个 "renderToString"。
jsx
const App = () => <div>React 应用</div>;
// 在服务器上的某个地方
const html = renderToString(<App />); // 输出将是 <div>React 应用</div>
由于我们已经在服务器上处理字符串。我需要做的就是将空的 "root" div 替换。让我们试试?
转到 backend/index.ts
并清理我们之前所做的任何修改。找到被注释掉的代码:
jsx
// return c.html(preRenderApp(html));
取消注释。重新记录性能。最终结果应该像这样:
FCP 和 LCP 同时发生。在主 React 生成的 JavaScript 触发之前甚至在 JavaScript 加载完成之前。这意味着内容预渲染正在工作!
这就是 SSR 值得追求的地方。
SSR 可能使初始加载变得更糟
不稳定,因为性能方面没有银弹。如果有人说 SSR 将 100% 提高单页应用的初始加载性能,他们就错了。现在你知道网络条件、客户端和服务器端渲染的工作原理,你能想到 SSR 使 LCP 变糟的情况吗?
现在,在启用和不启用预渲染的情况下测量 LCP。
对于我来说,结果是这样的。在不启用预渲染的情况下,即 "单页应用" 模式,LCP 在 2.13 秒左右。在启用预渲染的情况下,即 "SSR" 模式,它在 2.62 秒左右。几乎长了 500ms!
SSR 与前端
浏览器 API 与 SSR
还记得我是如何获取发送到浏览器的 HTML 的吗?我只是用 React 的 renderToString
生成了一个字符串,然后将其注入到另一个字符串中。
那么,浏览器变量调用会怎么样呢? window.location
和 window.history
以及 document.getElementById
?没有好消息。window
、document
等将变为 undefined
。
因此,当 React 尝试渲染一个访问这些变量的组件时,它将因 window 未定义
错误而失败。整个应用将崩溃。
useEffect 与 SSR
在 use-client-router
文件。如果你仔细看,会发现我不必在 useEffect
中检查 typeof window
:
jsx
useEffect(() => {
const handlePopState = () => {
setPath(window.location.pathname);
};
window.addEventListener('popstate', handlePopState);
return () =>
window.removeEventListener('popstate', handlePopState);
}, []);
这是因为当在服务器上运行(通过 renderToString
等)时,React 不会触发 useEffect
。useLayoutEffect
也是如此。这些钩子将在水合发生后在客户端触发。
第三方库
并非所有外部依赖项都支持 SSR。
有些需要在客户端 JavaScript 加载后动态导入。
有些需要从项目中移除并替换为更友好的 SSR 库。
如果非 SSR 的库是项目的核心,比如状态管理解决方案或 CSS-in-JS 解决方案,这将特别痛苦。
例如,尝试在学习项目中某个地方使用 Material UI 图标:
jsx
// 例如在 src/App.tsx 中的任何地方
import { Star } from '@mui/icons-material';
function App() {
// 其余代码相同
return (
<>
...
<Star />
</>
);
}
重新构建并启动------你应该会看到 SSR 崩溃,显示:
vbnet
[vite] (ssr) Error when evaluating SSR module @/App: deepmerge is not a function