【快把我添加到屏幕】本博客 PWA 化的体验和过程

前言

PWA 全称 Progressive Web Apps(渐进式 Web 应用程序),是一种将普通 Web 站点一定程度的“原生化”让其带来更好的客户端体验的“类原生”技术。原本由 Google 主导,在 Chrome 上优先实现。不过现在已被 Apple 和 Microsoft 所支持。

和普通网站相比,其最大的区别是 PWA 可以持久性的缓存网页资源(甚至包括页面文档,也就是 HTML)以达到离线访问的目的。再加上一些和系统结合的特性(例如离线推送、安装、快速打开等),使之有接近原生应用的体验。

效果

  1. 首次访问博客首页会提示添加到主屏幕(添加后会出现一个叫“蓝雨博客”的应用):


    如果没有提示,也可以手动添加(展开 Chrome 菜单有一个“添加到屏幕”的功能)

  2. 添加到主屏幕以后就可以像原生应用那样打开:


    如上:地址栏不存在了,还包含状态栏主题。

  3. PWA 不仅会添加到应用列表,甚至还可以单独卸载掉:


    因为添加到屏幕的 PWA 此时已经真实的成为了系统上的一个应用,只不过是一个依赖 Chrome 的“外壳应用”。

微信小程序

有很多人会把“微信小程序”和 PWA 当一回事,确实他们的理念是相似的,但却又是完全不同的:

首先,PWA 化的网站不会影响任何浏览器的正常访问(不管是支持或不支持 PWA),而微信小程序已经不再是 Web 站点,仅仅是底层利用了 Web 技术而已,不可能再被浏览器公开访问。对于微信小程序而言,底层甚至可以重构成跟 Web 毫无关系的技术,例如以前很多软件利用 Android 动态加载的能力远程载入插件,甚至能做到免安装跑一个 Native 应用(主应用提供外壳框架),只不过现在来看是非法的。又因为 Web 是天然的最佳跨平台技术,所以小程序选择了它,但是将小程序和网站或者 PWA 混为一谈是不对的。

PWA 网站被访问时,不仅有正常网站的所有特点,同时还包括 PWA 带来的优势(例如 Service Worker 的离线缓存)。即使不将我的网站添加到屏幕上,在离线状态下浏览器仍然能够访问(只要 ServiceWorker 仍然在工作),因为此时缓存不再由服务端控制,反而是由客户端自主决定什么时候更新。

如果说将一个博客制作成小程序是多此一举的,那么将博客 PWA 化则百益无害,因为 PWA 是在网站作为正常 Web 程序运作的基础上工作的,而微信小程序则是一个技术独立的,且仅运行于微信的东西,将博客分化成了多个前端。

过程

首先需要介绍一下前提,如你们所看到的(如果有看过我之前有关博客前端的文章或者研究过此博客的网页的话),我的博客前台(后台这里不讨论)是一个以 React 技术栈为主的 SPA,由 Webpack 构建。所以下面的过程的有些部分是单独应对这种情况而出现的。

如果接触过 Android 的话,对 AndroidManifest.xml 一定不会陌生,它是 Android 应用都会存在的清单文件。清单中定义了 App 的图标、所有的 Activity 或者 Service 等组件、声明了所有需要用到的权限,以及提供许多的细节属性配置等元素。这可能是 Google 的一个风格,也被推行到 PWA 这里来了:manifest.json 即是 PWA 的清单文件,里边可以定义 LOGO、名称、方向等元素,给将 PWA 安装到系统上提供属性支持。所以我们先定义一个清单文件:

{
  "name": "Blog for BlueRain",
  "short_name": "蓝雨博客",
  "display": "standalone",
  "lang": "cn",
  "start_url": "/",
  "theme_color": "#1898D8",
  "background_color": "#51b8ec",
  "orientation": "portrait",
  "icons": [
    {
      "src": "/dist/logo.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/dist/logo.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "/dist/logo.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "/dist/logo.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/dist/logo.png",
      "sizes": "256x256",
      "type": "image/png"
    }
  ]
}

由于我的项目组织架构原因,所以我会将 manifest.json 放进 dist 目录中。然后在网页中引用:

<head>
    <!-- other -->
    <link rel="manifest" href="/dist/manifest.json"/>
    <!-- other -->
</head>

接着在网页中嵌入一段脚本,这段脚本是用来判断浏览器是否支持 PWA 若支持则拉取 Service Worker 并开始工作:

<script>
    if (navigator.serviceWorker != null) {
        navigator.serviceWorker.register('/sw.js')
            .then(function (registration) {
                console.log('Registered events at scope: ', registration.scope);
            });
    }
</script>

接下来我们需要编写 Service Worker(src/forestage/sw.js):

const cacheStorageKey = 'blog-pwa-1';

self.addEventListener('install', e => {
    console.log('[ServiceWorker] Install');
    e.waitUntil(
        caches.open(cacheStorageKey)
            .then(cache => {
                fetch('/dist/assets-manifest.json')
                    .then(resp => resp.json())
                    .then(assets => {
                        console.log('[ServiceWorker] Add caches:', JSON.stringify(Object.values(assets)));
                        cache.addAll([
                            "/",
                            assets["frontJs"],
                            assets["logo"]
                        ]);
                    })
            })
            .then(() => self.skipWaiting())
    )
});

self.addEventListener('fetch', function (e) {
    console.log('[ServiceWorker] Fetch');
    if (/(\/login\/?$)|(\/admin\/?$)|(\/admin\..+)|(\/admin\/.+)|(blog-api\.bluerain\.io)/.test(e.request.url)){
        console.log('[ServiceWorker] Ignore resource:', e.request.url);
        return;
    }
    e.respondWith(
        caches.match(e.request).then(function (response) {
            if (response != null) {
                return response
            }
            return fetch(e.request.url)
        })
    )
});

self.addEventListener('activate', function (e) {
    console.log('[ServiceWorker] Activate');
    e.waitUntil(
        caches.keys().then(function (keyList) {
            return Promise.all(keyList.map(function (key) {
                if (key !== cacheStorageKey) {
                    console.log('[ServiceWorker] Removing old cache:', key);
                    return caches.delete(key);
                }
            }));
        })
    );
    return self.clients.claim();
});

当然 sw.js 不会被任何 JS 所引用,它只被嵌入网页的脚本所加载。所以它是一个单独的 Webpack 入口:

entry: {
    // front entry
    // admin entry
    // ……
    sw: [srcPath + '/forestage/sw.js']
},

资源清单文件(assets-manifest.json):

{
  "frontJs": "/dist/front.js",
  "logo": "/dist/logo.png"
}

Service Worker 会读取其中的属性,得到定义的资源路径,然后动态的添加到缓存中。这个文件在 Developer 环境下也就是直接 yarn run server 启动前端项目的情况下可以是写死的,因为没有用 hash 命名资源。但是在 Production 环境也就是 CI 会利用 yarn run build 命令构建的情况下资源清单文件必须在构建过程中动态生成:

function () {
    this.plugin("done", stats => {
        const suffix = '.' + stats.hash + '.js';
        // sw.js 去 hash 命名
        fs.renameSync(`./dist/sw${suffix}`, './dist/sw.js');
        // 输出缓存资源清单文件
        const cdn = 'https://o4p9r7thl.qnssl.com/blog';
        const assets = `{
            "frontJs": "${cdn}/front${suffix}",
            "logo": "/dist/logo.png"
        }`;
        fs.writeFileSync('./dist/assets-manifest.json', assets);
        // 后续过程
        // ……
    });
}

例如在我这里将上面的函数添加至 Webpack 生产环境配置的 plugins 数组中即可。

有下面几点需要注意:

  • 每改变一次资源都需要将 cacheStorageKey 的值进行变化,让浏览器删除旧版本缓存
  • 这里的缓存列表没有直接定义在 JS 数组对象中,而是拉取了另一份资源清单文件中定义的文件。原因是 hash 版本命名后的资源并不适合直接放进 JS 数组中,因为每一次变更都会导致不同的资源名产生
  • 在 fetch 这个事件中,排除了 admin 相关的路径缓存,因为后台又是一个单独的 SPA,虽然跟前台集成在一个项目中,但实际是两个应用。同时也排除了对 API 访问的缓存,动态数据不解释。

将 logo.png 放进 dist 目录中,然后编辑 dist 中的 .gitignore 文件,排除掉这几个需要跟踪版本的文件:

*
!.gitignore
!*.json
!logo.png

到这里,dist 中多了三个文件:

  1. manifest.json(PWA 清单文件)
  2. assets-manifest.json(Service Worker 需要加载的缓存资源清单,生产环境下内容动态产生)
  3. logo.png(普通的图片资源,同时也是缓存资源之一,以及清单配置项之一)

这三个文件在我的博客的 /dist/ 路径下都能访问得到,还可以观察生产环境下的资源清单的动态产生效果,当你访问:https://blog.bluerain.io/dist/assets-manifest.json 便可以看到了。

其实上述代码还有一个弊端,是不是集成了 PWA 以后那么每一次资源更新都必须手动修改 cacheStoreKey 的值?这其实不是什么大问题,甚至连问题都算不上,因为你确实得修改它的值。不过这个值的修改可以借助 Webpack 的 externals 自动化完成:

module.exports = {
    externals: {
        'pwa-key': `"${new Date().getTime()}"`
    }
}

如上,在 module.exports 中添加 externals 配置项,加一个 pwa-key 和一个动态计算出来的值(典型的例如时间戳)。
它会将计算后的值替换到 JS 中对 pwa-key 的导入部分,所以 sw.js 中的 cacheStorageKey 应该这样定义:

const pwaKey = require('pwa-key');
const cacheStorageKey = `blog-pwa-${pwaKey}`;
// 后续代码……

此时每进行一次构建,都会产生一个新的 pwaKey,所以每进行一次发布缓存版本都会自动提升,并且这两个部分在开发和生产环境通用。
另外提一下:在开发环境下由于借助 webpack-dev-server 热更新资源,会在不重新启动 Webpack 的情况下加载新资源,但是 cacheStorageKey 没有重新产生所以浏览器依然从 Service Worker 中拿缓存。解决办法很简单,在 Chrome F12 的 Application 里边勾上 Bypass for network 即可(如果阅读过对 ServiceWorker 的调试章节的话,其实这一点应该是知道的)。

最后说明一点:为什么我不把 sw.js 跟其它入口文件一样最终传到 CDN?因为我觉得 sw.js 这么一点点内容并且几乎不会再变化的资源没必要一直跟随资源 hash 上升版本,不断的往 CDN 上传,有点浪费。

结束语

最后要提出一点,我的确看不起微信小程序,这个不仅仅是技术上的,更多是对微信这个封闭的平台的厌恶。PWA 现在已经被各大浏览器巨头所支持,所带来的平台兼容性无限接近于 Web 程序,是微信小程序这种东西无法比拟的。

还有我呼吁国内的个人开发者,互联网厂商都尽快的尝试 PWA,它能给你带来的超出传统 Web 的体验。现在的手机 Web 站点普遍各种强制推广客户端让人无法忍受,手机浏览器几乎成了一个搜索工具。而在 Web 逐渐完善和成熟的今天,简直是反其道而行。不过在为了装机量,为了“捆绑用户”的国内厂商眼里,PWA 如果流行或许的确是个“祸害”。