Web 的未来(和过去)是服务器端渲染
当服务器还在瑞士地下室时,它们所能提供的只有静态 HTML。也许,如果你够幸运,还能得到一张图片。
现在,一个网页可以是一个功能完备的应用程序,从多个来源拉取数据,进行即时操作,并允许终端用户进行完全的交互。这极大地提高了网络的实用性,但代价是大小、带宽和速度。在过去的10年里,桌面网页的平均大小从468 KB增加到2284 KB,增长了388.3%。对于移动设备来说,这个飞跃更加惊人——从145 KB到2010 KB——惊人的1288.1%的增长。
这对于网络传输来说是一个巨大的负担,尤其是对于移动设备。因此,用户体验糟糕、加载时间缓慢,并且在一切渲染完成之前缺乏交互性。但所有这些代码对于让我们的网站按预期运行是必需的。
这就是当今前端开发人员面临的问题。对于前端开发人员来说,最初构建炫酷网站并拥有所有花哨功能是很有趣的,但现在却变得不那么有趣了。我们现在要应对各种浏览器的支持、通过慢速网络传输代码以及间歇性的移动连接。支持所有这些排列组合是一个巨大的麻烦。
我们如何解决这个难题?通过回到服务器(无需瑞士地下室)。
我们走到这一步的简要回顾
最初有 PHP,并且它很棒,只要你喜欢问号。
Web 最初是一个静态 HTML 网络,但像 Perl 和 PHP 这样的 CGI 脚本语言,允许开发人员将后端数据源渲染到 HTML 中,引入了网站可以根据访问者进行动态化的概念。
这意味着开发人员能够构建动态网站并向终端用户提供实时数据或数据库数据(只要他们的 #、!、$ 和 ? 键正常工作)。
PHP 在服务器上运行,因为服务器是网络的强大组成部分。你可以在服务器上获取数据并渲染 HTML,然后将其全部发送到浏览器。浏览器的工作有限——仅仅解析文档并显示页面。这很有效,但这种解决方案主要关注显示信息,而不是与信息交互。
然后发生了两件事:JavaScript 变得更强大了,浏览器也变得更强大了。
这意味着我们可以在客户端直接做很多有趣的事情。为什么还要先在服务器上渲染所有内容并发送出去,当你只需将一个基本的 HTML 页面和一些 JS 传输到浏览器,让客户端处理一切即可?
客户端渲染
在 CSR(也称为动态渲染)中,代码主要在客户端(用户的浏览器)上运行。客户端的浏览器下载必要的 HTML、JavaScript 和其他资产,然后运行代码来渲染 UI。
这种方法有双重好处:
- 出色的用户体验。如果你有一个超快的网络,并且能够快速下载捆绑包和数据,那么一旦一切就绪,你就会拥有一个超快的网站。你无需返回服务器进行更多请求,因此每次页面更改或数据更改都会立即发生。
- 缓存。由于你没有使用服务器,因此可以将核心 HTML 和 JS 捆绑包缓存在 CDN 上。这意味着用户可以快速访问它们,并为公司保持低成本。
随着网络变得越来越具交互性(感谢 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,它通过一个称为“hydration”(水合)的过程与静态 HTML 结合。
发送 JS 进行水合会导致复杂性问题
- 我们是否在每个请求中都发送所有 JS?还是基于路由来发送?
- 水合是自上而下完成的吗?成本如何?
- 开发人员如何组织代码库?
在客户端,大型捆绑包可能导致内存问题,以及用户体验到“为什么什么都没发生?”的感觉,因为所有的 HTML 都已存在,但在水合完成之前你实际上无法使用它。
Deno 喜欢的一种方法是岛屿架构,即在一片静态 SSR'd HTML 的海洋中,存在交互性岛屿。(你可能已经注意到,我们默认不向客户端发送任何 JavaScript 的现代 Web 框架 Fresh 使用了岛屿。)
使用 SSR,你希望 HTML 能够快速提供和渲染,然后每个独立的组件也能独立提供和渲染。这样,你发送的 JavaScript 块更小,在客户端进行的渲染也更少。
这就是岛屿的工作方式。
岛屿并非渐进式渲染,而是独立渲染。一个岛屿的渲染不依赖于任何先前组件的渲染,并且对虚拟 DOM 其他部分的更新不会导致任何单个岛屿的重新渲染。
岛屿保留了整体 SSR 的优点,同时避免了大型水合包的缺点。巨大的成功。
如何从服务器渲染
并非所有服务器端渲染都相同。有服务器端渲染,然后有真正的服务器端渲染(Server-Side Rendering)。
在这里,我们将向您介绍一些在 Deno 中从服务器渲染的不同示例。我们将把 Jonas Galvez 的精彩服务器渲染简介移植到 Deno、Oak 和 Handlebars,并附带同一应用的三个变体:
这第三个版本是真正意义上的 SSR。我们将拥有一个同时供服务器和客户端使用的 JavaScript 文件,并且对列表的任何更新都将通过更新数据模型来完成。
但首先,让我们做一些模板化。在这个第一个例子中,我们所要做的就是渲染一个列表。这是主要的 server.ts
import { Application, Router } from "https://deno.land/x/oak@v11.1.0/mod.ts";
import { Handlebars } from "https://deno.land/x/handlebars@v0.9.0/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/oak@v11.1.0/mod.ts";
import { Handlebars } from "https://deno.land/x/handlebars@v0.9.0/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/oak@v11.1.0/mod.ts";
import { Router } from "https://deno.land/x/oak@v11.1.0/mod.ts";
import { DOMParser } from "https://deno.land/x/deno_dom@v0.1.36-alpha/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 });
这次变化很大。首先,我们又增加了一些新的端点:
- 一个将提供我们的
client.js
文件的 GET 端点 - 一个将提供我们数据的 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://: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 寻求解答。