蓝戒的博客


前端PWA技术实现,突破用户体验枷锁

一、 PWA登场背景

很长时间以来我们对项目采用web app 还是 native app的选择上有一些不可调和的矛盾点,究其实质就是用户体验和性能成本问题。

Web App vs Native App 优缺点对比:

理想很美好,优点:

开发成本较低:使用web开发技术就可以轻松的完成web app的开发。
维护比较轻松web app和一般的web一样,维护比较简单,它其实就是一个站点。
跨平台:一套代码实现android 和 ios系统同时运行。
免安装:打开浏览器,就能使用。
快速部署:升级不需要通知用户,在服务端更新文件即可,用户完全没有感觉,而native app更新体验较差、同时也比较麻烦,每一次发布新的版本,都需要做版本打包,且需要用户手动更新。
超链接:可以与其他网站互连,可以被搜索引擎检索。

现实很骨感,缺点:

操作体验差:Native app的操作流畅性,远超Web App
UI交互效果差:WebApp无法实现的Native App一些非常酷的交互效果

如果将来有一天,Web app会成为主流,一定有一个前提,那就是它的性能和体验可以赶上Native app。

PWA打开移动设备上浏览器web app用户体验枷锁的钥匙

         回顾2014到2015年那段时间,作为前端开发人员,我们正忙着前后端分离和体验优化。那时投入精力最多的就是移动站点的体验优化,例如提升首屏速度,提升动画流畅性等。为了达到更好的优化效果,我心力憔悴,HTTP缓存、HTTP2等一些优化技术都用上了,但体验依然比Native App差很多,始终无法突破移动设备上浏览器(和Web View)给Web带来的用户体验枷锁。想要继续前进,就必须打造解开枷锁的钥匙——PWA以及支撑PWA的一系列关键技术应运而生。

二、 PWA是什么

       PWA不是特指某一项技术,而是应用了多项技术的Web App(Progressive Web App),也就是渐进式的网页应用程序。其核心技术包括App Manifest、Service Worker、Web Push、Credential Management API,等等。其核心目标就是提升Web App的性能,改善Web App的用户体验。

       如果我们把PWA的关键技术之一ServiceWorker的出现作为PWA的诞生时间,那就应该是2015年。自2015年以来,PWA相关的技术不断升级优化,在用户体验和用户留存两方面都提供了非常好的解决方案。PWA可以将Web和App各自的优势融合在一起:渐进式、可响应、可离线、实现类似App的交互、即时更新、安全、可以被搜索引擎检索、可推送、可安装、可链接。其中,App Manifest让Web站点能被添加到手机桌面,解决了用户到达站点链条太长的问题;Service Worker让Web站点能够离线状态下正常使用,还有能让用户离线接受站点消息推送的Web Push,这两点非常值得关注。
       Service Worker是一个特殊的Web Worker,独立于页面主线程运行,它能够拦截和处理网络请求,并且配合Cache Storage API,开发者可以自由的对页面发送的HTTP请求进行管理,这就是为什么Service Worker能让Web站点离线的原因。Service Worker的工作流程如下图所示:

基本特性

  • 可靠(Reliable)
    即使在不稳定的网络环境下,也能瞬间加载并展现
  • 快速响应(Fast)
    快速响应,并且有平滑的动画响应用户的操作
  • 粘性(Engaging)
    像设备上的原生应用,具有沉浸式的用户体验,用户可以添加到桌面

PWA 本身强调渐进式,并不要求一次性达到安全、性能和体验上的所有要求,开发者可以通过 PWA Checklist 查看现有的特征。

除以上的基准要求外,还应该包括以下特性:

  • 渐进式 - 适用于所有浏览器,因为它是以渐进式增强作为宗旨开发的
  • 连接无关性 - 能够借助 Service Worker 在离线或者网络较差的情况下正常访问
  • 类似应用 - 由于是在 App Shell 模型基础上开发,因为应具有 Native App 的交互和导航,给用户 Native App 的体验
  • 持续更新 - 始终是最新的,无版本和更新问题
  • 安全 - 通过 HTTPS 协议提供服务,防止窥探和确保内容不被篡改
  • 可索引 - 应用清单文件和 Service Worker 可以让搜索引擎索引到,从而将其识别为『应用』
  • 粘性 - 通过推送离线通知等,可以让用户回流
  • 可安装 - 用户可以添加常用的 webapp 到桌面,免去去应用商店下载的麻烦
  • 可链接 - 通过链接即可分享内容,无需下载安装

三、 浏览器支持

业界厂商也感受到了一种压力,纷纷支持Service Worker,包括苹果:

注意:目前只能在 HTTPS 环境下才能使用server work,因为server work的权利比较大,能够直接截取和返回用户的请求,所以要考虑一下安全性问题。


前提条件

  • Service Worker 出于安全性和其实现原理,在使用的时候有一定的前提条件。
  • 由于 Service Worker 要求 HTTPS 的环境
    当然一般浏览器允许调试 Service Worker 的时候 host 为 localhost 或者 127.0.0.1
  • Service Worker 的缓存机制是依赖 Cache API (略过)
  • 依赖 HTML5 fetch API(略过)
  • 依赖 Promise 实现


iOS 内的所有的浏览器都基于 safari,所以iOS要在11.3以上
IE是放弃支持了,不过Edge好歹支持了。


事件机制



支持功能:

  • 后台数据的同步
  • 从其他域获取资源请求
  • 接受计算密集型数据的更新,多页面共享该数据
  • 客户端编译与依赖管理
  • 后端服务的hook机制
  • 根据URL模式,自定义模板
  • 性能优化
  • 消息推送
  • 定时默认更新
  • 地理围栏

四、 PWA如何实现

Manifest实现添加至主屏幕

index.html

<head>
  <title>Minimal PWA</title>
  <meta name="viewport" content="width=device-width, user-scalable=no" />
  <link rel="manifest" href="manifest.json" />
  <link rel="stylesheet" type="text/css" href="main.css">
  <link rel="icon" href="/e.png" type="image/png" />
</head>

manifest.json

{
  "name": "Minimal PWA", // 必填 显示的插件名称
  "short_name": "PWA Demo", // 可选  在APP launcher和新的tab页显示,如果没有设置,则使用name
  "description": "The app that helps you understand PWA", //用于描述应用
  "display": "standalone", // 定义开发人员对Web应用程序的首选显示模式。standalone模式会有单独的
  "start_url": "/", // 应用启动时的url
  "theme_color": "#313131", // 桌面图标的背景色
  "background_color": "#313131", // 为web应用程序预定义的背景颜色。在启动web应用程序和加载应用程序的内容之间创建了一个平滑的过渡。
  "icons": [ // 桌面图标,是一个数组
    {
    "src": "icon/lowres.webp",
    "sizes": "48x48",  // 以空格分隔的图片尺寸
    "type": "image/webp"  // 帮助userAgent快速排除不支持的类型
  },
  {
    "src": "icon/lowres",
    "sizes": "48x48"
  },
  {
    "src": "icon/hd_hi.ico",
    "sizes": "72x72 96x96 128x128 256x256"
  },
  {
    "src": "icon/hd_hi.svg",
    "sizes": "72x72"
  }
  ]
}

service worker实现离线缓存

Service Workers 的强大在于它们拦截 HTTP 请求的能力
进入任何传入的 HTTP 请求,并决定想要如何响应。在你的 Service Worker 中,可以编写逻辑来决定想要缓存的资源,以及需要满足什么条件和资源需要缓存多久。一切尽归你掌控!

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Hello Caching World!</title>
  </head>
  <body>
    <!-- Image -->
    <img src="/images/hello.png" />                 
    <!-- JavaScript -->
    <script async src="/js/script.js"></script>     
    <script>
      // 注册 service worker
      if ('serviceWorker' in navigator) {           
        navigator.serviceWorker.register('/service-worker.js', {scope: '/'}).then(function (registration) {
          // 注册成功
          console.log('ServiceWorker registration successful with scope: ', registration.scope);
        }).catch(function (err) {                   
          // 注册失败 :( 
          console.log('ServiceWorker registration failed: ', err);
        });
      }
    </script>
  </body>
</html>

注:Service Worker 的注册路径决定了其 scope 默认作用页面的范围。
如果 service-worker.js 是在 /sw/ 页面路径下,这使得该 Service Worker 默认只会收到 页面/sw/ 路径下的 fetch 事件。
如果存放在网站的根路径下,则将会收到该网站的所有 fetch 事件。
如果希望改变它的作用域,可在第二个参数设置 scope 范围。示例中将其改为了根目录,即对整个站点生效。

service-worker.js

var cacheName = 'helloWorld';     // 缓存的名称  
// install 事件,它发生在浏览器安装并注册 Service Worker 时        
self.addEventListener('install', event =&gt; { 
/* event.waitUtil 用于在安装成功之前执行一些预装逻辑
 但是建议只做一些轻量级和非常重要资源的缓存,减少安装失败的概率
 安装成功后 ServiceWorker 状态会从 installing 变为 installed */
  event.waitUntil(
    caches.open(cacheName)                  
    .then(cache =&gt; cache.addAll([    // 如果所有的文件都成功缓存了,便会安装完成。如果任何文件下载失败了,那么安装过程也会随之失败。        
      '/js/script.js',
      '/images/hello.png'
    ]))
  );
});
  
/**
为 fetch 事件添加一个事件监-听。接下来,使用 caches.match() 函数来检查传入的请求 URL 是否匹配当前缓存中存在的任何内容。如果存在的话,返回缓存的资源。
如果资源并不存在于缓存当中,通过网络来获取资源,并将获取到的资源添加到缓存中。
*/
self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.match(event.request)                  
    .then(function (response) {
      if (response) {                            
        return response;                         
      }
      var requestToCache = event.request.clone();  //          
      return fetch(requestToCache).then(                   
        function (response) {
          if (!response || response.status !== 200) {      
            return response;
          }
          var responseToCache = response.clone();          
          caches.open(cacheName)                           
            .then(function (cache) {
              cache.put(requestToCache, responseToCache);  
            });
          return response;             
    })
  );
});

注:为什么用request.clone()和response.clone()
需要这么做是因为request和response是一个流,它只能消耗一次。因为我们已经通过缓存消耗了一次,然后发起 HTTP 请求还要再消耗一次,所以我们需要在此时克隆请求
Clone the request—a request is a stream and can only be consumed once.

serice worker实现消息推送


步骤一、提示用户并获得他们的订阅详细信息
步骤二、将这些详细信息保存在服务器上
步骤三、在需要时发送任何消息

不同浏览器需要用不同的推送消息服务器。以 Chrome 上使用 Google Cloud Messaging<GCM> 作为推送服务为例,第一步是注册 applicationServerKey(通过 GCM 注册获取),并在页面上进行订阅或发起订阅。每一个会话会有一个独立的端点(endpoint),订阅对象的属性(PushSubscription.endpoint) 即为端点值。将端点发送给服务器后,服务器用这一值来发送消息给会话的激活的 Service Worker (通过 GCM 与浏览器客户端沟通)。

步骤一和步骤二
index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Progressive Times</title>
    <link rel="manifest" href="/manifest.json">                                      
  </head>
  <body>
    <script>
      var endpoint;
      var key;
      var authSecret;
      var vapidPublicKey = 'BAyb_WgaR0L0pODaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY';
      // 方法很复杂,但是可以不用具体看,知识用来转化vapidPublicKey用
      function 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;
      }
      if ('serviceWorker' in navigator) {
        navigator.serviceWorker.register('sw.js').then(function (registration) {
          return registration.pushManager.getSubscription()                            
            .then(function (subscription) {
              if (subscription) {                                                      
                return;
              }
              return registration.pushManager.subscribe({                              
                  userVisibleOnly: true,
                  applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
                })
                .then(function (subscription) {
                  var rawKey = subscription.getKey ? subscription.getKey('p256dh') : '';
                  key = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : '';
                  var rawAuthSecret = subscription.getKey ? subscription.getKey('auth') : '';
                  authSecret = rawAuthSecret ?
                    btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret))) : '';
                  endpoint = subscription.endpoint;
                  return fetch('./register', {                                         
                    method: 'post',
                    headers: new Headers({
                      'content-type': 'application/json'
                    }),
                    body: JSON.stringify({
                      endpoint: subscription.endpoint,
                      key: key,
                      authSecret: authSecret,
                    }),
                  });
                });
            });
        }).catch(function (err) {
          // 注册失败 :( 
          console.log('ServiceWorker registration failed: ', err);
        });
      }
    </script>
  </body>
</html>

步骤三 服务器发送消息给service worker

app.js

const webpush = require('web-push');                 
const express = require('express');
var bodyParser = require('body-parser');
const app = express();
webpush.setVapidDetails(                             
  'mailto:contact@deanhume.com',
  'BAyb_WgaR0L0pODaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY',
  'p6YVD7t8HkABoez1CvVJ5bl7BnEdKUu5bSyVjyxMBh0'
);
app.post('/register', function (req, res) {           
  var endpoint = req.body.endpoint;
  saveRegistrationDetails(endpoint, key, authSecret); 
  const pushSubscription = {                          
    endpoint: req.body.endpoint,
    keys: {
      auth: req.body.authSecret,
      p256dh: req.body.key
    }
  };
  var body = 'Thank you for registering';
  var iconUrl = 'https://example.com/images/homescreen.png';
  // 发送 Web 推送消息
  webpush.sendNotification(pushSubscription,          
      JSON.stringify({
        msg: body,
        url: 'http://localhost:3111/',
        icon: iconUrl
      }))
    .then(result =&gt; res.sendStatus(201))
    .catch(err =&gt; {
      console.log(err);
    });
});
app.listen(3111, function () {
  console.log('Web push app listening on port 3111!')
});

service worker监听push事件,将通知详情推送给用户
service-worker.js

self.addEventListener('push', function (event) {
 // 检查服务端是否发来了任何有效载荷数据
  var payload = event.data ? JSON.parse(event.data.text()) : 'no payload';
  var title = 'Progressive Times';
  event.waitUntil(
    // 使用提供的信息来显示 Web 推送通知
    self.registration.showNotification(title, {                           
      body: payload.msg,
      url: payload.url,
      icon: payload.icon
    })
  );
});

四、 PWA总结

PWA的优势

  • 可以将app的快捷方式放置到桌面上,全屏运行,与原生app无异
  • 能够在各种网络环境下使用,包括网络差和断网条件下,不会显示undefind
  • 推送消息的能力
  • 其本质是一个网页,没有原生app的各种启动条件,快速响应用户指令


PWA存在的问题

  • 支持率不高:现在ios手机端不支持pwa,IE也暂时不支持
  • Chrome在中国桌面版占有率还是不错的,安卓移动端上的占有率却很低
  • 各大厂商还未明确支持pwa
  • 依赖的GCM服务在国内无法使用
  • 微信小程序的竞争


尽管有上述的一些缺点,PWA技术仍然有很多可以使用的点。

service worker技术实现离线缓存,可以将一些不经常更改的静态文件放到缓存中,提升用户体验。
service worker实现消息推送,使用浏览器推送功能,吸引用户
渐进式开发,尽管一些浏览器暂时不支持,可以利用上述技术给使用支持浏览器的用户带来更好的体验。

参考文档:

1. https://www.cnblogs.com/pqjwyn/p/9016901.html

2. https://blog.csdn.net/qq_19238139/article/details/77531191

3. https://segmentfault.com/a/1190000012353473

本文固定链接: http://www.webzsky.com/?p=1262 | 蓝戒的博客

cywcd
该日志由 cywcd 于2018年07月25日发表在 javascript 分类下, 你可以发表评论,并在保留原文地址及作者的情况下引用到你的网站或博客。
原创文章转载请注明: 前端PWA技术实现,突破用户体验枷锁 | 蓝戒的博客
关键字: ,

前端PWA技术实现,突破用户体验枷锁:等您坐沙发呢!

发表评论


快捷键:Ctrl+Enter
来自的朋友,欢迎您 点击这里 订阅我的博客 o(∩_∩)o~~~
×