翻译自 Deno 团队的Fresh 原文:A Gentle Introduction to Islands
现代的 JavaScript Web 框架包含了大量 JavaScript。
但是大部分网站并不需要包含那么多 JavaScript。但是有些网站是需要的。如果你正在开发一个动态地、交互式的仪表盘,你可以尽情地使用 JavaScript。另一方面,文档页面、博客、静态内容网站等等不需要任何 JavaScript。例如,这篇博客的原文就没有包含任何 JavaScript。
但是,还有很多网站处于一种中间状态,它们需要一些交互性,但不是太多:
这些 "Goldilocks" (恰到好处) 的网站恰好是现在框架的问题所在:你不能静态地生成这些页面,但为了一个图片轮播按钮,而将整个框架打包并通过网络发送给用户,似乎过于浪费。我们可以为这类网站做什么呢?
给他们 islands 架构。
什么是 Islands?
这个是我们的 商品网站,它使用 Fresh 开发,一个使用 Deno 开发的基于 Islands 架构 Web 框架。
这个页面的主要内容是静态的 HTML:页眉和页脚、标题、链接和文本。这些都不需要交互能力,因此没有使用任何 JavaScript。但是,该页面上的三个元素需要进行交互:
- "Add to Cart" 按钮
- 图片轮播图
- 购物车按钮
这些就是 islands。Islands 是隔离的 Preact 组件 ,然后会在客户端和静态渲染的 HTML 进行水合(hydration)。
- 隔离:这些组件是独立编写和发布的,与页面中的其他部分无关;
- Preact:一个仅有 3kb 大小的 React 替代,所以即使 Fresh 正在发布 islands,它仍然仅使用最少量的 JS;
- 水合:如何将 JavaScript 从服务器渲染添加到客户端页面;
- 静态渲染的 HTML 页面:没有 JavaScript 的基本 HTML 会从服务器发送到客户端,如果页面上没有使用 islands,那么只会发送 HTML。
其中最关键的部分是水合。这是 JavaScript 框架正在努力解决的问题,因为这是框架工作的基础,但同时水合是纯粹的开销。
JavaScript 框架水合页面,但是 Islands 框架水合的是组件。
水合的问题 - "Hydrate level 4, please"
为什么没有使用 Islands 架构时,会发送非常多的 JavaScript?因为这是现代 "meta" JavaScript 框架的工作方式。你可以使用框架来创建你的内容,并为页面添加交互能力,分别发送它们,然后在浏览器里使用一种叫 "水合" 的机制将它们合并。
在开始时,这些东西是分离的。你有一个服务端的框架来生成 HTML(PHP,Django,再到 NodeJS)和一个客户端的插件来提供交互能力(最常见的是 jQuery)。然后,我们来到了 React SPA 的领域,所有事情变成了在客户端一侧。你发布了一个基本的 HTML 框架,而整个网站,包括内容、数据和交互能力都在客户端生成。
后来,页面越来越大,SPA 变得越来越慢。服务端渲染又回来了,但是通过相同的代母添加交互能力,而不是一个另外的插件。你使用 JavaScript 创建整个应用,然后在构建阶段,交互能力和应用的初始状态(组件的状态以及从 API 服务器获取的任何数据)被序列化并打包成 JavaScript 和 JSON。
当一个页面被请求时,服务器端会发送 HTML 以及交互能力和状态所需要的打包后的 JavaScript。之后,客户端会 "水合" JavaScript,也就是:
- 从根节点遍历整个 DOM 树;
- 对于每个 DOM 节点,如果它是可交互的,就为它添加事件监听器,设置初始状态然后重新渲染。如果节点不需要交互,复用原始 DOM 中的节点进行调和(reconcile)。
使用这种方式 HTML 会被迅速地显示出来,用户不需要盯着一个白屏,等待 JavaScript 加载完成页面拥有交互能力。
水合就像这幅图一样:
构建阶段从你的应用中提取出所有精华部分,留下一个干瘪的外壳。然后,你可以将这个干瘪的外壳和单独的水一起发送,由客户端的 Black & Decker hydrator 浏览器进行组合。这会带给你一个可食用的披萨 / 可用的网站(感谢这个 SO 回答的类比)。
这样做的问题是什么?水合将页面视为一个单独的组件。水合自上而下地进行,遍历整个 DOM 树寻找需要被水合的节点。即使你在开发中将应用分解为组件,但这些信息在水合时会被丢弃,所有东西会被打包在一起发布。
这些框架开发的应用还会发送框架自带的 JavaScript。如果我们创建一个新的 Next 应用,移除所有东西仅在首页保留一个 h1
标签,我们仍然会发现 JavaScript 被发送到了客户端,包含一个 JavaScript 版本的 h1
渲染函数,即使构建阶段知道这个页面可以被静态生成。
代码分割(code-splitting)和渐进式水合(progressive hydration)是解决这个基础问题的变通手段。它们将原本打包的代码和水合过程分割为单独的块或者步骤。这可以使得页面获得交互能力的速度变快,因为你可以在剩余部分下载完成前,就开始第一个块的水合。
但是,你还是将所有的 JavaScript 发送到了可能并不需要使用它的客户端,并且必须对它进行处理以便之后的使用。
Fresh 中的 Islands 架构
如果我们在基于 Deno 的 Web 框架 Fresh 中做类似的事情,我们会发现应用没有 JavaScript。
这个页面上没有任何东西需要 JavaScript,所有没有 JavaScript 被发送。
现在让我们以 island 的形式添加一些 JavaScript。
所以我们有了 3 个 JavaScript 文件:
chunk-A2AFYW5X.js
island-counter.js
main.js
为了演示这些 JavaScript 文件是如何产生的,这是请求接收后发生了什么的时间线。
渲染一个 Fresh 应用:
- 服务器端:
- Fresh 边缘服务器接收到一个 HTTP 请求;
- Fresh 从 manifest 文件中定位到 islands;
- 创建 vnodes,Preact 定位 "island" 节点并为其添加相应 HTML 注释;
- 所需要的 JavaScript 文件被生成和打包,准备发送给客户端;
- 服务器发送 HTML 和水合需要的 JavaScript 文件;
- 客户端:
- 浏览器接收到 HTML 并缓存所有静态资源,包含 JavaScript;
- 浏览器运行
main.js
,遍历所有的 islands,遍历 DOM 树寻找 HTML 注释,然后对它们进行水合; - Islands 现在可以交互了。
注意这个时间线是对于一个 Fresh 应用的首次请求。对于已经缓存的静态资源,后续的请求只需要简单地从缓存中检索。
让我们深入一些关键步骤来看看 islands 是如何工作的。
从 fresh.gen.ts
中为 islands 检查 manifest
第一步定位所有 islands 需要从 fresh.gen.ts
检查 manifest
。这是一个你的应用自动生成的文档,它可以列出应用中的所有页面和 islands。
ts
// fresh.gen.ts
import config from "./deno.json" assert { type: "json" };
import * as $0 from "./routes/index.tsx";
import * as $$0 from "./islands/Counter.tsx";
const manifest = {
routes: {
"./routes/index.tsx": $0,
},
islands: {
"./islands/Counter.tsx": $$0,
},
baseUrl: import.meta.url,
config,
};
export default manifest;
Fresh 框架会将 manifest 清单处理成不同的页面(此处没有展示)和组件。任何 islands 会被传入一个 islands 数组。
ts
// context.ts
// Overly simplified for sake of example.
for (const [self, module] of Object.entries(manifest.islands)) {
const url = new URL(self, baseUrl).href;
if (typeof module.default !== "function") {
throw new TypeError(
`Islands must default export a component ('${self}').`,
);
}
islands.push({ url, component: module.default });
}
在服务端渲染时将每个 island 替换为唯一的 HTML 注释
在 render.ts 进行服务端渲染时,Preact 创建了一个虚拟 DOM。既然每个虚拟 DOM 都会被创建,因此 Preact 的 options.vnode.hook 会被调用。
ts
// render.ts
options.vnode = (vnode) => {
assetHashingHook(vnode);
const originalType = vnode.type as ComponentType<unknown>;
if (typeof vnode.type === "function") {
const island = ISLANDS.find((island) => island.component === originalType);
if (island) {
if (ignoreNext) {
ignoreNext = false;
return;
}
ENCOUNTERED_ISLANDS.add(island);
vnode.type = (props) => {
ignoreNext = true;
const child = h(originalType, props);
ISLAND_PROPS.push(props);
return h(
`!--frsh-${island.id}:${ISLAND_PROPS.length - 1}--`,
null,
child,
);
};
}
}
if (originalHook) originalHook(vnode);
};
动态生成的水合脚本
下一步是基于检测到的 islands 生成水合脚本,即根据所有被加入集合 ENCOUNTERED_ISLANDS
的 islands。
在 render.ts
中,如果 ENCOUNTERED_ISLANDS
不是一个空集,那么我们会为即将发送到客户端的水合脚本,添加从 main.js
导入的 revive
函数的语句。
ts
if (ENCOUNTERED_ISLANDS.size > 0) {
// ...
script += `import { revive } from "${bundleAssetUrl("/main.js")}";`;
注意到如果 ENCOUNTERED_ISLANDS
是空集,那么整个 islands 部分的处理会被跳过,并且没有个 JavaScript 会被发送到客户端。
然后,render
函数会将每个 island 的 JavaScript(/island-${island.id}.js
)添加到数组中,同时将相应的 import
语句添加到 script
里。
ts
//render.ts, continued
let islandRegistry = "";
for (const island of ENCOUNTERED_ISLANDS) {
const url = bundleAssetUrl(`/island-${island.id}.js`);
script += `import ${island.name} from "${url}";`;
islandRegistry += `${island.id}:${island.name},`;
}
script += `revive({${islandRegistry}}, STATE[0]);`;
}
在 render
函数的最后,所有 import
语句合成的 script
字符串和 revive()
函数,会被添加到 HTML 中。除此之外,每个 island 的 JavaScript 的 URL 路径的 import
数组会被渲染为 HTML 字符串。
下面是 script
字符串在被加载到浏览器中的样子。
html
<script type="module">
const STATE_COMPONENT = document.getElementById("__FRSH_STATE");
const STATE = JSON.parse(STATE_COMPONENT?.textContent ?? "[[],[]]");
import { revive } from "/_frsh/js/1fx0e17w05dg/main.js";
import Counter from "/_frsh/js/1fx0e17w05dg/island-counter.js";
revive({counter:Counter,}, STATE[0]);
</script>
为了方便查看,语句之间添加了换行符。
当这个字符串被浏览器加载后,它将会从 main.js 里运行
revive方法去水合
Counter` island。
浏览器运行 revive
main.js
(main.ts
压缩后的版本)中定义了 revive
函数。它会遍历虚拟 DOM,搜索正则表达式匹配 Fresh 在之前步骤中添加的 HTML 注释。
js
// main.js
function revive(islands, props) {
function walk(node) {
let tag = node.nodeType === 8 &&
(node.data.match(/^\s*frsh-(.*)\s*$/) || [])[1],
endNode = null;
if (tag) {
let startNode = node,
children = [],
parent = node.parentNode;
for (; (node = node.nextSibling) && node.nodeType !== 8;) {
children.push(node);
}
startNode.parentNode.removeChild(startNode);
let [id, n] = tag.split(":");
re(
ee(islands[id], props[Number(n)]),
createRootFragment(parent, children),
), endNode = node;
}
let sib = node.nextSibling,
fc = node.firstChild;
endNode && endNode.parentNode?.removeChild(endNode),
sib && walk(sib),
fc && walk(fc);
}
walk(document.body);
}
var originalHook = d.vnode;
d.vnode = (vnode) => {
assetHashingHook(vnode), originalHook && originalHook(vnode);
};
export { revive };
如果我们查看 index.html
,会发现下面能够匹配 revive
函数里正则表达式的注释:
html
<!--frsh-counter:0-->
当 revive
函数发现了这个注释,它会调用 createRootFragment
使用 Preact 的 render
/ h
函数来渲染这个组件。
现在客户端拥有一个可以交互的 island,可以立即使用!
其他框架中的 Islands
Fresh 不是使用 islands 架构的唯一框架。Astro 也基于 islands 架构,但是使用了不同配置,你可以指定每个组件如何加载 JavaScript。例如,这个组件不需要加在 JavaScript。
html
<MyReactComponent />
但是,你可以添加一个 client 指令,现在它会加载 JavaScript。
html
<MyReactComponent client:load />
其它框架例如 Marko 使用了部分水合。它和 islands 之间的区别是微妙的。
在 Islands 中,开发人员明确知道哪些组件将会被水合,哪些不会。例如,Fresh中,仅在具有 CamelCase 或 kebab-case 命名的 islands 目录中的组件才会发送 JavaScript。
在部分水合中,组件是和正常一样编写的,而框架会在构建过程中确定哪些 JavaScript 会被发送。
另外一个解决这个问题的答案是 React 服务端组件,他支持了 NextJS 的新 /app 目录结构。这有助于更清晰地定义哪些在工作服务端进行,哪些在客户端进行,尽管它是否减少了发送的 JavaScript 数量仍然有待商榷。
除了 islands 架构外最令人兴奋的发展是 Qwik 的 resumability 特性。它们将水合步骤完全移除,取而代之的是将 JavaScript 序列化到打包 的 HTML 中。一旦 HTML 发送到客户端,整个应用就都可以使用,包括所有交互能力。
Islands 架构总结
将 islands 架构和 resumability 特性结合在一起可能可以发送更少的 JavaScript,并且移除掉水合步骤。
但是,islands 架构带来的不仅仅是更小的打包体积。Islands 架构的一个巨大的好处是,它带给你开发过程中的心智模型。使用 islands,你必须选择 JavaScript 是否被发送。你永远不要错误地将不必要的 JavaScript 发送到客户端里。当开发者构建一个应用时,每个交互能力的包含与否都应该是开发者的选择后的结果。
因此,发送更少的 JavaScript 不是架构或者框架的责任,而是你作为开发者的责任。