# 应用外壳（app shell）模型

> 应用外壳架构如何把预缓存的界面框架与按路由抓取的内容分开、它依赖的缓存清理，以及与服务端渲染之间的取舍。

应用外壳模型把 PWA 分成两部分：外壳，即绘制应用框架所需的最小 HTML、CSS、JavaScript 与图标，由 Service Worker 在安装时预缓存，并在之后的每次导航中从 Cache Storage 提供；内容，在外壳渲染之后再抓取或从数据缓存读取。结果是首次访问之后的任何一次访问，首次绘制都不依赖网络。

## 工作原理

外壳就是跨路由完全相同的部分：页眉、导航、布局骨架，以及把内容渲染进去的脚本。它只在部署时变化，所以可以在构建时列出并在 `install` 事件中缓存；它体积小，所以安装能在一次访问内完成。随后 `fetch` 处理函数用缓存的外壳文档应答导航请求，页面自己的脚本再抓取路由数据，于是浏览器在内容的第一个字节到达之前就画出了框架（[The App Shell Model](https://developer.chrome.com/blog/app-shell)，developer.chrome.com）。

### 版本与清理

外壳缓存以版本命名（`app-shell-v3`）。新部署改变名称，新 Service Worker 的 `install` 填充新缓存，其 `activate` 删除名称以外壳前缀开头但不等于当前名称的缓存。把删除限定在前缀内，运行时缓存和数据缓存（`articles`、`images`）不受影响。在 `activate` 运行之前旧 worker 继续提供旧外壳，所以除非 worker 调用 `skipWaiting()` 和 `clients.claim()`，部署要到第二次加载才可见。

### 取舍

外壳优先的架构框架快、内容慢：首次访问时外壳尚未缓存，之后的每次访问内容仍需一次请求，所以 Largest Contentful Paint 取决于外壳之后路由数据到达得多快。服务端渲染的 HTML 配合 `NetworkFirst` 或 `StaleWhileRevalidate` 策略在首次访问就给出完整的首次绘制，代价是重复访问要等网络。许多应用两者并用：离线导航用预缓存的外壳，在线时提供服务端渲染的页面。外壳还需要一个离线兜底页来应对内容未缓存的路由，否则框架会围着一个错误渲染。

### 支持位置

该模型是建立在 Service Worker API 与 Cache Storage 之上的模式，两者存在于 Chrome 40、Firefox 44、Safari 11.1 以及所有现行引擎（BCD `api.ServiceWorker`、`api.CacheStorage`）。没有 Service Worker 时外壳仍作为普通页面配合 HTTP 缓存加载，所以该模式退化为普通站点而不是失败。

## 示例

下面的 worker 代码就是整个模式；页面侧的代码只负责注册它。

### 预缓存外壳并用它应答导航

安装步骤缓存外壳文档和资源；fetch 处理函数用外壳应答导航请求，其他请求缓存优先、网络兜底。

```js
const SHELL = 'app-shell-v3';
const SHELL_ASSETS = ['/shell.html', '/styles/app.css', '/scripts/app.js', '/icons/icon-192.png'];

self.addEventListener('install', (event) => {
  event.waitUntil(caches.open(SHELL).then((cache) => cache.addAll(SHELL_ASSETS)));
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((k) => k.startsWith('app-shell-') && k !== SHELL).map((k) => caches.delete(k)))
    )
  );
});

self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(caches.match('/shell.html').then((shell) => shell ?? fetch(event.request)));
    return;
  }
  event.respondWith(caches.match(event.request).then((hit) => hit ?? fetch(event.request)));
});
```

`event.request.mode === 'navigate'` 是顶级导航的判据；作用域下的每个路由都拿到同一个外壳，客户端路由读取 `location.pathname` 决定加载什么内容。

### 注册并在没有 Service Worker 时退回

页面在 API 存在时注册 worker，否则什么都不做，站点就以配合 HTTP 缓存的服务端渲染页面继续工作。

```js
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js').catch((err) => console.warn('没有外壳缓存：', err));
} else {
  document.documentElement.dataset.shell = 'network'; // 没有 Service Worker：普通页面加载
}
```

`data-shell` 属性让 CSS 或分析代码无需其他改动就能区分两种模式。

:::observed
在 Chrome DevTools 的 Network 面板里，由上面的 `fetch` 处理函数应答的导航在 **Size** 列显示 `(ServiceWorker)` 而不是传输大小；当 worker 自己的 fetch 落到网络时，该请求行带一个齿轮图标（[Network features reference](https://developer.chrome.com/docs/devtools/network/reference)，developer.chrome.com）。勾选 **Disable cache** 后外壳仍然来自 Cache Storage，因为该选项只影响 HTTP 缓存；让同一次导航真正打到服务器的开关是 Application > Service workers 里的 **Bypass for network**。
:::

## 另请参阅

- [The App Shell Model](https://developer.chrome.com/blog/app-shell)（developer.chrome.com）
- [Offline and background operation](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Offline_and_background_operation)（developer.mozilla.org）
- [预缓存策略](/zh/reference/performance/precaching/)
- [导航预加载与 Service Worker 启动时间](/zh/reference/performance/navigation-preload/)
- [缓存策略](/zh/reference/service-worker/caching-strategies/)
- [离线兜底](/zh/reference/service-worker/offline-fallback/)