一种基于 Service Worker 的渐进式渲染方案的基本原理

前言

笔者前面发过两篇关于流式SSR的文章:

流式SSR就是一种渐进式渲染,在传统的页面加载流程是:请求 → 等待 → 渲染。而渐进式渲染的思路是:

  1. 立即展示缓存的页面快照(即使是旧内容)
  2. 后台请求最新的页面内容
  3. 无缝替换为最新内容

这样用户感知到的加载时间接近于零,体验类似于原生 App。

前面笔者的文章中,提到关于H5页面的快照是客户端做的。本篇文章讲述一种基于 Service Worker 的渐进式渲染方案的原理,简单来讲就是将客户端的工作挪到了service worker中。通过给站点开启一个后台运行的service worker(service worker可以独立于webview运行在后台),在service worker中劫持包括主文档在内的网络请求,对文档内容进行存储,并修改返回。

技术方案设计

整体架构

markdown 复制代码
┌─────────────┐
│  用户访问    │
└──────┬──────┘
       │
       ▼
┌─────────────────┐
│ Service Worker  │ ◄─── 拦截请求
└────┬────────┬───┘
     │        │
     │        └─────────┐
     ▼                  ▼
┌─────────┐      ┌──────────┐
│ 缓存快照 │      │ 网络请求  │
└────┬────┘      └─────┬────┘
     │                 │
     └────────┬────────┘
              ▼
       ┌─────────────┐
       │  流式替换    │
       └─────────────┘

核心代码实现

1. HTML 页面注册 Service Worker

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>渐进式渲染示例</title>
</head>
<body>
  <h1>Hello World</h1>
  
  <script data-snapshot>
    (function () {
      const swEnabled = location.search.indexOf('x-sw=false') < 0;

      // 注册 Service Worker
      swEnabled && navigator.serviceWorker && navigator.serviceWorker.register('/sw.js')
        .then(function (registration) {
          console.log('Service Worker 注册成功:', registration);
        })
        .catch(function (error) {
          console.log('Service Worker 注册失败:', error);
        });

      // 如果禁用,则注销 Service Worker
      !swEnabled && navigator.serviceWorker && navigator.serviceWorker.getRegistration(location.href).then((r) => {
        r && r.unregister();
      });
    }());
  </script>
</body>
</html>

关键点说明:

  • data-snapshot 属性标记这是快照阶段需要保留的脚本
  • 支持通过 ?x-sw=false 参数禁用 Service Worker
  • 禁用时会自动注销已注册的 Service Worker

2. Service Worker 核心逻辑

js 复制代码
// sw.js
self.addEventListener('fetch', (event) => {
  // 只拦截主文档请求
  if (event.request.destination !== 'document') {
    return;
  }

  // 支持禁用功能
  if (event.request.url.indexOf('x-sw=false') >= 0) {
    event.waitUntil(caches.delete('my-cache'));
    return;
  }

  event.respondWith(handleFetch(event.request));
});

self.addEventListener('install', (event) => {
  console.log('Service Worker 安装');
  self.skipWaiting(); // 立即激活
});

3. 脚本过滤策略

js 复制代码
function replaceScripts(text, regularStream) {
  return text.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, (match) => {
    // 快照阶段:只保留 data-snapshot 脚本
    // 正式阶段:只保留普通脚本
    if (match.indexOf('data-snapshot') >= 0) {
      return regularStream ? '' : match;
    }
    return regularStream ? match : '';
  });
}

为什么要过滤脚本?

  • 快照阶段:避免执行业务逻辑脚本(可能依赖未加载的资源)
  • 正式阶段:避免重复执行初始化脚本

4. 流式渲染核心

js 复制代码
function withSnapshot(snapshot, request) {
  return new Response(new ReadableStream({
    start(controller) {
      const encoder = new TextEncoder();
      const decoder = new TextDecoder();
      
      // 第一步:立即输出快照
      controller.enqueue(encoder.encode(snapshot));

      let firstStream = true;
      
      // 第二步:请求最新内容
      fetchAndStore(request).then((response) => {
        const reader = response.body.getReader();

        function push() {
          reader.read().then(({ done, value }) => {
            if (done) {
              controller.close();
              return;
            }

            if (firstStream) {
              firstStream = false;
              
              // 第三步:清空页面
              controller.enqueue(encoder.encode(
                '<script>document.head.innerHTML = "";document.body.innerHTML = "";</script>'
              ));

              // 第四步:注入最新内容
              const text = decoder.decode(value);
              const head = text.match(/<head>([\s\S]*?)<\/head>/i);
              const body = text.match(/<body>([\s\S]*?)<\/body>/i);

              if (head && body) {
                controller.enqueue(encoder.encode(
                  `<script>document.head.innerHTML = '${head[1].trim().replace(/\n/g, '')}'</script>`
                ));
                controller.enqueue(encoder.encode(replaceScripts(body[1], true)));
              }
            } else {
              controller.enqueue(value);
            }
            push();
          });
        }

        push();
      });
    },
  }));
}

为什么要清空 DOM?

  • 快照内容和最新内容可能结构不同
  • 直接追加会导致内容重复
  • 清空后重新注入,确保页面状态一致

为什么用 innerHTML 注入?

  • 流式响应中,我们无法直接操作 DOM
  • 只能通过推送 <script> 标签让浏览器执行 JavaScript
  • innerHTML 是最简单的 DOM 替换方式

5. 缓存管理

Service Worker 的缓存存储在 Cache Storage API 中,这是浏览器提供的专门用于 Service Worker 的持久化存储空间。实际上,不需要关心物理位置,因为浏览器完全管理这些文件。

js 复制代码
function fetchAndStore(request) {
  return fetch(request)
    .then((networkResponse) => {
      if (networkResponse.ok) {
        // 克隆响应用于缓存
        const cacheResponse = networkResponse.clone();
        caches.open('my-cache').then((cache) => {
          cache.put(request, cacheResponse);
        });
      }
      return networkResponse;
    });
}

function handleFetch(request) {
  return caches.match(request)
    .then((response) => {
      if (response) {
        // 有缓存:先展示快照,再更新
        return readResponseText(response).then((snapshot) => {
          return withSnapshot(snapshot, request);
        });
      }
      // 无缓存:直接请求
      return fetchAndStore(request);
    });
}

为什么要 clone 响应?

  • Response 对象的 body 只能读取一次(流的特性)
  • 需要一份给缓存,一份给浏览器
  • clone() 创建独立的副本

工作流程详解

首次访问(无缓存)

bash 复制代码
用户访问 → Service Worker 拦截 → 无缓存 → 网络请求 → 返回内容 → 存入缓存

二次访问(有缓存)

bash 复制代码
用户访问
  ↓
Service Worker 拦截
  ↓
读取缓存快照(去除普通脚本)
  ↓
立即返回快照内容 ← 用户看到页面
  ↓
后台发起网络请求
  ↓
清空 DOM
  ↓
注入最新 head 和 body
  ↓
更新缓存

注意事项

上述只讲述了该方案的基本原理,实际应用要考虑更多的因素如App 环境兼容性、缓存策略、基础设施依赖等,下面是方案对比:

维度 客户端方案 Service Worker 方案
首次访问拦截 ✅ 可以拦截 ❌ 无法拦截
跨平台能力 ❌ 需要各端适配 ✅ Web 标准,通用
更新速度 ⚠️ 需要发版 ✅ 实时生效
开发成本 ⚠️ 需要端上开发 ⚠️ 需要 Web 开发
维护成本 ❌ 多端维护 ✅ 单一维护
灵活性 ⚠️ 受限于客户端版本 ✅ 完全可控
降级能力 ⚠️ 需要发版回滚 ✅ 秒级降级

总结:

  • 如果你的业务是纯 Web 应用(PWA) → Service Worker 是最佳选择
  • 如果你的业务在 App 内 → 优先考虑客户端方案

参考:

相关推荐
前端小端长2 小时前
Vue 中 keep-alive 组件的原理与实践详解
前端·vue.js·spring
FeelTouch Labs3 小时前
Nginx核心架构设计
运维·前端·nginx
雪球工程师团队3 小时前
别再“苦力”写后台,Spec Coding “跑” 起来
前端·ai编程
m0_471199633 小时前
【场景】前端怎么解决离线收银、数据同步异常等场景问题
前端·javascript
Curvatureflight3 小时前
前端性能优化实战:从3秒到300ms的加载速度提升
前端·人工智能·性能优化
用户99045017780093 小时前
ruoyi集成dmn规则引擎
前端
袋鱼不重4 小时前
AI入门知识点:什么是 AIGC、多模态、RAG、Function Call、Agent、MCP?
前端·aigc·ai编程
NuLL4 小时前
空值检测工具函数-统一规范且允许自定义配置的空值检测方案
前端
栀秋6664 小时前
“无重复字符的最长子串”:从O(n²)哈希优化到滑动窗口封神,再到DP降维打击!
前端·javascript·算法