跳到主要内容
Deno on an island with a mai tai.

岛屿架构的温和介绍

现代 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 页面中的客户端上进行水合

  • 隔离:这些组件是独立于页面的其余部分编写和交付的。
  • Preact:React 的 3kb 轻量级替代品,因此即使 Fresh 交付岛屿,它仍然使用最少量的 JS。
  • 水合:JavaScript 如何从服务器渲染添加到客户端页面。
  • 静态渲染的 HTML 页面:从服务器发送到客户端的基本 HTML,不包含 JavaScript。如果此页面上未使用岛屿,则只会发送 HTML。

其中的关键部分是水合。这是 JavaScript 框架正在努力解决的问题,因为它对于它们的工作方式至关重要,但同时水合是纯粹的开销

JS 框架正在水合页面。岛屿框架正在水合组件。

水合的问题 - “请水合 4 级”

为什么在没有岛屿的情况下发送了这么多 JavaScript?这是现代“元”JavaScript 框架工作方式的一个功能。您可以使用这些框架来创建内容并向页面添加交互性,分别发送它们,然后在浏览器中通过称为“水合”的技术将它们组合起来。

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

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

每当请求页面时,HTML 会与交互性和状态所需的 JS 捆绑包一起发送。然后客户端“水合”JS,这意味着

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

这样,HTML 会快速显示,以便用户不会盯着空白页,然后在 JS 加载后页面变为交互式

您可以这样考虑水合

Hydrate level four please! 回到未来,第二部

构建步骤从您的应用程序中提取所有有价值的部分,留下干燥的外壳。然后,您可以将该干燥的外壳与单独的一加仑水一起运送,以便由您客户的Black & Decker 水合器浏览器组合。这让您重新获得可食用的披萨/可用的网站(感谢这个SO 答案的类比)。

这有什么问题?水合将页面视为单个组件。水合自上而下进行,并遍历整个 DOM 以查找需要水合的节点。即使您在开发中将应用程序分解为组件,但这也会被丢弃,并且所有内容都捆绑在一起并交付。

这些框架还交付特定于框架的 JavaScript。如果我们创建一个新的 next 应用程序并删除索引页上的所有内容,只留下一个 h1,我们仍然会看到 JS 被发送到客户端,包括 h1 的 JS 版本,即使构建过程说此页面是静态生成的

Hello from Next

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

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

岛屿在 Fresh 中如何工作

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

Hello from Fresh without JavaScript

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

现在让我们以岛屿的形式添加一些 JavaScript

Hello from Fresh with islands

所以我们有三个 JavaScript 文件

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

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

Timeline of events from request

请注意,此时间线适用于首次请求 Fresh 应用程序。在资产被缓存后,后续请求只需从缓存中检索必要的脚本。

让我们深入研究使岛屿工作的关键步骤。

检查 fresh.gen.ts 中的 manifest 以查找岛屿

定位任何岛屿的第一步是检查 fresh.gen.ts 中的 manifest。这是一个在您自己的应用程序中自动生成的文档,其中列出了应用程序中的页面和岛屿

//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 处理成单独的页面(此处未显示)和组件。任何岛屿都会被推送到岛屿数组中

//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 注释替换每个岛屿

在使用render.ts进行服务器渲染期间,Preact 创建一个虚拟 DOM。当创建每个虚拟节点时,都会在 Preact 中调用options.vnode hook

// 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 既是一个函数,又具有与岛屿数组中元素相同类型的函数(因此,是岛屿的原始节点),则 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 问题。)

动态生成水合脚本

下一步是根据检测到的岛屿生成水合脚本,基于添加到集合 ENCOUNTERED_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,则会跳过整个岛屿部分,并且不会向客户端发送任何 JavaScript。

然后,render 函数将每个岛屿的 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 中。最重要的是,包含每个岛屿 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 岛屿。

浏览器运行 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 以渲染组件。

现在您就拥有了一个可以在客户端上使用的交互式岛屿!

其他框架中的岛屿

Fresh 不是唯一使用岛屿的框架。Astro 也使用岛屿,尽管配置不同,您可以在其中指定希望每个组件如何加载其 JavaScript。例如,此组件将加载 0 JS

<MyReactComponent />

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

<MyReactComponent client:load />

诸如 Marko 之类的其他框架使用部分水合。岛屿和部分水合之间的区别是细微的。

在岛屿中,开发人员和框架明确知道哪些组件将被水合,哪些组件不会被水合。例如,在 Fresh 中,唯一交付 JavaScript 的组件是岛屿目录中具有 CamelCase 或 kebab-case 命名的组件。

使用部分水合,组件通常是编写的,并且框架在构建过程中确定在构建过程中应交付哪些 JS。

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

除了岛屿之外,最令人兴奋的发展是 Quik 的可恢复性。它们完全消除了水合,而是在 HTML 捆绑包中序列化 JavaScript。一旦 HTML 到达客户端,整个应用程序就可以正常运行,包括交互性。

流中的岛屿

可能可以将岛屿和可恢复性结合起来,以交付少量 JS 并消除水合。

但是岛屿不仅仅是更小的捆绑包。岛屿架构的一个巨大好处是它使您开发的心理模型。您必须选择使用岛屿中的 JavaScript。您永远不会错误地将 JavaScript 交付给客户端。当开发人员构建应用程序时,每次包含交互性和 JavaScript 都是开发人员的明确选择。

这样,减少 JavaScript 交付的不是架构和框架——实际上是您,开发人员。

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