一般情况下,当请求一个包含 Service Worker 的页面,并且此 Service Worker 尚未运行,那么浏览器将会等到 Service Worker 启动之后才会发起导航请求(如下图),也由于受各种因素的影响,Service Worker 的启动时间会有不同程度的延迟,这种延迟将直接导致导航请求的延迟,进而增加了页面的整体渲染时间。

上文中我们提到了导航请求,这里我们先简单了解下相关概念,在 Fetch 规范中的定义为:请求实体为 document 的请求。通俗来讲就是当我们在浏览器的地址栏中输入网址,或通过链接等手段从一个页面跳转到另外一个页面时所发送的请求。由于导航请求响应中的 HTML 负责启动所有脚本、样式、图片等资源的请求,因此任何导航请求的延迟都终将导致空白页问题的出现。

正是为了解决因 Service Worker 启动而导致导航请求的延迟问题,Service Worker 提供了导航预加载机制,该机制在 Service Worker 开始启动时,便立刻发起导航请求,这样 Service Worker 启动便能与导航请求并行执行(如下图),从而大大降低了因延迟而导致空白页的几率。

# 使用

导航预加载的使用非常简单,首先在 Service Workeractivate 事件中启用该功能:

self.addEventListener('activate', event => {
  event.waitUntil((async () => {
    if (self.registration.navigationPreload) {
      await self.registration.navigationPreload.enable();
    }
  })());
});

然后在 Service Workerfetch 事件中将预加载的导航请求响应返回即可:

self.addEventListener('fetch', event => {
  const { request } = event;
  if (request.method.toLowerCase() === 'get') {
    event.respondWith((async () => {
      //...其他类型请求处理逻辑
      const preloadResponse = await event.preloadResponse;
      if (preloadResponse) {
        return preloadResponse;
      }
    })());
  }
});
  • 需要注意的是:如果开启了导航预加载,那么在 fetch 事件中必须对 event.preloadResponse 进行消费,否则这将导致该请求会被请求两次。
  • 导航预加载请求中会携带请求头 Service-Worker-Navigation-Preload,且默认值为 true,可通过以下方式来修改其默认值:
navigator.serviceWorker.ready.then(registration => {
  return registration.navigationPreload.setHeaderValue(newValue);
});

# 与应用 Shell 集成

应用 Shell 中,我们通过将 top shell、正文信息、bottom shell 等内容拼装在一起的方式来响应页面请求,该方式虽然很大程度上解决了恶劣网络环境下的页面响应问题,但根据上文的论述可以得知,正文信息的请求响应依旧存在着一定程度的延迟,因此本节我们将尝试将两种技术融合在一起使用,以求得到更快速的响应。

function fetchPage(cacheKey, event) {
  //... 根据 cacheKey 获取 shell 类型
  const stream = new ReadableStream({
    start(controller) {
      //... pushStream 函数定义
      (async () => {
        //... top shell 处理逻辑
        let context;
        try {
          context = await event.preloadResponse;
        } catch {
        }
        if (!context) {
          context = await fetch(cacheKey, {
            headers: {
              'only_content': 1
            }
          });
        }
        if (content) {
          await pushStream(content.body);
        } else {
          const errorContent = new Response(
            '<div class="message">网络错误</div>',
            { headers: { 'Content-Type': 'text/html' } }
          );
          await pushStream(errorContent.body);
        }
        //... bottom shell 处理逻辑
        controller.close();
      })();
    }
  });

  return new Response(stream, {
    headers: { 'Content-Type': 'text/html' }
  });
}

self.addEventListener('fetch', event => {
  const { request } = event;
  if (request.method.toLowerCase() === 'get') {
    event.respondWith((async () => {
      const cacheKey = new URL(request.url, location).pathname;
      //...其他类型请求处理逻辑
      return fetchPage(cacheKey, event);
    })());
  }
});

上述代码基于应用 Shell 中所展示的代码为基础进行了修改:

  • fetchPage 方法增加了 fetch 事件的 event 参数,以便获取导航预加载请求响应(即:event.preloadResponse)。
  • fetchPage 方法中,我们首先尝试获取导航预加载请求响应:
let context;
try {
  context = await event.preloadResponse;
} catch {
}

如果导航预加载请求响应出现异常(比如服务器不响应)或响应内容为空,则尝试通过传统的 fetch 方法获取正文信息:

if (!context) {
  context = await fetch(cacheKey, {
    headers: {
      'only_content': 1
    }
  });
}

由于 Service Worker 获得页面的控制权后,所有的页面请求都只需要返回正文部分的信息即可,而导航预加载请求并未携带头信息 'only_content': 1,故我们需要修改服务端代码以适应其变化:

async function renderPage(ctx, type, content) {
  const { headers } = ctx.request;
  if (parseInt(headers['only_content'], 10) === 1 || headers['service-worker-navigation-preload'] === 'true') {
    //.... 返回正文部分
  } else {
    //... 返回整个文档
  }
}

# 总结

上文中,我们首先对为解决因 Service Worker 启动而导致导航请求延迟问题的导航预加载进行了说明,然后,介绍了如何与应用 Shell 搭配使用来进一步加速页面的渲染。至此,我们完成了预缓存、应用 Shell 及导航预加载的学习,相信大家此刻已经能够很好的处理恶劣网络环境下的页面响应问题。然而在实施这些方案时,往往会发现我们与之打交道最多的便是缓存,那么究竟如何处理缓存的使用与更新这些问题呢?在接下来的章节中将为大家一一讲解。

阅读全文