发布于 2026-01-06 4 阅读
0

Service Worker 端渲染 (SWSR)

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'},
  ),
);


Enter fullscreen mode Exit fullscreen mode

在这里,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>


Enter fullscreen mode Exit fullscreen mode

这岂不是很棒?这段代码既可以在服务器端运行,也可以在 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>


Enter fullscreen mode Exit fullscreen mode

想象一下,如果我们访问一个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/>


Enter fullscreen mode Exit fullscreen mode

缺点

还没完全达到。

目前,Astro 的应答尚未直播不过,Astro 的核心维护者之一Nate提到

Astro 的好消息是,从一开始,流式传输就是我们的最终目标!我们不需要对架构进行任何更改即可支持它——Astro 组件本质上就是异步迭代器。我们主要是在等待 SSR API 稳定后再开放流式传输功能。

请看下面这段摘自 Astro 源代码的代码片段:



export async function render(htmlParts: TemplateStringsArray, ...expressions: any[]) {
  return new AstroComponent(htmlParts, expressions);
}


Enter fullscreen mode Exit fullscreen mode

看起来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);
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

正如 Nate 所说,它只是一个异步迭代器。这意味着它甚至有可能在 Astro 表达式中使用Promise 和可迭代对象,例如:



---
import Header from '../src/components/Header.astro';

function* renderLongList() {
  yield "item 1";
  yield "item 2";
}
---
<html>
  <Header/>
  {renderLongList()}
</html>


Enter fullscreen mode Exit fullscreen mode

fetch或者像我们在本文前面看到的例子:



<Header/>
<Sidemenu/>
{fetch(`/blog/${id}.html`)}
<Footer/>


Enter fullscreen mode Exit fullscreen mode

目前 Astro 代码库中正在进行关于此RFC 的讨论。如果您对此未来发展方向感到兴奋,请留言向维护者表达您的兴趣。然而,这样做是有代价的。此外,还有一些其他功能提案会使流式响应无法实现,例如 HTML 后处理,或者<astro:head>子组件可以添加到 `<head>` 元素的概念。这两种方式都与流式响应不兼容。不过,或许这些功能并非必须相互排斥;Astro 甚至可以通过以下方式使渲染器可配置astro.config.mjs



export default defineConfig({
  ssr: {
    output: 'stream'
  }
});


Enter fullscreen mode Exit fullscreen mode

有很多值得思考和考虑的地方,但无论如何,请务必查看 RFC 讨论并留下您的想法,或者简单地点赞/发送表情符号!

捆绑包大小

另一个缺点是 bundlesize。诚然,Astro 在 Service Worker 中运行时,其 bundle 体积确实很大。不过,我还没有进行太多实验,但感觉 bundlesize 方面还有很大的改进空间。

天文服务人员

虽然 Astro 的流式响应功能可能还需要一段时间才能实现,但我已经将我的 Service Worker 实验成果转化为一个 Astro 集成,您现在就可以使用:astro-service-worker。此集成会获取您的 Astro SSR 项目,并为其创建一个 Service Worker 构建版本。

入门很简单,只需安装依赖项即可:



npm i -S astro-service-worker


Enter fullscreen mode Exit fullscreen mode

并将集成添加到您的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()
  ]
});


Enter fullscreen mode Exit fullscreen mode

演示

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']
    }),
  ]
});


Enter fullscreen mode Exit fullscreen mode

自定义 Service Worker 逻辑

您还可以扩展 Service Worker 并添加自定义逻辑。为此,您可以使用swSrc相应的选项。



export default defineConfig({
  integrations: [
    serviceWorker({
      swSrc: 'my-custom-sw.js',
    }),
  ]
});


Enter fullscreen mode Exit fullscreen mode

my-project/my-custom-sw.js



self.addEventListener('fetch', (e) => {
  console.log('Custom logic!');
});


Enter fullscreen mode Exit fullscreen mode

与其他集成方案结合使用

您甚至可以将此与其他 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()
  ]
});


Enter fullscreen mode Exit fullscreen mode
文章来源:https://dev.to/thepassle/service-worker-side-rendering-swsr-cb1