跳到主要内容
Deno 2.4 发布,带来 deno bundle、字节/文本导入、OTel 稳定版等新特性
了解更多
Deno on an island with a mai tai.

Islands 编程范式简介

现代 JavaScript 框架包含了大量的 JavaScript。这似乎是显而易见的。

但大多数网站并不包含大量的 JavaScript。

有些网站确实如此。如果你正在构建一个动态交互式仪表盘,那就尽情使用 JS 吧。另一方面,文档页面、博客、静态内容网站等则不需要任何 JavaScript。例如,这个博客就没有使用 JS。

但在这两者之间,还有一大批网站需要一些交互性,但又不需要太多。

the goldilocks of javascript

对于框架来说,这些“刚刚好”的网站是个难题:你无法静态生成这些页面,但为了一个图片轮播按钮而打包整个框架并通过网络发送,似乎有些小题大做。我们能为这些网站做些什么呢?

给它们 Islands 架构

什么是 Islands?

以下是它在我们的商品网站上的样子,该网站使用 Fresh(我们基于 Deno 的 Web 框架,使用 Islands 架构)构建。

islands of interactivity on our merch site

页面的主要部分是静态 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 加载后页面变得可交互。

你可以这样理解水合:

请执行水合四级! 《回到未来2》

构建步骤将应用程序中所有“多汁”的部分取出,只留下一个干燥的外壳。然后你可以将这个干燥的外壳连同单独的一加仑水一起发送,由客户端的Black & Decker 水合器浏览器进行组合。这样你就能得到一个可食用的披萨/可用的网站(感谢这篇 Stack Overflow 回答 提供的这个比喻)。

这有什么问题呢?水合将页面视为一个单一组件。水合是从上到下进行的,它遍历整个 DOM 以找到需要水合的节点。即使你在开发过程中将应用程序分解为组件,但在打包和交付时,所有这些都被丢弃并捆绑在一起。

这些框架还会发送框架特定的 JavaScript。如果我们创建一个新的 Next 应用,并删除除首页上的 h1 之外的所有内容,我们仍然会看到 JS 被发送到客户端,包括 h1 的 JS 版本,即使构建过程表明该页面是静态生成的。

Hello from Next

代码分割和渐进式水合是解决这个根本问题的变通方法。它们将初始包和水合分解成单独的块和步骤。这应该会使页面更快地变得可交互,因为你可以在其余部分下载之前从第一个块开始水合。

但你最终仍然会将所有这些 JavaScript 发送给可能不会使用它的客户端,并且客户端必须处理它才能确定是否会使用它。

Islands 在 Fresh 中的工作原理

如果我们在 Fresh(我们基于 Deno 的 Web 框架)中做类似的事情,我们将看到零 JavaScript。

Hello from Fresh without JavaScript

页面上没有任何内容需要 JS,因此没有发送任何 JS。

现在让我们添加一些 Islands 形式的 JavaScript

Hello from Fresh with islands

因此我们有三个 JavaScript 文件

  • chunk-A2AFYW5X.js
  • island-counter.js
  • main.js

为了展示这些 JS 文件是如何出现的,以下是收到请求时发生的时间线:

Timeline of events from request

请注意,此时间线是针对 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 上关注我们