PWA实战

2019-08-20  本文已影响0人  zxhnext

二、PWA实战

你的第一个pwa应用

首先我们新建目录结构如下:


image.png

我们在index.html文件中配置如下:

<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
    <title>PWA-demo</title>
    <script src="https://cdn.bootcss.com/jquery/2.2.1/jquery.js"></script>
    <link rel="stylesheet" type="text/css" href="/style/main.css" />
    <link rel="manifest" href="/manifest.json">
</head>
<body>
    <div class="nav">
       <div class="button ind-btn">index</div>
       <div class="button det-btn">detail</div>       
    </div>
    <div class="index">
        <p>index页</p>
    </div>
    <div class="detail">
        <p>detail页</p>
    </div>
    <script src="/js/main.js"></script>
</body>
</html>

然后在main.js文件中注册service worker

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

                // 注册失败:(
                console.log('ServiceWorker registration failed: ', err);
            });
    });
}

下面来看看sw.js做了什么

let dataCacheName = 'new-data-v1'
let cacheName = 'new-data' // 缓存区域名
let filesToCache = [
    '/',
    '/index.html',
    '/js/main.js',
    '/style/main.css',
    '/assets/images/icons/icon_144x144.png',
    '/assets/images/icons/icon_152x152.png',
    '/assets/images/icons/icon_192x192.png',
    '/assets/images/icons/icon_512x512.png'
]

self.addEventListener('install', function (e) { // // 安装阶段
    // 如果监听到了 service worker 已经安装成功的话,就会调用 e.waitUntil 回调函数
    e.waitUntil(
      caches.open(cacheName).then(function (cache) { // caches.open() 开启一个缓存区
        // 通过 cache 缓存对象的 addAll 方法添加 precache 缓存
        return cache.addAll(filesToCache)
      })
    )
    // 当修改sw.js时,因为只能有一个service worker,所以新的service worker处于waiting状态,只有卸载旧的service worker后,新的才会进入activate状态
    // 强制当前处在 waiting 状态的 Service Worker 进入 activate 状态,使得新的sw立即生效
    self.skipWaiting()
})

self.addEventListener('activate', function (e) {
    e.waitUntil(
      // 清理旧版本
      caches.keys().then(function (keyList) {
        return Promise.all(keyList.map(function (key) {
          if (key !== cacheName && key !== dataCacheName) {
            return caches.delete(key)
          }
        }))
      })
    )
    // 更新客户端, 取得页面的控制权, 这样之后打开页面都会使用版本更新的缓存。旧的 Service Worker 脚本不再控制着页面,之后会被停止
    return self.clients.claim()
})

通过manifest.json将web添加到桌面,配置如下:

{
    "name": "PWA实战",
    "short_name": "PWA",
    "icons": [
      {
        "src": "assets/images/icons/icon_144x144.png",
        "sizes": "144x144",
        "type": "image/png"
      },
      {
        "src": "assets/images/icons/icon_152x152.png",
        "sizes": "152x152",
        "type": "image/png"
      },
      {
        "src": "assets/images/icons/icon_192x192.png",
        "sizes": "192x192",
        "type": "image/png"
      },
      {
        "src": "assets/images/icons/icon_512x512.png",
        "sizes": "256x256",
        "type": "image/png"
      }
    ],
    "start_url": "/index.html",
    "display": "standalone",
    "background_color": "#fff",
    "theme_color": "#1976d2"
}

接下来我们用ko2启一个服务,在server目录中新建app.js,

const Koa = require('koa');
const statics = require('koa-static')
const path = require('path')
const fs = require('fs')
const cors = require('koa2-cors');
const router = require('koa-router')()
const app = new Koa();

app.use(cors({
    origin: function (ctx) {
        // if (ctx.url === '/test') {
            return "*"; // 允许来自所有域名请求
        // }
        // return "http://localhost:8080"; // 这样就能只允许 http://localhost:8080 这个域名的请求了
    },
    exposeHeaders: ['WWW-Authenticate', 'Server-Authorization'],
    maxAge: 5,
    credentials: true, // 当设置成允许请求携带cookie时,需要保证"Access-Control-Allow-Origin"是服务器有的域名,而不能是"*";
    allowMethods: ['GET', 'POST', 'DELETE'],
    allowHeaders: ['Content-Type', 'Authorization', 'Accept'],
}))

app.use(statics(path.join(__dirname, '../public')))

router.get('/index',async (ctx)=>{
    const result = JSON.parse(fs.readFileSync(path.join(__dirname,'../public/assets/mockData/index.json')))
    ctx.body = result;
})

router.get('/detail',async (ctx)=>{
    const result = JSON.parse(fs.readFileSync(path.join(__dirname,'../public/assets/mockData/detail.json')))
    ctx.body = result;
})

router.get('/', async (ctx, next)=>{
    ctx.type= 'text/html;charset=utf-8'
    ctx.response.body = fs.readFileSync(path.join(__dirname,'../public/index.html'))
})

app.use(router.routes());
app.use(router.allowedMethods());

app.listen(8088, function() {
    console.log("🚀🚀🚀8088端口已启动,可以发射!")
});

这时我们就可以启动了,最后在main.js中请求koa2中的接口

$('.detail').hide()
let navIndex = 1
$(".ind-btn").click(function(){
    if(navIndex != 1) {
        fetch('/index').then(res => {
            return res.json()
        }).then(res => {
            const text = res.data.data
            $(".index p").text(text);
            $(".detail").hide();
            $('.index').show()
            navIndex = 1
        })
    }
});
$(".det-btn").click(function(){
    if(navIndex != 2) {
        fetch('/detail').then(res => {
            return res.json()
        }).then(res => {
            const text = res.data.data
            $(".detail p").text(text);
            $('.index').hide()
            $(".detail").show();
            navIndex = 2
        })
    }
});

最后我们再试一下对路由拦截,在sw.js中对路由进行拦截

self.addEventListener('fetch', function (event) {
    console.log('SW Fetch', event.request.url)
    let result = JSON.stringify({
      "status": 0,
      "errMsg": "",
      "data": {
        "data": "Index Page222"
      }
    })
    if (event.request.url === 'http://localhost:8088/index') {
        event.respondWith(new Response(result))
    }
})

三、service worker生命周期

image.png

service worker执行流程如下:


image.png

四、cache API

// 打开cache对象
caches.open(cacheName).then(cache => {/* 获得 Cache 对象 */})

添加缓存
Cache 对象提供了 put()、add()、addAll() 三个方法来添加或者覆盖资源请求响应的缓存。需要注意的是,这些添加缓存的方法只会对 GET 请求起作用。

put
资源请求响应在通过 Cache API 进行存储的时候,会以请求的 Request 对象作为键,响应的 Response 对象作为值,因此 put() 方法需要依次传入资源的请求和响应对象,然后生成键值对并缓存起来。下面举例说明它的使用方法:

cache.put(
  new Request('/data.json'),
  new Response(JSON.stringify({name: 'lilei'}))
)

// 或结合 Fetch API 来获取并存储服务端所返回的资源
fetch('/data.json').then(response => {
  if (response.ok) {
    cache.put(new Request('/data.json'), response.clone())
  }
})

add() 和 addAll() 方法的功能类似于 Fetch API 结合 put() 方法实现对服务端资源的抓取和缓存。add() 和 addAll() 的区别在于,add() 只能请求和缓存一个资源,而 addAll() 能够抓取并缓存多个资源。有了这两个方法,缓存服务端资源将变得更为简单:

cache.add('/data.json').then(() => {/* 缓存成功 */})
cache.addAll([
  '/data.json',
  '/info.txt'
])
.then(() => {/* 缓存成功 */})

cache.match() 和 cache.matchAll() 可以实现对缓存的查找。其中 match() 会返回第一个匹配条件的缓存结果,而 matchAll() 则会返回所有满足匹配条件的缓存结果。下面举例说明如何查找“/data.json”的缓存资源,相关代码如下所示

// 使用 match() 进行查找
cache.match('/data.json').then(response => {
  if (response == null) {
    // 没有匹配到任何资源
  }
  else {
    // 成功匹配资源
  }
})
// 使用 matchAll() 进行查找
cache.matchAll('/data.json').then(responses => {
  if (!responses.length) {
    // 没有匹配到任何资源
  }
  else {
    // 成功匹配到资源
  }
})

上述查找方法可以传入第二参数来控制匹配过程,比如设置 ignoreSearch 参数,会在匹配过程中忽略 URL 中的 Search 部分,下面通过代码举例说明这一匹配过程

// 假设缓存的请求 URL 为 /data.json?v=1
cache.match('/data.json?v=2', {ignoreSearch: true}).then(response => {
  // 匹配成功
})

match()、matchAll() 方法会返回匹配到的响应,但如果需要获取匹配到的请求,可以通过 cache.keys() 方法实现:

cache.keys('/data.json', {ignoreSearch: true}).then(requests => {
  // requests 可能包含 /data.json、/data.json?v=1、/data.json?v=2 等等请求对象
  // 如果匹配不到任何请求,则返回空数组
})
// 如果没有传入任何参数,cache.keys() 会默认返回当前 Cache 对象中缓存的全部请求
cache.keys().then(requests => {
  // 返回全部请求对象
})

删除缓存
通过 cache.delete() 方法可以实现对缓存的清理。其语法如下所示:

cache.delete(request, options).then(success => {
  // 通过 success 判断是否删除成功
})

比如要删除前面添加成功的“/data.json”请求,相关代码如下所示:

cache.delete('/data.json').then(success => {
  // 将打印 true,代表删除成功
  console.log(success)
})

假如删除一个未被缓存的请求,则执行删除后返回的 success 为 false:

cache.delete('/no-cache.data').then(success => {
  // 将打印 false,代表删除失败
  console.log(success)
})

在调用 cache.delete() 时可以传入第二参数去控制删除操作中如何匹配缓存,其格式与 match()、matchAll() 等匹配方法的第二参数一致。因此下面举例的删除过程能够忽略 Search 参数:

// 假设缓存的请求 URL 为 /data.json?v=1.0.1
// 那么设置 ignoreSearch 之后同样也回删除该缓存
cache.delete('/data.json', {ignoreSearch: true}).then(success => {
  // /data.json?v=1.0.1 已被成功删除
})

五、桌面通知

新建notification.js,首先来设置用户权限。

function main () {
    var $state = document.getElementById('notification-state')
    if (typeof Notification === 'undefined') {
      $state.innerText = '浏览器不支持 Notification API'
      $state.classList.add('disabled')
      return
    }
  
    if (Notification.permission === 'denied') {
      $state.innerText = 'Notification 权限已被禁用'
      return
    }
  
    if (Notification.permission === 'granted') {
      $state.innerText = 'Notification 可用'
      register()
    } else {
      Notification.requestPermission().then(function (permission) { // 向用户申请通知权限
        switch (permission) {
          case 'granted':
            $state.innerText = 'Notification 可用'
            register()
            break
          case 'denied':
            $state.innerText = 'Notification 权限已被禁用'
            break
          default:
            $state.innerText = 'Notification 权限尚未授权'
        }
      })
    }
}

来绑定几个点击事件推送消息

function register () {
    // 标题&内容
    document.getElementById('btn-title-body').addEventListener('click', notifyTitleAndBody)
    document.getElementById('btn-long-title-body').addEventListener('click', notifyLongTitleAndBody)
    document.getElementById('btn-notificationclick').addEventListener('click', notifyOpenWindow)
  }
  
  function displayNotification (title, options) {
    navigator.serviceWorker.getRegistration().then(function (registration) {
      registration.showNotification(title, options)
    })
  }
  
  function notifyTitleAndBody () {
    displayNotification('PWA-Book-Demo Notification 测试标题内容', {
      body: 'Simple piece of body text.\nSecond line of body text :)'
    })
  }
  
  function notifyLongTitleAndBody () {
    displayNotification('PWA-Book-Demo Notification 测试长标题长内容', {
      body: 'Simple piece of body text.\nSecond line of long long long long long long long long body text :) '
    })
  }
  
  // 测试点击事件,点击打开新页面
  function notifyOpenWindow () {
    displayNotification('你好', {
      body: '我叫李雷,交个朋友吧',
      icon: 'https://gss0.baidu.com/9rkZbzqaKgQUohGko9WTAnF6hhy/assets/pwa/demo/pwa-icon.png',
      data: {
        time: new Date(Date.now()).toString(),
        url: 'https://www.baidu.com'
      }
    })
}
// 执行main
main()

在sw.js中添加桌面推送通知

// 桌面通知
self.addEventListener('notificationclick', function (event) {
  const notification = event.notification
  console.log('测试 data 通知时间:' + notification.data)
  // 点击点赞
  if (event.action === 'like') {
    console.log('点击了点赞按钮')
  }
  // 关闭通知
  event.notification.close()
  // 打开网页
  if (notification.data && notification.data.url) {
    event.waitUntil(clients.openWindow(event.notification.data.url))
  }
})

六、网络推送

Web Push 协议出于用户隐私考虑,在应用和推送服务器之间没有进行强身份验证,这为用户应用和推送服务都带来了一定的风险。解决方案是对 Web Push 使用自主应用服务器标识(VAPID)协议,VAPID 规范允许应用服务器向推送服务器标识身份,推送服务器知道哪个应用服务器订阅了用户,并确保它也是向用户推送信息的服务器。使用 VAPID 服务过程很简单,通过几个步骤可以理解 VAPID 如何实现安全性。
如果我们使用 Node.js 作为服务端语言,那么可以通过安装 web-push 来协助生成公钥。

npm install web-push -g
web-push generate-vapid-keys

然后我们在server/config.js 文件中配置 VAPIDKeys 公钥和私钥,以及配置 Firebase 云服务(FCM)生成的 GCMAPIkey。

module.exports = {
  VAPIDKeys: {
    publicKey: '<Your Public Key>',
    privateKey: '<Your private Key>'
  },
  GCMAPIkey: 'FCM Public Key'
}

在主线程文件中注册 Service Worker、申请桌面通知权限、订阅推送等等工作。

const publicKey = 'FCM Public Key'
let registration
if ('serviceWorker' in navigator) {
    window.addEventListener('load', function () {
        navigator.serviceWorker.register('/sw.js', {scope: '/'})
            .then(function (reg) {
                reg.update()
                registration = reg
                // 注册成功
                console.log('ServiceWorker registration successful with scope: ', registration.scope);
            })
            .then(() => {
                // 申请桌面通知权限
                requestNotificationPermission()
            })
            .then(() => {
                // 订阅推送
                subscribeAndDistribute(registration)
            })
            .catch(function (err) {
                // 注册失败:(
                console.log('ServiceWorker registration failed: ', err);
            });
    });
}

// 申请桌面通知权限
function requestNotificationPermission () {
    // 系统不支持桌面通知
    if (!window.Notification) {
      return Promise.reject('系统不支持桌面通知')
    }
    return Notification.requestPermission()
      .then(function (permission) {
        if (permission === 'granted') {
          return Promise.resolve()
        }
        return Promise.reject('用户已禁止桌面通知权限')
    })
}

// 订阅推送并将订阅结果发送给后端
function subscribeAndDistribute (registration) {
    if (!window.PushManager) {
      console.log("系统不支持消息推送")
      return Promise.reject('系统不支持消息推送')
    }
    // 检查是否已经订阅过
    return registration.pushManager.getSubscription().then(function (subscription) {
      // 如果已经订阅过,就不重新订阅了
      if (subscription) {
         console.log("用户已订阅")
           return
         }
      // 如果尚未订阅则发起推送订阅
      return registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: base64ToUint8Array(publicKey)
      })
        // 订阅推送成功之后,将订阅信息传给后端服务器
        .then(function (subscription) {
          distributePushResource(subscription)
        })
    })
}

// 假设后端接收并存储订阅对象的接口为 '/api/push/subscribe'
function distributePushResource (subscription) {
    return fetch('/api/push/subscribe', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        endpoint: subscription.endpoint,
        keys: {
          p256dh: uint8ArrayToBase64(subscription.getKey('p256dh')),
          auth: uint8ArrayToBase64(subscription.getKey('auth'))
        }
      })
    })
}
// 可分别通过 pushSubscription.getKey('p256dh') 和 pushSubscription.getKey('auth') 来获取密钥和校验码信息。由于通过 getKey() 方法获取到的密钥信息类型为 ArrayBuffer,因此还需要通过转码将其转成 base64 字符串以便于传输。
function uint8ArrayToBase64 (arr) {
    return btoa(String.fromCharCode.apply(null, new Uint8Array(arr)))
}
// subscribe 方法通过 applicationServerKey 传入所需要的公钥。一般来说得到的公钥一般都是 base64 编码后的字符串,需要将其转换成 Uint8Array 格式才能作为 subscribe 的参数传入
function base64ToUint8Array (base64String) {
    let padding = '='.repeat((4 - base64String.length % 4) % 4)
    let base64 = (base64String + padding)
      .replace(/-/g, '+')
      .replace(/_/g, '/')
    let rawData = atob(base64)
    let outputArray = new Uint8Array(rawData.length)
    for (let i = 0; i < rawData.length; i++) {
      outputArray[i] = rawData.charCodeAt(i)
    }
    return outputArray
}

sw.js中监听push,监听点击事件

// 监听 push 事件
self.addEventListener('push', function (e) {
  if (!e.data) {
    return
  }
  // 解析获取推送消息
  let payload = e.data.text()
  // 根据推送消息生成桌面通知并展现出来
  // rrayBuffer():将消息解析成 ArrayBuffer 对象;
  // blob():将消息解析成 Blob 对象;
  // json():将消息解析成 JSON 对象;
  // text():将消息解析成字符串
  let promise = self.registration.showNotification(payload.title, {
    body: payload.body,
    icon: payload.icon,
    data: {
      url: payload.url
    }
  })
  e.waitUntil(promise)
})

// 监听通知点击事件
self.addEventListener('notificationclick', function (e) {
  // 关闭窗口
  e.notification.close()
  // 打开网页
  e.waitUntil(self.clients.openWindow(e.data.url))
})

最后我们在服务器中监听推送消息

const bodyParser = require('koa-bodyparser')
const webpush = require('web-push')
const config = require('./config')

const VAPIDkeys = config.VAPIDKeys
const GCMAPIkey = config.GCMAPIkey

// 配置 web push
webpush.setVapidDetails(
    'mailto:xiaohannext@qq.com',
    VAPIDkeys.publicKey,
    VAPIDkeys.privateKey
)
webpush.setGCMAPIKey(GCMAPIkey)

// 存储 pushSubscription 对象
let pushSubscriptionSet = new Set()

// 定时任务,每隔 10 分钟向推送服务器发送消息
setInterval(function () {
  if (pushSubscriptionSet.size > 0) {
    pushSubscriptionSet.forEach(function (pushSubscription) {
      webpush.sendNotification(pushSubscription, JSON.stringify({
        title: '你好',
        body: '我叫李雷,很高兴认识你',
        icon: 'https://gss0.baidu.com/9rkZbzqaKgQUohGko9WTAnF6hhy/assets/pwa/demo/pwa-icon.png',
        url: 'https://www.baidu.com'
      }))
    })
  }
}, 10 * 60)

// 服务端提供接口接收并存储 pushSubscription
router.post('/api/push/subscribe', function (ctx, next) {
    if (ctx.request.body) {
      try {
        pushSubscriptionSet.add(ctx.request.body)
        ctx.status = 200
      } catch (e) {
        ctx.status= 403
      }
    } else {
        ctx.status = 403
    }
})

文档参考:
lavas
service worker

上一篇 下一篇

猜你喜欢

热点阅读