VillainHR

PWA 具体实践

practice PWA 2017-03-09

前面几篇文章大概介绍了 service worker 的底层原理,这里,我们通过实例来看看,如何自己做好一个 service worker 的应用。先说一下我这项目的大致架构,借由 react-redux-starter-kit 作为基础底层开发环境(这真是个神库,各种开发配置帮你写好了,自己就不用当轮子哥了,改一改就能用了),上层是 React + Redux 来写。废话就不说了,我们直接开始吧。

响应式部分

这里一块,主要利用 @media 的相关属性来对页面做响应式的优化,具体为:

@media (min-width : 375px) and (max-width : 667px) and (resolution : 2dppx){
  html{font-size: 37.5px;}
}

//iphone5 的基准值
@media (min-width : 320px) and (max-width : 568px) and (resolution : 2dppx){
  html{font-size: 32px;}
}

然后利用 rem 单位进行相关长度的设置。例如在:components/Content/content.scss 中,定义相关形状的大小:

@import 'base';
.icon-cw{
    right: px2rem(6px);
    top: px2rem(3px);
    &::before{
      width:px2rem(32px);
    }
  }
  .icon-plus{
    bottom:px2rem(6px);
    right:px2rem(6px);
  }

其中,px2rem 是根据自定义函数来写的一个转换函数。

@function px2rem($px){
  $rem : 37.5px;
  @return ($px/$rem) + rem;
}

这一部分也没多少可以讲的,具体参考前面那篇响应式文章即可。

Service Worker

这里,主要是将 SW 的主要功能给实现一遍,主要有 CachePushNotificationMessage。我们一个一个来:

注册

因为 SW 不是天生就是可以使用的,需要经过用户允许,所以,我们需要首先在 index.js 中写入:

SW.register('sw.js').then(function (registration) {
        // doSth
    }).catch(function (err) {
    });

这样,先请求一下权限。OK 之后,就到了用户注册阶段。这里,我们就需要在 sw.js 中,进行 install 的相关操作了。

const CACHE_VERSION = 1;
const CURRENT_CACHES = {
  prefetch: 'prefetch-cache-v' + CACHE_VERSION
};

self.addEventListener('install', function(event) {
  
 // 缓存指定的文件
  const urlsToPrefetch = [
    'vendor.js'
  ];

  event.waitUntil(
    caches.open(CURRENT_CACHES.prefetch).then(function(cache) {
      var cachePromises = urlsToPrefetch.map(function(urlToPrefetch) {
        var url = new URL(urlToPrefetch,location.origin); // 拼路径

        console.log('now send the request to' + url);

        var request = new Request(url);
        return fetch(request).then(function(response) {
          if (response.status >= 400) {
            throw new Error('request for ' + urlToPrefetch +
              ' failed with status ' + response.statusText);
          }

          return cache.put(urlToPrefetch, response);
        }).catch(function(error) {
          console.error('Not caching ' + urlToPrefetch + ' due to ' + error);
        });
      });

      return Promise.all(cachePromises).then(function() {
        console.log('Pre-fetching complete.');
      });
    }).catch(function(error) {
      console.error('Pre-fetching failed:', error);
    })
  );
});

经过上面的 prefetch 我们会创建一个 cache-object 用来存放指定域名的缓存文件。之后,如果有其他请求发送的话,就会接着触发 fetch 事件。

fetch 缓存

有时候,我们嫌自己硬编码在 fetch 里面 code 比较累人,那么建议可以直接在 fetch 中对相关的资源做处理。这里提供一种思路:

  • 根据 pathname 来判断是否缓存,比如只缓存 / 的资源文件。
  • 根据 method 来判断是否缓存,比如只缓存 GET 的资源文件。
  • 根据 contentType 来判断是否缓存,比如只缓存 js/css/png 文件。

OK,那么我们接下来就开始做吧。

importScripts('./path-to-regexp.js');

const FILE_LISTS = ['js','css','png'];
const PATH_FILE = '/:file?'; // 缓存接受的路径文件


var goSaving = function(url){
  for(var file of FILE_LISTS){
    if(url.endsWith(file)) return true;
  }
  return false;
}


// 判断 path/method/contentType

function checkFile(request){
  var matchPath = pathtoRegexp(PATH_FILE);
  var url = location.pathname;
  var method = request.method.toLowerCase();
   url = matchPath.exec(url)[1];
  return !!(goSaving(url) && method === 'get');
}

self.addEventListener('fetch', function(event) {
  // 检查是否需要缓存!!!!!!!!很重要!!!!!
  if(!checkFile(event.request))return;

  event.respondWith(
    caches.match(event.request).then(function(resp) {
      return resp || fetch(event.request).then(function(response) {
          console.log('save file:' + location.href);
          // 需要缓存,则将资源放到 caches Object 中
          return caches.open(CURRENT_CACHES.prefetch).then(function(cache) {
            cache.put(event.request, response.clone());
            return response;
          });
        });
    })
  );
});

上面的代码缓存部分大话,大家看前面的文章就 OK。这里,需要给大家普及一点:fetch 是会捕获所有的请求,如果你直接 return 回去,则相当于 bypass,不会对请求有任何影响! 所以,对不是指定资源的文件,直接通过网络获取。其余的采取有缓存就给,没缓存则向远端拉取。

这里,根据 path-to-reg 来对 pathname 做相关的处理。另外,现在也有比较高级的库,比如 Google 的 sw-tool。不过,本人认为 SW 能够做的事情比较少,如果在额外用一个比较重的库,可能回有点麻烦,不过,这也得取决于具体的业务。

document 更新

不过,这也会涉及到一个问题,如何更新 / 下的文件,即,document 文件? 这里我们可以通过 message 来做一层 document 的文件的懒更新:

// 在 index.js 中
if (SW.controller) { // 这是当前页面的 `controller`
      console.log('send message ::');
      SW.controller.postMessage("fetch document")
    }

然后在 SW 中,接收相关的指令,触发更新:

self.addEventListener('message',event =>{

  console.log("receive message" + event.data);
  // 更新根目录下的 html 文件。
  var url = self.location.href;
  console.log("update root file " + url);
  event.waitUntil(
    caches.open(CURRENT_CACHES.prefetch).then(cache=>{
        return fetch(url)
          .then(res=>{
            cache.put(url,res);
          })
    })
  )
});

缓存讲完了,之后,就该到我们的 Notification 阶段。

Notification

Notification 也不是一开始就具备的,这也需要用户的同意才行:

Notification.requestPermission();

同意了之后,我们可以先在 SW 中做个测试:

function sendNote(){
  console.log('send Note');
  var title = 'Yay a message.';
  var body = 'We have received a push message.';
  var icon = '/student.png';
  var tag = 'simple-push-demo-notification-tag'+ Math.random();
  var data = {
    doge: {
      wow: 'such amaze notification data'
    }
  };
    self.registration.showNotification(title, {
      body: body,
      icon: icon,
      tag: tag,
      data: data,
      actions:[
        {
          action:"focus",
          title:"focus"
        }]
    })
}

这里,一个简单的 notification 是额外需要相关的资源的,比如 ICON。ICON 的话,在网上随便找一个配一配就行。那我们如何模拟像 APP 推送,能够打开 APP 这种行为呢?很简单,我们只要在 SW 中监听 notificationclick 即可:


function focusOpen(){
  var url = location.href;
  clients.matchAll({
    type:'window',
    includeUncontrolled: true
  }).then(clients=>{
    for(var client of clients){
      if(client.url = url) return client.focus(); // 经过测试,focus 貌似无效
    }
    console.log('not focus');
    clients.openWindow(location.origin);
  })
}


self.addEventListener('notificationclick', function(event) {
  var messageId = event.notification.data;
  event.notification.close();
  if(event.action === "focus"){
    focusOpen();
  }
});

上面那种判断方法是根据 PC 端的推送指定的,通过 Note 中的 actions 属性,来进行不同的行为。不过,在手机端上,我们一般使用 openWindow 即可,因为手机端无法显示 action

// 手机端上处理
self.addEventListener('notificationclick', function(event) {
  var messageId = event.notification.data;
  event.notification.close();
  clients.openWindow(location.origin);
});

如果测试通过,那么童鞋,恭喜你,可以进入 web-push 阶段了。

Web Push

Web Push 前面也说过,是一个比较复杂的过程,首先要生成 pair keys,接着通过客户端订阅,然后才能发送。生成 key 的话,我这里就不想详述了,前面也已经说过了。我们直接开始订阅这一块:

 urlBase64ToUint8Array(base64String) {
    const padding = '='.repeat((4 - base64String.length % 4) % 4);
    const base64 = (base64String + padding)
      .replace(/\-/g, '+')
      .replace(/_/g, '/');

    const rawData = window.atob(base64);
    const outputArray = new Uint8Array(rawData.length);

    for (let i = 0; i < rawData.length; ++i) {
      outputArray[i] = rawData.charCodeAt(i);
    }
    return outputArray;
  }
  
  subscribe() {
    var key = this.urlBase64ToUint8Array('BPLISiRYgXzzLY_-mKahMBdYPeRZU-8bFVzgJMcDuthMxD08v0cEfc9krx6pG5VGOC31oX_QEuOSgU5CYLqpzf0');
    navigator.serviceWorker.ready.then(reg=> {
      reg.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey:key
      })
        .then(subscription=> {
          return fetch('/subscription', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json'
            },
            body: JSON.stringify(subscription)
          })
        })
    })
  }

其中的 key 是我们之前用 web-push 生成的。我们通过 subscribe() 会自动得到本次订阅独一无二的描述,当然,我们之前需要检查一下订阅状态,如果已经订阅了,就没必要重复订阅了。

navigator.serviceWorker.ready.then(reg=>{
      reg.pushManager.getSubscription().then(sub=>{
        if(sub)return;
        this.subscribe();
      })
    })

这里,不经需要前端做,后台也需要存储该次订阅的信息。后台的话,就使用 express4.x 来做。然后收到订阅的同时,立即发送一次 push 给前台。

const webpush = require('web-push');

webpush.setVapidDetails(
  'mailto:villainthr@gmail.com',
  vapidKeys.publicKey,
  vapidKeys.privateKey
);

app.route('/subscription')
  .post((req,res,next)=>{
    console.dir("receive subscribe : " + req.body);
    console.log(JSON.stringify(req.body));
    // 返回数据给前端
    res.json({status:'ok'});
    // 立即,push 一次信息
     webpush.sendNotification(req.body,"ok")
      .then(info=>{
        console.log('发送成功:' + info)
      })
      .catch(err =>{
        console.log(err);
      })
  })

之后,我们在 sw.js 中的 push 事件中做相关处理即可。

self.addEventListener('push', function(event) {
    sendNote(event.data)
});

function sendNote(push){
  console.log('send Note');
  var title = 'Yay a message.';
  var body = push;
  var icon = '/student.png';
  var tag = 'simple-push-demo-notification-tag'+ Math.random();
  var data = {
    doge: {
      wow: 'such amaze notification data'
    }
  };
    self.registration.showNotification(title, {
      body: body,
      icon: icon,
      tag: tag,
      data: data,
      actions:[
        {
          action:"focus",
          title:"focus"
        }]
    })
}

不过,你能不能成功还是要看运气的。因为,我们一直活在墙中,如果你使用 Chrome 的话。它的 message server 你是连不上的 (;′⌒‘)。 所以,关于 push 还需要等国内的浏览器跟上才行。

那么到这里,SW 做的所有工作,都已经做完了。剩下最后一点的就是如何将 Web 添加到桌面。这个就需要 Manifest 的帮助,前面已经讲过我这里就不赘述了。通过 webmanifest.st 生成自己网站的 Manifest,然后在 HTML 中,通过 link 标签引入:

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

如果 Chrome 检测到上述 link 标签,那么就会提示你是否愿意添加到桌面。如果你想对此做相关的优化,可以直接使用 beforeinstallprompt 事件。

var deferredPrompt;
window.addEventListener('beforeinstallprompt', function(e) {
  console.log('beforeinstallprompt Event fired');
  e.preventDefault();
  deferredPrompt = e;
  return false;
});
btnSave.addEventListener('click', function() {
  if(deferredPrompt !== undefined) {
    deferredPrompt.prompt();
    deferredPrompt.userChoice.then(function(choiceResult) {
      console.log(choiceResult.outcome);
      if(choiceResult.outcome == 'dismissed') {
      // 拒绝添加
        console.log('User cancelled home screen install');
      }
      else {
        console.log('User added to home screen');
      }
      // 不在提醒
      deferredPrompt = null;
    });
  }
});

这里,我简单实践了一下,最后是可以成功添加到桌面上的。

webApp.png-137.9kB

实例

该项目已经放到 github 上了。具体使用也很简单:

// 下载依赖包
npm install

期间你会遇见一些关于包的问题,最大的问题就是 node-sass。大家自己去下一个对应的编译包放进去就行。

没问题之后,就可以运行:

npm start

访问:localhost:3000 即可查看。

原文链接: https://www.villianhr.com/2017/03/09/PWA 具体实践