Web 的未来(以及过去)是服务器端渲染
当服务器还位于瑞士地下室时,它们只需要提供静态 HTML。也许,如果你足够幸运,还能看到一张图片。
现在,一个网页可以是一个功能齐全的应用,从多个来源拉取数据,进行即时操作,并允许最终用户完全互动。这极大地提高了 Web 的实用性,但也以大小、带宽和速度为代价。在过去的 10 年里,桌面网页的平均大小已从 468 KB 增加到 2284 KB,增长了 388.3%。对于移动设备,这一增长更加惊人——从 145 KB 增加到 2010 KB——惊人的增长了 1288.1%。
通过网络传输这么多内容,尤其对于移动设备而言,负担太重了。结果,用户体验很差,加载时间慢,并且在所有内容渲染完成之前缺乏互动性。但是,所有这些代码对于使我们的网站按照我们想要的方式工作是必要的。
这就是今天前端开发人员面临的问题。对于前端开发人员来说,最初构建具有各种花哨功能的出色网站很有趣,但现在变得有点不那么有趣了。我们现在正在与不同的浏览器作斗争以获得支持,在慢速网络上传输代码,以及不稳定的移动连接。支持所有这些排列组合是一个巨大的难题。
我们如何才能解决这个难题?通过回到服务器(不需要瑞士地下室)。
我们如何走到今天的简要回顾
起初有 PHP,只要你喜欢问号,它就很棒。
Web 最初是一个静态 HTML 网络,但 CGI 脚本语言(如 Perl 和 PHP)允许开发人员将后端数据源渲染为 HTML,引入了网站可以根据访问者动态变化的想法。
这意味着开发人员能够构建动态站点,并将实时数据或来自数据库的数据提供给最终用户(只要他们的 #、!、$ 和 ? 键工作正常)。
PHP 在服务器上工作,因为服务器是网络中强大的部分。你可以在服务器上获取数据并渲染 HTML,然后将其全部发送到浏览器。浏览器的工作是有限的——只是解释文档并显示页面。这种方式效果很好,但这种解决方案完全是关于显示信息,而不是与之互动。
然后发生了两件事:JavaScript 变得优秀,浏览器变得强大。
这意味着我们可以在客户端直接做很多有趣的事情。当你可以将基本的 HTML 页面和一些 JS 管道传输到浏览器,并让客户端处理所有事情时,为什么还要首先在服务器上渲染所有内容并发送它呢?
这就是单页应用程序 (SPA) 和客户端渲染 (CSR) 的诞生。
客户端渲染
在 CSR 中,也称为动态渲染,代码主要在客户端(用户的浏览器)上运行。客户端的浏览器下载必要的 HTML、JavaScript 和其他资源,然后运行代码来渲染 UI。
这种方法的好处是双重的
- 出色的用户体验。如果你有极快的网络,并且可以快速下载 bundle 和数据,那么一旦一切就绪,你将拥有一个超快的网站。你无需返回服务器进行更多请求,因此每次页面更改或数据更改都会立即发生。
- 缓存。因为你没有使用服务器,所以你可以将核心 HTML 和 JS bundle 缓存在 CDN 上。这意味着用户可以快速访问它们,并降低公司的成本。
随着 Web 变得更具互动性(感谢 JavaScript 和浏览器),客户端渲染和 SPA 成为默认选择。Web 感觉快速而充满活力……特别是如果你是在台式机上,使用流行的浏览器,并使用有线互联网连接。
对于其他人来说,Web 速度慢如蜗牛。随着 Web 的成熟,它可以在更多设备和不同的连接上使用。管理 SPA 以确保一致的用户体验变得更加困难。开发人员不仅要确保站点在 IE 和 Chrome 上渲染相同,还要考虑它在繁忙城市的公共汽车上的手机上如何渲染。如果你的数据连接无法从缓存中下载 JS bundle,你就无法访问网站。
我们如何轻松地确保跨各种设备和带宽的一致性?答案是:回到服务器。
服务器端渲染
将浏览器渲染网站的工作转移到服务器有很多好处
- 性能更高,因为 HTML 已经生成并在页面加载时准备好显示。
- 兼容性更高,因为 HTML 是在服务器上生成的,因此它不依赖于最终浏览器。
- 复杂性更低,因为服务器完成了生成 HTML 的大部分工作,因此通常可以用更简单、更小的代码库来实现。
使用 SSR,我们在服务器上完成所有操作
有许多同构 JavaScript 框架支持 SSR:它们使用 JavaScript 在服务器上渲染 HTML,并将该 HTML 与 JavaScript 打包在一起,以便客户端进行交互。编写同构 JavaScript 意味着更小的代码库,更易于推理。
其中一些框架,例如 NextJS 和 Remix,构建于 React 之上。React 开箱即用是一个客户端渲染框架,但它确实具有 SSR 功能,使用 renderToString
(以及其他推荐版本,例如 renderToPipeableStream
、renderToReadableStream
等)。NextJS 和 Remix 在 renderToString
之上提供了更高的抽象,使其易于构建 SSR 站点。
SSR 确实带来了一些权衡。我们可以控制更多并更快地交付,但 SSR 的限制在于,对于交互式网站,你仍然需要发送 JS,这与静态 HTML 结合在一个称为“水合”的过程中。
发送用于水合的 JS 会遇到复杂性问题
- 我们是在每个请求上发送所有 JS?还是根据路由发送?
- 水合是自上而下完成的吗,成本有多高?
- 开发人员如何组织代码库?
在客户端,大型 bundle 可能会导致内存问题,以及用户“为什么什么都没发生?”的感觉,因为所有 HTML 都在那里,但在水合之前你无法真正使用它。
我们在 Deno 这里喜欢的一种方法是岛屿架构,就像在静态 SSR HTML 的海洋中,存在着交互岛屿。(你可能已经注意到 Fresh,我们默认情况下不向客户端发送 JavaScript 的现代 Web 框架,使用了岛屿架构。)
你希望 SSR 实现的是快速提供和渲染 HTML,然后独立地提供和渲染每个单独的组件。这样,你发送的 JavaScript 块更小,并且在客户端上进行的渲染块也更小。
这就是岛屿架构的工作原理。
岛屿不是渐进式渲染的,它们是单独渲染的。岛屿的渲染不依赖于任何先前组件的渲染,并且对虚拟 DOM 其他部分的更新不会导致任何单个岛屿的重新渲染。
岛屿保留了整体 SSR 的优势,但没有大型水合 bundle 的权衡。非常成功。
如何从服务器渲染
并非所有服务器端渲染都是相同的。有服务器端渲染,然后还有服务器端渲染。
在这里,我们将带你了解一些在 Deno 中从服务器渲染的不同示例。我们将 Jonas Galvez 的优秀服务器渲染入门介绍移植到 Deno、Oak 和 Handlebars,使用同一个应用程序的三个变体
- 一个简单的、模板化的、服务器渲染的 HTML 示例,没有任何客户端交互 (源代码)
- 一个最初是服务器端渲染的示例,然后由客户端更新 (源代码)
- 一个完全服务器端渲染的版本,具有同构 JS 和共享数据模型 (源代码)
正是这第三个版本才是最真实的 SSR。我们将有一个单独的 JavaScript 文件,服务器和客户端都将使用它,并且对列表的任何更新都将通过更新数据模型来完成。
但首先,让我们做一些模板化。在第一个示例中,我们只打算渲染一个列表。这是主要的 server.ts
import { Application, Router } from "https://deno.land/x/[email protected]/mod.ts";
import { Handlebars } from "https://deno.land/x/[email protected]/mod.ts";
const dinos = ["Allosaur", "T-Rex", "Deno"];
const handle = new Handlebars();
const router = new Router();
router.get("/", async (context) => {
context.response.body = await handle.renderView("index", { dinos: dinos });
});
const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());
await app.listen({ port: 8000 });
请注意,我们不需要 client.html
。相反,使用 Handlebars,我们创建以下文件结构
|--Views
| |--Layouts
| | |
| | |--main.hbs
| |
| |--Partials
| |
| |--index.hbs
main.hbs
包含你的主 HTML 布局,其中包含我们 {{{body}}}
的占位符
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dinosaurs</title>
</head>
<body>
<div>
<!--content-->
{{{body}}}
</div>
</body>
</html>
{{{body}}}
来自 index.hbs
。在本例中,它使用 Handlebars 语法来迭代我们的列表
<ul>
{{#each dinos}}
<li>{{this}}</li>
{{/each}}
</ul>
所以发生的情况是
- 客户端调用根路径
- 服务器将 dinos 列表传递给 Handlebars 渲染器
- 该列表的每个元素都在
index.hbs
内的列表中渲染 - 来自 index.hbs 的整个列表都在
main.hbs
中渲染 - 所有这些 HTML 都以响应正文的形式发送到客户端
服务器端渲染!好吧,有点。虽然它确实在服务器上渲染,但这是非交互式的。
让我们向列表中添加一些交互性——添加项目的功能。这是一个经典的客户端渲染用例,基本上是一个 SPA。服务器没有太大变化,除了添加了一个 /add 端点来向列表中添加项目
import { Application, Router } from "https://deno.land/x/[email protected]/mod.ts";
import { Handlebars } from "https://deno.land/x/[email protected]/mod.ts";
const dinos = ["Allosaur", "T-Rex", "Deno"];
const handle = new Handlebars();
const router = new Router();
router.get("/", async (context) => {
context.response.body = await handle.renderView("index", { dinos: dinos });
});
router.post("/add", async (context) => {
const { value } = await context.request.body({ type: "json" });
const { item } = await value;
dinos.push(item);
context.response.status = 200;
});
const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());
await app.listen({ port: 8000 });
但是,Handlebars 代码这次发生了很大变化。我们仍然有用于生成 HTML 列表的 Handlebars 模板,但 main.hbs
包含了自己的 JavaScript 来处理 Add
按钮:绑定到按钮的 EventListener
事件,它将执行以下操作
- 将新的列表项
POST
到/add
端点 - 将项目添加到 HTML 列表
[...]
<input />
<button>Add</button>
</body>
</html>
<script>
document.querySelector("button").addEventListener("click", async () => {
const item = document.querySelector("input").value;
const response = await fetch("/add", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ item }),
});
const status = await response.status;
if (status === 200) {
const li = document.createElement("li");
li.innerText = item;
document.querySelector("ul").appendChild(li);
document.querySelector("input").value = "";
}
});
</script>
但这并不是真正意义上的服务器端渲染。使用 SSR,你在客户端和服务器端运行相同的同构 JS,它只是根据运行位置以不同的方式运行。在上面的示例中,我们在服务器和客户端上都运行了 JS,但它们是独立工作的。
所以接下来是真正的 SSR。我们将放弃 Handlebars 和模板化,而是创建一个 DOM,我们将使用我们的恐龙来更新它。我们将有三个文件。首先,再次是 server.ts
import { Application } from "https://deno.land/x/[email protected]/mod.ts";
import { Router } from "https://deno.land/x/[email protected]/mod.ts";
import { DOMParser } from "https://deno.land/x/[email protected]/deno-dom-wasm.ts";
import { render } from "./client.js";
const html = await Deno.readTextFile("./client.html");
const dinos = ["Allosaur", "T-Rex", "Deno"];
const router = new Router();
router.get("/client.js", async (context) => {
await context.send({
root: Deno.cwd(),
index: "client.js",
});
});
router.get("/", (context) => {
const document = new DOMParser().parseFromString(
"<!DOCTYPE html>",
"text/html",
);
render(document, { dinos });
context.response.type = "text/html";
context.response.body = `${document.body.innerHTML}${html}`;
});
router.get("/data", (context) => {
context.response.body = dinos;
});
router.post("/add", async (context) => {
const { value } = await context.request.body({ type: "json" });
const { item } = await value;
dinos.push(item);
context.response.status = 200;
});
const app = new Application();
app.use(router.routes());
app.use(router.allowedMethods());
await app.listen({ port: 8000 });
这次发生了很多变化。首先,同样,我们有一些新的端点
- 一个 GET 端点,它将提供我们的
client.js
文件 - 一个 GET 端点,它将提供我们的数据
但我们的根端点也发生了很大的变化。现在,我们正在使用来自 deno_dom
的 DOMParser
创建 DOM 文档对象。DOMParser
模块的工作方式类似于 ReactDOM
,允许我们在服务器上重新创建 DOM。然后我们使用创建的文档来渲染 notes 列表,但我们没有使用 handlebars 模板化,而是从一个新文件 client.js
获取了这个渲染函数
let isFirstRender = true;
// Simple HTML sanitization to prevent XSS vulnerabilities.
function sanitizeHtml(text) {
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
export async function render(document, dinos) {
if (isFirstRender) {
const jsonResponse = await fetch("https://127.0.0.1:8000/data");
if (jsonResponse.ok) {
const jsonData = await jsonResponse.json();
const dinos = jsonData;
let html = "<html><ul>";
for (const item of dinos) {
html += `<li>${sanitizeHtml(item)}</li>`;
}
html += "</ul><input>";
html += "<button>Add</button></html>";
document.body.innerHTML = html;
isFirstRender = false;
} else {
document.body.innerHTML = "<html><p>Something went wrong.</p></html>";
}
} else {
let html = "<ul>";
for (const item of dinos) {
html += `<li>${sanitizeHtml(item)}</li>`;
}
html += "</ul>";
document.querySelector("ul").outerHTML = html;
}
}
export function addEventListeners() {
document.querySelector("button").addEventListener("click", async () => {
const item = document.querySelector("input").value;
const dinos = Array.from(
document.querySelectorAll("li"),
(e) => e.innerText,
);
dinos.push(item);
const response = await fetch("/add", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ item }),
});
if (response.ok) {
render(document, dinos);
} else {
// In a real app, you'd want better error handling.
console.error("Something went wrong.");
}
});
}
这个 client.js
文件可供服务器和客户端使用——它是我们真正的 SSR 所需的同构 JavaScript。我们在服务器中使用 render
函数来初始渲染 HTML,但我们也在客户端中使用 render
来渲染更新。
此外,在每次调用时,数据都直接从服务器拉取。数据使用 /add
端点添加到数据模型。与第二个示例(客户端应用程序直接在 HTML 中附加项目到列表)相比,在本例中,所有数据都通过服务器路由。
来自 client.js
的 JS 也直接在客户端的 client.html
中使用
<script type="module">
import { addEventListeners, render } from "./client.js";
await render(document);
addEventListeners();
</script>
当客户端第一次调用 client.js
时,HTML 变为水合状态,其中 client.js
调用 /data
端点以获取未来渲染所需的数据。对于较大的 SSR 页面,水合可能会变得缓慢而复杂(岛屿架构 在这里可以真正发挥作用)。
这就是 SSR 的工作原理。你拥有
- 在服务器上重新创建的 DOM
- 同构 JS 可供服务器和客户端使用,以在两者中渲染数据,并在客户端的初始加载时设置水合,以获取使应用程序对用户完全交互所需的所有数据
使用 SSR 简化复杂的 Web
我们正在为每种屏幕尺寸和每种带宽构建复杂的应用程序。人们可能在隧道中的火车上使用你的网站。确保在所有这些场景中获得一致体验的最佳方法,同时保持你的代码库小巧且易于推理,就是 SSR。
注重用户体验的高性能框架将只向客户端发送所需的内容,而不会发送更多内容。为了最大程度地减少延迟,请将你的 SSR 应用程序部署在靠近用户的边缘位置。你今天可以使用 Fresh 和 Deno Deploy 完成所有这些操作。
遇到困难?来 我们的 Discord 获取答案。