islands学习

背景

CSR

CSR 客户端渲染 (Client Side Rendering) ,通常是只有一个 HTML 的单页应用,首次请求返回的 HTML 没有任何内容,需要通过网络请求 JS、CSS 等静态资源进行渲染,其渲染过程在客户端完成。

现在大部分的项目都是这种模式,这也是传统意义上的SPA应用的工作模式。在这种渲染模式下可以做到完全的前后端分离,前端开发完成直接将模板和相关 JS、CSS 资源上传 CDN 即可,这种场景下的页面托管只需要返回一个空白页,然后通过 CDN 获取相应的资源文件,完成页面渲染 。不过这也会带来几个老生常谈的问题:

  • 在客户端发请求、渲染页面,导致首次渲染白屏时间比较长
  • HTML 中没有具体的页面数据,对 SEO 不太友好。

由此,诞生了 SSR (服务端渲染)。

SSR

SSR 即服务端渲染(Server Side Rendering), 与客户端渲染相对,在服务端生成完整的 HTML 返回给客户端,然后由客户端进行激活。

主要分为两步

  1. 在服务端请求数据,并生成html内容返回给客户端,此时客户端并不存在交互能力
  2. 在客户端进行hydrate,使用js对dom进行事件绑定,让页面变得可交互

//   server/index.js
import Koa from 'koa';
import koaBody from 'koa-body';
import koaStatic from 'koa-static';
import path from "path";
import React from 'react';
import {renderToString} from 'react-dom/server'
import App from "../containers/App";
const app=new Koa();
const content= renderToString(<App/>);
app.use(koaStatic(path.join(__dirname, '../public')))
app.use(koaBody())
app.use((ctx)=> {
    ctx.body= `
   <html>
     <head>
       <title>hello</title>
     </head>
     <body>
      <div id="root">${content}</div>
     </body>
     <script  src="./client.js"></script>
   </html>
 `
})
app.listen(3001,()=>{
     console.log('http://localhost:3001/ start')
})

//  client/index.js
import {hydrate} from 'react-dom'
import React from 'react'
import App from "../containers/App";
 hydrate(<App/>,document.getElementById('root'))
复制代码

SSR 很好地解决了上述 CSR 的种种问题:

  • 服务端获取数据,首屏加载时间(FCP)更快
  • 完整的 HTML 内容直出,SEO 友好

Hydrate

这里解释一下hydrate,与render不同,render会直接把root节点的子节点进行清理,并创建新的dom实例。而hydrate目标就是让页面变得可交互的,并尽可能的复用当前浏览器已经存在的 DOM 实例以及 DOM 上的属性,拿react举例,react进行hydrate时,会在构造 workInProgress 树的过程中,同时对比现有的 DOM 的attribute以及 fiber 的pendingProps,判断是否可以hydrate。然后将 dom 实例和 fiber 节点相互关联(通过 dom 实例的__reactFiber$以及__reactProps$

//dom 关联到fiber上
fiber.stateNode = dom;
//组件的props关联到dom上
dom.__reactProps$ = fiber.pendingProps;
//fiber关联到dom上
dom.__reactFiber$ = fiber;
复制代码

众所周知,react是通过事件委托来实现它的事件机制的,当dom和fiber进行关联后,自然而然就完成了事件的绑定,实现类似下面的demo:


function onclick(e){
  const target = e.target
  const fiberProps = target.__reactProps$
  const clickhandle = fiberProps.onClick
  if(clickhandle){
    clickhandle(e)
  }
}
复制代码

Time To Interactive

TTI(首次可交互时长)指标测量页面从开始加载到主要子资源完成渲染,并能够快速、可靠地响应用户输入所需的时间,这个指标用来衡量一个页面的交互性水平。

它取决于以下时间

  • 页面显示有用的内容。
  • 页面上最明显的元素是交互式的(可以点击或对鼠标移动有反应)。
  • 页面对用户互动的响应时间最长为50毫秒。

如何再提升 TTI

对于SSR框架来说,想要提高TTI的最好方法就是删除或者延迟加载的javascript

页面的互动时间直接受JavaScript脚本的影响,脚本会阻碍页面的渲染。脚本越多,TTI的延迟越大。这在不同设备上的影响可能会有所不同,因为不同设备的脚本性能差异很大。处理器的速度越慢,分析和编译脚本的时间就越多。例如,在移动设备上,CPU比桌面设备上的CPU要有限得多,因此这些JS对移动设备上加载的网站影响要大得多,交互时间也要长得多。

在SSR的过程中,相比于页面的快速展现,hydrate时间也是昂贵的。

一般的SSR渲染都类似上面的demo,hydrate都将直接从Root节点进行,这意味着框架所需的 JS 必须全部被下载和解析,并且用户必须等待所有这些发生才能与网页进行交互。即使在不需要交互的页面(例如文档页、条款页)上,都需要下载全量的js脚本以及执行全量的hydrate过程,造成页面的首屏 TTI 劣化。

那么是否可以做到渐进式的hydrate呢?

islands

什么是islands架构?

“孤岛架构”的概念最初是由 Etsy 的前端架构师Katie Sylor-Miller于 2019 年提出的,并由 Preact 的创建者 Jason Miller在 Islands Architecture 中进行了扩展。

当一个页面中只有部分的组件交互,那么对于这些可交互的组件,我们可以执行 hydration 过程,因为组件之间是互相独立的。

对于静态组件,即不可交互的组件, 会像传统SSR一样向浏览器输出HTML,我们可以让其不参与 hydration 过程,直接复用服务端下发的 HTML 内容。

交互组件就像HTML海洋中的孤岛,会在浏览器异步、并发渲染。

因此这种模式叫做 Islands 架构。

Islands 实现原理

islands的框架都是通过在服务端的编译阶段拿到island组件的一些信息,如组件url、组件props、组件的渲染方式,然后将这些信息注入到html的标签中进行保存。

在客户端的时候将dom的属性进行解析,恢复服务端的一下状态,拿到对应的信息进行独立的hydrate。

具体实现通过下面这几个框架来看看

Islands 框架

这里介绍两个比较流行的,islands设计的SSR框架

Astro

Astro 的定位:用于构建快速、以内容为中心的网站。如大多数的营销网站、出版网站、文档网站、博客、个人作品集和一些电子商务网站,体现出一个重内容,轻交互的特点。

Astro 为了更好、更快的首屏加载体验,它选择了MPA,它是一个用ts编写的多页应用程序(MPA)框架。

这是官网上一些关于Astro的数据比较:

为什么Astro会有这样的优势呢?这其实跟Astro的设计理念有关

Astro的设计理念是zero js

在 Astro 中,默认所有的组件都是静态组件,比如:

//Test.jsx
import {useState,useCallback} from 'react'

export function Test() {
    const [count, setCount] = useState(0)
   const subtraction = useCallback(()=>{
      setCount(count-1)
   },[count])
   const addition = useCallback(()=>{
      setCount(count+1)
   },[count])
    return <div>
      <button onClick={subtraction}>
         -
      </button>
      {
         ` ${count} `
      }
      <button onClick={addition}>
         +
      </button>
    </div>
}

export default Test

// index.astro
---
import Test from '../components/Test.jsx';
---

<html>
  <body>
    <Test />
  </body>
</html>
复制代码

这种写法不会在浏览器添加任何额外的 JS 代码,点击这个button也不会有反应。Astro默认不会进行hydrate

但有时我们需要在组件中绑定一些交互事件,那么这时就需要激活孤岛组件了,在使用组件时加上client:load指令即可。

介绍一下几个指令,这几个指令可以将island组件划分不同粒度

  1. client:load

    1. 优先级:高
    2. 适用于:立即可见的UI元素,需要尽快进行互动。

在页面加载时,立即加载并激活组件的 JavaScript。

<BuyButton client:load />
复制代码
  1. client:idle

    1. 优先级:中
    2. 适用于:优先级较低的 UI 元素,不需要立即进行互动。

一旦页面完成了初始加载,并触发 requestIdleCallback 事件,就会加载并激活组件中的 JavaScript。如果你所在的浏览器不支持 requestIdleCallback,那么就会使用document的 load 事件。

<ShowHideButton client:idle />
复制代码
  1. client:visible

    1. 优先级:低
    2. 适用于:低优先级 UI 元素,这些元素要么在页面的下方(折叠中),要么加载时非常耗费资源,如果用户没有看到这些元素,你宁愿不加载它们。

一旦组件进入视口,就加载组件的 JavaScript 并使其激活。内部是使用 IntersectionObserver 来实现的。

<HeavyImageCarousel client:visible />
复制代码
  1. client:media

    1. 优先级:低
    2. 适用于:侧边栏折叠或其他可能只在某些屏幕尺寸上可见的元素。

client:media={string} 一旦满足一定的 CSS 媒体查询条件,就会加载并激活组件的 JavaScript。

  1. client:only

client:only={string} 跳过 HTML 服务端渲染,只在客户端进行渲染。它的作用类似于 client:load,它在页面加载时立即加载、渲染和润色组件。

注意,必须要传递正确的框架名字,因为 Astro 不会在构建过程中/在服务器上运行该组件,Astro 不知道你的组件使用什么框架,除非你明确告诉它。

<SomeReactComponent client:only="react" />
<SomePreactComponent client:only="preact" />
<SomeSvelteComponent client:only="svelte" />
<SomeVueComponent client:only="vue" />
<SomeSolidComponent client:only="solid-js" />
复制代码

demo:

---
import Test1 from '../components/Test1.jsx';
import Test2 from '../components/Test2.jsx';
---

<html>
  <body>
    <Test1 client:load  />
    <Test2 client:load  />
  </body>
</html>
复制代码

构建产物:

NetWork:

这两个island组件hydrate时是相互独立的,可以看到拉取了两份不同的js

Element:

添加这个指令后,对应的组件会被这个自定义标签包裹,并在这个标签记录组件的一些信息,比如拉取组件的路径,渲染器的路径,props,加载方式等

由于Astro是使用自身的Astro 语法,所以在解析的时候,可以很方便的得到island组件的相关信息,进而进行对应代码分割,在后续的hydrate过程中按需加载。

Astro通过babel对astro文件里的jsx进行解析编译,将信息挂载到处理节点上,下面的这个执行会给节点添加属性:

client:component-path ="componentPath"

//packages/astro/src/jsx/babel.ts
//使用babel对节点处理,并收集节点信息
if (!existingAttributes.find((attr) => attr === 'client:component-path')) {
 const componentPath = t.jsxAttribute(
  t.jsxNamespacedName(t.jsxIdentifier('client'), t.jsxIdentifier('component-path')),
  t.stringLiteral(meta.resolvedPath)
 );
 node.openingElement.attributes.push(componentPath);
}
复制代码

然后会删除指令(因为jsx语法其实并不支持click:client:load这样的指令写法),接着将节点转换成jsx,并记录island组件的信息

///用于提取指令,即"click:client:load"有关组件的信息。
//找到这些特殊的道具并从传递到组件的内容中删除它们。
export function extractDirectives(
 displayName: string,
 inputProps: Record<string | number | symbol, any>
): ExtractedProps {
 let extracted: ExtractedProps = {
  isPage: false,
  hydration: null,
  props: {},
 };
 for (const [key, value] of Object.entries(inputProps)) {
  if (key.startsWith('server:')) {
   if (key === 'server:root') {
    extracted.isPage = true;
   }
  }
  if (key.startsWith('client:')) {
   if (!extracted.hydration) {
    extracted.hydration = {
     directive: '',
     value: '',
     componentUrl: '',
     componentExport: { value: '' },
    };
   }
   switch (key) {
    case 'client:component-path': {
     extracted.hydration.componentUrl = value;
     break;
    }
    case 'client:component-export': {
     extracted.hydration.componentExport.value = value;
     break;
    }
    // This is a special prop added to prove that the client hydration method
    // was added statically.
    case 'client:component-hydration': {
     break;
    }
    case 'client:display-name': {
     break;
    }
    default: {
     extracted.hydration.directive = key.split(':')[1];
     extracted.hydration.value = value;

     // throw an error if an invalid hydration directive was provided
     if (!HydrationDirectives.has(extracted.hydration.directive)) {
      throw new Error(
       `Error: invalid hydration directive "${key}". Supported hydration methods: ${Array.from(
        HydrationDirectiveProps
).join(', ')}`
      );
     }

     // throw an error if the query wasn't provided for client:media
     if (
      extracted.hydration.directive === 'media' &&
      typeof extracted.hydration.value !== 'string'
     ) {
      throw new AstroError(AstroErrorData.MissingMediaQueryDirective);
     }

     break;
    }
   }
  } else if (key === 'class:list') {
   if (value) {
    // support "class" from an expression passed into a component (#782)
    extracted.props[key.slice(0, -5)] = serializeListValue(value);
   }
  } else {
   extracted.props[key] = value;
  }
 }
 for (const sym of Object.getOwnPropertySymbols(inputProps)) {
  extracted.props[sym] = inputProps[sym];
 }

 return extracted;
复制代码

收集到的island组件的信息后,再生成html模版,在模版中定义astro-island,标签,然后在这个标签初始化的时候做hydrate事件的注册

// packages/astro/src/runtime/server/astro-island.ts
// astro会在 start方法里拉取 island组件,这个方法被封装在 astro-island 这个标签里面
start() {
 const opts = JSON.parse(this.getAttribute('opts')!) as Record<string, any>;
 const directive = this.getAttribute('client') as directiveAstroKeys;
 if (Astro[directive] === undefined) {
 //注册hydrate事件
  window.addEventListener(`astro:${directive}`, () => this.start(), { once: true });
  return;
 }
 //执行Astro指令事件
 Astro[directive]!(
  async () => {
   // 拿到渲染器的runtime文件地址
   const rendererUrl = this.getAttribute('renderer-url');
   //  拉取远端的组件代码和runtime
    const [componentModule, { default: hydrator }] = await Promise.all([
    import(this.getAttribute('component-url')!),
    rendererUrl ? import(rendererUrl) : () => () => {},
   ]);
   const componentExport = this.getAttribute('component-export') || 'default';
   if (!componentExport.includes('.')) {
    this.Component = componentModule[componentExport];
   } else {
    this.Component = componentModule;
    for (const part of componentExport.split('.')) {
     this.Component = this.Component[part];
    }
   }
   this.hydrator = hydrator;
   return this.hydrate;
  },
  opts,
  this
 );
}
//hydrate 执行的入口函数
hydrate = () => {
 if (
  !this.hydrator ||
  (this.parentElement && this.parentElement.closest('astro-island[ssr]'))
 ) {
  return;
 }
 const slotted = this.querySelectorAll('astro-slot');
 const slots: Record<string, string> = {};
 // Always check to see if there are templates.
 // This happens if slots were passed but the client component did not render them.
 const templates = this.querySelectorAll('template[data-astro-template]');
 for (const template of templates) {
  const closest = template.closest(this.tagName);
  if (!closest || !closest.isSameNode(this)) continue;
  slots[template.getAttribute('data-astro-template') || 'default'] = template.innerHTML;
  template.remove();
 }
 for (const slot of slotted) {
  const closest = slot.closest(this.tagName);
  if (!closest || !closest.isSameNode(this)) continue;
  slots[slot.getAttribute('name') || 'default'] = slot.innerHTML;
 }
 const props = this.hasAttribute('props')
  ? JSON.parse(this.getAttribute('props')!, reviver)
  : {};
  // 进行独立的hydrate
 this.hydrator(this)(this.Component, props, slots, {
  client: this.getAttribute('client'),
 });
 this.removeAttribute('ssr');
 window.removeEventListener('astro:hydrate', this.hydrate);
 window.dispatchEvent(new CustomEvent('astro:hydrate'));
};
复制代码

Astro 的islands设计做到了与UI框架无关,除了支持本身 Astro 语法之外,它也支持 Vue、React 等框架,可以通过插件的方式来导入。

//astro.config.mjs
import { defineConfig } from 'astro/config';

// https://astro.build/config
import react from "@astrojs/react";

// https://astro.build/config
export default defineConfig({
  integrations: [react()]
});
复制代码

在构建的时候,Astro 只会打包并注入 Islands 组件的代码,并且在浏览器渲染,分别调用不同框架(Vue、React)的渲染函数完成各个 Islands 组件的 hydrate 过程。

Astro是一个非常热门的优秀框架,更多的功能和设计思路可以去它的官网看看 Astro

Fresh

Fresh 是一个基于 Preact 和 Deno 的SSR框架,同时也主打 Islands 架构。值得一提的是,与Astro提前编译好源代码不同,Fresh的构建层做到了 Bundle-less,即应用代码不需要打包即可直接部署上线。

Demo:

Fresh约定项目中的 islands 目录专门存放 island 组件:

.
├── README.md
├── components
│   └── Button.tsx
├── deno.json
├── dev.ts
├── fresh.gen.ts
├── import_map.json
├── islands                 // Islands 组件目录
│   └── Counter.tsx
├── main.ts
├── routes
│   ├── [name].tsx
│   ├── api
│   │   └── joke.ts
│   └── index.tsx
├── static
│   ├── favicon.ico
│   └── logo.svg
└── utils
    └── twind.ts
复制代码

Fresh 在渲染层核心主要做了以下的事情:

  • 通过扫描 islands 目录记录项目中声明的所有 Islands 组件。
  • 拦截 Preact 中 vnode 的创建逻辑,目的是为了匹配之前记录的 Island 组件,如果能匹配上,则记录 Island 组件的 props 信息,并将组件用 的注释标签来包裹,id 值为 Island 的 id,数字为该 Island 的 props 在全局 props 列表中的位置,方便 hydrate 的时候能够找到对应组件的 props。
  • 调用 Preact 的 renderToString 方法将组件渲染为 HTML 字符串。
  • 向 HTML 中注入客户端 hydrate 的逻辑。
  • 拼接完整的 HTML,返回给前端。

//hydrate 入口
const STATE_COMPONENT = document.getElementById("__FRSH_STATE");
const STATE = JSON.parse(STATE_COMPONENT?.textContent ?? "[[],[]]");
import {revive} from "/xxx/main.js";
import Counter from "/xxx/island-counter.js";

revive({counter: Counter,}, STATE[0]);
复制代码

服务端通过拦截 vnode 实现可以感知到项目中用到了哪些 Island 组件

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);
};
复制代码

那么服务端就会注入对应的 import 代码,并挂在到全局,通过 <script type="module"> 的方式注入到 HTML 中。

//. fresh/src/server/render.ts

 export async function render(){
 //...
 let script =
  `const STATE_COMPONENT = document.getElementById("__FRSH_STATE");const STATE = JSON.parse(STATE_COMPONENT?.textContent ?? "[[],[]]");`;
 script += `import { revive } from "${bundleAssetUrl("/main.js")}";`;

// Prepare the inline script that loads and revives the islands
let islandRegistry = "";
for (const island of ENCOUNTERED_ISLANDS) {
  const randomNonce = crypto.randomUUID().replace(/-/g, "");
  if (csp) {
    csp.directives.scriptSrc = [
      ...csp.directives.scriptSrc ?? [],
      nonce(randomNonce),
    ];
  }
  const url = bundleAssetUrl(`/island-${island.id}.js`);
  imports.push([url, randomNonce] as const);
  script += `import ${island.name} from "${url}";`;
  islandRegistry += `${island.id}:${island.name},`;
}
script += `revive({${islandRegistry}}, STATE[0]);`;
//....
 bodyHtml +=
  `<script type="module" nonce="${randomNonce}">${script}</script>`;
 }
复制代码

浏览器执行这些代码时,会给服务端发起/islands/Counter的请求,服务端接收到请求,对 Counter 组件进行实时编译打包,然后将结果返回给浏览器,这样浏览器就能拿到 Esbuild 的编译产物并执行了。

// hydrate
export function revive(islands: Record<string, ComponentType>, props: any[]) {
  function walk(node: Node | null) {
    // 1. 获取注释节点信息,解析出 Island 的 id
    const tag = node!.nodeType === 8 &&
      ((node as Comment).data.match(/^\s*frsh-(.*)\s*$/) || [])[1];
    let endNode: Node | null = null;
    if (tag) {
      const startNode = node!;
      const children = [];
      const parent = node!.parentNode;
      // 拿到当前 Island 节点的所有子节点
      while ((node = node!.nextSibling) && node.nodeType !== 8) {
        children.push(node);
      }
      startNode.parentNode!.removeChild(startNode); // remove start tag node

      const [id, n] = tag.split(":");
      // 2. 单独渲染 Island 组件
      render(
        h(islands[id], props[Number(n)]),
        htmlElement
      );
      endNode = node;
    }
    // 3. 继续遍历 DOM 树,直到找到所有的 Island 节点
    const sib = node!.nextSibling;
    const fc = node!.firstChild;
    if (endNode) {
      endNode.parentNode?.removeChild(endNode); // remove end tag node
    }

    if (sib) walk(sib);
    if (fc) walk(fc);
  }
  walk(document.body);
}
复制代码

这个框架缺点其实也很明显

  1. 目前仅支持 Preact 框架,不支持React、Vue等其他UI框架,这让很多开发者望而止步;
  2. 由于架构的原因,开发阶段没有 HMR 的能力,只能 page reload;
  3. 对于 Island 组件,必须要放到 islands 目录,没有Astro灵活

对比Astro,Fresh框架显得不是特别成熟(代码量就差了特别多),Fresh 能解决的一些问题,Astro也能解决,并且比它做的更好,Astro的islands组件粒度划分更细。但是这个框架是基于deno运行时的,对于 Deno 和 Preact 的用户来说,这个框架是他们的不错的选择。

SSRislands嵌套islands支持多个UI框架Bundle-lessruntime
Astronode
Freshdeno

React Streaming SSR

在React18中,其实也有类似的概念,叫做 Selective Hydration

React 18 提供了 renderToPipeableStream API,真正实现了 SSR 场景下的 Selection Hydration,主要有如下的几个特点:

  • 在完整的 HTML 渲染之前就可以进行组件的 hydrate,而不用等待 HTML 的内容发送完毕
  • hydration 可中断。比如页面中有两个组件: Sidebar 和 Comment,当这个部分的 HTML 发送至浏览器时,React 打算开始对 Sidebar 组件进行 hydrate:

如果用户在这个过程中点击了 Comment 组件,那么 React 会中断当前对于 SideBar 组件的 hydrate,从而去执行 Comment 组件的 hydrate:

对比:

  1. Streaming SSR 依赖框架和流式(Streaming)渲染,服务端需要加上 transfer-encoding: chunked 的响应头,而islands本质上与框架无关,例如Astro,可以使用不同的UI框架(React、Preact,Vue)
  2. 从下载执行的js总量来看,Streaming SSR 仍然需要加载和执行全量的 JS 代码,而island设计可以做到加载部分组件的 JS 代码

因此,虽然两者都是在 Hydration 上做文章,但其实是两种完全不同的方案,而且 islands 的设计更加通用,限制更少,执行的 JS 更少。

参考

Why Efficient Hydration in JavaScript Frameworks is so Challenging

From Static to Interactive: Why Resumability is the Best Alternative to Hydration

Islands 架构原理和实践

Islands Architecture

Selective Hydration

深入解读基于 Bundle-less 的 SSR 框架 Fresh

Astro 1.0 正式发布,给前端带来了什么?

Islands Architecture

分类:
前端
标签: