Progressive Web Apps

一.What?

A new way to deliver amazing user experiences on the web.

一种提升Web用户体验的方式。除了Web天生的(便捷)体验外,还有3个特点:Reliable, Fast, Engaging

  • 可靠:在不确定的网络环境下,也能立即加载,而不会(因为断网而)瞬间回到远古时代

    可靠指的是离线缓存,断网状态走缓存,保证离线场景仍然可用,service worker配合cache API建立缓存-代理机制

  • 快速:迅速以丝滑的动画作为交互反馈,而不存在掉帧卡顿的滚动

    快速,只是强调交互反馈“感觉快”,与推崇的Material Design有关,并没有真正的速度优势(至少首屏没有)

    另外,得益于缓存-代理机制,再次访问时走本地缓存会相当快

  • 类native:像设备原生App一样,具有沉浸式的用户体验(即全屏)

    除了全屏外,还有主屏图标(让Web App在主屏幕有一席之地)和系统通知(“拉活”的能力),通过Web App Manifest配置来实现,依赖用户环境支持

P.S.Engaging这个抽象形容词真不好翻译,这里暂且取其实际意义,类native

所以,表面上看,PWA的亮点分2部分:

  • (离线)缓存-代理机制

  • 全屏,主屏图标和系统通知等类native特性

缓存机制在Web App/SPA里一点不新鲜,抽离出数据层之后,缓存顺手就做了。但侧重点不同,PWA的缓存机制偏向于静态资源缓存,而Web App/SPA的缓存层多用来做动态内容缓存(上次的内容没过期的话,不再重新获取动态部分,而是直接做客户端渲染)

至于全屏,主屏图标以及系统通知等类native特性,算是渐进增强中的增强,在支持的用户环境是可用的(一些浏览器提供了支持,但更广泛的WebView环境在不久的将来可能还是不行)。但这表明Web正在以渐进增强的方式走出PC时代,向着移动化发展

二.试玩

依赖环境

  • HTTPS

要求服务源必须是安全的,所以需要HTTPS环境。除了出于Web信息安全的考虑,想要推进HTTPS普及也是一个重要原因,HTTPS作为Web技术发展的必要基础设施,对于拍照,录音,push API等新特性,都需要获得用户许可,而HTTPS是权限工作流的关键部分,必不可少

P.S.在permission.site能够体验到HTTPS与HTTP环境在获取用户授权方面的差异

类native增强

通过引入Web App Manifest配置文件来实现类native增强,在支持PWA的浏览器生效(在不支持的环境最坏结果也就是多请求一个JSON文件):

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

注意,有个比较相似的东西,叫Application Cache(HTML5特性,已过时),其manifest引入方式不同:

<html manifest="example.appcache">
  ...
</html>

因为二者引入方式不同,所以Web App Manifest与Application Cache是不相干的,没有历史包袱的后顾之忧

P.S.Application Cache对SPA支持较好,对多页应用则不适用,但存在很多问题,这里不多做介绍

主屏图标

Web App Manifest内容示例如下:

{
  "short_name": "主屏显示的应用名称",
  "name": "安装banner显示的应用名称",
  "icons": [
    {
      "src": "launcher-icon-1x.png",
      "type": "image/png",
      "sizes": "48x48",
      "density": "1.0"
    },
    {
      "src": "launcher-icon-2x.png",
      "type": "image/png",
      "sizes": "96x96",
      "density": "1.0"
    },
    {
      "src": "launcher-icon-4x.png",
      "type": "image/png",
      "sizes": "192x192",
      "density": "1.0"
    }
  ],
  "start_url": "index.html?launcher=true"
}

P.S.安装banner是指一个类似于获取权限的弹出面板,用户可以选择添加至主屏幕或取消,满足一定条件的话,Chrome会自动弹出安装banner,具体见Web App Install Banners

这样理想情况下我们就拥有了主屏图标,支持Web App Manifest的环境会选用最合适的(最接近48dp的)图标

注意:index.html里的内容应该是首屏渲染需要的最小化内容,为了达到首屏立即加载的效果,可以把带loading和默认占位图的页面框架作为App Shell展示出来。另外,为了达到秒开可用的首屏性能,Web App首屏性能优化其它常规手段在PWA也是推荐使用的,比如数据直出。如开篇所说,PWA并没有天生的(首屏)性能优势,Web App适用的常规优化手段仍然是必要的

闪屏(Splash)

从主屏图标进入,可定制的启动过程显示内容包括:标题,背景色和图像。新配置项如下:

// 背景色
"background_color": "#2196F3",
// 主题色,包括工具栏
"theme_color": "#2196F3",

图像从icons中选取最接近128dp的图像作为闪屏,支持动图

另外,还可以指定显示模式和页面方向:

// 全屏(隐藏浏览器的UI)
"display": "standalone",
// 显示浏览器外壳,像打开书签一样
"display": "browser",
// 横屏
"orientation": "landscape"

P.S.关于闪屏的示例及更多信息请查看Adding a Splash Screen for Installed Web Apps in Chrome 47

特别注意:如果manifest.json文件有更新,这些改动不会自动生效,除非用户重新添加应用到主屏

系统通知

与Web App Manifest无关,依赖Push API。简单示例如下:

// service-worker.js
self.addEventListener("push", function (event) {
  event.waitUntil(
    self.self.registration.showNotification("发布新文章啦", {
      body: "有新文章发布啦,点击查看。"
    })
  );
});

这里不多做介绍(目前(2017/12/15)几乎可以认为这个特性不存在),因为规范定义了API,但没规定统一个push协议,所以各家浏览器的push机制不同,比如Chrome的GCM在我们这片天空下就不可用

关于Push API的更多信息,请查看【Service Worker】消息推送功能“全军覆没”

缓存-代理

缓存分为几部分:

  • 首屏静态资源缓存(预缓存)

  • 已访问资源缓存(运行时缓存)

  • 动态内容缓存(运行时缓存)

缓存是纯数据操作(包括持久化),而service worker能够在后台运行,尤其适合处理这种与页面及交互无关的事情,所以service worker与Cache API,Push API成了搭档。但service worker自身也应该看做“增强”项,在不支持service worker的环境应该跳过缓存机制保证基本的页面体验,简单的特征检测方案如下:

if ('serviceWorker' in navigator) {
  navigator.serviceWorker
           .register('./service-worker.js')
           .then(function() { console.log('Service Worker Registered'); });
}

service worker在install事件处理器完成包括App Shell在内的首屏静态资源缓存:

// service-worker.js
var cacheName = 'weatherPWA-step-6-1';
var filesToCache = [
  // 入口URL
  '/',
  '/index.html',
  '/scripts/app.js',
  '/styles/inline.css',
  // App Shell需要的资源
  '/images/ic_add_white_24px.svg',
  '/images/ic_refresh_white_24px.svg',
  // 内容展示可能用到的资源
  '/images/clear.png',
  '/images/cloudy-scattered-showers.png',
  '/images/cloudy.png',
  '/images/fog.png',
  '/images/partly-cloudy.png',
  '/images/rain.png',
  '/images/scattered-showers.png',
  '/images/sleet.png',
  '/images/snow.png',
  '/images/thunderstorm.png',
  '/images/wind.png'
];

self.addEventListener('install', function(e) {
  console.log('[ServiceWorker] Install');
  e.waitUntil(
    caches.open(cacheName).then(function(cache) {
      console.log('[ServiceWorker] Caching app shell');
      //! 只要有一个失败就不接着做下一个了
      return cache.addAll(filesToCache);
    })
  );
});

当然,还需要对缓存做基本的版本控制:

// service-worker.js
self.addEventListener('activate', function(e) {
  console.log('[ServiceWorker] Activate');
  e.waitUntil(
    caches.keys().then(function(keyList) {
      return Promise.all(keyList.map(function(key) {
        // 以为cacheName为cache key,如果存在旧的缓存,删除掉
        if (key !== cacheName) {
          console.log('[ServiceWorker] Removing old cache', key);
          return caches.delete(key);
        }
      }));
    })
  );
  // 要求立即激活service worker,避免边界case
  return self.clients.claim();
});

P.S.边界case指的是某些情况下service worker无法立刻恢复激活态,导致不走缓存。为了屏蔽这些边界case,推荐使用GoogleChromeLabs/sw-precache帮助处理缓存控制问题(包括过期,更新策略等等)

缓存有了,接下来实现代理部分,拦截请求,并把缓存内容作为响应:

// service-worker.js
// 拦截请求
self.addEventListener('fetch', function(event) {
  console.log('[ServiceWorker] Fetch', e.request.url);
  // 自定义响应内容
  e.respondWith(
    // 查找缓存,没有才请求
    caches.match(e.request).then(function(response) {
      return response || fetch(e.request);
    })
  );
});

到这里基本的缓存-代理机制就准备好了,我们做了这些事情:

  1. 按资源列表预先缓存静态资源

  2. 拦截请求

  3. 把缓存内容作为响应给过去

有3个注意事项

  • 浏览器缓存可能会影响缓存更新,所以install事件处理器中的请求不会走缓存,而是直接进入网络

  • 注销service worker不会清掉缓存,cache key不变的话,之后还会拿到旧的缓存内容

  • 默认新注册的service worker在页面重新载入之后才会生效,除非做特殊处理

另外,我们的简版实现还存在一些问题,例如:

  • 缓存版本控制依赖一个静态的cache key,每次更新service-worker.js都要修改这个key

  • 一旦cache key有变化,会抹掉所有缓存,重新请求一遍,对于静态资源有些浪费

  • 缺少运行时缓存,资源列表不够灵活,期望更强大的边访问边缓存

第1个问题没什么太好的办法,第2个问题可以通过细分资源类型来缓解,例如:

// Shorthand identifier mapped to specific versioned cache.
var CURRENT_CACHES = {
  font: 'font-cache-v' + FONT_CACHE_VERSION,
  css: 'css-cache-v' + CSS_CACHE_VERSION,
  img: 'img-cache-v' + IMG_CACHE_VERSION
};

通过更细粒度的版本控制,能在一定程度上降低强制更新缓存的成本,当然,缓存层下面还有HTTP Cache兜底,缓存更新成本不是非常关键

至于运行时缓存,实际上只需要再做最后一小步就好了:

  1. 没命中缓存的话,请求资源*并缓存*

具体如下:

// 查找缓存,没有才请求
caches.match(e.request).then(function(response) {
  return response || fetch(e.request).then(function(res) {
    return caches.open(dataCacheName).then(function(cache) {
      // 并缓存起来
      cache.put(e.request.url, res.clone());
      return res;
    )
  })
})

另外,还可以根据资源类型及场景要求,针对性的选用合适的缓存策略,例如:

// service-worker.js
self.addEventListener('fetch', function(e) {
  console.log('[Service Worker] Fetch', e.request.url);
  var dataUrl = 'https://cache.domain.com/fresh/';
  // 策略1:有实时性要求的资源,请求优先,fetch then cache
  if (e.request.url.indexOf(dataUrl) > -1) {
    e.respondWith(
      caches.open(dataCacheName).then(function(cache) {
        return fetch(e.request).then(function(response){
          cache.put(e.request.url, response.clone());
          return response;
        });
      })
    );
  } else {
    // 策略2:一般资源,缓存优先,cache falling back to fetch
  }
});

P.S.更多缓存策略,见参考资料部分

三.Demo

官方Demo:Weather PWA,可能无法正常访问

搬运Demo(把官方Demo挪到github pages):https://ayqy.github.io/pwa/demo/weather-pwa/index.html

P.S.github pages非常适合用作试验田,稳定可靠的HTTPS,发布内容没有任何限制可以随便折腾,以后的博客Demo都会逐步迁移过去(之前一直放在自己的FTP,那可真蠢..)

weather-pwa

weather-pwa

不太乐观的消息:事实上,故意精心准备了用户环境(官方正版Chrome + 官方Demo),在小米4上没有自动弹出安装banner(可能是操作姿势等条件不满足,见上文),手动点击“添加至主屏幕”,toast添加成功,但主屏幕上啥也没有……这就是提不起兴趣手写Demo试玩的原因(当然,主要原因是懒;))

四.案例

注意,隐身模式可能会导致阿里巴巴国际站的service worker抛如下错误:

Uncaught (in promise) DOMException: Quota exceeded.

正常环境可正常体验

P.S.更多案例,请查看Case Studies | Web | Google | Developers

五.应用场景

简言之,PWA算是Web App的升级版,主要亮点是类native支持。以渐进增强的方式,不需要太高成本就能完成Web App到PWA的“升级”,让部分用户(支持PWA的环境)获得更快(缓存)更便捷(主屏图标)的类native体验(全屏)

那么具体应用场景分以下几种:

  • 缓存能带来明显收益的Web App

  • 期望具有离线能力,或类native体验,或者单纯只是想要个主屏图标的Web应用

  • 期望蹭个技术热点/协助推动其发展的Web应用或浏览器供应商

不管应用场景,话说回来,正如zxx某篇关于缓存(还是worker?)的文章所说,这么点儿成本就能让页面获得离线能力,真切看到缓存带来的收益,何乐而不为呢?

另外,Angular,React,Vue等主流框架都提供了PWA脚手架,具体请查看The Ultimate Guide to Progressive Web Applications

参考资料

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code