Islands Architecture(孤岛架构)

背景 #

基于 Angular,Vue,React 等现代前端框架的单页应用往往会存在这两个问题:

  1. 首屏渲染时间较长
  2. SEO 效果极差

在大多数的场景例如中后台管理系统中,这两个问题可以不用考虑。但是对于部分 To C 的应用而言,首屏渲染时间是一个不容忽视的指标;对于内容型站点来说,SEO 效果又是优先级最高的需求。为了解决这两个问题,同时又不舍弃现代前端框架提供的便利性,业界主流的方案是将页面的渲染过程向服务端移动,并由此催生了 SSG(Static-Site Generation,静态页面生成)和 SSR(Server-Side Rendering,服务端渲染)等技术。

SSG 与 SSR #

SSG
SSR

同样是在服务端上生成网页,SSG 技术会在构建时就生成最终页面,缺乏动态性,只适用于内容型网站等网页数据在构建时就已确定的应用;而 SSR 技术则在服务端生成的基础上赋予了处理动态数据的能力,数据不必在代码构建时注入页面,而是可以在请求到达时才确定,这个特性使得 SSR 能适应更多的使用场景,从而成为最通用的服务端生成技术之一。

SSR with Hydration #

在 SSR 中,有一种 “同构渲染” 的实现方式。这种方案结合了 CSR 和 SSR 的特点,会在服务端进行一次预渲染生成 HTML,将 HTML 发送到客户端,此时,该网页是静态的,尚未绑定任何的 JS 脚本,不具备交互能力。随后,网页在经过一次被称为 Hydration(水合)的处理后,将获得和客户端渲染应用一样的可交互性。

以 React 举例,Hydration 的代码实现是在客户端调用 ReactDOM.hydrate 方法,在服务端生成的静态 HTML 上绑定事件处理:

ReactDOM.hydrate(<App />, document.getElementById('root'));

就像小说《三体》中描述的那样,页面(活的三体人)在服务端往客户端传输(从乱纪元向恒纪元过渡)时,会先 “脱水” 为静态的 HTML 骨架(脱水体),当页面到达客户端后,对静态的骨架进行 Hydration(“浸泡”)处理,将 JS(水分)注入到 HTML 骨架(脱水体)中,从而使页面获得交互能力(三体人恢复活力)。

缺点 #

这种基于普通的 Hydration 流程的同构渲染方式确实大大减少了首页的白屏时间,图中的 FCP(First Contentful Paint,首次内容绘制)在客户端收到响应以后就开始了,此时,用户已经能够看到页面上的静态内容。但是,TTI(Time to Interactive,可交互时间)依然要等到客户端的 Hydration 流程全部完成,这意味着,只有在完全走完 Hydration 流程后,用户才能和页面进行交互。在此之前,页面不会对用户的操作作出任何响应,用户体验还有提升的空间。

Progressive Hydration #

在普通的 Hydration 方案中,用户需要等到整个页面都完成 Hydration,才可以与页面交互,优化思路很简单,就是把整个页面的 Hydration 过程拆分开。Progressive Hydration(渐进式水合)的思路便是按照一定的策略依次对不同的页面部分进行 Hydration,这个策略可以是优先水合视窗内的部分、更有可能用于交互的部分,或是页面中最重要的部分。通常来说,可以采用自上而下依次水合的策略,因为用户很少会一开始就与页面最下方的元素进行交互。

从上到下依次 Hydration,使得最上方的元素提前支持交互

Islands Architecture #

还有另外一种做局部水合(Partial Hydration)的思路,就是把页面分成多个不同的 islands,分别进行 hydration,这就是 Islands Architecture。

Islands #

一个网页通常由多个部分组合而成,其中既有静态内容,也有动态内容。下图是一个极简的网页布局,其中 Header,侧边栏和中间的轮播图效果是动态的,其余部分是静态的。静态部分可以直接在服务端生成,而动态的组件需要在客户端进行 hydration。在 Islands Architecture 中,这些彼此独立的组件会各自进行 hydration,犹如页面中一个个的孤岛,这样的独立组件,就是 Islands。

Astro 中的实现 #

Astro 是一个集多功能于一体的 Web 框架,主要用于构建内容型网站,它的主要卖点是兼容非常多的前端框架以及生成访问速度极快的页面。

在构建页面产物时,Astro 会默认剥离页面里的所有 JS 脚本,产出纯静态的页面,从而在根本上解决了页面渲染慢的问题。对于内容型网站(博客、文档等)而言,内容重于交互,这种极端的优化方式确实有不错的效果。

以 React 为例,下面是一个最简单的计数器组件,点击按钮时,按钮上的数字会自增:

// src/components/Counter.tsx
import { useState } from 'react';

const Counter = () => {
  const [count, setCounter] = useState(0);
  return (
    <button onClick={() => setCounter((number) => number + 1)}>
      Counter: {count}
    </button>
  );
};

export default Counter;

通过 @astrojs/react 库,我们可以把这个 React 组件用在 Astro 项目中:

// src/pages/index.astro
---
import Counter from '../components/Counter';
---

<Counter />

如果此时编译这个 Astro 项目,我们会发现点击按钮毫无反应,这是因为 JS 脚本在构建的过程中被 Astro 默认移除了。若想保留这个组件的交互能力,我们需要告诉 Astro,在渲染页面的时候,对这个组件做局部水合(Partial Hydration)。在原有的代码上添加一段指令 client:load,把这个组件声明成一个 Astro Island,这样 Counter 组件就能保留交互能力:

// src/pages/index.astro
---
import Counter from '../components/Counter.jsx';
--

<Counter client:load />

如果页面中还有多个需要交互的组件,则需要分别声明,并且它们在客户端会各自进行水合,不会互相阻塞。这种 “默认静态,按需水合” 的设计思路正是 Astro 框架实现了 Islands Architecture 的体现。

其它的 Astro Client 指令 #

在 Astro 中,设置了 client:load 的组件会在页面加载时立刻进行水合,除此之外,Astro 还提供了多种局部水合的策略:

  • <Component client:idle />:组件在页面初次加载完成以后,只有在 requestIdleCallback 的回调触发时才进行水合。
  • <Component client:visible />:组件只有在进入用户可视区域时才开始水合,底层由 IntersectionObserver API 实现。
  • <Component client:media={string} />:用 CSS 中的媒体查询条件来控制水合,只有当指定的条件满足时才会进行。
  • <Component client:only={string} />:整个组件直接跳过在服务端的渲染过程,只在客户端直接渲染。

Live Demo 如下:

参考 #