PWA 实践

Mar 17 2018

什么是PWA

背景

从 AJAX 的发明到 SPA 的流行,Web 开发技术在不断进步着,但在智能手机带来的移动互联网浪潮中,Native App 凭借更好的系统集成带来的体验成为时代的弄潮儿。于是便催生了一种结合传统 Web 开发的优势与 Native App 的体验的方案,使得 Web 应用在移动设备上的浴火重生。

Web VS APP

概念

PWA 全称 Progressive Web App,即渐进式 Web 应用。

PWA 不是特指某一项技术,而是应用了多项技术的 Web App。其核心技术包括 Web App ManifestService WorkerPush Notification等等。其核心目标就是提升 Web App 的体验,使其接近于 Native App。

自 2015 年以来,PWA 相关的技术不断升级优化,在用户体验和用户留存两方面都提供了非常好的解决方案,将 Web 和 App 各自的优势融合在了一起:渐进式、可响应、可离线、有类似 Native App 的交互、即时更新、安全、可以被搜索引擎检索、可推送、可安装、可链接等。

特征

PWA 比传统的 Web App 主要多了这几个特征:

  • 本地缓存与离线执行,并且在网络恢复时可以同步最新数据。
  • 能安装到主屏幕,点击主屏图标有启动动画,可隐藏地址栏。
  • 实现了消息推送,让 Web App 能在未被激活时发起通知。

而比起 Native App,PWA 又多了以下优势:

  • 用户可以在安装前就通过浏览器体验你的 App。
  • 无需通过应用商店下载,并能自动升级不需要用户手动升级。
  • 使用开放的 W3C 标准的 Web 技术开发,无需掌握客户端开发技术。

技术与示例

这里我通过开发一个简单的 PWA 应用,来阐述所需的关键技术及实现步骤。

Web App Manifest 与 安装到主屏幕

技术

Web App Manifest 是为了解决用户留存问题而诞生的一项技术,使 Web App 也可以启用一些 Native App 的特性。它是一个外链的 JSON 文件,定义了站点的名称,地址,图标等等元数据。通过在浏览器中引入这个文件,访问应用时,会弹出是否添加到主屏幕对话框,并在主屏幕上生成图标,通过主屏幕打开后全屏显示,没有浏览器地址栏,达到沉浸式体验。

文档地址

示例

在页面的 \<head> 标签中用 \<link> 标签引入 manifest.json 文件。

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

{
  "lang": "en-US", // 指定name和short_name成员中的值的主要语言。 该值是包含单个语言标记的字符串。
  "dir": "ltr", // 指定名称、短名称和描述成员的主文本方向。与lang一起配置,可以帮助正确显示右到左文本。
  "name": "PWA DEMO", // 为应用程序提供一个人类可读的名称,例如在其他应用程序的列表中或作为图标的标签显示给用户。
  "short_name": "PWA", // 为应用程序提供简短易读的名称。 这是为了在没有足够空间显示Web应用程序的全名时使用。
  "scope": "/", // 定义此Web应用程序的应用程序上下文的导航范围。 如果用户在范围之外浏览应用程序,则返回到正常的网页。
  "start_url": "/",  // 指定用户从设备启动应用程序时加载的URL。 如果以相对URL的形式给出,则基本URL将是manifest的URL。
  "display": "standalone", // 定义开发人员对Web应用程序的首选显示模式。standalone让这个应用程序看起来像一个独立的应用程序。
  "theme_color": "black", // 定义应用程序的默认主题颜色。
  "background_color": "transparent", // 为web应用程序预定义的背景颜色。
  "description": "Minsky's PWA DEMO", // 提供有关Web应用程序的一般描述。
  "orientation": "any", // 定义所有Web应用程序首选的默认方向。
  "related_applications": [], // 指定一个“应用程序对象”数组,代表对应的可由底层平台安装或可访问的本机应用程序。
  "icons": [ // 指定可在各种环境中用作应用程序图标的图像对象数组。
    {
      "src"           : "/images/logo/logo048.png",
      "sizes"         : "48x48",
      "type"          : "image/png"
    },
    {
      "src"           : "/images/logo/logo144.png",
      "sizes"         : "144x144",
      "type"          : "image/png"
    },
    {
      "src"           : "/images/logo/logo256.png",
      "sizes"         : "256x256",
      "type"          : "image/png"
    },
    {
      "src"           : "/images/logo/logo512.png",
      "sizes"         : "512x512",
      "type"          : "image/png"
    }
  ]
}

之后在 Chrome 开发者工具中的 Application 下 Manifest 选项,你可以验证你的 manifest.json 文件,并有 “Add to homescreen” 选项供将页面添加到主屏幕。

Application截图

Web App Manifest 作为 PWA 的「户口本」,承载着 Web App 与操作系统集成能力的重任,必会在日后不断扩展,以满足 Web App 高速演化的需要。

Service Worker 与 离线缓存

技术

Service Worker 是一个特殊的 Web Worker,独立于页面主线程运行。它就像一个位于浏览器与网络之间的客户端代理,可以拦截、处理、响应流经的 HTTP 请求,配合 Cache Storage API,开发者可以自由管理 HTTP 请求的缓存,以使得 Web 站点离线可用。

工作流程

文档地址

示例

Service Worker 要求网站必须使用 HTTPS,除了使用本地开发环境调试时(如域名使用 localhost)。

注册

在我们的 APP 组件中注册 Service Worker

// APP.js

......
  componentDidMount () {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/service-worker.js')
        .then(function (registration) {
          // 注册成功
          console.log('ServiceWorker registration successful')
        }).catch(function (err) {
          // 注册失败 :(
          console.log('ServiceWorker registration failed: ', err)
        })
    }
  }
......

Service Worker 提供了一组生命周期供我们使用,其中最主要的是 install,activate 和 fetch 这三个事件。

生命周期

安装

注册完之后,Service Worker 就会触发安装事件,这时我们可以把应用所需的资源预先下载并缓存到 Cache Storage 中去。

// service-worker.js

// 缓存的名称
const CACHE_NAME = 'PWA-DEMO'

// 安装事件
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => cache.addAll([
        './',
        './styles.css',
        './script.js'
      ]))
  )
})

waitUtil 方法可以传入一个 Promise 对象,用于执行预装逻辑。通常会做一些轻量级和非常重要资源的缓存,如果所有的文件都成功缓存了,便会安装完成。安装成功后 Service Worker 的状态会从 installing 变为 installed。

经过上面的操作,创建和添加了一个叫 PWA-DEMO 缓存库。
Cache Storage

激活

当安装完成后,ServiceWorker 进入激活状态。

// service-worker.js

// 激活事件
self.addEventListener('active', function (event) {
  console.log('service worker is active')
})

你可能会问,再次进入页面是不是又会重新注册安装和激活了一个 Service Worker?虽然又调了一次注册代码,但浏览器会检测这个 service-worker.js 是否已经注册过,就不会再执行注册代码,进而不会触发 install 和 active 事件。

请求

Service Worker 之后会触发 fetch 事件,通过 fetch 事件可以截获页面的网络请求,这些请求包括页面脚本、图片、字体,以及获取数据的 AJAX。

使用 caches.match() 函数来检查传入的请求是否匹配当前缓存中存在的内容。如果存在的话,返回缓存的资源,否则通过网络来获取资源,并将其添加到缓存中。这样当网络无法访问时,我们就可以从缓存中获取资源,运行我们的 Web App。

注意:跨域的资源不能缓存,response.status 会返回0。

// service-worker.js

// 请求事件
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(function (response) {
        if (response) {
          // 返回缓存资源
          return response
        }
        const requestToCache = event.request.clone()
        return fetch(requestToCache).then(
          function (response) {
            if (!response || response.status !== 200) return response
            const responseToCache = response.clone()
            // 将请求资源缓存
            caches.open(CACHE_NAME)
              .then(function (cache) {
                cache.put(requestToCache, responseToCache)
              })
            return response
          })
      }
    )
   )
})

这里用了 request.clone() 和 response.clone(),因为 request 和 response 是一个流,它只能消耗一次。我们已经通过缓存消耗了一次,诸侯发起 HTTP 请求还要再消耗一次,所以我们需要预先克隆请求的内容。

Chrome 开发者工具的 Application 下提供了查看 Service Worker 相关信息的选项。

Application

Service Worker 的强大在于拦截 HTTP 请求的能力,它对 PWA 的意义就相当于 XMLHTTPRequest 之于 Ajax,Media Query 之于响应式设计,是支撑 PWA 最核心的技术。

Push Notification 与 消息推送

技术

Push Notification 其实是两个 API 的结合,Notification APIPush API。 前者提供了给用户发送通知的能力,包括通知的权限管理、发起通知、通知的类型与音效,以及通知被点击或关闭时的回调等等。Push API,则是定义了 Web App 如何向服务端发起订阅、如何响应推送消息,即使用户并没有打开页面,甚至没有打开浏览器。

文档地址

示例

在 PWA 中,我们通过 Service Worker 的后台处理能力结合 Push API 对服务端的推送事件进行响应,并利用 Notification API 实现通知。

// service-worker.js

// 推送事件
self.addEventListener('push', event => {
  const data = event.data ? JSON.parse(event.data.text()) : {}
  event.waitUntil(
    // 使用提供的信息来显示 Web 推送通知
    self.registration.showNotification('Hello World!', {
      body: data.msg,
      url: data.url,
      icon: data.icon
    })
  )
})

// 通知点击事件
self.addEventListener('notificationclick', event => {
  event.notification.close();
})

// 通知关闭事件
self.addEventListener('notificationclose', event => {
  console.log('notification closed')
})

服务端发送消息给 Service Worker

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

const app = express()

webpush.setVapidDetails(
  'mailto:wu@minsky.me',
  '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 = 'images/homescreen.png'
  webpush.sendNotification(pushSubscription,
    JSON.stringify({
      msg: body,
      url: 'http://localhost:3111/',
      icon: iconUrl
    }))
    .then(result => res.sendStatus(201))
    .catch(err => {
      console.log(err)
    })
})

app.listen(3111, function () {
  console.log('Web push app listening on port 3111!')
})

总结

PWA 的出现,为现有的 Web 增加了很多令人鼓舞的特性,使得 Web 应用能拥有更接近原生的体验,虽然目前兼容性不是很好,但前景必定是光明的,是一项值得关注的技术。

参考资料