Islands 编程范式简介
现代 JavaScript 框架包含了大量的 JavaScript。这似乎是显而易见的。
但大多数网站并不包含大量的 JavaScript。
有些网站确实如此。如果你正在构建一个动态交互式仪表盘,那就尽情使用 JS 吧。另一方面,文档页面、博客、静态内容网站等则不需要任何 JavaScript。例如,这个博客就没有使用 JS。
但在这两者之间,还有一大批网站需要一些交互性,但又不需要太多。
对于框架来说,这些“刚刚好”的网站是个难题:你无法静态生成这些页面,但为了一个图片轮播按钮而打包整个框架并通过网络发送,似乎有些小题大做。我们能为这些网站做些什么呢?
给它们 Islands 架构。
什么是 Islands?
以下是它在我们的商品网站上的样子,该网站使用 Fresh(我们基于 Deno 的 Web 框架,使用 Islands 架构)构建。
页面的主要部分是静态 HTML:页眉和页脚、标题、链接和文本。这些都不需要交互性,因此都不使用 JavaScript。但页面上有三个元素确实需要交互性:
- “添加到购物车”按钮
- 图片轮播
- 购物车按钮
这些就是 Islands。Islands 是独立的 Preact 组件,它们在静态渲染的 HTML 页面中在客户端进行水合。
- 独立:这些组件是独立于页面其余部分编写和交付的。
- Preact:一个微小的 3kb React 替代品,因此即使 Fresh 交付 Islands,它也只使用最少量的 JS。
- 水合 (Hydration):指 JavaScript 如何从服务器渲染添加到客户端页面。
- 静态渲染的 HTML 页面:从服务器发送到客户端的不含 JavaScript 的基本 HTML。如果此页面上没有使用 Islands,则只发送 HTML。
其中的关键部分是水合。这是 JavaScript 框架面临困境的地方,因为它是它们工作方式的基础,但与此同时,水合本身就是纯粹的开销。
JS 框架是对页面进行水合。Islands 框架是对组件进行水合。
水合的问题 — “请执行水合四级”
为什么在没有 Islands 的情况下会发送如此多的 JavaScript?这是现代“元”JavaScript 框架工作方式的体现。你使用这些框架既可以创建内容,也可以为页面添加交互性,然后将它们分开发送,最后在浏览器中通过一种称为“水合”的技术将它们组合起来。
最初,这些是分离的。你有一个服务器端语言来生成 HTML(PHP、Django,然后是 NodeJS),以及提供交互性的客户端插件(jQuery 是最普遍的)。然后我们进入了 React SPA 时代,一切都变成了客户端。你交付一个裸露的 HTML 骨架,而整个网站,包括内容、数据和交互性,都在客户端生成。
后来页面变得庞大,SPA 变得缓慢。SSR 卷土重来,但交互性是通过相同的代码库添加的,无需插件。你用 JS 创建整个应用程序,然后在构建步骤中,应用程序的交互性和初始状态(组件的状态以及从 API 服务器端拉取的数据)被序列化并打包成 JS 和 JSON。
每当请求一个页面时,HTML 会与交互性和状态所需的 JS 包一起发送。然后客户端“水合”JS,这意味着:
- 从根节点遍历整个 DOM
- 对于每个节点,如果元素是交互式的,则附加一个事件监听器,添加初始状态,并重新渲染。如果节点不应是交互式的(例如 h1),则重用原始 DOM 中的节点并进行协调。
这样,HTML 可以快速显示,用户不会盯着空白页面,然后在 JS 加载后页面变得可交互。
你可以这样理解水合:
构建步骤将应用程序中所有“多汁”的部分取出,只留下一个干燥的外壳。然后你可以将这个干燥的外壳连同单独的一加仑水一起发送,由客户端的Black & Decker 水合器浏览器进行组合。这样你就能得到一个可食用的披萨/可用的网站(感谢这篇 Stack Overflow 回答 提供的这个比喻)。
这有什么问题呢?水合将页面视为一个单一组件。水合是从上到下进行的,它遍历整个 DOM 以找到需要水合的节点。即使你在开发过程中将应用程序分解为组件,但在打包和交付时,所有这些都被丢弃并捆绑在一起。
这些框架还会发送框架特定的 JavaScript。如果我们创建一个新的 Next 应用,并删除除首页上的 h1 之外的所有内容,我们仍然会看到 JS 被发送到客户端,包括 h1 的 JS 版本,即使构建过程表明该页面是静态生成的。
代码分割和渐进式水合是解决这个根本问题的变通方法。它们将初始包和水合分解成单独的块和步骤。这应该会使页面更快地变得可交互,因为你可以在其余部分下载之前从第一个块开始水合。
但你最终仍然会将所有这些 JavaScript 发送给可能不会使用它的客户端,并且客户端必须处理它才能确定是否会使用它。
Islands 在 Fresh 中的工作原理
如果我们在 Fresh(我们基于 Deno 的 Web 框架)中做类似的事情,我们将看到零 JavaScript。
页面上没有任何内容需要 JS,因此没有发送任何 JS。
现在让我们添加一些 Islands 形式的 JavaScript
因此我们有三个 JavaScript 文件
- chunk-A2AFYW5X.js
- island-counter.js
- main.js
为了展示这些 JS 文件是如何出现的,以下是收到请求时发生的时间线:
请注意,此时间线是针对 Fresh 应用程序的首次请求。资产被缓存后,后续请求将直接从缓存中检索必要的脚本。
让我们深入了解使 Islands 正常工作的关键步骤。
fresh.gen.ts
中检查 Islands 的 manifest
从 定位任何 Islands 的第一步是检查 fresh.gen.ts
中的 manifest。这是你自己的应用程序中自动生成的一个文档,其中列出了应用程序中的页面和 Islands。
//fresh.gen.ts
import config from "./deno.json" with { 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 数组。
//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 });
}
在服务器端渲染期间,用唯一的 HTML 注释替换每个 Island
在使用 render.ts 进行服务器渲染期间,Preact 会创建一个虚拟 DOM。当每个虚拟节点创建时,Preact 中的 options.vnode 钩子 会被调用。
// 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);
};
函数 options.vnode
可以在 vnode 渲染之前对其进行修改。大多数 vnode(例如 <div>
)都会按预期渲染。但如果 vnode 既是一个函数,并且其函数类型与 Islands 数组中的某个元素相同(因此,它是 Island 的原始节点),那么该 vnode 将被包装在两个 HTML 注释中。
<!--frsh-${island.id}:${ISLAND_PROPS.length - 1}-->
// the island vnode
</!--frsh-${island.id}:${ISLAND_PROPS.length - 1}-->
对于各位技术爱好者,尽管结束注释 </!-- xxx -->
并非有效的 HTML,但浏览器仍然可以正确解析和渲染它。
此信息还会添加到 ENCOUNTERED_ISLANDS
集合中。
在我们的例子中,标题和柠檬图片将按预期渲染,但一旦创建了 Counter
的 vnode,HTML 注释 !--frsh-counter:0--
就会被插入。
(Fresh 使用注释而非 <div>
或自定义元素的原因是,引入新元素有时可能会干扰页面的样式和布局,导致 CLS 问题。)
动态生成水合脚本
下一步是根据检测到的 Islands,即根据所有添加到 ENCOUNTERED_ISLANDS
集合中的 Islands,生成水合脚本。
在 render.ts
中,如果 ENCOUNTERED_ISLANDS
大于 0,那么我们将把从 main.js
导入 revive
函数的 import
语句添加到将发送给客户端的水合脚本中。
//render.ts
if (ENCOUNTERED_ISLANDS.size > 0) {
script += `import { revive } from "${bundleAssetUrl("/main.js")}";`;
请注意,如果 ENCOUNTERED_ISLANDS
为 0,则整个 Islands 部分将被跳过,并且不会向客户端发送任何 JavaScript。
然后,render
函数将每个 Island 的 JavaScript (/island-${island.id}.js
) 添加到一个数组中,并将其导入行添加到 script
字符串中。
//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
函数结束时,包含导入语句和 revive()
函数的 script
字符串会被添加到 HTML 的主体中。此外,包含每个 Island JavaScript URL 路径的 imports
数组会被渲染成一个 HTML 字符串。
当这个 script
字符串加载到浏览器中时,它看起来是这样的:
<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
浏览器运行 revive
函数定义在 main.js
中(它是 main.ts
的缩小版本)。它会遍历虚拟 DOM,搜索正则表达式匹配,以识别 Fresh 在早期步骤中插入的任何 HTML 注释。
//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
中查看,我们会看到有与该正则表达式匹配的注释。
<!--frsh-counter:0-->
当 revive
找到注释时,它会调用 createRootFragment
并使用 Preact 的 render
/ h
来渲染组件。
现在,你就有了一个在客户端随时可用的交互性 Island!
其他框架中的 Islands
Fresh 并不是唯一使用 Islands 架构的框架。Astro 也使用 Islands,但其配置方式不同,你可以在其中指定每个组件如何加载其 JavaScript。例如,这个组件将加载 0 JS:
<MyReactComponent />
但添加一个 客户端指令 后,它现在将通过 JS 加载。
<MyReactComponent client:load />
其他框架,例如 Marko,使用部分水合。Islands 和部分水合之间的区别很微妙。
在 Islands 架构中,开发者和框架可以明确指定哪些组件将被水合,哪些不会。例如,在 Fresh 中,唯一会附带 JavaScript 的组件是那些位于 islands 目录中,并采用驼峰式或烤串式命名(CamelCase 或 kebab-case)的组件。
对于部分水合,组件是正常编写的,框架在构建过程中决定应该交付哪些 JS。
解决这个问题的其他方案包括 React Server Components,它是 NextJS 新的 /app
目录结构的基础。这有助于更清晰地定义服务器端和客户端的工作,尽管它是否能减少交付包中 JS 的数量仍在争论中。
除了 Islands 之外,最令人兴奋的进展是 Qwik 的可恢复性 (resumability)。它们完全消除了水合,而是将 JavaScript 序列化到 HTML 包中。一旦 HTML 到达客户端,整个应用程序就可以直接运行,包括交互性。
流中的 Islands
将 Islands 与可恢复性结合,可能可以减少 JS 的传输量并消除水合。
但 Islands 的优势不仅仅在于更小的包。Islands 架构的一个巨大好处是它促使你形成的心智模型。使用 Islands,你必须主动选择使用 JavaScript。你永远不会意外地将 JavaScript 发送给客户端。当开发者构建应用程序时,每一次交互性和 JavaScript 的引入都是开发者明确的选择。
这样一来,减少 JavaScript 传输的不是架构和框架——实际上是你,开发者。
不要错过任何更新 — 在 Twitter 上关注我们。