OneKuma

OneKuma's Blog

One Lonely Kuma.
github
bilibili
twitter

【翻译】Islands 架构和 Fresh 框架简介

翻译自 Deno 团队的 Fresh 原文:A Gentle Introduction to Islands

现代的 JavaScript Web 框架包含了大量 JavaScript。

但是大部分网站并不需要包含那么多 JavaScript。但是有些网站是需要的。如果你正在开发一个动态地、交互式的仪表盘,你可以尽情地使用 JavaScript。另一方面,文档页面、博客、静态内容网站等等不需要任何 JavaScript。例如,这篇博客的原文就没有包含任何 JavaScript。

但是,还有很多网站处于一种中间状态,它们需要一些交互性,但不是太多:

goldilocks-of-javascript

这些 "Goldilocks" (恰到好处) 的网站恰好是现在框架的问题所在:你不能静态地生成这些页面,但为了一个图片轮播按钮,而将整个框架打包并通过网络发送给用户,似乎过于浪费。我们可以为这类网站做什么呢?

给他们 islands 架构

什么是 Islands?#

这个是我们的 商品网站,它使用 Fresh 开发,一个使用 Deno 开发的基于 Islands 架构 Web 框架。

islands-from-merch-shop

这个页面的主要内容是静态的 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 加载完成页面拥有交互能力。

水合就像这幅图一样:

Back to the Future, Part 2

Back to the Future, Part 2

构建阶段从你的应用中提取出所有精华部分,留下一个干瘪的外壳。然后,你可以将这个干瘪的外壳和单独的水一起发送,由客户端的 Black & Decker hydrator 浏览器进行组合。这会带给你一个可食用的披萨 / 可用的网站(感谢这个 SO 回答的类比)。

这样做的问题是什么?水合将页面视为一个单独的组件。水合自上而下地进行,遍历整个 DOM 树寻找需要被水合的节点。即使你在开发中将应用分解为组件,但这些信息在水合时会被丢弃,所有东西会被打包在一起发布。

这些框架开发的应用还会发送框架自带的 JavaScript。如果我们创建一个新的 Next 应用,移除所有东西仅在首页保留一个 h1 标签,我们仍然会发现 JavaScript 被发送到了客户端,包含一个 JavaScript 版本的 h1 渲染函数,即使构建阶段知道这个页面可以被静态生成。

hello-from-next

代码分割(code-splitting)和渐进式水合(progressive hydration)是解决这个基础问题的变通手段。它们将原本打包的代码和水合过程分割为单独的块或者步骤。这可以使得页面获得交互能力的速度变快,因为你可以在剩余部分下载完成前,就开始第一个块的水合。

但是,你还是将所有的 JavaScript 发送到了可能并不需要使用它的客户端,并且必须对它进行处理以便之后的使用。

Fresh 中的 Islands 架构#

如果我们在基于 Deno 的 Web 框架 Fresh 中做类似的事情,我们会发现应用没有 JavaScript。

hello-from-fresh-no-island

这个页面上没有任何东西需要 JavaScript,所有没有 JavaScript 被发送。

现在让我们以 island 的形式添加一些 JavaScript。

hello-from-fresh-with-island

所以我们有了 3 个 JavaScript 文件:

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

为了演示这些 JavaScript 文件是如何产生的,这是请求接收后发生了什么的时间线。

request-timeline

渲染一个 Fresh 应用:

  1. 服务器端:
    1. Fresh 边缘服务器接收到一个 HTTP 请求;
    2. Fresh 从 manifest 文件中定位到 islands;
    3. 创建 vnodes,Preact 定位 “island” 节点并为其添加相应 HTML 注释;
    4. 所需要的 JavaScript 文件被生成和打包,准备发送给客户端;
    5. 服务器发送 HTML 和水合需要的 JavaScript 文件;
  2. 客户端:
    1. 浏览器接收到 HTML 并缓存所有静态资源,包含 JavaScript;
    2. 浏览器运行 main.js,遍历所有的 islands,遍历 DOM 树寻找 HTML 注释,然后对它们进行水合;
    3. Islands 现在可以交互了。

注意这个时间线是对于一个 Fresh 应用的首次请求。对于已经缓存的静态资源,后续的请求只需要简单地从缓存中检索

让我们深入一些关键步骤来看看 islands 是如何工作的。

fresh.gen.ts 中为 islands 检查 manifest#

第一步定位所有 islands 需要从 fresh.gen.ts 检查 manifest。这是一个你的应用自动生成的文档,它可以列出应用中的所有页面和 islands。

// 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 数组。

// 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 会被调用。

// 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 函数的语句。

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 里。

//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 字符串在被加载到浏览器中的样子。

<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.jsmain.ts 压缩后的版本)中定义了 revive 函数。它会遍历虚拟 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,会发现下面能够匹配 revive 函数里正则表达式的注释:

<!--frsh-counter:0-->

revive 函数发现了这个注释,它会调用 createRootFragment 使用 Preact 的 render / h 函数来渲染这个组件。

现在客户端拥有一个可以交互的 island,可以立即使用!

其他框架中的 Islands#

Fresh 不是使用 islands 架构的唯一框架。Astro 也基于 islands 架构,但是使用了不同配置,你可以指定每个组件如何加载 JavaScript。例如,这个组件不需要加在 JavaScript。

<MyReactComponent />

但是,你可以添加一个 client 指令,现在它会加载 JavaScript。

<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 不是架构或者框架的责任,而是你作为开发者的责任。

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.