PWA 知不知

3,569 阅读17分钟

什么是 PWA

Progressive Web App, 简称 PWA,是「渐进式」提升 Web App 体验的一种新方法,能给用户类似原生应用的体验。

「高可靠,高性能,优体验」是 PWA 惯用的形容词,他的另外一个优点就是「渐进式」,开发者可以对照 PWA Checklist 逐步对自己站点进行 PWA 化升级。

PWA 的发展史

2007

苹果前 CEO,Steve Jobs,2007 年 WWDC 上提出了为初代 iPhone 开发应用的概念,当时所介绍的,就是 Web App——可以从主屏直接启动的 Web 应用。

图片来源 appleinsider.com

可惜当时这个理念太过超前,并没有引发太多关注,反而是后来的原生 App 应用更符合当时的市场需求,互联网公司更愿意投入人力在原生 App 的开发上,而忽略了 Web。因此原生 App 的大量出现,占据了移动时代的主流地位,Web 似乎就要被 App 所取代。

2014

随着 Web 技术的发展,时间来到 2014 年, W3C 公布了 Service Worker 的相关草案,其生产环境在 2015 年被 Chrome 支持。随后 PWA 加以完善,相关技术不断升级优化,在用户体验和用户保活两方面的可发掘价值越来越大。

2017

继移动站点喷井式发展之焰末,原生 App 的弊端越发明显,对于它来讲,最大的痛点便是其天生封闭的基因导致的内容无法被索引,相对的 Web 站点可索引的优势开始凸显,与此同时,PWA 遵循 W3C 标准开发的技术,完全开放,能够快速地被各大浏览器厂商支持,市场支持度一夜崛起。

另外一边,App 的推广并不顺利,据调查统计,移动设备用户 80% 的时间花费在了常用的 5 个应用上16年近一半的美国用户平均每月安装「0」个新 App,用户积极探索新 App 已经成为了过去式,拉新和保活的成本越来越高。

原生 App 的发展遇到了天花板,推广也正向瓶颈一步步靠近,Web 看到了自己的机遇,PWA 以及支撑 PWA 的一系列关键技术应运而生。

2018

2018 年对于 PWA 来说是里程碑的一年,万众瞩目的 Apple 终于在 iOS 11.3 里支持了 Web App Manifest,以及内置的 Safari 11.1 支持了 Service Worker

与此同时,全球顶级浏览器厂商,Google、Microsoft、Apple 已经全数宣布支持 PWA 技术,这预示着,Web App 将会迎来全新的时代

2019

截至当下的 PWA 支持度

依据 Can I use 的统计(20190515)

  • App Manifest 的支持度达到 58.82%
  • Service Worker 的支持度达到 90.36%
  • Notifications API 的支持度达到 76.12%
  • Push API 的支持度达到 78.35%
  • Background Sync 的支持度达到 71.35%

Service Worker 以全数「登船」。信息来源于 jakearchibald.github.io/isservicewo…

PWA 的核心

PWA 有几个核心功能,分别是「离线,安装,推送」

离线浏览

弱网或离线的情况下依然可以「正常访问」甚至「秒开」,这种体验甚至超过了 app。主要的技术点就是 Service Worker。

Service Worker

SW 类似于我们熟知的 Web Worker,Web Worker 可以脱离主线程,处理一些「脏累」活,干完后通过 postMessage 向主线程汇报工作结果。所以,SW 也是脱离主线程的存在,与 Web Worker 不同的是,SW 具有持久化的能力。

SW 还具备有以下功能和特性:

  • 一个独立的 worker 线程,独立于当前网页进程,有自己独立的 worker context。
  • 一旦被 install,就永远存在,除非被手动 unregister
  • 用到的时候可以直接唤醒,不用的时候自动睡眠
  • 可编程拦截代理请求和返回,缓存文件,缓存的文件可以被网页进程取到(包括网络离线状态)
  • 离线内容开发者可控
  • 能向客户端推送消息
  • 不能直接操作 DOM
  • 必须在 HTTPS 环境下才能工作
  • 异步实现,内部大都是通过 Promise 实现

基于以上我们可以看到 SW 要让缓存做到极致优雅的伟大使命。

Service Worker 的生命周期

想要灵活的使用 SW 功能,就要充分了解他的生命周期,以及各阶段的状态。

以下是 MDN 给出的 SW 的详细生命周期图。

可以看到,SW 的生命周期包含这么几个状态 安装中, 安装后, 激活中, 激活后废弃

  • 安装( installing ):这个状态发生在 Service Worker 注册之后,表示开始安装,触发 install 事件回调指定一些静态资源进行离线缓存。 install 事件回调中有两个方法:

    • event.waitUntil():传入一个 Promise 为参数,等到该 Promise 为 resolve 状态为止。

    • self.skipWaiting():self 是当前 context 的 global 变量,执行该方法表示强制当前处在 waiting 状态的 Service Worker 进入 activate 状态。

  • 安装后( installed ):Service Worker 已经完成了安装,并且等待其他的 Service Worker 线程被关闭。

  • 激活( activating ):在这个状态下没有被其他的 Service Worker 控制的客户端,允许当前的 worker 完成安装,并且清除了其他的 worker 以及关联的旧缓存资源,等待新的 Service Worker 线程被激活。

    activate 回调中有两个方法:

    • event.waitUntil():传入一个 Promise 为参数,等到该 Promise 为 resolve 状态为止。

    • self.clients.claim():在 activate 事件回调中执行该方法表示取得页面的控制权, 这样之后打开页面都会使用版本更新的缓存。旧的 Service Worker 脚本不再控制页面,之后会被停止。

  • 激活后( activated ):在这个状态会处理 activate 事件回调 (提供了更新缓存策略的机会)。并可以处理功能性的事件,fetch (请求)、sync (后台同步)和 push (推送)。

  • 废弃状态 ( redundant ):这个状态表示一个 Service Worker 生命周期的结束。

    这里特别说明一下,进入废弃状态的原因可能为这几种:

    • 安装 (install) 失败

    • 激活 (activating) 失败

    • 新版本的 Service Worker 替换了它并成功激活

Service Worker 支持的所有事件

MDN 也列出了 Service Worker 所有支持的事件:

  • install:Service Worker 安装成功后被触发的事件,在事件处理函数中可以添加需要缓存的文件

  • activate:当 Service Worker 安装完成后并进入激活状态,会触发 activate 事件。通过监听 activate 事件你可以做一些预处理,如对旧版本的更新、对无用缓存的清理等。

  • message:Service Worker 运行于独立 context 中,无法直接访问当前页面主线程的 DOM 等信息,但是通过 postMessage API,可以实现他们之间的消息传递,这样主线程就可以接受 Service Worker 的指令操作 DOM。

Service Worker 有几个重要的「功能性事件」,这些功能性的事件支撑和实现了 Service Worker 的特性。

  • fetch (请求):当浏览器在当前指定的 scope 下发起请求时,会触发 fetch 事件,并得到传有 response 参数的回调函数,回调中可以做各种代理和缓存操作。

  • push (推送):push 事件是为推送准备的。不过首先需要了解一下 Notification API 和 PUSH API。通过 PUSH API,当订阅了推送服务后,可以使用推送方式唤醒 Service Worker 以响应来自系统消息传递服务的消息,即使用户已经关闭了页面。

  • sync (后台同步):sync 事件由 background sync (后台同步)发出。background sync 配合 Service Worker 推出的 API,用于为 Service Worker 提供一个可以实现注册和监听同步处理的方法。但它还不在 W3C Web API 标准中。在 Chrome 中这也只是一个实验性功能,需要访问 chrome://flags/#enable-experimental-web-platform-features ,开启该功能,然后重启生效。

Service Worker 的使用

有了以上 SW 的事件及 API,接下来就是实战部分了。

先决条件
  • 浏览器支持,包括 Cache APIPromiseHTML5 fetch API。关于目前 SW 的浏览器支持情况,后面将有介绍。
  • HTTPS,可用 127.0.0.1localhost 测试,但部署须在 https 协议下。

巧妇难为无米之炊,以上两个先决条件是必须要满足的。

注册 Service Worker
if ('serviceWorker' in navigator) {
  window.addEventListener('load', function() {
    navigator.serviceWorker.register('/sw.js', {scope: '/'}).then(function(registration) {
      // 注册成功
      console.log('ServiceWorker registration successful with scope: ', registration.scope);
    }, function(err) {
      // 注册失败 :(
      console.log('ServiceWorker registration failed: ', err);
    });
  });
}

其实,关键代码只有一行

navigator.serviceWorker.register('/sw.js', {scope: '/'})

注意,此处有坑

Service Worker 的注册路径决定了其 scope 默认作用域,如 SW 注册文件的路径为 https://www.a.com/public/sw.js 时,对应默认 scope 是 /public/,其作用范围如下

域名 是否生效
www.a.com/
www.a.com/page/
www.a.com/public/
www.a.com/public/page…
www.b.com/
www.b.com/public/

以上可看出,当作用域 scope 为 /public/ 后,其作用范围只限于本身和子域,父域和兄弟域皆无效,跨域就更免谈了。

当然,我们可以通过设置 scope 来限定自己的作用域,但是!请注意,『以下写法是错误的』。

navigator.serviceWorker.register('/public/sw.js', {scope: '/'})         // 错误写法
navigator.serviceWorker.register('/public/sw.js', {scope: '/page'})     // 错误写法

以上写法均会报错

The path of the provided scope ('/') is not under the max scope allowed ('/public/'). Adjust the scope, move the Service Worker script, or use the Service-Worker-Allowed HTTP header to allow the scope.

所以,sw.js 文件最好放在根域名下

navigator.serviceWorker.register('/sw.js', {scope: '/page'}) 

当 scope 不同时,请求被监控情况也有不同

代号 请求
r1 www.a.com/api
r2 www.a.com/page1/api
r3 www.a.com/page2/api
r4 www.a.com/static/img1.png
r5 www.b.com/api2
r6 www.b.com/static/img2.png
域名 scope 被监控请求
www.a.com/ / r1-6
www.a.com/ /page1
www.a.com/page1 / r1-6
www.a.com/page1 /page1 r1-6
www.a.com/page1 /page2

所以,scope 与被监控请求的域并没有什么关系,他只与站点域名有关

查看是否注册成功

我们可以通过打开 Chrome 的 DevTools -> Application -> Service Workers 查看 SW 的注册情况。

看到类如 Status: #xxxx activated and is running,即说明注册并激活成功。

也可以通过打开 Chrome 的管理页 chrome://inspect/#service-workers 查看

安装 Service Worker

在受控页面启动注册流程后,我们来看看处理 install 事件的 Service Worker 脚本。

最基本的例子是,您需要为安装事件定义回调,并处理想要缓存的文件。

self.addEventListener('install', function(event) {
  // Perform install steps
});

在 install 回调的内部,我们可以执行以下步骤(当然也可以啥也不干):

  1. 打开缓存。
  2. 缓存文件。
  3. 确认所有需要的资产是否已缓存。
var CACHE_NAME = 'my-site-cache-v1';
var urlsToCache = [
  '/',
  '/styles/main.css',
  '/script/main.js'
];

self.addEventListener('install', function(event) {
  // Perform install steps
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(function(cache) {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
});

此处,我们以所需的缓存名称调用 caches.open(),之后再调用 cache.addAll() 并传入文件数组。 这是一个 promise 链(caches.open()cache.addAll())。 event.waitUntil() 方法带有 promise 参数并使用它来判断安装所花费的时间,以及安装是否成功。

如果所有文件都成功缓存,则将安装 Service Worker。 如有任意文件无法下载,则安装失败。此设计可保证 SW 启动的正确性,但过长的资源列表也增加了安装失败的几率,可根据项目情况自行定义,也可不定义。

自定义请求响应

在安装 Service Worker 且用户转至其他页面或刷新当前页面后,Service Worker 将开始接收 fetch 事件。下面提供了一个示例。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    //匹配缓存
    caches.match(event.request)     
      .then(function(response) {
        //命中走观察
        if (response) {
          return response;
        }
        //未命中则透传向网络
        return fetch(event.request);
      }
    )
  );
});

如果希望连续缓存新请求,可以通过处理 fetch 请求的响应并将其添加到缓存来实现,如下所示。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request)
      .then(function(response) {
        if (response) {
          return response;
        }

        /** 
         * 通过检查,则克隆响应。
         * 这样做的原因在于,该响应是数据流, 因此主体只能使用一次。
         * 由于我们想要返回能被浏览器使用的响应,并将其传递到缓存以供使用,
         * 因此需要克隆一份副本。我们将一份发送给浏览器,另一份则保留在缓存。
         */
        var fetchRequest = event.request.clone();

        return fetch(fetchRequest).then(
          function(response) {
            // 只缓存成功的请求,第三方资源不缓存,当然也可以处理缓存。
            if(!response || response.status !== 200 || response.type !== 'basic') {
              return response;
            }

            var responseToCache = response.clone();

            caches.open(CACHE_NAME)
              .then(function(cache) {
                cache.put(event.request, responseToCache);
              });

            return response;
          }
        );
      })
    );
});

workbox

上一部分介绍了 SW 的使用,以及自定义请求响应,实际上,fetch 的玩法有很多,但也都是大同小异。

因此,为了使 SW 更容易使用,GoogleChrome 团队在 Chrome Submit 2017 上首次推出的一套 Web App 静态资源和请求结果本地存储的解决方案 workbox。

来直接感受下 workbox 的语法

// sw.js。 SW 的注册不变,改变的只是 `sw.js` 的写法
importScripts('https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js');

// 不同的资源使用不同的缓存策略,并存储在不同的 storage 中
workbox.routing.registerRoute(
  /\.(?:s?html)/,
  workbox.strategies.staleWhileRevalidate({
    cacheName:'kl-main'
  })
);

workbox.routing.registerRoute(
  /\.(?:js|css)/,
  new workbox.strategies.CacheFirst({
    cacheName:'kl-static'
  })
);

workbox.routing.registerRoute(
  /http:\/\/(?:haitao\.nos\.netease\.com|haitao\.nosdn2\.127\.net)/,
  new workbox.strategies.CacheFirst({
    cacheName:'kl-cdn'
  })
);

一看就懂,是不是很简单呢?

这里有个缓存策略,简略介绍一下。

  • Stale While Revalidate

此策略会优先匹配缓存,如果未命中,则透传网络,如果命中则返回缓存响应,同时在后台更新缓存网络响应,此策略比较安全,更像是竞争策略,谁快谁响应。

  • Network First

    网络优先策略,如果网络通畅,返回网络响应并缓存,如果离线,则返回缓存响应

  • Cache First

    缓存优先策略,如果缓存匹配,返回响应,如果不匹配则透传网络,并缓存「有效」响应

  • Network Only

    强制网络响应,即为普通请求

  • Cache Only

    强制缓存响应,无匹配则返回 404

更多关于 workbox 请前往 developers.google.com/web/tools/w…

可「安装」

PWA 另外一个爆点就是「可安装」,学名叫作「添加到主屏幕」,这归功于一个配置文件 manifest.json,它给予开发者自定义图标、显示名称、启动方式等信息并添加至桌面的能力,同时也提供 API 方便开发者管理网络应用安装横幅,让用户可以方便快捷地将站点添加到主屏幕中。

支持度

当前 manifest.json 的标准仍属于草案阶段,Chrome、Firefox 和 Apple 都已经实现了这个功能或者功能的部分,微软正努力在 Edge 浏览器上实现。

caniuse 中可查到 manifest 的支持度,数据显示 92.64% 的移动浏览器已经达到了支持或者部分支持的程度,想必在不久以后,当规范标准通过后,manifest 的支持度可以达到一个新高度。

配置

我以一个相对完整的 manifest.json 配置文件进行讲解

<!-- 配置因为的引入 -->
<link rel="manifest" href="path-to-manifest/manifest.json">

配置介绍

// manifest.json
{
    /* 自定义名称 */
    "short_name": "短名称",
    "name": "这是一个完整名称",
    
    /** 自定义安装 icon 
     * 当PWA添加到主屏幕时,浏览器会根据有效图标的 sizes 字段进行选择。
     * 首先寻找与显示密度相匹配并且尺寸调整到 48dp 屏幕密度的图标;
     * 如果未找到任何图标,则会查找与设备特性匹配度最高的图标;
     * 如果匹配到的图标路径错误,将会显示浏览器默认 icon。
     *
     * 在启动应用时,启动画面图像会从图标列表中提取最接近 128dp 的图标进行显示
     */
    "icons": [
        {
            "src": "path-to-images/icon-96x96.png",
            "type": "image/png",
            "sizes": "96x96"
        },
        {
            "src": "path-to-images/icon-144x144.png",
            "type": "image/png",
            "sizes": "144x144"
        }
    ],
    
    /* 设置启动网址 */
    "start_url": "index.html",
    
    /** 设置启动背景颜色 
     * 完整色值 "#0000ff"
     * 缩写 "#00f"
     * 预设色值 "blue"
     * rgb "rgb(0, 0, 255)"
     * transparent 背景色显示为黑色
     */
    "background_color": "#0000ff",
    
    /** 设置启动显示类型 
     * fullscreen	应用的显示界面将占满整个屏幕
     * standalone	浏览器相关UI(如导航栏、工具栏等)将会被隐藏
     * minimal-ui	显示形式与standalone类似,浏览器相关UI会最小化为一个按钮,不同浏览器在实现上略有不同
     * browser	浏览器模式,与普通网页在浏览器中打开的显示一致	
     */
    "display": "fullscreen",
    
    /** 指定页面显示方向
     * 更多配置介绍:https://lavas.baidu.com/pwa/engage-retain-users/add-to-home-screen/improved-webapp-experience#%E6%8C%87%E5%AE%9A%E9%A1%B5%E9%9D%A2%E6%98%BE%E7%A4%BA%E6%96%B9%E5%90%91
     */
    "orientation": "landscape",
    
    /* 设置主题颜色 */
    "theme_color": "#000",
    
    /** 设置作用域
     * start_url 必须在作用域内
     */
    "scope": "/"
    
}

测试

配置完之后就可以在 Chrome 的 DevTools 中进行验证测试了。

图片来源于 developers.google.com

可推送消息

消息推送是 App 保活冲绩效的常用手段,由于 HTTP 是一个无状态协议,推送功能在用户关闭了浏览器之后便没了办法,这一次 PWA 赋予了 Web 这个能力。

其中便包含了两个技术点

  • 推送 push:连接服务端和 SW 进行消息传递
  • 通知 notification:控制客户端(浏览器)进行消息提示

下面我们来解析一个通知体

// 消息体的 title
self.addEventListener('push', event => {
    const title = "Credit Card";
    const options = {
        // 主内容
        "body": "Did you make a $1,000,000 purchase at Dr. Evil...", 
        // 视觉配置,如 icon,Badge,image 等,不同的视觉配置展示的位置也不同
        // 详情参看 https://lavas.baidu.com/pwa/engage-retain-users/notification/notification-display
        "icon": "images/ccard.png",
        // 震动设置,其中的数字以2个为一组,分别表示震动的毫秒数,和不震动的毫秒数
        "vibrate": [200, 100, 200, 100, 200, 100, 400],
        // 铃声
        "sound": "path/to/sound.mp3",
        // 标签,用于客户端消息归类
        "tag": "request",
        // actions,用户操作后会将结果反馈给浏览器
        "actions": [
            { "action": "yes", "title": "Yes", "icon": "images/yes.png" },
            { "action": "no", "title": "No", "icon": "images/no.png" }
        ]
    }
    // 激活通知
    self.registration..showNotification(title, options);
});

self.addEventListener('notificationclick', event => {  
  // Do something with the event  
  event.notification.close();  
});

self.addEventListener('notificationclose', event => {  
  // Do something with the event  
});

以上的消息配置,展示的结果如下图。

关于推送功能的更多实操不属于本文探究的范畴,有实际需求的同学可以前往官网进行了解。

传送门>>

developers.google.com/web/fundame…

lavas.baidu.com/pwa/engage-…

PWA & 小程序

有人说「PWA 是小程序的祖宗」,不无道理,PWA 对小程序肯定存在一定的借鉴意义,但是否会挤压 PWA 的市场?我们应该放心,小程序的设计并不是 Web 的替代者,而是介于原生 App 和 Web 之间的存在。

小程序更倾向于轻便及时触手可得。既没有原生 App 的「沉重」也没有 Web 「迟钝」。在此得天独厚的基础之上加之以「社交流量」的加持,微信小程序的存在并非偶然。

但是,如果没了网络,一样玩不转;主流的搜索引擎并无法捕获小程序的内容。所以,App、Web 和小程序是相辅相成的。

另外,笔者想表达另外一个观点

「存在即合理,合理未必长久」

在经历过一段痛苦的微信小程序洗礼之后,我们「欣然」接受了。奈何众XX小程序『竞相开放,争奇斗艳,不亦乐乎』。殊不知,我等不才,竟要为了这区区语法之差异,彻夜无法停歇,然,产与出相比,孰轻孰重?

唉~程序员何必为难程序员~~

总结

关于 PWA 的技术早在 2 年前即已相对完整,只是由于「天朝人民太过赋予」,对支持与否未发布意见的 Apple 在天朝市场有着举足轻重的地位,而「外围」仿佛对 Android 机更为推崇,所以,PWA 在国内的发展和推广并不理想。

此时此刻,PWA 的支持度也达到了一个相对让人满意的水平,虽然体验依然无法和原生 App 相提并论,但作为 App 短板的补丁已是绰绰有余。

所以,『架构师』们,可以盘起来了。

以上就是 PWA 的相关知识点,希望对你有所帮助。

[1]. developers.google.com/web/fundame…

[2]. developers.google.com/web/fundame…

[3]. developers.google.com/web/tools/w…

[4].下一代 Web 应用模型 —— Progressive Web App

[5]. lavas.baidu.com/


首发:zwwill/blog#33

作者:木羽

转载请标明出处