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

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

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

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

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

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

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

给他们 islands 架构

什么是 Islands?

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

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

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

水合就像这幅图一样:

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

Back to the Future, Part 2

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

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

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

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

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

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

Fresh 中的 Islands 架构

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

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

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

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

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

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

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

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

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

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

原文链接:https://juejin.cn/post/7321596167585611812 作者:yjl9903

(0)
上一篇 2024年1月9日 上午10:21
下一篇 2024年1月9日 上午10:31

相关推荐

发表回复

登录后才能评论