Service Worker 端渲染 (SWSR)
服务器端渲染 (SSR) 似乎风靡一时。水合策略成了热门话题,说实话,它确实让人耳目一新,摆脱了以往客户端 JavaScript 框架的束缚。然而,我总是很惊讶,在这些讨论中,对 Service Worker 的探讨却如此之少。
单页应用(SPA)和渐进式 Web 应用(PWA)架构(呼!)如今已相当成熟;您只需构建应用框架,预缓存所需资源,并获取动态数据,即可实现应用的功能。此外,单页应用(SPA)在构建完成后通常也比较容易转换为 PWA 。
然而,对于多页应用(MPA)来说,情况就不同了。对于MPA,你必须从项目一开始就将任何离线功能融入到架构中。而且,我总觉得目前还没有真正优秀的PWA解决方案能够像许多JS框架或SSR框架那样,为MPA提供卓越的开发者体验。静态站点生成器似乎也没有在这个领域投入太多精力。事实上,我能找到的针对这种架构的解决方案寥寥无几!
服务器上的服务工作线程
其中一个解决方案是由才华横溢的Jeff Posnick提出的。他的博客jeffy.info完全由 Service Worker 渲染。初始渲染在服务器端进行,使用Cloudflare Worker,它与浏览器中运行的 Service Worker 使用相同的 API。这种方法的妙处在于,它允许 Jeff 在服务器端和客户端复用相同的代码。这也被称为同构渲染。
当用户首次访问博客时,Cloudflare Worker 会渲染页面,客户端的 Service Worker 开始安装。Service Worker 安装完成后,即可接管网络请求并自行处理响应,从而有可能完全绕过服务器,实现即时响应。
你可以阅读 Jeff 在他的博客上详细了解他是如何建立博客的,但归根结底,主要在于:流媒体。
流式缝合
请看以下示例:
registerRoute(
new URLPatternMatcher({pathname: '/(.*).html'}).matcher,
streamingStrategy(
[
() => Templates.Start({site}),
async ({event, params}) => {
const post = params.pathname.groups[0];
const response = await loadStatic(event, `/static/${post}.json`);
if (response?.ok) {
const json = await response.json();
return Templates.Page({site, ...json});
}
return Templates.Error({site});
},
() => Templates.End({site}),
],
{'content-type': 'text/html'},
),
);
在这里,Jeff 使用了一种我称之为“流式拼接”的模式。这很棒,因为浏览器可以在 HTML 流式传输到达时就开始渲染。这也意味着你可以先流式传输<head>页面,页面可能已经开始下载脚本、解析样式和其他资源,同时等待剩余的 HTML 流式传输。
从技术角度来看,这确实令人兴奋,但我总觉得开发者的体验还有待提高。Workbox在提供流式 API 的抽象方面做得非常出色,让你无需手动操作,还能协助你完成诸如路由注册和匹配之类的工作,但即便如此,它仍然给人一种过于依赖底层硬件的感觉,尤其是在与那些花哨的 SSR 框架的开发者体验相比时。为什么 Service Worker 就不能拥有同样出色的体验呢?
使用 Astro 进行 Service Worker 端渲染
最近我一直在研究Astro SSR 项目,并考虑创建一个 Cloudflare 适配器,将我的 Astro SSR 应用部署到 Cloudflare 环境中。在阅读 Cloudflare Worker 相关资料时,我想起了Jeff Posnick 和 Luke Edwards 之前关于他博客和这篇博文中提到的架构的讨论,这让我不禁思考:既然我能将 Astro 部署在与 Service Worker 如此相似的环境中……为什么我不能在真正的 Service Worker 中运行 Astro 呢?
于是我开始尝试编写一些代码,结果发现完全可行。在这个例子中,你可以看到一个由 Service Worker 运行的真实 Astro SSR 应用。这令人兴奋,原因有以下几点:
- 您的Astro应用程序现在支持离线使用。
- 您的应用现在可以安装了
- 由于请求可以由浏览器中的服务工作线程来处理,因此对主机提供商的函数调用次数将大幅减少。
- 巨大的性能优势
- 这是一个渐进式增强
但最重要的是,这可能意味着我们离拥有卓越的开发者体验越来越近了!Astro 很可能成为首个能够为我们提供以下开发者体验的框架:
/blog/[id].astro:
---
import Header from '../src/components/Header.astro';
import Sidemenu from '../src/components/Sidemenu.astro';
import Footer from '../src/components/Footer.astro';
const { id } = Astro.params;
---
<html>
<Header/>
<Sidemenu/>
{fetch(`/blog/${id}.html`)}
<Footer/>
</html>
这岂不是很棒?这段代码既可以在服务器端运行,也可以在 Service Worker 中运行。然而!尽管这很酷,但我们还没完全实现。目前,Astro 还不支持流式响应,我们稍后会讨论这个问题,但现在,请和我一起畅想一下吧。
这段代码片段的作用如下:首次访问时,服务器会渲染此页面,就像 Jeff 的博客示例中那样。然后,Service Worker 会被安装并接管请求处理,这意味着从那时起,浏览器中的 Service Worker 就可以渲染完全相同的代码,并立即返回响应。
此外,在这个例子中,`header`<Header/>和` <Sidemenu/>sidemenu` 是静态组件,可以立即进行流式传输。Promisefetch返回一个响应,其主体……没错,就是一个流!这意味着浏览器可以立即开始渲染头部(同时可能还会开始下载其他资源),渲染侧边栏,然后立即开始将 Promise 的结果流式传输fetch到浏览器。
同构渲染
我们甚至可以进一步拓展这种模式:
---
import Header from '../src/components/Header.astro';
import Sidemenu from '../src/components/Sidemenu.astro';
import Footer from '../src/components/Footer.astro';
const { id } = Astro.params;
---
<html>
<Header/>
<Sidemenu/>
{fetch(`/blog/${id}.html`).catch(() => {
return caches?.match?.('/404.html') || fetch('/404.html');
})}
<Footer/>
</html>
想象一下,如果我们访问一个id不存在的 URL。如果用户尚未安装 Service Worker,服务器会:
- 尝试获取
/blog/${id}.html,但失败了。 - 运行
catch回调函数,并尝试执行caches?.match?.('/404.html')我们在服务器上没有访问权限的操作。 - 所以它会退而求其次
|| fetch('/404.html')。
但是,如果用户已经安装了 Service Worker,它可能'/404.html'在安装过程中预先缓存了该服务,可以直接从缓存中立即加载。
你甚至可以想象一些帮手,例如:
<Header/>
{cacheFirst(`/blog/${id}.html`)}
{staleWhileRevalidate(`/blog/${id}.html`)}
{networkFirst(`/blog/${id}.html`)}
<Footer/>
缺点
还没完全达到。
目前,Astro 的应答尚未直播。不过,Astro 的核心维护者之一Nate提到:
Astro 的好消息是,从一开始,流式传输就是我们的最终目标!我们不需要对架构进行任何更改即可支持它——Astro 组件本质上就是异步迭代器。我们主要是在等待 SSR API 稳定后再开放流式传输功能。
请看下面这段摘自 Astro 源代码的代码片段:
export async function render(htmlParts: TemplateStringsArray, ...expressions: any[]) {
return new AstroComponent(htmlParts, expressions);
}
看起来AstroComponent像这样:
class AstroComponent {
constructor(htmlParts, expressions) {
this.htmlParts = htmlParts;
this.expressions = expressions;
}
get [Symbol.toStringTag]() {
return "AstroComponent";
}
*[Symbol.iterator]() {
const { htmlParts, expressions } = this;
for (let i = 0; i < htmlParts.length; i++) {
const html = htmlParts[i];
const expression = expressions[i];
yield markHTMLString(html);
yield _render(expression);
}
}
}
正如 Nate 所说,它只是一个异步迭代器。这意味着它甚至有可能在 Astro 表达式中使用Promise 和可迭代对象,例如:
---
import Header from '../src/components/Header.astro';
function* renderLongList() {
yield "item 1";
yield "item 2";
}
---
<html>
<Header/>
{renderLongList()}
</html>
fetch或者像我们在本文前面看到的例子:
<Header/>
<Sidemenu/>
{fetch(`/blog/${id}.html`)}
<Footer/>
目前 Astro 代码库中正在进行关于此RFC 的讨论。如果您对此未来发展方向感到兴奋,请留言向维护者表达您的兴趣。然而,这样做是有代价的。此外,还有一些其他功能提案会使流式响应无法实现,例如 HTML 后处理,或者<astro:head>子组件可以添加到 `<head>` 元素的概念。这两种方式都与流式响应不兼容。不过,或许这些功能并非必须相互排斥;Astro 甚至可以通过以下方式使渲染器可配置astro.config.mjs:
export default defineConfig({
ssr: {
output: 'stream'
}
});
有很多值得思考和考虑的地方,但无论如何,请务必查看 RFC 讨论并留下您的想法,或者简单地点赞/发送表情符号!
捆绑包大小
另一个缺点是 bundlesize。诚然,Astro 在 Service Worker 中运行时,其 bundle 体积确实很大。不过,我还没有进行太多实验,但感觉 bundlesize 方面还有很大的改进空间。
天文服务人员
虽然 Astro 的流式响应功能可能还需要一段时间才能实现,但我已经将我的 Service Worker 实验成果转化为一个 Astro 集成,您现在就可以使用:astro-service-worker。此集成会获取您的 Astro SSR 项目,并为其创建一个 Service Worker 构建版本。
入门很简单,只需安装依赖项即可:
npm i -S astro-service-worker
并将集成添加到您的astro.config.mjs:
import { defineConfig } from 'astro/config';
import netlify from '@astrojs/netlify';
+import serviceWorker from 'astro-service-worker';
export default defineConfig({
adapters: netlify(),
integrations: [
+ serviceWorker()
]
});
演示
astro-service-worker您可以在此演示中找到使用该功能的小型应用程序示例,您可以在这里找到演示的源代码。
服务器优先、仅服务器、服务工作线程优先、仅服务工作线程
在将 Astro 应用使用 Service Worker 时,需要注意的是,您在 Astro frontmatter 中编写的 Astro 代码现在也应该能够在浏览器中运行。这意味着您不能使用任何 CommonJS 依赖项或 Node 内置函数,例如 `<script>`'fs'标签。但是,您可能仍然需要一些仅限服务器端运行的代码,例如访问数据库、Webhook、重定向回调等等。在这种情况下,您可以将这些端点从输出的 Service Worker 包中排除。
这意味着你可以在同一个项目中拥有包含服务器优先、纯服务器、Service Worker优先和纯Service Worker代码的完整全栈代码库。此外,Service Worker完全是一个渐进增强机制。即使用户使用的浏览器不支持Service Worker,服务器仍然可以正常渲染你的应用。
仅限网络
您可能需要使用一些仅限服务器端使用的端点或页面,例如用于创建数据库连接或其他依赖于浏览器中不可用的 Node.js 内置模块的功能。如果是这种情况,您可以指定要从 Service Worker 包中排除的页面:
export default defineConfig({
integrations: [
serviceWorker({
networkOnly: ['/networkonly-page', '/db-endpoint', 'etc']
}),
]
});
自定义 Service Worker 逻辑
您还可以扩展 Service Worker 并添加自定义逻辑。为此,您可以使用swSrc相应的选项。
export default defineConfig({
integrations: [
serviceWorker({
swSrc: 'my-custom-sw.js',
}),
]
});
my-project/my-custom-sw.js:
self.addEventListener('fetch', (e) => {
console.log('Custom logic!');
});
与其他集成方案结合使用
您甚至可以将此与其他 SSR 集成结合使用;如果您的组件支持 SSR,它们也应该支持 SWSR!但请注意,传统服务器环境和 Service Worker 之间可能存在一些差异。这意味着您可能需要进行一些额外的配置。
import { defineConfig } from 'astro/config';
import netlify from '@astrojs/netlify';
import customElements from 'custom-elements-ssr/astro.js';
import serviceWorker from './index.js';
export default defineConfig({
adapter: netlify(),
integrations: [
customElements(),
serviceWorker()
]
});

