Progressive Web App 初体验

最近访问 Twitter Mobile微博 HTML5 版 的时候,发现两者纷纷都兼容了 PWA(Progressive Web App) 特性,得益于 Service Worker,PWA 具有了一些以往传统 WebApp 做不到的,诸如离线消息推送等等的功能,如果在 WebApp 和原生应用性能和功能相差不大的情况下,已经可以直接把 Web 端当成简洁版的客户端使用了(尤其是 Twitter Mobile,移动 Web 端的体验和 Android 原生 App 的体验几乎 90% 一致)。毫无疑问 PWA 接下来将会带来更大的应用场景,于是为了跟上前端圈技术的泥石流,本辣鸡接触了一下这项新的技术。

关于 PWA

不仅仅是微博,国内的饿了么等站也同样支持 PWA,个人看来 PWA 的应用场景和前景都是相当广泛的。甚至,从微信小程序当中你也能看到它和 PWA 之间有着概念的重合。至于 PWA 的诞生背景就不多做介绍。各位可以尝试一下 Twitter 的 PWA,体验真的很好。

如何看待 Progressive Web Apps 的发展前景?

传统 WebApp 在移动端上的体验不是很好一直为人所诟病,问题在于移动端浏览器在对页面的渲染和 DOM 的操作上有性能方面的差距,以及并不能做一些高级的事情,例如驻后台更新数据、推送通知等等。如果说 RN/Weex 是用前端的技术栈做移动端的原生应用,以此来作为移动端 App 的功能、性能与开发成本之间的权衡,那么 PWA 和前两者便有一些区别,在前两者向 NativeApp 妥协的时候,它另辟蹊径,坚守 WebApp,而把目光着重于优化和实现一些 WebApp 做不到的事情。某种意义上说有点类似于 Hybrid?但是壳什么的,浏览器已经帮你准备好了。你只需要在访问支持 PWA 的站点的时候按一下“添加到主屏幕”,它就如同一个 App 一样躺在你的桌面了。

讲了很多 PWA 的优点(总结一下就是:支持添加到主屏,可以完成通知推送等工作(甚至支持 GCM);其他的还有诸如离线缓存等),PWA 也存在一些不足的地方。首先一个比较大的问题就是兼容性,作为 Google 首推的东西 Chrome 自然是支持的,新版本的 FF 目测也可以,但是对于别的浏览器来说就十分难受了:

Can I Use 中关于 Service Worker 的兼容性报告

caniuse 的报告中可以看到兼容性有些感人。

Service Worker

刚刚说到的一些 PWA 的特性,例如后台消息推送、离线缓存等等,都是由 Service Worker 来实现的。所以说一个 PWA 应用的核心就在于 Service Worker,完全不过分。

一个 Service Worker是一段运行在浏览器后台进程里的脚本,他独立于当前页面,提供了那些不需要与 web 页面交互的功能在网页背后悄悄执行的能力。在将来,基于它可以实现消息推送,静静更新以及地理围栏等服务,但是目前它首先要具备的功能是拦截和处理网络请求的功能,包括可编程的消息缓存管理能力。

通俗地讲,Service Worker 就是挂在后台的一段脚本,你在前台该干嘛干嘛,它可以在后台搞一些事情,比如缓存你看的页面或者资源啊,接收到通知的时候推送给你啊,转发你的请求啊,这些都是目前比较多见的应用;当你关掉了页面之后,它还是会在后台待着继续做上面这些事情。

Service Worker API - MDN Service worker是一个注册在指定源和路径下的事件驱动worker。它采用JavaScript控制关联的页面或者网站,拦截并修改访问和资源请求,细粒度地缓存资源。你可以完全控制应用在特定情形(最常见的情形是网络不可用)下的表现。Service worker运行在worker上下文,因此它不能访问DOM。相对于驱动应用的主JavaScript线程,它运行在其他线程中,所以不会造成阻塞。它设计为完全异步,同步API(如XHR和localStorage)不能在service worker中使用。

可以看到,Service Worker 不具备对 DOM 的访问权限,工作完全都在后台。同时因为 Service Worker 运行在后台的特性,它的操作都是异步的,因此 Promise 在这里非常好用。无法使用 XHR 这样的同步 API 还怎么和服务器通信呢,我们有 fetch 呀。在 Service Worker 中 fetch 也扮演了一个很重要的角色。

Service Worker 的工作机制是这样的:用户访问一个具有 Service Worker 的页面,浏览器就会下载这个 Service Worker 并尝试安装、激活。一旦激活,Service Worker 就会到后台开始工作。接下来用户访问这个页面或者每隔一个时段浏览器都会下载这个 Service Worker,如果监测到 Service Worker 有更新,就会重新安装并激活新的 Service Worker,同时 revoke 掉旧的 Service Worker,这就是 SW 的生命周期。

因为 Service Worker 有着最近的权限接触数据,因此 Service Worker 只能被安装在 HTTPS 加密的页面中,虽然无形当中提高了 PWA 的门槛,不过也是为了安全做考虑。GitHub Pages 等服务默认支持 HTTPS,所以各位如果想尝试 Service Worker 又被需要 HTTPS 所烦恼的话,可以考虑一下。

In Action

多说一句,一些用 React/Vue 这类框架做的 SPA 原本就有很完整的体验, 如果加上 ServiceWorker,让它成为一个 PWA,岂不美哉 开发移动端的经费都省了。事实上刚才说到的 Twitter(基于 React) 和新版微博(基于 Vue) 便是很好的实践的例子。而 create-react-app 创建的 React App 默认也会启用 ServiceWorker 特性,在 cra 创建的应用中,你可以看到和 ServiceWorker 安装激活有关的代码,同时 webpack 中也有相应的配置。

我的 Blog 默认采用 HTTPS 安全连接,前端也是用 webpack 构建的,用来尝试 PWA 一些基础的功能(例如离线缓存,生成单独的 App 图标)完全可以,所以我就以博客为小白鼠试了一下,只做了十分基本的离线缓存。

Step 1 / 编写并注入 ServiceWorker 到前端页面中

这块我们就直接抄走 create-react-app 生成的 App 的代码好啦。在 src 目录下有一个 registerServiceWorker.js,就是用来管理安装/激活/检查/注销 ServiceWorker 的,我们来看看它:

// In production, we register a service worker to serve assets from local cache.

// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on the "N+1" visit to a page, since previously
// cached resources are updated in the background.

// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
// This link also includes instructions on opting out of this behavior.

这里告诉我们它用 registerWorker 的目的在于缓存一些资源,但是呢会导致如果生产环境的页面被开发者更新了之后,看到新效果之前可能有延迟。

const isLocalhost = Boolean(
  window.location.hostname === 'localhost' ||
    window.location.hostname === '[::1]' ||
    window.location.hostname.match(
      /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
    )
);
// 执行注册 ServiceWorker 的操作,可以看出只有在生产环境下才做这件事
export default function register() {
  if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
    const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
    if (publicUrl.origin !== window.location.origin) {
      return;
    }
    // 页面加载完成后,执行注册操作
    window.addEventListener('load', () => {
      const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
      if (!isLocalhost) {
        registerValidSW(swUrl);
      } else {
        checkValidServiceWorker(swUrl);
      }
    });
  }
}

我们看看注册操作的主函数:

function registerValidSW(swUrl) {
  navigator.serviceWorker
    .register(swUrl)
    .then(registration => {
      registration.onupdatefound = () => {
        const installingWorker = registration.installing;
        installingWorker.onstatechange = () => {
          if (installingWorker.state === 'installed') {
            if (navigator.serviceWorker.controller) {
              console.log('New content is available; please refresh.');
            } else {
              console.log('Content is cached for offline use.');
            }
          }
        };
      };
    })
    .catch(error => {
      console.error('Error during service worker registration:', error);
    });
}

可以看到我们navigator.serviceWorker.register(serviceWorkerJsUrl) 告诉浏览器应该注册从 serviceWorkerJsUrl 注册这个 ServiceWorker, 然后指定当浏览器监测到 serviceWorker 更新之后该做的事情(事实上没有什么东西,就是 console.log 告诉用户内容是否有更新,以及当前页面的内容已经被缓存了)。

function checkValidServiceWorker(swUrl) {
  // Check if the service worker can be found. If it can't reload the page.
  fetch(swUrl)
    .then(response => {
      // Ensure service worker exists, and that we really are getting a JS file.
      if (
        response.status === 404 ||
        response.headers.get('content-type').indexOf('javascript') === -1
      ) {
        // No service worker found. Probably a different app. Reload the page.
        navigator.serviceWorker.ready.then(registration => {
          registration.unregister().then(() => {
            window.location.reload();
          });
        });
      } else {
        // Service worker found. Proceed as normal.
        registerValidSW(swUrl);
      }
    })
    .catch(() => {
      console.log(
        'No internet connection found. App is running in offline mode.'
      );
    });
}

这段代码是检查 Service Worker 的有效性。首先对 serviceWorkerJsUrl 发出 fetch 请求,如果 serviceWorker 不存在了(返回 404,或者类型不是 JS 文件),那么程序会认为已经不再需要 ServiceWorker 了,那么就调用 registration.unregister() 执行注销操作并刷新页面。如果 fetch 不成功,程序会认为当前没有可用的网络,运行在离线模式 (offline mode) 中。

export function unregister() {
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.ready.then(registration => {
      registration.unregister();
    });
  }
}

这一段是注销的函数,当你不再需要的时候也可以人工调用这个函数注销 ServiceWorker.

然后我们在自己项目的 src 里引用这个文件,注册 service worker. 例如,采用 ES6 的写法:

import registerServiceWorker from './registerServiceWorker';
registerServiceWorker();

这样就注册完成了,很简单。

Step 2 / 编写 service-worker.js

因为这里我们要做的只是缓存,所以我们把要缓存的东西以及相关的代码写入 service-worker.js 中就可以了,如果你要推送东西,同样可以参照一下 API 然后写在这里。这个 js 脚本便是即将在浏览器的后台执行的脚本了。当然,如果在很简单的需求下,我们不需要手写 service-worker.js,可以直接生成的。

webpack 有一个插件叫做 sw-precache-webpack-plugin, 我们可以用它来生成 service-worker.js 以便用 SW 缓存我们的资源。首先我们安装这个插件:yarn add sw-precache-webpack-plugin --dev.

然后修改 webpack.config:

const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin');

module.exports = {
    // ...
    plugins: [
        new SWPrecacheWebpackPlugin({
        dontCacheBustUrlsMatching: /\.\w{8}\./,
        filename: 'service-worker.js',
        logger(message) {
            if (message.indexOf('Total precache size is') === 0) {
            return;
            }
            if (message.indexOf('Skipping static resource') === 0) {
            return;
            }
        },
        minify: true,
        navigateFallback: '/index.html',
        navigateFallbackWhitelist: [/^(?!\/__).*/],
        staticFileGlobsIgnorePatterns: [/\.map$/, /asset-manifest\.json$/]
        }),
    ],
    // ...
}

这段代码会在 webpack 打包进程中自动帮我们生成 service-worker.js, 通过看代码我们发现这个 js 告诉了浏览器我们可以离线哪些资源,并且注册了安装、激活、更新时应该做的事情,等等。

完成后打开浏览器,打开 DevTools 切到 Application 窗口,再点左边的 ServiceWorker,如果没有出错的话就可以看到 ServiceWorker 被安装并激活了。你也可以在这个窗格调试你的 ServiceWorker.

devtools service-worker

你可以转到 chrome://inspect 查看当前所有站点注册的 ServiceWorker 的情况。

Step 3 / 生成 asset-manifest.json

这一步我们同样用插件:webpack-manifest-plugin, 用法同样很简单:

const ManifestPlugin = require('webpack-manifest-plugin');
// ...
plugins: [
    new ManifestPlugin({
      fileName: 'asset-manifest.json'
    }),
]

打包完后根目录下便会有一个 asset-manifest.json 文件,这个文件告诉了浏览器有哪些静态文件。

Step 4 / 写好 manifest.json

最后一步,如果要让我们的应用在手机端访问 Chrome 的时候,提示安装的横幅(就是添加到主屏幕的提示),我们还需要做一件事——为你的 WebApp 写一个 manifest.json,这里不同于上一步的 asset-manifest.jsonmanifest.json 主要是告诉浏览器这个站点的一些信息,包括名称、图标、首页、主题色等等。加上这个文件之后才能算是一个完整的 PWA。例如:

{
  "short_name": "YumeのDiary",
  "name": "吟梦酱的 Blog",
  "description": "是吟梦酱的 Blog 的说~",
  "icons": [
    {
      "src": "favicon.png",
      "sizes": "192x192",
      "type": "image/png"
    }
  ],
  "start_url": "/",
  "display": "standalone",
  "theme_color": "#FF98A8",
  "background_color": "#ffffff"
}

这是我的 manifest 文件。这些配置项都很简单易懂,看的懂英文的人都知道是什么意思了。例如,short_name 是被添加到手机桌面时显示的标签, name 就是 App 的名称,display 设置为 standalone 表示不打开浏览器界面而单独打开此 App(就是传说中的套壳2333),background_color 是从桌面启动 PWA 时第一屏的背景色……

然后在 HTML 中链接 manifest.json:

<head>
  <link rel="manifest" href="/manifest.json">
</head>

把 manifest.json 放在根目录,完成之后你可以打开 DevTool -> Application -> Manifest 查看情况,也可以测试一下是否可以正常添加。

Installable Web Apps with the Web App Manifest in Chrome for Android

Finally

做完了这一切,我们可以试试是否正常。如果正常的话,使用移动版 Chrome(>=42) 打开你的页面,你可以看到“添加到主屏幕”的提示:

add-to-homescreen: kirainmoe.com pwa

如果看到了提示,说明成功了。如果没有看到的话,可能有以下原因:

  1. ServiceWorker 没有被正常加载。使用桌面端的 Chrome 看看是否成功加载了 ServiceWorker、console 是否报错等等。
  2. 当前页面不是 HTTPS 协议。只有 HTTPS 协议才能激活 ServiceWorker 并提示安装 PWA。
  3. Chrome 无法正确识别 manifest.json. 有很多原因导致,可以在上文讲到的 DevTool -> Application -> Manifest 中尝试添加,看看控制台是否报错和报错内容。比如说:你的图标有问题(需要 mime-type 为 image/png,并且尺寸一定要 100% 符合,且尺寸要大于 144x144)……等等。

解决了上面这些问题之后再试一次,应该就没有问题了。

新技能 get√ 感觉逼格又提升了一些(wu _(:з」∠)_