【PWA】可能是 SSR 最好的解决方案

使用现代页面 UI 框架(Vue/React)的童鞋可能都知道 SPA 应用存在“SEO支持不好”、“首屏加载时间过长”等问题,而此类问题最好的解决方案就是前后端同构,也就是 SSR。

但是 SSR 也并非银弹,它自身也有缺陷,最突出的就是每次后端服务接收到页面请求,都会进行实例化渲染,性能消耗很大。虽然我们可以通过缓存的方式进行实例复用,但是这也会引入新的工作量 —— 缓存状态维护。

其实我们回过头来看这个问题,我们为什么需要 SSR,便于搜索引擎爬虫抓取、减少首次白屏时间。其中首次白屏时间可通过页面资源缓存、html 加载动画等方式优化用户体验,但 SEO 在浏览器端是无法处理的。到这里其实我们可以看出,SSR 并不是面向浏览器端推出的解决方案,它面向的是爬虫这类只会访问某个链接、不会去进行页面渲染的应用程序。

那我们不禁想说,能不能将两者分开,浏览器端仍然使用 SPA 技术,而爬虫程序类使用 SSR 技术?答案是肯定的,这个解决方案就是今天我们要讲的 PWA 技术。

 1. 什么是 PWA ?

关于 PWA 的介绍我这里不做过多展开,这里贴一段 MDN 上的介绍:

PWA(Progressive Web Apps,渐进式 Web 应用)运用现代的 Web API 以及传统的渐进式增强策略来创建跨平台 Web 应用程序。这些应用无处不在、功能丰富,使其具有与原生应用相同的用户体验优势。这组文档和指南告诉您有关 PWA 的所有信息。

PWA 是可被发现、易安装、可链接、独立于网络、渐进式、可重用、响应性和安全的。

可以看出,PWA 是一个 Web 应用,它包含了很多特性,但是在我们这次要解决的场景里,我们其实只需要其中的“可重用”特性。而 PWA 中实现可重用的技术解决方案就是 —— Service Worker。

2. 什么是 Service Worker ?

跟上面一样,我们直接引用 MDN 上关于 Service Worker 的解释(下面简称 SW):

Service workers 本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器。这个 API 旨在创建有效的离线体验,它会拦截网络请求并根据网络是否可用来采取适当的动作、更新来自服务器的的资源。它还提供入口以推送通知和访问后台同步 API。

SW 本质上就是页面与服务器之间的中间层,它可以拦截页面到服务器的请求,然后根据需求对请求进行任意操作,它出现的场景是为了让 Web 应用有一个良好的离线体验,毕竟我们的认知中,Web 应用必须要有网络。 

3. SW 入门

前面也说了,SSR 主要是为了解决 SEO 问题,即应用入口的 html 必须要返回已渲染的状态,而浏览器端其实根本不关心有没有 SSR,有或者没有影响微乎其微。

既然浏览器端不在乎 html 是否已经渲染,那我们就可以缓存浏览器端的 html 请求。这里有两种解决方案:

  1. 服务端控制缓存。即服务器在返回 html 时加上缓存 header 信息(Expires、Cache-Control、Last-Modified、ETag等)
  2. 客户端控制缓存。即客户端拦截请求,并进行逻辑判断是否进行缓存,已经是否返回缓存等。

服务端控制缓存的方案可以说已经很成熟了,市面上的浏览器也都支持,但是它有个最大的问题,无法有效的判断访问者是用户还是爬虫。在今天,爬虫程序已经越来越像用户,服务端几乎没有有效的方案去辨别,这也就造成了这个方案无法针对用户返回缓存的 html。

客户端控制缓存在以前也是无法实现的,毕竟 html 作为 Web 应用的入口,永远是第一个访问的,你的应用根本无法拦截或者感知。但是在今天,随着 SW 的出现,客户端控制缓存方案也就变成了现实。

3.1 开始之前

在使用 SW 之前,我们要了解一些重要知识点。

3.1.1 SW 只能工作在 https 下

这个很好理解,如果不是工作在 https 下,然后很容易发起中间人攻击,在你的 Web 应用中插入 SW 代码,将你的所有请求转发到黑客的服务器,那你的隐私全完蛋了。

你可能会想,既然有 https 的限制,那开发怎么办?难道还要搞一套证书才能开发吗?当然不用,浏览器厂商们替你考虑到了,当 hostname 为 localhost 时,浏览器也会正常执行 SW 的逻辑的,这样开发就没问题了。

3.1.2 SW 的工作是有 Scope 概念的

即它只能处理这个 Scope 下的请求。SW 的 Scope 是怎么得到的?它是根据你 SW 的 js 文件路径决定的,这也是几乎所有的 PWA 应用的 sw.js 文件都是在根目录(/)的原因。

3.1.3 SW 的生命周期

大概过程就是 installing → installed → activating → activated,可以在注册成功后通过 registration 获取。

3.2 使用 SW

3.2.1 注册 SW

通过调用 navigator.serviceWorker.register 完成。

navigator.serviceWorker.register('./sw.js').then(reg => {
    console.log('register success', reg)
    // 当前注册的 SW 的 Scope 就是 /
    if (registration.installing) {
        console.log('sw is installing')
    } else if (registration.waiting) {
        console.log('sw is waiting')
    } else if (registration.active) {
        console.log('sw is active')
    }
}).catch(err => {
    console.error('register failed', err)
})

很简单地就将 SW 引入到了当前页面,刷新页面,注册成功后会在控制台打印出“register success”,

3.2.2 拦截请求

this.addEventListener('fetch', (evt) => {
  console.log('catch fetch', evt);
})

没错,就是这么简单,监听 fetch 事件就可捕获当前页面的所有请求。

我们下面尝试访问一个不存在的 api,然后通过 SW 拦截并响应。

(async function() {
    const res = await fetch('/404')
    const res_text = await res.text()
    console.log('fetch 404', res)
})()
this.addEventListener('fetch', (evt) => {
  console.log('catch fetch', evt);
  if (evt.request.url === `${location.protocol}//${location.host}/404`) {
    evt.respondWith(new Response('not 404', {
      status: 200,
      statusText: "success"
    }))
  }
})

在使 SW 生效后,我们再次刷新页面,查看 network 面板,可以看到一个不存在的请求已经成功返回了,而且是由 ServiceWorker 处理的。

3.2.3 缓存请求

说到缓存,很多人可能会说了,这还不简单,在 fetch 时进行缓存判断,命中返回缓存,或者保存结果到缓存。

嗯,说的其实也没错,只不过需要注意的一点是,SW 也是 Worker,是无法访问 LocalStorage 之类的缓存后端的。

这里需要使用专门为 SW 提供的 Cache 接口。

this.addEventListener('fetch', async (evt) => {
  console.log('catch fetch', evt);
  if (evt.request.url === `${location.protocol}//${location.host}/`) {
    evt.respondWith(caches.open('v1').then(async (cache) => {
      const req = evt.request
      const cached_res = await cache.match(req)
      if (cached_res) return cached_res
      const res = await fetch(evt.request)
      cache.put(req, res)
      return res
    }))
  }
})

通过 Network 面板,我们可以看到已经成功的通过 SW 拦截了 html 请求并缓存了。而且即使是离线状态,也能正常返回,这也是 PWA 的基石。

3.2.4 SW 的更新策略

当我们修改了 SW 本身时,这个 SW 就已经是一个新的应用了。浏览器加载时,如果没有运行中的 SW 服务,那这个新的 SW 就行执行;但如果已经存在 SW 服务,那么这个新的 SW 就会处于 waiting 状态,除非旧服务停止,或者新服务 skipWaiting。

这样的逻辑就会产生几种更新策略

  • 静默式更新,即新服务直接调用 skipWaiting 方法强制替换旧服务,这种方式可能会干扰用户体验,比如因为网络问题,SW 模块加载较慢,此时用户已经开始交互,当加载完成新旧服务替换的时候,一定会干扰到用户的操作。
  • 交互式更新,即新服务加载完成处于 waiting 状态时,界面提示用户是否进行更新操作,由用户自行决定什么时候进行更新。

这两种方式其实没有本质的区别,只是决定是否更新的条件变成了人为或者代码。

下面贴一下关于交互式更新的样例。

navigator.serviceWorker.register('./sw.js').then(reg => {
  if (reg.waiting) {
    if (window.confirm('内容存在更新')) {
      reg.waiting.postMessage('skipWaiting');
    }
    return
  }

  reg.onupdatefound = () => {
    const installing_worker = reg.installing;
    installing_worker.onstatechange = function () {
      switch (installing_worker.state) {
        case 'installed':
            if (navigator.serviceWorker.controller) {
                if (window.confirm('内容存在更新')) {
                    reg.waiting.postMessage('skipWaiting');
                }
            }
            break;
        }
    };
  };
}).catch(err => {
  console.error('register failed', err)
})
this.addEventListener('message', evt => {
  if (evt.data === 'skipWaiting') {
    self.skipWaiting();
  }
})

4. SSR 使用 PWA/SW 优化思路

有了上面的验证,那么接下来我们就开始 SSR 结合 SW 解决性能问题。

4.1 页面调整

首先就是,SW 必须拦截所有的入口 html 请求,并返回一个指定的 html,这个 html 应该是一个基础框架,至少要包含所有的资源引用。

this.addEventListener('fetch', async (evt) => {
  console.log('catch fetch', evt);
  if (evt.request.method === 'GET' && evt.request.mode === 'navigate') {
    evt.respondWith(caches.open('v1').then(async (cache) => {
      const req = evt.request
      const cached_res = await cache.match('/skeleton')
      if (cached_res) return cached_res
      const res = await fetch('/skeleton')
      cache.put(req, res)
      return res
    }))
  }
})

这样,在 SW 正常工作后,所有的页面访问都会渲染成 /skeleton 页面。

然后是数据获取逻辑。

正常的 SSR 场景下,页面的初始状态是由服务端返回的,而且 Router、Store 都是处于已初始化的状态,浏览器初始加载渲染后不需要进行数据获取,所以我们一般都将获取数据的逻辑放入 Router 的 onReady 钩子中。

// entry-client.js
app = new App();
router.onReady(() => {
    fetchData();
    app.$mount('#app');
});

但是在 SW 介入后情况不一样了,所有的访问都是同一个页面,也就是说此时的页面其实是一个 SPA 应用,我们需要触发数据获取动作以渲染正确的页面。

所以,我们需要通过某种方式判断当前页面是 SW 返回的还是服务端返回的,因为 SW 固定返回同一个界面(/skeleton),那我们可以在这个界面上打上特殊的标识。

// skeleton.vue
<template>
</template>
<script>
export default {
    metaInfo: {
        bodyAttrs: {
            'skeleton': true
        }
    }
};
</script>

我们通过使用第三方库 vue-meta 来在 body 标签上打上 skeleton 属性,这样,当客户端入口发现了这个属性,说明这个 html 是由 SW 返回的。因为除非特意访问,服务端 SSR 是不会返回 /skeleton 页面的。

// entry-client.js
const app = new App()
const is_skeleton = document.body.hasAttribute('skeleton');
if (is_skeleton) {
    fetchData()
    app.$mount('#app')
} else {
    router.onReady(() => {
        fetchData();
        app.$mount('#app');
    });
}

至此,浏览器端的改造已经完成,现在的渲染逻辑是:

  • 用户首次访问,Service Worker 进行安装。
  • 用户再次访问,首屏由服务端 SSR 渲染,并由 SW 进行拦截,缓存指定的 skeleton 页面。
  • 用户后续访问,首屏都是由 SW 返回,此时页面是一个 SPA 应用,由页面自行渲染。

4.2 服务端调整

服务端无需特殊调整,唯一的要求就是在浏览器的 SW 请求 /skeleton 页面时,能正确的设置 body 的 skeleton 属性。

5. 生产环境落地 PWA/SW

通过前面的例子,我们已经可以验证 PWA 优化 SSR 方案的可行性,但是在实际生产中,我们不可能这样手写缓存处理逻辑。

一方面是因为现代前端工程基本都是通过构建工具进行过打包处理的,你无法得知最终的生成物的具体名称,也就无法手动去做 cache 处理;

另一方面是因为 fetch 事件的监听还不完善,我们只是通过 url 进行判断,实际情况你还需要判断这个请求是资源请求、导航请求还是 ajax 请求;

上面这些逻辑如果完全自己去实现一遍,完全是不值得的,我们可以使用现成的工具,将我们的精力集中在业务实现上。

而这个成熟的解决方案就是 Google 出品的 WorkBox。它使用良好的抽象将原本复杂繁琐的 SW 逻辑做了封装,你可以简单的进行路由、资源的缓存处理。

详细使用方法可以去官网查看,这里我就贴一些常用的接口。

import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing'
import { CacheFirst } from 'workbox-strategies'
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
import { ExpirationPlugin } from 'workbox-expiration'

// precache webpack manifest
precacheAndRoute([
  ...self.__WB_MANIFEST,
  '/appshell'
]);

// 页面路由拦截
registerRoute(
  new NavigationRoute(createHandlerBoundToURL('/skeleton'), {
    denylist: [/^\/admin/]
  })
)

// 页面静态资源拦截
registerRoute(
  /(static|media)/,
  new CacheFirst({
    cacheName: 'local-static',
    matchOptions: {
      ignoreVary: true,
    },
    plugins: [
      new ExpirationPlugin({
        maxEntries: 500,
        maxAgeSeconds: 63072e3,
        purgeOnQuotaError: true,
      }),
      new CacheableResponsePlugin({
        statuses: [0, 200]
      })
    ]
  })
);

// CDN资源请求拦截
registerRoute(
  /^https?\:\/\/[^.]+\.gstatic\.com/,
  new CacheFirst({
    cacheName: 'gstatic',
    matchOptions: {
      ignoreVary: true,
    },
    fetchOptions: {
      mode: 'cors',
      credentials: 'omit'
    },
    plugins: [
      new ExpirationPlugin({
        maxEntries: 500,
        maxAgeSeconds: 63072e3,
        purgeOnQuotaError: true,
      }),
      new CacheableResponsePlugin({
        statuses: [0, 200]
      })
    ]
  })
);

registerRoute(
  /^https?\:\/\/fonts\.googleapis\.com/,
  new CacheFirst({
    cacheName: 'google-fonts',
    matchOptions: {
      ignoreVary: true,
    },
    fetchOptions: {
      mode: 'cors',
      credentials: 'omit'
    },
    plugins: [
      new ExpirationPlugin({
        maxEntries: 500,
        maxAgeSeconds: 63072e3,
        purgeOnQuotaError: true,
      }),
      new CacheableResponsePlugin({
        statuses: [0, 200]
      })
    ]
  })
);

registerRoute(
  /^https?\:\/\/cdnjs\.cloudflare\.com/,
  new CacheFirst({
    cacheName: 'cloudflare-cdnjs',
    matchOptions: {
      ignoreVary: true,
    },
    fetchOptions: {
      mode: 'cors',
      credentials: 'omit'
    },
    plugins: [
      new ExpirationPlugin({
        maxEntries: 500,
        maxAgeSeconds: 63072e3,
        purgeOnQuotaError: true,
      }),
      new CacheableResponsePlugin({
        statuses: [0, 200]
      })
    ]
  })
);

6. 总结

至此,我们已经完整的学习了一遍 PWA/SW 应用的构建过程,也学习了怎么使用 SW 去优化 SSR 方案。而这套解决方案已经完整地在本博客站上实现并落地了,经过这次优化,非首次访问本站的流量只剩下 ajax 请求,极大的缓解了国外服务器访问速度慢的问题了。