Angular 6 PWA 开发踩坑

643 阅读8分钟

更新中

提示:在测试程序的时候尽量使用Chrome的隐身模式,确保 Service Worker 不会从以前的残留状态中读取数据!!

PWA在Angular 6 工程上的初始化:

sudo ng new pwa新建工程之后,在工程的根目录上运行sudo ng add @angular/pwa,此时就会自动添加Service Worker文件,Manifest.json文件和各种不同尺寸的icon文件。 Angular PWA中文网传送门

PWA程序的更新

app.component.ts中引入import { SwUpdate } from '@angular/service-worker';来加载SW的更新模块,每次PWA程序有更新都可以在这里使用SwUpdate模块获取更新,并使用如下代码可实现程序的更新操作:

export class AppComponent {
  update: boolean;
  constructor(updates: SwUpdate, private data: DataService) {
    updates.available.subscribe( event => {
      this.update = true;
      updates.activateUpdate().then(() =>
        document.location.reload()
      );
      }
    );
  }
  title = 'PWA';
}

SwUpdate文档传送门

然后在html中使用一个*ngIf来判断是否更新,(是则显示text,不是则不显示):

<span *ngIf="update">There's an update associated with your progressive web application!</span>

每次更新了程序都要重新build production程序,在根目录上运行sudo ng build --prod,然后进入cd dist/PWA,最后运行http-server -o在服务器上运行更新后的程序。

由于 ng serveService Worker 无效,所以必须用一个独立的 HTTP 服务器在本地测试项目。 可以使用任何 HTTP 服务器,我使用的是来自 npm 中的 http-server 包。当然也可以自定义端口以防止port冲突:

http-server -p 8080 -c-1 dist/<project-name>

当使用http-server打开服务器后,却无法正常打开网页的时候,我曾遇到过ERR_INVALID_REDIRECT这样的问题导致无法正常显示网页。更换http-server版本就可以解决这个问题: npm install -g http-server@0.9.0

注意: 如果想定期更新PWA,也就是使用interval创建一个周期轮询方法,需要先让应用注册Aervice worker的进程进入稳定状态,再让它开始执行轮询的过程,如果不断轮询更新(比如调用 interval())将阻止应用程序达到稳定态,也就永远不会往浏览器中注册 ServiceWorker 脚本。另外:应用中所执行的各种轮询都会阻止它达到稳定态

constructor(appRef: ApplicationRef, updates: SwUpdate) {
        // Allow the app to stabilize first, before starting polling for updates with `interval()`.
        const appIsStable$ = appRef.isStable.pipe(first(isStable => isStable === true));
        const everySixHours$ = interval(6 * 60 * 60 * 1000);
        const everySixHoursOnceAppIsStable$ = concat(appIsStable$, everySixHours$);
        everySixHoursOnceAppIsStable$.subscribe(() => updates.checkForUpdate());
  }

所以对于自动更新模块的使用总结:

constructor(appRef: ApplicationRef, updates: SwUpdate, private data: DataService) {
    const appIsStable$ = appRef.isStable.pipe(first(isStable => isStable === true));
    const everySixHours$ = interval(6 * 1000);
    const everySixHoursOnceAppIsStable$ = concat(appIsStable$, everySixHours$);
    everySixHoursOnceAppIsStable$.subscribe(() => {
      updates.checkForUpdate();
      // console.log('check update in Service Worker');
    });
    updates.available.subscribe(event => {
      console.log('gotta new version here', event.available);
      updates.activateUpdate().then(() => document.location.reload());
    });
  }

每6秒检测一次更新版本,如果没有updates.activateUpdate().then(() => document.location.reload());则只是在检测到新版本时候提醒并不刷新并更新程序。 测试的时候需要重新ng build --prod然后http-server -p 8080 -c-1 dist/PWA重新运行http服务器,这时候在原来的页面上的console上就会发现出现了新版本的提醒。

(其实每次运行build命令都会出现版本更新无论是否更改代码,当应用的一个新的构建发布时,Service Worker 就把它看做此应用的一个新版本,版本是由 ngsw.json 文件的内容决定的,包含了所有已知内容的哈希值。 如果任何一个被缓存的文件发生了变化,则该文件的哈希也将在ngsw.json中随之变化,从而导致 Angular Service Worker 将这个活动文件的集合视为一个新版本)

如何缓存文件以及API的地址以及其他项目?

全在nsgw-config.json文件中定义PWA缓存,比如想缓存google的Montserrat字体和API地址,该文件中所有的代码形式都是glob格式,也就是:

  • ' ** ' 匹配 0 到多段路径
  • ' * ' 匹配 0 个或更多个除 / 之外的字符
  • ? 匹配除 / 之外的一个字符
  • ! 前缀表示该模式是反的,也就是说只包含与该模式不匹配的文件

比如:

  • /**/*.html 指定所有 HTML 文件
  • /*.html 仅指定根目录下的 HTML 文件
  • !/**/*.map 排除了所有源码映射文件

在实际代码中这样做:

<link href="https://fonts.googleapis.com/css?family=Montserrat" rel="stylesheet">

在已经被创建的assetGroups中添加:

"urls": [
    "https://fonts.googleapis.com/**"
  ]

AssetGroup遵循的TypeScript接口规则为:

interface AssetGroup {
  name: string;
  installMode?: 'prefetch' | 'lazy';
  // prefetch 告诉 Angular Service Worker 在缓存当前版本的应用时要获取每一个列出的资源。 这是个带宽密集型的模式,但可以确保这些资源在请求时可用,即使浏览器正处于离线状态
  // lazy 不会预先缓存任何资源。相反,Angular Service Worker 只会缓存它收到请求的资源。 这是一种按需缓存模式。永远不会请求的资源也永远不会被缓存。 这对于像为不同分辨率提供的图片之类的资源很有用,那样 Service Worker 就只会为特定的屏幕和设备方向缓存正确的资源。
  updateMode?: 'prefetch' | 'lazy';
  // prefetch 会告诉 Service Worker 立即下载并缓存更新过的资源
  // lazy 告诉 Service Worker 不要缓存这些资源,而是先把它们看作未被请求的,等到它们再次被请求时才进行更新。 
  lazy 这个 updateMode 只有在 installMode 也同样是 lazy 时才有效。
  resources: {
    files?: string[];
    /** @deprecated As of v6 `versionedFiles` and `files` options have the same behavior. Use `files` instead. */
    versionedFiles?: string[];
    urls?: string[];
  };
}

在下方创建dataGroups缓存API地址:

"dataGroups": [
    {
      "name": "jokes-api",
      "urls": [
        "https://api.chucknorris.io/jokes/random"
      ],
      "cacheConfig": {
        "strategy": "freshness",
        "maxSize": 20,
        "maxAge": "1h",
        "timeout": "5s"
      }
    }
  ]

dataGroups的配置遵循下面的接口:

export interface DataGroup {
  name: string;
  urls: string[];
  version?: number;
  cacheConfig: {
    maxSize: number;
    maxAge: string;
    timeout?: string;
    strategy?: 'freshness' | 'performance';
  };
}

其中的缓存设置中的几个项目分别是:

  • "strategy" :
    1. performance,默认值,为尽快给出响应而优化。如果缓存中存在某个资源,则使用这个缓存版本。 它允许资源有一定的陈旧性(取决于 maxAge)以换取更好的性能。适用于那些不经常改变的资源,例如用户头像。
    2. freshness 为数据的即时性而优化,优先从网络获取请求的数据。只有当网络超时时,请求才会根据 timeout 的设置回退到缓存中。这对于那些频繁变化的资源很有用,例如账户余额。
  • "maxSize" : (必需)缓存的最大条目数或响应数。开放式缓存可以无限增长,并最终超过存储配额,建议适时清理。
  • "maxAge" : d(必需)maxAge 参数表示在响应因失效而要清除之前允许在缓存中留存的时间。(d:天数,h:小时数,m:分钟数,s:秒数,u:微秒数)
  • "timeout" : 这个表示持续时间的字符串用于指定网络超时时间。 如果配置了它,Angular Service Worker 在开始使用缓存之前就会先等待网络给出响应,这个等待时间就是网络超时时间。

PWA Push Notification

测试push notification API的功能无法在隐身模式下测试

  1. 首先生成VAPID (Voluntary Application Server Identification),使用node的webpush库直接引入依赖:npm install web-push -g,然后创建VAPID key: web-push generate-vapid-keys --json。获得类似如下的VAPID:
{
    "publicKey":"BApAO10ISTLAR1bWho_6f4yL5-5z2RWHgnkqzG7SB81WdcsLkDdxrc1iWwHZ49trIUFekIEFGyBjomxjuKDZGc8",
    "privateKey":"7y1-NPiG_igcck_iIJ5sidurBa7ghC4Py0MTQPOFLGM"
}
  1. 在component中我们需要引入Service Worker的push模块(SwPush)来支持我们的代码,同时也要引入Service服务来获取网络请求。
    subscribeToNotifications() {
        this.swPush.requestSubscription({
            serverPublicKey: this.VAPID_PUBLIC_KEY
        })  // 浏览器弹出消息请求,如果请求同意会获得一个Promise
        .then(sub => this.newsletterService.addPushSubscriber(sub).subscribe())  // 这里会获得一个PushSubscription object
        .catch(err => console.error("Could not subscribe to notifications", err));
    }

PushSubscription object:

    {
      "endpoint": "https://fcm.googleapis.com/fcm/send/cbx2QC6AGbY:APA91bEjTzUxaBU7j-YN7ReiXV-MD-bmk2pGsp9ZVq4Jj0yuBOhFRrUS9pjz5FMnIvUenVqNpALTh5Hng7HRQpcUNQMFblTLTF7aw-yu1dGqhBOJ-U3IBfnw3hz9hq-TJ4K5f9fHLvjY",
      "expirationTime": null,
      "keys": {
        "p256dh": "BOXYnlKnMkzlMc6xlIjD8OmqVh-YqswZdut2M7zoAspl1UkFeQgSLYZ7eKqKcx6xMsGK7aAguQbcG9FMmlDrDIA=",
        "auth": "if-YFywyb4g-bFB1hO9WMw=="
      }
    }
  1. 在后端的Node服务器上可以设置payload来自定义Notification内容:
const notificationPayload = {
    "notification": {
        "title": "Angular News",
        "body": "Newsletter Available!",
        "icon": "assets/main-page-logo-small-hat.png",
        "vibrate": [100, 50, 100],
        "data": {
            "dateOfArrival": Date.now(),
            "primaryKey": 1
        },
        "actions": [{
            "action": "explore",
            "title": "Go to the site"
        }]
    }
};
  1. 然后使用webpush模块推送通知:
Promise.all(allSubscriptions.map(sub => webpush.sendNotification(
    sub, JSON.stringify(notificationPayload) )))
    .then(() => res.status(200).json({message: 'Newsletter sent successfully.'}))
    .catch(err => {
        console.error("Error sending notification, reason: ", err);
        res.sendStatus(500);
    });

给Service worker添加新的listener

自己定义的话需要自己建立两个新的文件:sw-custom.jssw-master.js

  • sw-custom.js里面定义我们想添加的listener,比如:
(function () {
  'use strict';
  self.addEventListener('notificationclick', (event) => {
    event.notification.close();
    console.log('notification details: ', event.notification);
  });
}());
  • sw-master.js用于将sw-custom.jsngsw-worker.js两个Service Worker文件结合,不丢失原有功能:
importScripts('./ngsw-worker.js');
importScripts('./sw-custom.js');

文件建立好了之后需要让Angular在初始化渲染的时候能够将我们自定义的文件包含进去,所以在angular.json文件中的assets部分添加"src/sw-master.js"。最后在app.module.ts中注册service worker的地方注册我们的新service worker文件:

ServiceWorkerModule.register('/sw-master.js', { enabled: environment.production })

烦人的一点:因为Angular封装好的 ngsw-worker.js 只能在 ng build --prod之后创建的dist文件夹中,所以不build就没法使用service worker。这就是为什么测试PWA的Service Worker功能时候没法在ng serve上运行。所以如果我们在测试自己的功能的时候,为了避免麻烦可以将ServiceWorkerModule.register('/sw-master.js', { enabled: environment.production })注释掉,换成ServiceWorkerModule.register('sw-custom.js'),这时候我们没法使用原来的那些Angular封装好的功能但是可以测试我们自己的listener。

解决方法: 在environment.tsenvironment.prod.ts文件中添加新的环境变量,这样就可以在不一样的环境下运行不同的Service Worker依赖包。

  • environment.ts中添加: serviceWorkerScript: 'sw-custom.js'
  • environment.prod.ts中添加: serviceWorkerScript: 'sw-master.js'
  • 注册Service Worker中时候使用: ServiceWorkerModule.register(environment.serviceWorkerScript)