网络的未来(和过去)是服务器端渲染
当服务器位于 瑞士地下室 时,它们只需提供静态 HTML。也许,如果幸运的话,你会得到一张 图片。
现在,一个网页可以是一个功能齐全的应用程序,从多个来源提取数据,进行实时操作,并允许最终用户进行完全交互。这极大地提高了网络的实用性,但以大小、带宽和速度为代价。在过去 10 年中,桌面网页的中位数大小 从 468 KB 增加到 2284 KB,增长了 388.3%。对于移动设备而言,这一增长更为惊人——从 145 KB 增加到 2010 KB,增长了惊人的 1288.1%。
对于网络,特别是对于移动设备而言,这是需要传输的相当大的负担。因此,用户会遇到糟糕的用户体验、加载时间缓慢以及在所有内容渲染完毕之前无法进行交互。但所有这些代码都是为了使我们的网站按我们想要的方式工作而必需的。
这就是如今作为前端开发人员所面临的困境。最初对前端开发人员来说,构建带有所有花里胡哨功能的超酷网站是一件有趣的事,现在却变得不那么有趣了。我们现在需要应对不同浏览器的支持问题、代码传输的网络速度缓慢以及间歇性的移动连接。支持所有这些排列组合是一件令人头疼的事。
我们如何解决这个难题?答案是返回服务器(瑞士地下室不再需要)。
简要回顾我们如何走到今天
一开始有 PHP,它很棒,只要你喜欢问号。
网络最初是一组静态 HTML,但 CGI 脚本语言(如 Perl 和 PHP)允许开发人员将后端数据源渲染成 HTML,引入了根据访问者动态生成网站的概念。
这意味着开发人员能够构建动态网站,并将实时数据或数据库中的数据提供给最终用户(只要他们的 #、!、$ 和 ? 键可以正常工作)。
PHP 在服务器上运行是因为服务器是网络中功能强大的部分。您可以获取数据并在服务器上渲染 HTML,然后将所有内容传输到浏览器。浏览器的作用仅限于解释文档并显示页面。这种方法效果不错,但这种解决方案主要侧重于显示信息,而不是与信息进行交互。
然后发生了两件事:JavaScript 变得很棒,浏览器变得功能强大。
这意味着我们可以在客户端直接执行许多有趣的操作。为什么要先在服务器上渲染所有内容,然后将内容传输到浏览器,而不能将基本 HTML 页面连同一些 JS 传输到浏览器,让客户端处理所有操作呢?
这就是单页面应用程序 (SPA) 和客户端渲染 (CSR) 的诞生。
客户端渲染
在 CSR 中,也称为动态渲染,代码主要在客户端(用户的浏览器)上运行。客户端的浏览器下载必要的 HTML、JavaScript 和其他资产,然后运行代码以渲染 UI。
这种方法有两个好处
- 出色的用户体验。如果您拥有超快的网络,并且可以快速下载捆绑包和数据,那么一旦所有内容到位,您的网站将非常快。您无需返回服务器以发出更多请求,因此每个页面更改或数据更改都会立即发生。
- 缓存。因为您没有使用服务器,所以您可以在 CDN 上缓存核心 HTML 和 JS 捆绑包。这意味着用户可以快速访问它们,并降低公司成本。
随着网络变得更加交互式(感谢 JavaScript 和浏览器),客户端渲染和 SPA 成为默认选择。网络感觉既快又猛烈……特别是如果您使用的是桌面电脑、流行的浏览器和有线互联网连接。
对于其他所有人来说,网络速度变得很慢。随着网络的成熟,它变得可以在更多设备和不同连接上使用。管理 SPA 以确保一致的用户体验变得更加困难。开发人员不仅要确保网站在 IE 中的渲染效果与在 Chrome 中的渲染效果相同,还要考虑网站在繁华城市公交车上的手机中的渲染效果。如果您的数据连接无法从缓存中下载该 JS 捆绑包,那么您将无法访问网站。
我们如何轻松确保在各种设备和带宽下的一致性?答案是返回服务器。
服务器端渲染
将浏览器渲染网站的工作转移到服务器上有许多好处
- 性能更高,因为 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?或者我们是否根据路由发送?
- 水合是否自上而下完成,成本有多高?
- 开发人员如何组织代码库?
在客户端,大型捆绑包会导致内存问题以及用户会遇到的“为什么什么也没发生”的感觉,因为所有 HTML 都已存在,但您无法使用它,直到它完成水合。
我们 Deno 团队喜欢的一种方法是 岛屿架构,就像在一片静态 SSR 的 HTML 之海中,存在着交互式岛屿。(您可能已经发现,我们的现代 Web 框架 Fresh 默认情况下不会向客户端发送任何 JavaScript,它使用岛屿)。
您希望使用 SSR 做到的是快速地提供和渲染 HTML,然后独立地提供和渲染每个组件。这样,您就可以发送更小的 JavaScript 块,并在客户端执行更小的渲染块。
这就是岛屿的工作原理。
岛屿不是逐步渲染的,而是独立渲染的。岛屿的渲染不依赖于任何先前组件的渲染,对虚拟 DOM 其他部分的更新不会导致任何单个岛屿重新渲染。
岛屿保留了 SSR 的所有好处,但没有大型水合捆绑包的权衡。非常成功。
如何从服务器渲染
并非所有服务器端渲染都是相同的。有服务器端渲染,也有服务器端渲染。
在这里,我们将带您了解一些在 Deno 中从服务器渲染的不同示例。我们将移植 Jonas Galvez 的优秀 服务器端渲染入门 到 Deno,Oak 和 Handlebars,具有三个不同版本的同一应用程序。
- 一个简单的、模板化的、服务器端渲染的 HTML 示例,没有任何客户端交互 (源代码)
- 一个最初在服务器端渲染、然后在客户端更新的示例 (源代码)
- 一个完全服务器端渲染的版本,具有同构 JS 和共享数据模型 (源代码)
正是这个第三个版本才是真正的 SSR。我们将使用一个 JavaScript 文件,它将被服务器和客户端同时使用,并且列表的任何更新都将通过更新数据模型来完成。
但首先,让我们先做一些模板化操作。在这个第一个例子中,我们只渲染一个列表。以下是 main.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>
所以发生的事情是。
- 根目录由客户端调用。
- 服务器将恐龙列表传递给 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 来处理 添加
按钮:一个绑定到按钮的 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。然后,我们使用创建的文档来渲染笔记列表,但不再使用 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 获取答案。