xjcloudy

Progressive Web Apps 入门(1)

xjcloudy · 2017-01-31翻译 · 1633阅读 原文链接

PWA应用简介

PWA应用主要指的是使用现代浏览器技术构建的网站,其具有移动应用一样的体验和能力。2015由谷歌的工程师Alex Russell和Frances Berriman 成立了最初的Progressive Web Apps团队。Google做了很多的工作,使得PWA应用的使用体验接近native应用那样。以下是PWA应用所拥有的典型特征:

  • 在浏览器中可以访问

  • 可以选择将应用添加到主屏

  • 渐进式的呈现应用类的功能。比如离线使用,推送消息还有后台进程。

直到现在,web应用任然无法真正做到很多移动端应用可以做的事情。 这个任务现在就由PWA应用来完成。其集网页与应用程序的优点与一身。 PWA应用可以离线使用,发送推送消息,在网络不好的情况下也可以很快加载完成。同时还可以借助Web App Manifest来实现从主屏启动。

> "一个PWA应用本质上是一个由现代web技术构建的网站。但是其行为和体验却像移动端应用程序那样"

是否对移动端应用才有的闪屏页有印象?现在,Android上最新版本的Chrome已经可以实现web应用的闪屏页了。和native应用的感觉一模一样 !

Splash Screen

PWA应用的特点

那么“渐进性”对于web应用来说意味着什么呢? 简单说来就是下面这些特性:

  • 响应式: 用户界面可以兼容多种设备,比如:桌面,移动端,平板。
  • 应用化: 交互体验接近native应用。

  • 网络低依赖: 无网络或者低速网络下依然可以使用。

  • 延续性: 借助推送功能,能够维持用户粘性。

  • 可安装: 可以被安装到主屏,这样用户随时可以从主屏启动应用。

  • 可被发现: 被视为一个独立应用,而且可以被搜索.

  • 可更新: 当用户重新联网时,可以更新内容.

  • 安全: 可以使用HTTPS来防止内容伪造和中间人攻击。

  • 渐进性: 所有用户都可以使用,浏览器无关。

  • 兼容url: 可以通过url传播。

典型应用实例

已经有不少开发者和公司按照渐进性应用的标准,重构了他们的网站。我来简单介绍一下其中3个比较有特色的产品和重构为PWA应用的好处。

  • Flipkart Lite: FlipKart 是印度最大的电商. 他们建了一个pwa应用叫, Flipkart Lite 结果转化率提升到了70%。service workers,推送,安装到主屏,闪屏页,流畅的动画,利用这些给力的特性。效果非常不错:

  • 流量提高了接近3倍。

  • 超过40%的二次访问率

  • 提高了用户停留时间。

  • 用户转化率提高到70%。

Flipkart Splashscreen

Flipkart 的闪屏页

Flipkart Homescreen option

Flipkart 的安装提示

更多的信息请查看

  • Housing: Housing 是印度一家优秀的创业公司。他们在印度提供在线房产交易服务。他们利用pwa应用提升了38%的转化率,并且:

  • 用户跳出率降低40%

  • 提升10%的平均在线时长

  • 页面加载速度提升30%

Housing Homescreen

Housing - Push Notifications Option

更多的信息请查看

  • AliExpress: AliExpress,作为全球知名的在线零售商,一直以来采用的是引导用户下载app然后保持尽可能高的留存率的方法,现在也遇到了问题。他们将他们的移动端站点改造为了pwa应用,其效果非常显著::

  • 新用户的转化率提升了104%

  • 来自浏览器的单个用户使用时长提升了74%

  • 浏览器用户平均访问页面数量增加了2倍

AliExpress Mobile

AliExpress Homepage

更多的信息请查看

这些公司在PWA中应用受益匪浅。接下来,我们将深入我们的主题,pwa应用的关键组件之一,“service workers”。

Service Workers

service woker就是一个代理程序,在浏览器后台运行的一段脚本。它可以拦截并且处理http请求,然后以多种方式返回响应。不仅可以拦截响应网络请求,还可以处理推送消息,处理网络状态变更等其他工作。Google 的一位工程师,Jeff Posnick。给出了一个最佳的解释: > Service Worker 就像是一个塔台。想象一下你的web应用的网络请求就像是即将降落的一架架飞机。Service Worker 就是管控跑道的塔台。他可以选择走网络请求也可以选择走缓存。

Service worker虽然不能访问DOM,但是可以使用Fetch API。如果想要减少网络请求来提升体验,你可以使用service worker来缓存所有的静态资源。也可以实现当用户离线时,即时给用户提示,并引导用户到专门的页面等待。

Service woker所在的文件,如“sw.js”需要放置与应用的根目录下:

Service Worker JavaScript file

在开始运行前,你需要注册你的service worker文件。假如你应用的入口文件是“app.js”,那么在里面需要有这么一段代码:

 if ('serviceWorker' in navigator) {
    navigator.serviceWorker
             .register('./sw.js')
             .then(function() { console.log('Service Worker Registered'); });
  }

这段代码,先检测浏览器是否支持service worker,如果支持,就注册service worker 文件。注册好了之后,当用户第一次访问页面时,service worker就立刻开始运行了。 Service worker的生命周期如下:

  • Install :当用户首次访问页面时,会触发一个install事件。 在这个阶段 server worker将被安装进浏览器中。在安装阶段你可以缓存你应用的静态资源:
 // 安装阶段
self.addEventListener('install', function(event) {

    console.log('Service Worker: Installing....');

    event.waitUntil(

        // 打开缓存
        caches.open(cacheName).then(function(cache) {
            console.log('Service Worker: Caching App Shell at the moment......');

            // 将文件加入缓存
            return cache.addAll(filesToCache);
        })
    );
});
  • filesToCache 变量是一个数组,包含你想要缓存的文件。

  • cacheName 是缓存的名称(类似数据库名)。

  • Activate: 当service worker 运行时触发activate事件.
 // 当service worker 运行时
self.addEventListener('activate', function(event) {

    console.log('Service Worker: Activating....');

    event.waitUntil(
        caches.keys().then(function(cacheNames) {
            return Promise.all(cacheNames.map(function(key) {
                if( key !== cacheName) {
                    console.log('Service Worker: Removing Old Cache', key);
                    return caches.delete(key);
                }
            }));
        })
    );
    return self.clients.claim();
});

service worker 检查应用的脚本文件是否更新,并更新缓存。

  • Fetch: 这个事件属于缓存机制。 caches.match() 每当有web请求产生就会触发事件,同时检查是否在命中缓存。如果命中则直接返回缓存的版本,否则则通过网络获取 。调用 e.respondWith()结束web请求,返回到web页。
 self.addEventListener('fetch', function(event) {

    console.log('Service Worker: Fetch', event.request.url);

    console.log("Url", event.request.url);

    event.respondWith(
        caches.match(event.request).then(function(response) {
            return response || fetch(event.request);
        })
    );
});

截至本文写作时,已经支持service workers的有 Chrome,Oprea和Firefox。而 Safari和Edge还不支持。

Service Worker Support

Service Worker Specificationprimer对学习Service Worker 用很大帮助。

Application Shell

上文中我们提到过的app shell(被忽略了。*注)。Application Shell 其实是一个很小的html页面。可以包含一些CSS和Javascript。作为应用的一个交互界面。这个Application Shell页面可以被缓存下来,以便在用户访问应用时能够快速的加载和展现给用户。

Application Shell

我们将要做一个什么应用?

我们将做一个简单的PWA应用。它会实时的显示一个开源项目所接收到的提交信息。当然,需要有些PWA应用的特色功能:

  • 离线使用:用户离线也可以正常使用该应用
  • 当再次打开应用时,实现即时加载。
  • 用户可以打开推送通知的按钮,这样就可以接收到最新的提交信息的推送通知。
  • 可以被安装到主屏
  • 使用manifest文件

不废话了!开工

在项目文件夹里新建一个 index.htmllatest.html文件 :

 <!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Commits PWA</title>
  <link rel="stylesheet" type="text/css" href="css/style.css">
</head>
<body>
    <div>
      <header>
        <span>
          &lt;svg class="menu__icon no--select" width="24px" height="24px" viewBox="0 0 48 48" fill="#fff"&gt;
            &lt;path d="M6 36h36v-4H6v4zm0-10h36v-4H6v4zm0-14v4h36v-4H6z"&gt;&lt;/path&gt;
          &lt;/svg&gt;
        </span>

        <span>PWA - Home</span>
      </header>

      <div>
        <div></div>
        <ul>
          <li><a href>Home</a></li>
          <li><a href>Latest</a></li>
      </div>

      <div></div>

      <div>

        <section>
          <h3> Stay Up to Date with R-I-L </h3>


          <p>Latest Commits on Resources I like!</a></p>
        </section>

        <div>
          <div></div>

        </div>


        <div></div>
      </div>
    </div>

    &lt;/script&gt;
    &lt;/script&gt;
    &lt;/script&gt;
    &lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;

index.html

&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
  &lt;meta charset="utf-8"&gt;
  &lt;meta http-equiv="X-UA-Compatible" content="IE=edge"&gt;
  &lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&gt;
  &lt;title&gt;Commits PWA&lt;/title&gt;
  &lt;link rel="stylesheet" type="text/css" href="css/style.css"&gt;
&lt;/head&gt;
&lt;body&gt;
    <div>
      <header>
        <span>
          &lt;svg class="menu__icon no--select" width="24px" height="24px" viewBox="0 0 48 48" fill="#fff"&gt;
            &lt;path d="M6 36h36v-4H6v4zm0-10h36v-4H6v4zm0-14v4h36v-4H6z"&gt;&lt;/path&gt;
          &lt;/svg&gt;
        </span>
        <span>PWA - Commits</span>
      </header>

      <div>
        <div></div>
        <ul>
          <li><a href>Home</a></li>
          <li><a href>Latest</a></li>
        </ul>
      </div>

      <div></div>

      <section>
        <h2>Latest Commits!</h2>

        <div>
            <section>

            </section>
            <section>

            </section>
            <section>

            </section>
            <section>

            </section>
            <section>

            </section>
        </div>
      </section>

       <div>
          &lt;svg viewBox="0 0 32 32" width="32" height="32"&gt;
            &lt;circle id="spinner" cx="16" cy="16" r="14" fill="none"&gt;&lt;/circle&gt;
          &lt;/svg&gt;
        </div>


      <div></div>      
    </div>

  &lt;/script&gt;
  &lt;/script&gt;
  &lt;/script&gt;
  &lt;/script&gt;
  &lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;

新建一个css文件夹,然后放一个style.css文件。内容请从 这里获取。

新建一个js文件夹,然后添加如下几个文件app.jsmenu.js,offline.js,latest.js,toast.js:

 (function () {
  'use strict';

  var header = document.querySelector('header');
  var menuHeader = document.querySelector('.menu__header');

  //监听contentLoaded事件
  document.addEventListener('DOMContentLoaded', function(event) {
    //检查网络连接状态
    if (!navigator.onLine) {
      updateNetworkStatus();
    }

    window.addEventListener('online', updateNetworkStatus, false);
    window.addEventListener('offline', updateNetworkStatus, false);
  });

  //更新网络状态
  function updateNetworkStatus() {
    if (navigator.onLine) {
      header.classList.remove('app__offline');
      menuHeader.style.background = '#1E88E5'; 
    }
    else {
      toast('You are now offline..');
      header.classList.add('app__offline');
      menuHeader.style.background = '#9E9E9E';
    }
  }
})();

offline.js

这段代码实现网络连接状态的可视化显示。

 (function () {
  'use strict';

  var menuIconElement = document.querySelector('.header__icon');
  var menuElement = document.querySelector('.menu');
  var menuOverlayElement = document.querySelector('.menu__overlay');

  //菜单点击事件
  menuIconElement.addEventListener('click', showMenu, false);
  menuOverlayElement.addEventListener('click', hideMenu, false);
  menuElement.addEventListener('transitionend', onTransitionEnd, false);

   //显示菜单
  function showMenu() {
    menuElement.style.transform = "translateX(0)";
    menuElement.classList.add('menu--show');
    menuOverlayElement.classList.add('menu__overlay--show');
  }

  //隐藏菜单
  function hideMenu() {
    menuElement.style.transform = "translateX(-110%)";
    menuElement.classList.remove('menu--show');
    menuOverlayElement.classList.remove('menu__overlay--show');
    menuElement.addEventListener('transitionend', onTransitionEnd, false);
  }

  var touchStartPoint, touchMovePoint;

  /*从边缘划出并打开菜单*/

  //`TouchStart` event to find where user start the touch
  document.body.addEventListener('touchstart', function(event) {
    touchStartPoint = event.changedTouches[0].pageX;
    touchMovePoint = touchStartPoint;
  }, false);

  //监测用户触摸事件
  document.body.addEventListener('touchmove', function(event) {
    touchMovePoint = event.touches[0].pageX;
    if (touchStartPoint &lt; 10 && touchMovePoint &gt; 30) {          
      menuElement.style.transform = "translateX(0)";
    }
  }, false);

  function onTransitionEnd() {
    if (touchStartPoint &lt; 10) {
      menuElement.style.transform = "translateX(0)";
      menuOverlayElement.classList.add('menu__overlay--show');
      menuElement.removeEventListener('transitionend', onTransitionEnd, false); 
    }
  }
})();

menu.js

以上代码实现菜单动画。

 (function (exports) {
  'use strict';

  var toastContainer = document.querySelector('.toast__container');

  //显示推送消息
  function toast(msg, options) {
    if (!msg) return;

    options = options || 3000;

    var toastMsg = document.createElement('div');

    toastMsg.className = 'toast__msg';
    toastMsg.textContent = msg;

    toastContainer.appendChild(toastMsg);

    //Show toast for 3secs and hide it
    setTimeout(function () {
      toastMsg.classList.add('toast__msg--hide');
    }, options);

    //隐藏后移除dom
    toastMsg.addEventListener('transitionend', function (event) {
      event.target.parentNode.removeChild(event.target);
    });
  }

  exports.toast = toast; //Make this method available in global
})(typeof window === 'undefined' ? module.exports : window);

以上代码实现了消息弹窗功能。

latest.jsapp.js 暂时为空。

现在,可以在本地运行一下。看起来是这样的:

Sidemenu

Index

Latest Page

Higlighted App shell

注意app shell 此时还没有数据,稍后会通过Fetch API获取到项目的提交信息。

动态获取数据

打开 latest.js然后加入如下代码:

 (function() {
  'use strict';

  var app = {
    spinner: document.querySelector('.loader')
  };

  var container = document.querySelector('.container');

  // 使用Github API获取提交信息
  function fetchCommits() {
    var url = 'https://api.github.com/repos/unicodeveloper/resources-i-like/commits';

    fetch(url)
    .then(function(fetchResponse){ 
      return fetchResponse.json();
    })
    .then(function(response) {

        var commitData = {
            'first': {
              message: response[0].commit.message,
              author: response[0].commit.author.name,
              time: response[0].commit.author.date,
              link: response[0].html_url
            },
            'second': {
              message: response[1].commit.message,
              author: response[1].commit.author.name,
              time: response[1].commit.author.date,
              link: response[1].html_url
            },
            'third': {
              message: response[2].commit.message,
              author: response[2].commit.author.name,
              time: response[2].commit.author.date,
              link: response[2].html_url
            },
            'fourth': {
              message: response[3].commit.message,
              author: response[3].commit.author.name,
              time: response[3].commit.author.date,
              link: response[3].html_url
            },
            'fifth': {
              message: response[4].commit.message,
              author: response[4].commit.author.name,
              time: response[4].commit.author.date,
              link: response[4].html_url
            }
        };

        container.querySelector('.first').innerHTML = 
        "<h4> Message: " + response[0].commit.message + "</h4>" +
        "<h4> Author: " + response[0].commit.author.name + "</h4>" +
        "<h4> Time committed: " + (new Date(response[0].commit.author.date)).toUTCString() +  "</h4>" +
        "<h4>" + "<a href>Click me to see more!</a>"  + "</h4>";

        container.querySelector('.second').innerHTML = 
        "<h4> Message: " + response[1].commit.message + "</h4>" +
        "<h4> Author: " + response[1].commit.author.name + "</h4>" +
        "<h4> Time committed: " + (new Date(response[1].commit.author.date)).toUTCString()  +  "</h4>" +
        "<h4>" + "<a href>Click me to see more!</a>"  + "</h4>";

        container.querySelector('.third').innerHTML = 
        "<h4> Message: " + response[2].commit.message + "</h4>" +
        "<h4> Author: " + response[2].commit.author.name + "</h4>" +
        "<h4> Time committed: " + (new Date(response[2].commit.author.date)).toUTCString()  +  "</h4>" +
        "<h4>" + "<a href>Click me to see more!</a>"  + "</h4>";

        container.querySelector('.fourth').innerHTML = 
        "<h4> Message: " + response[3].commit.message + "</h4>" +
        "<h4> Author: " + response[3].commit.author.name + "</h4>" +
        "<h4> Time committed: " + (new Date(response[3].commit.author.date)).toUTCString()  +  "</h4>" +
        "<h4>" + "<a href>Click me to see more!</a>"  + "</h4>";

        container.querySelector('.fifth').innerHTML = 
        "<h4> Message: " + response[4].commit.message + "</h4>" +
        "<h4> Author: " + response[4].commit.author.name + "</h4>" +
        "<h4> Time committed: " + (new Date(response[4].commit.author.date)).toUTCString() +  "</h4>" +
        "<h4>" + "<a href>Click me to see more!</a>"  + "</h4>";

        app.spinner.setAttribute('hidden', true); //hide spinner
      })
      .catch(function (error) {
        console.error(error);
      });
  };

  fetchCommits();
})();

然后在latest.js里引入:

`&lt;script src="./js/latest.js"&gt;`&lt;/script$gt;

同时在latest.html里加一个下拉列表:

....
<div>
      &lt;svg viewBox="0 0 32 32" width="32" height="32"&gt;
        &lt;circle id="spinner" cx="16" cy="16" r="14" fill="none"&gt;&lt;/circle&gt;
      &lt;/svg&gt;
</div>

<div></div>

latest.js的代码里,你也许注意到了我们是直接通过GitHub的API获取的数据然后插入页面中。效果目前是这样的:

Latest Commits

使用Service worker 缓存 app shell

我们需要借助Service worker缓存 app shell 这样提高加载数据和保证离线可用。

  • 首先,为service worker加一个名为sw.js的文件
  • 然后,打开app.js文件,加入下面代码来注册service worker:
 if ('serviceWorker' in navigator) {
     navigator.serviceWorker
             .register('./sw.js')
             .then(function() { console.log('Service Worker Registered'); });
  }
  • sw.js文件中加入下面代码:
 var cacheName = 'pwa-commits-v3';

var filesToCache = [
    './',
    './css/style.css',
    './images/books.png',
    './images/Home.svg',
    './images/ic_refresh_white_24px.svg',
    './images/profile.png',
    './images/push-off.png',
    './images/push-on.png',
    './js/app.js',
    './js/menu.js',
    './js/offline.js',
    './js/toast.js'
];

// Install Service Worker
self.addEventListener('install', function(event) {

    console.log('Service Worker: Installing....');

    event.waitUntil(

        // Open the Cache
        caches.open(cacheName).then(function(cache) {
            console.log('Service Worker: Caching App Shell at the moment......');

            // Add Files to the Cache
            return cache.addAll(filesToCache);
        })
    );
});

// Fired when the Service Worker starts up
self.addEventListener('activate', function(event) {

    console.log('Service Worker: Activating....');

    event.waitUntil(
        caches.keys().then(function(cacheNames) {
            return Promise.all(cacheNames.map(function(key) {
                if( key !== cacheName) {
                    console.log('Service Worker: Removing Old Cache', key);
                    return caches.delete(key);
                }
            }));
        })
    );
    return self.clients.claim();
});

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

    console.log('Service Worker: Fetch', event.request.url);

    console.log("Url", event.request.url);

    event.respondWith(
        caches.match(event.request).then(function(response) {
            return response || fetch(event.request);
        })
    );
});

在上文中有过介绍,所有静态资源在filesToCatche这个数组里。当service worker安装完毕。会开启一个缓存实例然后将所有文件添加到名为pwa-commits-v3的缓存中。(后面内容与上面重复)。

注意: 使用sw-toolboxsw-precache这2个扩展,有助于快速实现缓存和service worker功能。

接下来,重新访问你的应用,同时打开控制台,打开Application,切到Service Worker 标签页

注意:需要选中 Update on reload 复选框。 Service Worker

离线使用OK 了吗?

再一次刷新应用,这次切换到Cache Storge标签页。查看当前应用使用的缓存列表。

Cache

当你点击app shell 页面 时 你会看到所有的数据已经被成功缓存了起来。我们测试一下离线状态。切换到在 Service Worker 标签页然后勾上 Offline 复选框. 一个黄色的小图标出现在了 Network 面包里:

Offline-Network Tab

然后重新刷新应用试试。是不是一起都正常?

Index Page Offline

看起来首页时ok的,那么latest页面呢?

Latest Page Offline

同样正常!等一下?数据呢?对了,代码里用的还是Github API 需要通过网络获取数据,显然在离线状态下请求失败了。

Behind the Scenes Fetch Offline failure

那该怎么办?有几种不同的解决办法。一种选择是在离线状态下通过service worker来提供数据。另一种选择是缓存之前获取到的提交数据。后面就从本地获取。当用户联网时再更新。可用存到IndexedDBlocal Storage里。

OK ,我们这就是我们接下来的内容。

(以下为广告内容,推销作者所在公司的服务)

译者xjcloudy尚未开通打赏功能

相关文章