xjcloudy

Progressive Web Apps 入门(3)

xjcloudy · 2017-01-18翻译 · 2212阅读 原文链接

TL;DR: 近几年随着Web技术的显著发展使得开发者能够很轻松的通过部署一个网站或者web应用服务全球成千上万的用户。仅仅通过一个浏览器,通过URL就可以访问到一个web应用。有了PWA ,开发者能够使用先进的web技术来给用户带来很棒的类似app一样的体验。在 第一篇第二篇 文章中,我们已经可以使我们的应用在离线状态下使用。这次我们将实现推送功能。


回顾和概述

第一篇文章中,我们介绍了PWA应用的典型特征和service work。还实现了离线缓存。在第二篇文章中,我们实现了缓存动态数据和即时加载。

本篇教程将包括如下内容:

  • 使用推送

  • 使用mainfest实现应用安装到主屏

推送

Push API 使web应用拥有了接收服务端推送消息的能力。这项能力与service worker密不可分。以下是标准的流程。

  • web应用弹窗请求用户允许接收推送
  • 用户订阅推送
  • service worker中的推送管理服务负责处理用户的订阅
  • 每一个服务端推送的消息中会包含用户的订阅ID,每个用户可以基于他们的订阅ID做不同的处理
  • service worker 通过监听push事件,来接收推送消息。

实战

以下简单说明一下实现推送功能所需的步骤:

  • 给用户一个按钮来开启或关闭推送功能
  • 用户开启推送功能后可以通过service worker的推送管理功能来订阅推送。
  • 实现一组接口,来管理用户订阅id同时也兼顾发送推送消息到用户端的功能。
  • 在Github上配置resources-i-like项目的webhook功能。这样当有新的提交就会即时的发送推送消息。

开工

新建一个js/nofification.js文件。在index.html中引用。

 `<script src="./js/notification.js">`</script>

然后添加如下代码:

 (function (window) {
  'use strict';

  //Push notification button
  var fabPushElement = document.querySelector('.fab__push');
  var fabPushImgElement = document.querySelector('.fab__image');

  //检查推送功能是否正常
  function isPushSupported() {
    //检查推送功能是否被用户禁止
    if (Notification.permission === 'denied') {
      alert('User has blocked push notification.');
      return;
    }

    //检查是否支持推送功能
    if (!('PushManager' in window)) {
      alert('Sorry, Push notification isn\'t supported in your browser.');
      return;
    }

    //订阅消息推送
    //如果`serviceWorker`已经注册
    navigator.serviceWorker.ready
      .then(function (registration) {
        registration.pushManager.getSubscription()
        .then(function (subscription) {
          //如果已经得到用户授权,则将推送按钮显示为可用
          if (subscription) {
            changePushStatus(true);
          }
          else {
            changePushStatus(false);
          }
        })
        .catch(function (error) {
          console.error('Error occurred while enabling push ', error);
        });
      });
  }

  // 询问用户是否原因订阅消息
  function subscribePush() {
    navigator.serviceWorker.ready.then(function(registration) {
      if (!registration.pushManager) {
        alert('Your browser doesn\'t support push notification.');
        return false;
      }

      //使用push manager 订阅消息
      registration.pushManager.subscribe({
        userVisibleOnly: true //Always show notification when received
      })
      .then(function (subscription) {
        toast('Subscribed successfully.');
        console.info('Push notification subscribed.');
        console.log(subscription);
        //saveSubscriptionID(subscription);
        changePushStatus(true);
      })
      .catch(function (error) {
        changePushStatus(false);
        console.error('Push notification subscription error: ', error);
      });
    })
  }

  // 取消订阅
  function unsubscribePush() {
    navigator.serviceWorker.ready
    .then(function(registration) {
      //Get `push subscription`
      registration.pushManager.getSubscription()
      .then(function (subscription) {
        //If no `push subscription`, then return
        if(!subscription) {
          alert('Unable to unregister push notification.');
          return;
        }

        //Unsubscribe `push notification`
        subscription.unsubscribe()
          .then(function () {
            toast('Unsubscribed successfully.');
            console.info('Push notification unsubscribed.');
            console.log(subscription);
            //deleteSubscriptionID(subscription);
            changePushStatus(false);
          })
          .catch(function (error) {
            console.error(error);
          });
      })
      .catch(function (error) {
        console.error('Failed to unsubscribe push notification.');
      });
    })
  }

  //To change status
  function changePushStatus(status) {
    fabPushElement.dataset.checked = status;
    fabPushElement.checked = status;
    if (status) {
      fabPushElement.classList.add('active');
      fabPushImgElement.src = '../images/push-on.png';
    }
    else {
     fabPushElement.classList.remove('active');
     fabPushImgElement.src = '../images/push-off.png';
    }
  }

  //Click event for subscribe push
  fabPushElement.addEventListener('click', function () {
    var isSubscribed = (fabPushElement.dataset.checked === 'true');
    if (isSubscribed) {
      unsubscribePush();
    }
    else {
      subscribePush();
    }
  });

  isPushSupported(); //Check for push notification support
})(window);

上面的代码干了一大堆事情,放松一下。我会一一解释清楚的。

 //Push notification button
  var fabPushElement = document.querySelector('.fab__push');
  var fabPushImgElement = document.querySelector('.fab__image');

Push Notification button

推送通知按钮

 function isPushSupported() {
    //To check `push notification` permission is denied by user
    if (Notification.permission === 'denied') {
      alert('User has blocked push notification.');
      return;
    }

    //Check `push notification` is supported or not
    if (!('PushManager' in window)) {
      alert('Sorry, Push notification isn\'t supported in your browser.');
      return;
    }

    //Get `push notification` subscription
    //If `serviceWorker` is registered and ready
    navigator.serviceWorker.ready
      .then(function (registration) {
        registration.pushManager.getSubscription()
        .then(function (subscription) {
          //If already access granted, enable push button status
          if (subscription) {
            changePushStatus(true);
          }
          else {
            changePushStatus(false);
          }
        })
        .catch(function (error) {
          console.error('Error occurred while enabling push ', error);
        });
      });
  }

这段代码检查浏览器是否支持推送功能. 同时会在service worker 注册成功时触发订阅。

 //To change status
  function changePushStatus(status) {
    fabPushElement.dataset.checked = status;
    fabPushElement.checked = status;
    if (status) {
      fabPushElement.classList.add('active');
      fabPushImgElement.src = '../images/push-on.png';
    }
    else {
     fabPushElement.classList.remove('active');
     fabPushImgElement.src = '../images/push-off.png';
    }
  }

Change Push Status to red

_当推送功能开启时将推送通知按钮变成红色

Change Push status to ash

_当推送功能关闭时将推送通知按钮变成灰色

changePushStatus 方法通过改变按钮颜色来反映用户是否开启了推送通知。

// Ask User if he/she wants to subscribe to push notifications and then 
  // ..subscribe and send push notification
  function subscribePush() {
    navigator.serviceWorker.ready.then(function(registration) {
      if (!registration.pushManager) {
        alert('Your browser doesn\'t support push notification.');
        return false;
      }

      //To subscribe `push notification` from push manager
      registration.pushManager.subscribe({
        userVisibleOnly: true //Always show notification when received
      })
      .then(function (subscription) {
        toast('Subscribed successfully.');
        console.info('Push notification subscribed.');
        console.log(subscription);
        //saveSubscriptionID(subscription);
        changePushStatus(true);
      })
      .catch(function (error) {
        changePushStatus(false);
        console.error('Push notification subscription error: ', error);
      });
    })
  }

以上代码负责,弹窗提示用户是否允许推送通知。如果用户允许,则给一个成功的提示,然后改变推送按钮的颜色并且保存订阅id。如果push mananger 不存在 ,则提示用户不支持此项功能。

注意: 代码中保存订阅id的代码被注释掉了.

Pop Up Notification

_询问用户

Subscription in the console

_在控制台中显示

 // Unsubscribe the user from push notifications
  function unsubscribePush() {
    navigator.serviceWorker.ready
    .then(function(registration) {
      //Get `push subscription`
      registration.pushManager.getSubscription()
      .then(function (subscription) {
        //If no `push subscription`, then return
        if(!subscription) {
          alert('Unable to unregister push notification.');
          return;
        }

        //Unsubscribe `push notification`
        subscription.unsubscribe()
          .then(function () {
            toast('Unsubscribed successfully.');
            console.info('Push notification unsubscribed.');
            //deleteSubscriptionID(subscription);
            changePushStatus(false);
          })
          .catch(function (error) {
            console.error(error);
          });
      })
      .catch(function (error) {
        console.error('Failed to unsubscribe push notification.');
      });
    })
  }

这段代码负责取消订阅、给出提示、改变推送按钮的颜色,最后删除订阅id.

注意: 删除id的代码被注释了

Unsubscription in the console

_取消订阅

 //Click event for subscribe push
  fabPushElement.addEventListener('click', function () {
    var isSubscribed = (fabPushElement.dataset.checked === 'true');
    if (isSubscribed) {
      unsubscribePush();
    }
    else {
      subscribePush();
    }
  });

这段代码绑定了一个点击事件。来响应用户的开启和关闭动作.

处理订阅id

实现保存和删除订阅id的功能

 function saveSubscriptionID(subscription) {
    var subscription_id = subscription.endpoint.split('gcm/send/')[1];

    console.log("Subscription ID", subscription_id);

    fetch('http://localhost:3333/api/users', {
      method: 'post',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ user_id : subscription_id })
    });
}

function deleteSubscriptionID(subscription) {
    var subscription_id = subscription.endpoint.split('gcm/send/')[1];

    fetch('http://localhost:3333/api/user/' + subscription_id, {
      method: 'delete',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json'
      }
    });
}

上面代码中,我们提取了订阅id然后传给服务端接口. saveSubscriptionID 创建了一个新用户然后保存id. deleteSubscriptionID 删除用户和他们关联的id。 是不是看起来有些奇怪,为什么要传给服务端?这么做是为了将所有用户的订阅id保存到服务端数据库中,以便我们可以同时推送给所有用户。

服务端

服务端需要管理订阅id,也要负责发送推送通知。我们设计了3个接口:

  • POST /api/users 创建新用户同时保存订阅id
  • DELETE /api/user/:user_id 删除用户和取消订阅
  • POST /api/notify 发送推送消息给所有订阅用户

    我已经写好了一份实现代码。你需要准备好nodemongodb。clone下来代码,然后在命令行中使用node server.jsrun起来。

PWA API Server

本地运行

要确保你创建了.env文件

Env file with values

注意: 你可以看 这个 来了解这个服务的实现步骤。我是照着教程做了一个简单的node版。

我们使用 Firebase Cloud Messaging做我们的消息服务。接下来在firebase上建立 一个新项目。然后 在Project settings > Cloud Messaging 里找到Server Key

Firebase Dashboard

Server Key 将作为.env文件中FCM_API_KEY的值。 在与Firebase Cloud Messaging通信时需要带上Server Key

server/controllers/notification.server.controller.js

 ....

notifyUsers: function(req, res){

    var sender = new gcm.Sender(secrets.fcm);

    // Prepare a message to be sent
    var message = new gcm.Message({
        notification: {
          title: "New commit on Github Repo: RIL",
          icon: "ic_launcher",
          body: "Click to see the latest commit'"
        }
    });

    User.find({}, function(err, users) {

      // user subscription ids to deliver message to
      var user_ids = _.map(users, 'user_id');

      console.log("User Ids", user_ids);

      // Actually send the message
      sender.send(message, { registrationTokens: user_ids }, function (err, response) {
        if (err) {
            console.error(err);
        } else {
          return res.json(response);
        } 
      });
    });

  },

  .....

我们回到notification.js文件。去掉saveSubscriptionIDdeleteSubscriptionID的注释。

 (function (window) {
  'use strict';

  //Push notification button
  var fabPushElement = document.querySelector('.fab__push');
  var fabPushImgElement = document.querySelector('.fab__image');

  //To check `push notification` is supported or not
  function isPushSupported() {
    //To check `push notification` permission is denied by user
    if (Notification.permission === 'denied') {
      alert('User has blocked push notification.');
      return;
    }

    //Check `push notification` is supported or not
    if (!('PushManager' in window)) {
      alert('Sorry, Push notification isn\'t supported in your browser.');
      return;
    }

    //Get `push notification` subscription
    //If `serviceWorker` is registered and ready
    navigator.serviceWorker.ready
      .then(function (registration) {
        registration.pushManager.getSubscription()
        .then(function (subscription) {
          //If already access granted, enable push button status
          if (subscription) {
            changePushStatus(true);
          }
          else {
            changePushStatus(false);
          }
        })
        .catch(function (error) {
          console.error('Error occurred while enabling push ', error);
        });
      });
  }

  // Ask User if he/she wants to subscribe to push notifications and then 
  // ..subscribe and send push notification
  function subscribePush() {
    navigator.serviceWorker.ready.then(function(registration) {
      if (!registration.pushManager) {
        alert('Your browser doesn\'t support push notification.');
        return false;
      }

      //To subscribe `push notification` from push manager
      registration.pushManager.subscribe({
        userVisibleOnly: true //Always show notification when received
      })
      .then(function (subscription) {
        toast('Subscribed successfully.');
        console.info('Push notification subscribed.');
        console.log(subscription);
        saveSubscriptionID(subscription);
        changePushStatus(true);
      })
      .catch(function (error) {
        changePushStatus(false);
        console.error('Push notification subscription error: ', error);
      });
    })
  }

  // Unsubscribe the user from push notifications
  function unsubscribePush() {
    navigator.serviceWorker.ready
    .then(function(registration) {
      //Get `push subscription`
      registration.pushManager.getSubscription()
      .then(function (subscription) {
        //If no `push subscription`, then return
        if(!subscription) {
          alert('Unable to unregister push notification.');
          return;
        }

        //Unsubscribe `push notification`
        subscription.unsubscribe()
          .then(function () {
            toast('Unsubscribed successfully.');
            console.info('Push notification unsubscribed.');
            console.log(subscription);
            deleteSubscriptionID(subscription);
            changePushStatus(false);
          })
          .catch(function (error) {
            console.error(error);
          });
      })
      .catch(function (error) {
        console.error('Failed to unsubscribe push notification.');
      });
    })
  }

  //To change status
  function changePushStatus(status) {
    fabPushElement.dataset.checked = status;
    fabPushElement.checked = status;
    if (status) {
      fabPushElement.classList.add('active');
      fabPushImgElement.src = '../images/push-on.png';
    }
    else {
     fabPushElement.classList.remove('active');
     fabPushImgElement.src = '../images/push-off.png';
    }
  }

  //Click event for subscribe push
  fabPushElement.addEventListener('click', function () {
    var isSubscribed = (fabPushElement.dataset.checked === 'true');
    if (isSubscribed) {
      unsubscribePush();
    }
    else {
      subscribePush();
    }
  });

  function saveSubscriptionID(subscription) {
    var subscription_id = subscription.endpoint.split('gcm/send/')[1];

    console.log("Subscription ID", subscription_id);

    fetch('http://localhost:3333/api/users', {
      method: 'post',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ user_id : subscription_id })
    });
  }

  function deleteSubscriptionID(subscription) {
    var subscription_id = subscription.endpoint.split('gcm/send/')[1];

    fetch('http://localhost:3333/api/user/' + subscription_id, {
      method: 'delete',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json'
      }
    });
  }

  isPushSupported(); //Check for push notification support
})(window);

我们试一下看打开推送是否在后台创建了新的用户并且存到数据库里了。 有个错。 Manifest Error 没事,这个问题是因为在我们项目里没有manifest.json文件 好,有趣的事情来了。创建一个manifest.json文件。可以解决刚才那个报错。同时可以使我们的应用具有安装到用户桌面的能力。酷吧!

来,在项目的根目录下建一个manifest.josn:

 {
  "name": "PWA - Commits",
  "short_name": "PWA",
  "description": "Progressive Web Apps for Resources I like",
  "start_url": "./index.html?utm=homescreen",
  "display": "standalone",
  "orientation": "portrait",
  "background_color": "#f5f5f5",
  "theme_color": "#f5f5f5",
  "icons": [
    {
      "src": "./images/192x192.png",
      "type": "image/png",
      "sizes": "192x192"
    },
    {
      "src": "./images/168x168.png",
      "type": "image/png",
      "sizes": "168x168"
    },
    {
      "src": "./images/144x144.png",
      "type": "image/png",
      "sizes": "144x144"
    },
    {
      "src": "./images/96x96.png",
      "type": "image/png",
      "sizes": "96x96"
    },
    {
      "src": "./images/72x72.png",
      "type": "image/png",
      "sizes": "72x72"
    },
    {
      "src": "./images/48x48.png",
      "type": "image/png",
      "sizes": "48x48"
    }
  ],
  "author": {
    "name": "Prosper Otemuyiwa",
    "website": "https://twitter.com/unicodeveloper",
    "github": "https://github.com/unicodeveloper",
    "source-repo": "https://github.com/unicodeveloper/pwa-commits"
  },
  "gcm_sender_id": "571712848651"
}

罗列一下manifest文件中的关键信息

  • name: 表示显示给用户看到的应用的名称.

  • short_name: 应用名称的简写.

  • description: 应用的描述.

  • start_url: 启动应用的url地址.

  • display: 规定默认的显示模式. 有3种 fullscreen, standalone, minimal-ui

  • orientation: 规定默认的屏幕方向. 可选值为 portrait or landscape.

  • background_color: 应用的背景色

  • theme_color: 默认的主题颜色。 在Android中可以改变状态栏的颜色

  • icons: 设置桌面、闪屏页、任务管理界面中应用的图标

  • author: 作者信息

  • gcm_sender_id: 用于在 Firebase Cloud中识别应用。换成你自己的id

Sender ID from Firebase Cloud Messaging

index.htmllatest.html中应用manifest.json:

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

然后,清缓存。重新打开应用然后点击推送通知的按钮。

Posted Subscription ID

搞定了!

Subscription ID in my database

数据库里也有了

你可以再试一下取消订阅。

发送推送消息

在服务端代码里,我们提供了一个/api/notify的接口。可以通过调用这个接口通过Firebase Cloud发送推送消息。除此自外,还需要在浏览器端监听push事件

sw.js

 self.addEventListener('push', function(event) {

  console.info('Event: Push');

  var title = 'New commit on Github Repo: RIL';

  var body = {
    'body': 'Click to see the latest commit',
    'tag': 'pwa',
    'icon': './images/48x48.png'
  };

  event.waitUntil(
    self.registration.showNotification(title, body)
  );
});

补上代码。然后测试一下。这次用Postman 来模拟一个到 http://localhost:3333/api/notify的请求:

Postman

当推送来的时候,在浏览器里将会这样显示:

Notification received in browser

收到推送消息后,当用户点击消息时,我们也可以做相应操作。我们加入以下代码到sw.js里:

 self.addEventListener('notificationclick', function(event) {

  var url = './latest.html';

  event.notification.close(); //Close the notification

  // Open the app and navigate to latest.html after clicking the notification
  event.waitUntil(
    clients.openWindow(url)
  );

});

上面代码,会监听用户点击推送消息的事件。event.notification.close()会关闭当前得消息。然后新开一个窗口或者标签页,定向到localhost:8080/latest.html中去。

注意: event.waitUntil() 是为了确保在打开新窗口前,浏览器没有中断service work。

自动发送推送

我们刚才是人工通过Postman制造了一个请求。实际上,我们要得是每当https://github.com/unicodeveloper/resources-i-like/这个项目收到一个提交,就会给用户发送一个推送。那么,怎样让这个过程自动化呢?

我们需要Webhooks来帮忙。

注意: 为了方便测试,请使用你自己的项目地址。

在github中进入你的项目,然后访问Settings > Webhooks:

Settings > Webhooks

点击Add webhook 按钮

Add a new webhook

然后添加一个hook地址。就是notify这个接口地址。当你生成一次提交,一次push事件就好触发。然后就会产生一个调用/api/notify这个接口的post请求。

Webhook Notify API endpoint

这里有一个细节,请注意看上面得内容。这里有一个Payload URL字段,内容为https://ea71f5aa.ngrok.io/api/notify。你是不是会感到奇怪,https://ea71f5aa.ngrok.io又是什么鬼?

使用 Ngrok

我们得服务运行在本地,但是我们需要一个外网可访问的url地址。所以需要借助一个工具Ngrok来实现将本地服务暴露到外网的功能。

安装好 Ngrok。在命令行中,使用ngrok 命令ping 本地服务的端口:

 `./ngrok http 3333`

Ngrok pinging local server

然后使用ngrok生成得地址作为webhook的值。

注意: Ngrok 会生成 httphttps 的url, 2个都可以用.

一旦我们配好webhook,GitHub会立即发送一个POST请求来检查接口是否可用。

Delivery

生成一次提交

一切准备就绪。现在可以生成一次提交。然后浏览器就会收到一条推送消息。

Push Commit, Receive Notification

整个过程就通了。

部署PWA

PWA应用的一个要求是必须使用HTTPS。使用Firebase hosting来部署,是一个不错得选择。

我们这个app就放到了Firebase上的。另外我把服务端放在了heroku上面。

为了应用能正常工作。我把notification.js中的url换成了heroku上的地址。webhook的地址也换了。

添加应用到主屏

使用移动端设备中的浏览器访问我们的应用。以chrome 为例:

Click on Elipsis icon

Add to homescreen

Shortcut added to homescreen

PWA应用的源码 。服务端的源码

总结

至此,我们完全实现了离线使用、即时加载、收取推送、安装到桌面这些功能。

第一篇文章中,我罗列出了pwa应用的特性。另外,这有个工具Lighthouse 会评估一个web应用是否具备PWA应用所需的特性。既可以作为chrome插件使用。也可以在 命令行 中使用。 我建议你经常用用这个工具。写作本教程时参考了以下文章,Ire's series on PWATimi's server side push notification tutorialGokulakrishnan's PWA demo app ,还有Google pwa团队小伙伴们的博客和工作. 谢谢他们!

译者xjcloudy尚未开通打赏功能

相关文章