背景
CSR
CSR 即 客户端渲染 (Client Side Rendering) ,通常是只有一个 HTML 的单页应用,首次请求返回的 HTML 没有任何内容,需要通过网络请求 JS、CSS 等静态资源进行渲染,其渲染过程在客户端完成。
现在大部分的项目都是这种模式,这也是传统意义上的SPA
应用的工作模式。在这种渲染模式下可以做到完全的前后端分离,前端开发完成直接将模板和相关 JS、CSS 资源上传 CDN 即可,这种场景下的页面托管只需要返回一个空白页,然后通过 CDN 获取相应的资源文件,完成页面渲染 。不过这也会带来几个老生常谈的问题:
- 在客户端发请求、渲染页面,导致首次渲染白屏时间比较长
- HTML 中没有具体的页面数据,对 SEO 不太友好。
由此,诞生了 SSR (服务端渲染)。
SSR
SSR 即服务端渲染(Server Side Rendering), 与客户端渲染相对,在服务端生成完整的 HTML 返回给客户端,然后由客户端进行激活。
主要分为两步
- 在服务端请求数据,并生成html内容返回给客户端,此时客户端并不存在交互能力
- 在客户端进行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 vs. SPA (Next.js) - 94% less JavaScript
- Astro vs. SPA (Next.js) - 34% 更快的加载
- Astro vs. SPA (Next.js) – 65% 网络使用减少
- Astro vs. SPA (Remix, SvelteKit) - “这令人置信的 Google Lighthouse 分数”
- Astro vs. Qwik - 43% 更快的 TTI
为什么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组件划分不同粒度
-
client:load
- 优先级:高
- 适用于:立即可见的UI元素,需要尽快进行互动。
在页面加载时,立即加载并激活组件的 JavaScript。
<BuyButton client:load />
复制代码
-
client:idle
- 优先级:中
- 适用于:优先级较低的 UI 元素,不需要立即进行互动。
一旦页面完成了初始加载,并触发 requestIdleCallback
事件,就会加载并激活组件中的 JavaScript。如果你所在的浏览器不支持 requestIdleCallback
,那么就会使用document的 load
事件。
<ShowHideButton client:idle />
复制代码
-
client:visible
- 优先级:低
- 适用于:低优先级 UI 元素,这些元素要么在页面的下方(折叠中),要么加载时非常耗费资源,如果用户没有看到这些元素,你宁愿不加载它们。
一旦组件进入视口,就加载组件的 JavaScript 并使其激活。内部是使用 IntersectionObserver
来实现的。
<HeavyImageCarousel client:visible />
复制代码
-
client:media
- 优先级:低
- 适用于:侧边栏折叠或其他可能只在某些屏幕尺寸上可见的元素。
client:media={string}
一旦满足一定的 CSS 媒体查询条件,就会加载并激活组件的 JavaScript。
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);
}
复制代码
这个框架缺点其实也很明显
- 目前仅支持 Preact 框架,不支持React、Vue等其他UI框架,这让很多开发者望而止步;
- 由于架构的原因,开发阶段没有 HMR 的能力,只能 page reload;
- 对于 Island 组件,必须要放到 islands 目录,没有Astro灵活
对比Astro,Fresh框架显得不是特别成熟(代码量就差了特别多),Fresh 能解决的一些问题,Astro也能解决,并且比它做的更好,Astro的islands组件粒度划分更细。但是这个框架是基于deno运行时的,对于 Deno 和 Preact 的用户来说,这个框架是他们的不错的选择。
SSR | islands | 嵌套islands | 支持多个UI框架 | Bundle-less | runtime |
---|---|---|---|---|---|
Astro | ✓ | ✓ | ✓ | ✗ | node |
Fresh | ✓ | ✗ | ✗ | ✓ | deno |
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:
对比:
- Streaming SSR 依赖框架和流式(Streaming)渲染,服务端需要加上
transfer-encoding: chunked
的响应头,而islands本质上与框架无关,例如Astro,可以使用不同的UI框架(React、Preact,Vue) - 从下载执行的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