跳转至主要内容
Deno 2 终于来了 🎉️
了解更多
Deno on an island with a mai tai.

海岛架构入门简介

现代 JavaScript 框架包含大量的 JavaScript 代码。这显而易见。

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

有些网站包含很多 JavaScript 代码。如果您正在构建动态交互式仪表板,尽管使用 JS。另一方面,文档页面、博客、静态内容网站等根本不需要 JavaScript。例如,此博客就没有 JS。

但是,还有大量的网站介于两者之间,它们需要一些交互性,但不需要太多。

the goldilocks of javascript

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

给他们海岛架构

什么是海岛架构?

以下是我们的周边商品网站上的示例,该网站使用 Fresh 构建,Fresh 是我们基于 Deno 的 Web 框架,它使用了海岛架构。

islands of interactivity on our merch site

页面的主要部分是静态 HTML:页眉和页脚、标题、链接和文本。这些都不需要交互性,因此都不使用 JavaScript。但此页面上的三个元素确实需要交互:

  • “添加到购物车”按钮
  • 图片轮播
  • 购物车按钮

这些就是海岛。海岛是*独立的 Preact 组件,然后在客户端的静态渲染 HTML 页面中进行 hydration*。

  • **独立的**:这些组件的编写和交付独立于页面的其余部分。
  • **Preact**:React 的一个微型替代品,大小只有 3kb,因此即使 Fresh 正在发送海岛,它仍然只使用了最少的 JS。
  • **Hydration(注水)**:JavaScript 如何从服务器渲染添加到客户端页面。
  • **静态渲染的 HTML 页面**:从服务器发送到客户端的基本 HTML,不包含 JavaScript。如果此页面上没有使用海岛,则只会发送 HTML。

其中的关键部分是 hydration。这是 JavaScript 框架的难点所在,因为它是它们工作方式的基础,但与此同时,hydration 是纯粹的开销

JS 框架对整个页面进行 hydration。海岛框架只对组件进行 hydration。

hydration 的问题 - “请加水到4级”

为什么在没有海岛架构的情况下会发送这么多 JavaScript 代码?这是现代“元”JavaScript 框架工作方式的一个函数。您使用框架来创建内容并向页面添加交互性,分别发送它们,然后在浏览器中使用一种称为“Hydration”的技术将它们组合起来。

起初,这些是分开的。您有一个服务器端语言生成 HTML(PHP、Django,然后是 NodeJS),客户端插件提供交互性(jQuery 最为流行)。然后我们进入了 React SPA 时代,一切都发生在客户端。您发送一个简单的 HTML 骨架,整个网站(包括内容、数据和交互性)都在客户端生成。

然后页面变大了,SPA 变慢了。SSR 回来了,但交互性是通过相同的代码库添加的,而不是通过插件。您用 JS 创建整个应用程序,然后在构建步骤中,将应用程序的交互性和初始状态(组件的状态以及从 API 获取的任何服务器端数据)序列化并捆绑到 JS 和 JSON 中。

每当请求页面时,HTML 都会与交互性和状态所需的 JS 包一起发送。然后客户端对 JS 进行“hydration”,这意味着:

  • 从根节点遍历整个 DOM
  • 对于每个节点,如果元素是交互式的,则附加一个事件侦听器,添加初始状态并重新渲染。如果该节点不应该

这样,HTML 可以快速显示,这样用户就不会盯着空白页面,一旦 JS 加载完毕,页面就会变得具有交互性。

您可以这样理解 hydration:

请加水到四级! 回到未来 2

构建步骤将应用程序中所有重要的部分都提取出来,只剩下一个干燥的空壳。然后,您可以将这个干燥的空壳与单独的一加仑水一起发送,由客户端的 Black & Decker 注水器 浏览器进行组合。这样您就可以得到一个可食用的披萨/可用的网站(感谢这个 SO 答案 中的比喻)。

这样做有什么问题?Hydration 将页面视为单个组件。Hydration 自上而下进行,并遍历整个 DOM 以找到需要 hydration 的节点。即使您在开发过程中将应用程序分解成组件,这些组件也会被丢弃,所有内容都会捆绑在一起并一起发送。

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

Hello from Next

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

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

Fresh 中 Islands 的工作原理

如果我们使用基于 Deno 的 Web 框架 Fresh 做类似的事情,我们会看到零 JavaScript。

Hello from Fresh without JavaScript

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

现在让我们以 island 的形式添加一些 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 中的 manifest 以查找 islands

查找任何 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.jsrevive 函数的 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 函数结束时,script(它是由导入语句后跟 revive() 函数组成的字符串)会被添加到 HTML 的 body 中。此外,包含每个 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 找到注释时,它会使用 Preact 的 render / h 调用 createRootFragment 来渲染组件。

现在,您就拥有了一个准备好用于客户端交互的 island!

其他框架中的 Islands

Fresh 并不是唯一使用 Islands 架构的框架。Astro 也使用了 Islands,但配置方式不同,你需要指定每个组件如何加载其 JavaScript。例如,以下组件将加载 0 个 JS

<MyReactComponent />

但是添加一个 客户端指令,它现在将加载 JS

<MyReactComponent client:load />

其他框架,例如 Marko,则使用部分水合。Islands 和部分水合之间的区别很微妙。

在 Islands 架构中,开发者和框架明确知道哪些组件将被水合,哪些不会。例如,在 Fresh 中,唯一发送 JavaScript 的组件是 islands 目录中采用驼峰式命名法或烤肉串式命名法的组件。

使用部分水合,组件的编写方式与平常一样,框架会在构建过程中确定哪些 JS 应该在构建过程中发送。

这个问题的其他解决方案是 React 服务器组件,它是 NextJS 新的 /app 目录结构 的基础。这有助于更清晰地定义在服务器和客户端上完成的工作,尽管它是否减少了已发送捆绑包中的 JS 量 仍然存在争议

除了 Islands 之外,最激动人心的发展是 Quik 的可恢复性。他们完全移除了水合过程,而是在 HTML 捆绑包中序列化 JavaScript。一旦 HTML 到达客户端,整个应用程序就可以运行了,包括交互性。

流式 Islands

可以将 Islands 和可恢复性结合起来,以发送更少的 JS 并移除水合过程。

但是,Islands 的好处不仅仅是更小的捆绑包。Islands 架构的一个巨大好处是它让你形成的心智模型。你必须选择加入 JavaScript 和 Islands。你永远不会错误地将 JavaScript 发送到客户端。当开发者构建应用程序时,每一次包含交互性和 JavaScript 都是开发者做出的明确选择。

这样,发送更少 JavaScript 的就不是架构和框架了,而是你,开发者本人。

不要错过任何更新 - 在 Twitter 上关注我们