内容简介

近年来,前端技术领域迅猛发展,各种新技术、框架、工具,层出不穷。业内众多优秀企业保持积极探索精神,对现有工具、技术进行了深入探索,甚至缘于研发工作需求进行了升级研发与深度定制。本期我们集合多位前端技术专家撰文分享各自在前端领域的创新探索实践。每篇文中都饱含了作者对诸多技术细节的改进思考,而一次次的改进也体现了他们追求完美的研发精神。

本书内容
下一代 Web 应用模型――Progressive Web App

文/黄玄

2016年,Google 提出了 PWA,志在增强 Web 体验。可显著提高加载速度、可离线工作、可被添加至主屏、全屏执行、推送通知消息……这些特性可使 Web 应用渐进式地变成 App,甚至与 App 相匹敌。这一系列特性背后有哪些核心关键技术支撑,本文将为你一一分析,解开 PWA 的神秘面纱。

下一代 Web 应用?

近年来, Web 应用在整个软件与互联网行业承载的责任越来越重,软件复杂度和维护成本越来越高, Web 技术,尤其是 Web 客户端技术,迎来了爆发式的发展。包括但不限于基于 Node.js 的前端工程化方案;诸如 Web pack、Rollup 这样的打包工具;Babel、PostCSS 这样的转译工具;TypeScript、Elm 这样转译至 JavaScript 的编程语言;React、AngularJS、Vue.js 这样面向现代 Web 应用需求的前端框架及其生态,也涌现出了像同构 JavaScript 与通用 JavaScript 应用这样将服务器端渲染(Server-side Rendering)与单页面应用模型(Single-page App)结合的 Web 应用架构方式,可以说是百花齐放。

但是, Web 应用在移动时代并没有达到其在桌面设备上流行的程度(见图1)。究其原因,尽管上述的各种方案已经充分利用了现有的 JavaScript 计算能力、CSS 布局能力、HTTP 缓存与浏览器 API 对当代基于 AJAX 与响应式设计的 Web 应用模型的性能与体验带来了工程角度的巨大突破,我们仍然无法在不借助原生程序辅助浏览器的前提下突破 Web 平台本身对 Web 应用固有的桎梏:客户端软件(即网页)需要下载所带来的网络延迟;与 Web 应用依赖浏览器作为入口所带来的体验问题

图1  Web 与原生应用在移动平台上的使用时长对比(图片来源:Google)

图1 Web 与原生应用在移动平台上的使用时长对比(图片来源:Google)

在桌面设备上,由于网络条件稳定,屏幕尺寸充分,交互方式趋向于多任务,这两点造成的负面影响对比 Web 应用免于安装、随叫随到、无需更新等优点,瑕不掩瑜。但是在移动时代,脆弱的网络连接与全新的人机交互方式使得这两个问题被无限放大,严重制约了 Web 应用在移动平台的发展。在用户眼里,原生应用不会出现“白屏”,清一色都摆在主屏幕上;而 Web 应用则是浏览器这个应用中的应用,使用起来并不方便,而且加载也比原生应用要慢。

Progressive Web Apps(以下简称 PWA)以及构成 PWA 的一系列关键技术的出现,终于让我们看到了彻底解决这两个平台级别问题的曙光:能够显著提高应用加载速度,甚至让 Web 应用可以在离线环境使用的 Service Worker 与 Cache Storage;用于描述 Web 应用元数据(Metadata)、让 Web 应用能够像原生应用一样被添加到主屏、全屏执行的 Web App Manifest;以及进一步提高 Web 应用与操作系统集成能力,让 Web 应用能在未被激活时发起推送通知的 Push API 与 Notification API 等等。

将这些技术组合在一起会是怎样的效果呢?“印度阿里巴巴”——Flipkart 在2015年一度关闭了自己的移动端网站,却在年底发布了现在最为人津津乐道的 PWA 案例 FlipKart Lite,成为世界上第一个支撑大规模业务的 PWA。发布的一周后它就亮相于 Chrome Dev Summit 2015上,我当时就被惊艳到了。为了方便各媒介上的读者观看,我做了几幅图方便给大家介绍。

当浏览器发现用户需要 Flipkart Lite 时,它就会提示用户“嘿,你可以把它添加至主屏哦”(用户也可以手动添加)。这样,Flipkart Lite 就会像原生应用一样在主屏上留下一个自定义的 icon 作为入口;与一般的书签不同,当用户点击 icon 时,Flipkat Lite 将直接全屏打开,不再受困于浏览器的 UI,而且有自己的启动屏效果(见图2)。

图2  PWA案例FlipKart Lite展示(图片来源:Hux & Medium.com)

图2 PWA 案例 FlipKart Lite 展示(图片来源:Hux & Medium.com)

更强大的是,在无法访问网络时,Flipkart Lite 可以像原生应用一样照常执行,还会很骚气的变成黑白色;不但如此,曾经访问过的商品都会被缓存下来得以在离线时继续访问。在商品降价、促销等时刻,Flipkart Lite 会像原生应用一样发起推送通知,吸引用户回到应用(见图3)。

图3  FlipKart Lite离线时访问及推送消息效果展示(图片来源: Hux & Medium.com)

图3 FlipKart Lite 离线时访问及推送消息效果展示(图片来源:Hux & Medium.com)

无需担心网络延迟;有着独立入口与独立的保活机制。之前两个问题的一并解决,宣告着 Web 应用在移动设备上的浴火重生:满足 PWA 模型的 Web 应用,将逐渐成为移动操作系统的一等公民,并将向原生应用发起挑战与“复仇”。

更令我兴奋的是,就在11月的 Chrome Dev Summit 2016上,Chrome 工程 VP Darin Fisher 介绍了 Chrome 团队正在做的一些实验:把“添加至主屏”重命名为“安装”,被安装的 PWA 不再仅以 Widget 的形式显示在桌面上,而是真正做到与所有原生应用平级,一样被收纳进应用抽屉(App Drawer)里,一样出现在系统设置中。

图4中从左到右分别为:类似原生应用的安装界面;被收纳在应用抽屉里的 Flipkart Lite 与 Hux Blog;设置界面中并列出现的 Flipkart 原生应用与 Flipkart Lite PWA(可以看到 PWA 巨大的体积优势)。

图4  被安装的PWA与原生应用平级

图4 被安装的 PWA 与原生应用平级

我相信,PWA 模型将继约20年前横空出世的 AJAX 与约10年前风靡移动互联网的响应式设计之后,掀起 Web 应用模型的第三次根本性革命,将 Web 应用带进一个全新的时代。

PWA 关键技术的前世今生

Web App Manifest

Web App Manifest,即通过一个清单文件向浏览器暴露 Web 应用的元数据,包括名字、icon 的 URL 等,以备浏览器使用,比如在添加至主屏或推送通知时暴露给操作系统,从而增强 Web 应用与操作系统的集成能力(见图5)。

图5  2008年iOS系统对 Web 应用在移动设备上获得原生应用体验的尝试(图片来源:appleinsider.com)

图5 2008年 iOS 系统对 Web 应用在移动设备上获得原生应用体验的尝试(图片来源:appleinsider.com)

让 Web 应用在移动设备上的体验更接近原生应用的尝试其实早在2008年的 iOS 1.1.3 与 iOS 2.1.0 时就开始了,它们分别为 Web 应用增加了对自定义 icon 和全屏打开的支持。

但是很快,随着越来越多的私有平台通过〈meta〉/〈link〉标签来为 Web 应用添加“私货”,〈head〉 很快就被塞满了:

<!-- Add to homescreen for Safari on iOS --><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black"><meta name="apple-mobile-web-app-title" content="Lighten"><!-- Add to homescreen for Chrome on Android --><meta name="mobile-web-app-capable" content="yes"><mate name="theme-color" content="#000000"><!-- Icons for iOS and Android Chrome M31~M38 --><link rel="apple-touch-icon-precomposed" sizes="144x144" href="images/touch/apple-touch-icon-144x144-precomposed.png"><link rel="apple-touch-icon-precomposed" sizes="114x114" href="images/touch/apple-touch-icon-114x114-precomposed.png"><link rel="apple-touch-icon-precomposed" sizes="72x72" href="images/touch/apple-touch-icon-72x72-precomposed.png"><link rel="apple-touch-icon-precomposed" href="images/touch/apple-touch-icon-57x57-precomposed.png"><!-- Icon for Android Chrome, recommended --><link rel="shortcut icon" sizes="196x196" href="images/touch/touch-icon-196x196.png"><!-- Tile icon for Win8 (144x144 + tile color) --><meta name="msapplication-TileImage" content="images/touch/ms-touch-icon-144x144-precomposed.png"><meta name="msapplication-TileColor" content="#3372DF"><!-- Generic Icon --><link rel="shortcut icon" href="images/touch/touch-icon-57x57.png"></mate>

显然,这种做法并不优雅:分散又重复的元数据定义多余且难以维持同步,与 HTML 耦合在一起也加重了浏览器检查元数据未来变动的成本。与此同时,社区里开始出现使用 manifest 文件以中心化地描述元数据的方案,比如 Chrome Extension、 Chrome Hosted Web Apps(2010)与 Firefox OS App Manifest(2011)使用 JSON;Cordova 与 Windows Pinned Site 使用 XML。

2013年,W3C Web Apps 工作组开始对基于 JSON 的 Manifest 进行标准化,于同年年底发布第一份公开 Working Draft,并逐渐演化成为今天的 W3C Web App Manifest:

{  "short_name": "Manifest Sample",  "name": "Web Application Manifest Sample",  "icons": [{      "src": "launcher-icon-2x.png",      "sizes": "96x96",      "type": "image/png"   }],  "scope": "/sample/",  "start_url": "/sample/index.html",  "display": "standalone",  "orientation": "landscape"  "theme_color": "#000",  "background_color": "#fff",}<!-- document --><link rel="manifest" href="/manifest.json">

诸如 name、icons、display 都是我们比较熟悉的,而大部分新增的成员则为 Web 应用带来了一系列以前 Web 应用想做却做不到(或在之前只能靠 Hack)的新特性:

  1. scope:定义了 Web 应用的浏览作用域,比如作用域外的 URL 就会打开浏览器而不会在当前 PWA 里继续浏览。
  2. start_url:定义了一个 PWA 的入口页面。比如说你添加我的个人博客 Hux Blog 的任何一个文章到主屏,从主屏打开时都会访问 Hux Blog 的主页。
  3. orientation:终于,我们可以锁定屏幕旋转了(喜极而泣……)。
  4. theme _ color/background_color:主题色与背景色,用于配置一些可定制的操作系统 UI 以提高用户体验,比如 Android 的状态栏、任务栏等。

这个清单的成员还有很多,比如用于声明“对应原生应用”的 related _ applications等,本文就不一一列举了。作为 PW A的“户口本”,承载着 Web 应用与操作系统集成能力的重任, Web App Manifest 还将在日后不断扩展,以满足 Web 应用高速演化的需要。

Service Worker

我们原有的整个 Web 应用模型,都是构建在“用户能上网”的前提之下的,所以一离线就只能玩小恐龙了。其实,对于“让 Web 应用离线执行”这件事,Service Worker 至少是 Web 社区的第三次尝试了。

故事可以追溯到2007年的 Google Gears:为了让自家的 Gmail、YouTube、Google Reader 等 Web 应用可以在本地存储数据与离线执行,Google 开发了一个浏览器拓展来增强 Web 应用。Google Gears 支持 IE 6、Safari 3、Firefox 1.5 等浏览器;要知道,那一年 Chrome 都还没出生呢。

在 Gears API 中,我们通过向 LocalServer 模块提交一个缓存文件清单来实现离线支持:

是不是感到很熟悉?好像 HTML5 规范中的 Application Cache 也是类似的东西?

是的,Gears 的 LocalServer 就是后来大家所熟知的 App Cache 的前身,大约从2008年开始 W3C 就开始尝试将 Gears 进行标准化了;除了 LocalServer,Gears 中用于提供并行计算能力的 WorkerPool 模块与用于提供本地数据库与 SQL 支持的 Database 模块也分别是日后 Web Worker 与 Web SQL Database(后被废弃)的前身。

HTML5 App Cache 作为第二波“让 Web 应用离线执行”的尝试,确实也服务了比如 Google Docs、尤雨溪早年作品 HTML5 Clear,以及一直用 Web 应用作为自己 iOS 应用的FT.com(Financial Times)等不少 Web 应用。那么,还有 Service Worker 什么事呢?

是啊,如果 App Cache 没有被设计得烂到完全不可编程、无法清理缓存、几乎没有路由机制、出了 Bug 一点救都没有,可能就真没 Service Worker 什么事了。App Cache 已经在前不久定稿的 HTML5.1 中被拿掉了,W3C 为了挽救 Web 世界真是不惜把自己的脸都打肿了……

时至今日,我们终于迎来了 Service Worker 的曙光。简单来说,Service Worker 是一个可编程的 Web Worker,它就像一个位于浏览器与网络之间的客户端代理,可以拦截、处理、响应流经的 HTTP 请求;配合随之引入 Cache Storage API,你可以自由管理 HTTP 请求文件粒度的缓存,这使得 Service Worker 可以从缓存中向 Web 应用提供资源,即使是在离线的环境下(见图6)。

图6  Service Worker就像一个运行在客户端的代理

图6 Service Worker 就像一个运行在客户端的代理

比如说,我们可以给网页 foo.html 注册这么一个 Service Worker,它将劫持由 foo.html 发起的一切 HTTP 请求,并统统返回未设置 Content-Type 的 Hello World!:

// sw.js self.onfetch = (e) => {  e.respondWith(new Response('Hello World!'))}

Service Worker 第一次发布于2014年的 Google I/O 上,目前已处于 W3C 工作草案的状态。其设计吸取了 Application Cache 的失败经验,作为 Web 应用开发者的你有着完全的控制能力;同时,它还借鉴了 Chrome 多年来在 Chrome Extension 上的设计经验(Chrome Background Pages 与 Chrome Event Pages),采用了基于“事件驱动”的唤醒机制,以大幅节省后台计算的能耗。比如上面的 fetch 其实就是会唤醒 Service Worker 的事件之一。

图7  Service Worker的生命周期

图7 Service Worker 的生命周期

除了类似 fetch 这样的功能事件外,Service Worker 还提供了一组生命周期事件,包括安装、激活等等(见图7)。比如,在 Service Worker 的“安装”事件中,我们可以把 Web 应用所需要的资源统统预先下载并缓存到 Cache Storage 中去:

// sw.jsself.oninstall = (e) => {  e.waitUntil(    caches.open('installation')      .then(cache =>  cache.addAll([        './',        './styles.css',        './script.js'      ]))  )});

这样,当用户离线,网络无法访问时,就可以从缓存中启动我们的 Web 应用:

//sw.jsself.onfetch = (e) => {  const fetched = fetch(e.request)  const cached = caches.match(e.request)  e.respondWith(    fetched.catch(_ => cached)  )}

可以看出,Service Worker 被设计为一个相对底层(low-level)、高度可编程、子概念众多,也因此异常灵活且强大的 API,故本文只能展示它的冰山一角。出于安全考虑,注册 Service Worker 要求你的 Web 应用部署于 HTTPS 协议下,以免利用 Service Worker 的中间人攻击。我在今年 GDG 北京的 DevFest 上分享了Service Worker 101,涵盖了 Service Worker,譬如“网络优先”、“缓存优先”、“网络与缓存比赛”这些更复杂的缓存策略、学习资料、以及示例代码[1],可以供大家参考(见图8)。

图8  Service Worker的一种缓存策略:让网络请求与读取缓存比赛

图8 Service Worker 的一种缓存策略:让网络请求与读取缓存比赛

你也可以尝试在支持 PWA 的浏览器中访问我的博客,感受 Service Worker 的实际效果:所有访问过的页面都会被缓存并允许在离线环境下继续访问,所有未访问过的页面则会在离线环境下展示一个自定义的离线页面。

在我看来,Service Worker 对 PWA 的重要性相当于 XMLHTTPRequest 之于 AJAX,媒体查询(Media Query)之于响应式设计,是支撑 PWA 作为“下一代 Web 应用模型”的最核心技术。由于 Service Worker 可以与包括 Indexed DB、Streams 在内的大部分 DOM 无关 API 进行交互,它的潜力简直无可限量。我几乎可以断言,Service Worker 将在未来十年里成为 Web 客户端技术工程化的兵家必争之地,带来“离线优先(Offline-first)”的架构革命。

Push Notification

PWA 推送通知中的“推送”与“通知”,其实使用的是两个不同但又相得益彰的 API。

Notification API 相信大家并不陌生,它负责所有与通知本身相关的机制,比如通知的权限管理、向操作系统发起通知、通知的类型与音效,以及提供通知被点击或关闭时的回调等,目前国内外的各大网站(尤其在桌面端)都有一定的使用。Notification API 最早应该是在2010年前后由 Chromium 提出草案以 Web kitNotifications 前缀方式实现;随着2011年进入标准化;2012年在 Safari 6(Mac OSX 10.8+)上获得支持;2015年 Notification API 成为 W3C Recommendation;2016年 Edge 的支持;Web Notifications 已经在桌面浏览器中获得了全面支持(Chrome、Edge、Firefox、Opera、Safari)的成就。

Push API 的出现则让推送服务具备了向 Web 应用推送消息的能力,它定义了 Web 应用如何向推送服务发起订阅、如何响应推送消息,以及 Web 应用、应用服务器与推送服务之间的鉴权与加密机制;由于 Push API 并不依赖 Web 应用与浏览器 UI 存活,所以即使是在 Web 应用与浏览器未被用户打开的时候,也可以通过后台进程接受推送消息并调用 Notification API 向用户发出通知。值得一提的是,Mac OSX 10.9 Mavericks 与 Safari 7在2013年就发布了自己的私有推送支持,基于 APNS 的 Safari Push Notifications。

在 PWA 中,我们利用 Service Worker 的后台计算能力结合 Push API 对推送事件进行响应,并通过 Notification API 实现通知的发出与处理:

// sw.jsself.addEventListener('push', event => {  event.waitUntil(    // Process the event and display a notification.    self.registration.showNotification("Hey!")  );});self.addEventListener('notificationclick', event => {    // Do something with the event    event.notification.close();  });self.addEventListener('notificationclose', event => {    // Do something with the event  });

对于 Push Notification,我的几次分享中一直都提的稍微少一些,一是因为 Push API 还处于 Editor Draft 的状态,二是目前浏览器与推送服务的互相支持都还不够成熟:Android 上的 Chrome(与其他基于 Blink 的浏览器)目前只支持基于 Google 私有的 GCM/FCM 通知推送,只有 Firefox 已经实现了由 IETF 进行标准化的 Web 推送协议(Web Push Protocol)。

不过,如果你已经在使用 Google 的云服务(比如 Firebase),并且主要面向的是海外用户,那么在 Web 应用上支持基于 GCM/FCM 的推送通知并不是一件费力的事情,我推荐你阅读一下 Google Developers 的系列文章,很多国外公司已经玩起来了。

从 Hybrid 到 PWA,从封闭到开放

2008年,当移动时代来临,唱衰移动 Web 的声音开始出现,而浏览器的进化并不能跟上时,来自 Nitobi 的 Brian Leroux 等人创造了 Phonegap,希望它能以 Polyfill 的形式,弥补目前浏览器与移动设备间的“鸿沟”,从此开启了混合应用(Hybrid Apps)的时代。几年间,Adobe AIR、Windows Runtime Apps、Chrome Apps、Firefox OS、 Web OS、Cordova/Phonegap、Electron 以及国内比如微信、淘宝,无数的 Hybrid 方案拔地而起,让 Web 开发者可以在继续使用 Web 客户端技术的同时,做到一些只有原生应用才能做到的事情,包括访问一些设备与操作系统API,给用户带来更加“Appy”的体验,以及进入 App Store 等等(见图9)。

图9  众多的 Hybrid 方案

图9 众多的 Hybrid 方案

PWA 作为一个涵盖性术语,与过往的这些或多或少通过私有平台 API 增强 Web 应用的尝试最大的不同,在于构成 PWA 的每一项基本技术,都已经或正在被 IETF、ECMA、W3C 或 WHATWG 标准化,不出意外的话,它们都将被纳入开放 Web 标准,并在不远的将来得到所有浏览器与全平台的支持。我们终于可以逃出 App Store 封闭的秘密花园,重新回到属于 Web 的那片开放自由的大地。

有趣的是,从上文中你也可以发现,组成 PWA 的各项技术的草案正是由上述各种私有方案背后的浏览器厂商或开发者直接贡献或间接影响的。可以说,PWA 的背后并不是某一家或两家公司,而是整个 Web 社区与整个 Web 规范。正是因为这种开放与去中心化的力量,使得万维网(World Wide Web )能够成为当今世界上跨平台能力最强,且几乎是唯一一个具备这种跨平台能力的应用平台。

“我们相信 Web,是因为相信它是解决设备差异化的终极方案;我们相信,当 Web 在今天做不到一件事的时候,是因为它还没来得及去实现,而不是因为它做不到。而 Phonegap,它的终极目的就是消失在 Web 标准的背后。”

在不丢失 Web 的开放灵魂,在不需要依靠 Hybrid 把应用放在 App Store 的前提下,让 Web 应用能够渐进式地跳脱出浏览器的标签,变成用户眼中的 App。这是 Alex Russell 在2015年提出 PWA 概念的原委。

而又正因为 Web 是一个整体,PWA 可以利用的技术远不止上述的几个而已:AJAX、响应式设计、JavaScript 框架、ECMAScript Next、CSS Next、Houdini、Indexed DB、Device APIs、 Web Bluetooth、 Web Socket、 Web Payment、孵化中的 Background Sync API、Streams、 Web VR……开放 Web 世界27年来的发展以及未来的一切,都与 PWA 天作之合。

鱼与熊掌的兼得

经过几年来的摸索,整个互联网行业仿佛在“ Web 应用 vs. 原生应用”这个问题上达成了共识见图10。

图10  原生应用、当代 Web 与PWA(图片来源: Hux & Google)

图10 原生应用、当代 Web 与 PWA(图片来源:Hux & Google)

  1. Web 应用是鱼:迭代快,获取用户成本低;跨平台强体验弱,开发成本低。适合拉新。
  2. 原生应用是熊掌:迭代慢,获取用户成本高;跨平台弱体验强,开发成本高。适合保活。

要知道,虽然用户花在原生应用上的时间要明显多于 Web 应用,但其中有80%的时间是花在前五个应用中的。调查显示,美国有一半的智能手机用户平均每月新 App 安装量为零,而月均网站访问量却有100个,更别提 Google Play 上有60%的应用从未被人下载过了。于是,整个行业的产品策略清一色地“拿鱼换熊掌”,比如我的老东家阿里旅行(飞猪旅行), Web 应用布满阿里系各种渠道,提供“优秀的第一手体验”,等你用的开心了,再引诱你去下载安装原生应用。

但是,PWA 的出现,让鱼与熊掌兼得变成了可能——它同时具备了 Web 应用与原生应用的优点,有着自己独有的先进性:浏览器→添加至主屏/安装→具备原生应用体验的 PWA→推送通知→具备原生应用体验的 PWA,PWA 自身就包含着从拉新到保活的闭环。

除此之外,PWA 还继承了 Web 应用的另外两大优点:无需先付出几十兆的下载安装成本即可开始使用,以及不需要经过应用超市审核就可以发布新版本。所以,PWA 可以称得上是一种“流式应用(Streamable App)”与“常青应用(Evergreen App)”

未来到来了吗

在我分享 PWA 的经历中,最不愿意回答的两个问题莫过于“PWA 已经被广泛支持了吗?”以及“PWA 与 ABCDEFG 这些技术方案相比有什么优劣?”,但是这确实是两个逃不开的问题。

PWA 的支持情况?

当我们说到 PWA 是否被支持时,其实我们在说的是 PWA 背后的几个关键技术都得到支持了没有。以浏览器内核来划分的话,Blink(Chrome、Oprea、Samsung Internet 等)与 Gecko(Firefox)都已经实现了 PWA 所需的所有关键技术,并已经开始探寻更多的可能性。EdgeHTML(Edge)简直积极得不能更积极了,所有的特性都已经处于“正在开发中”的状态。最大的绊脚石仍然来自于 Web kit(Safari),尤其是在 iOS 上,上述的四个 API 都未得到支持,而且由于平台限制,第三方浏览器也无法在 iOS 上支持。(什么你说 IE?)

不过,也不要气馁,Web kit 不但在它2015年发布的五年计划里提到了 Service Worker,更是已经在最近实现了 Service Worker 所依赖的 Request、Response 与 Fetch API,还把 Service Worker 与 Web App Manifest 纷纷列入了“正在考虑”的 API 中;要知道,Web kit可是把 Web Components 中的 HTML Imports 直接列到“不考虑”里去了……(其实 Firefox 也是)

更何况,由于 Web 社区一直以来所追求的“渐进增强、优雅降级”,一个 PWA 当然可以在 iOS 环境正常执行。事实上,华盛顿邮报将网站迁移到 PWA 之后发现,不止是 Android,在 iOS 上也获得了5倍的活跃度增长,(无论是不是它们之前的网站写得太烂吧),就算 iOS 现在还不支持 PWA 也不会怎么样,我们更是有理由相信 PWA 会很快在 iOS 上到来。

PWA vs. Others

贺老(贺师俊)曾说过:“从纯 Web 到纯 Native,之间有许多可能的点”。当考虑移动应用的技术选型时,除了 Web 与原生应用,我们还有各种不同程度的 Hybrid,还有今年爆发的诸多 JS-to-Native 方案。

虽然我在上文中用了“复仇”这样的字眼,不过无论从技术还是商业的角度,我们都没必要把 Web 或是 PWA 放到 Native 的对立面去看。它们当然存在竞争关系,但是更多的时候, Web-Only 与 App-Only 的策略都是不完美的,当公司资源足够的时候,我们通常会选择同时开发两者。当然,无论与不与原生应用对比,PWA 让 Web 应用变得体验更好这件事本身是毋庸置疑的。“不谈场景聊技术都是扯淡”,我们仍然还是需要根据自己产品与团队的情况来决定对应的技术选型与平台策略,只是 PWA 让 Web 应用在面对选型考验时更加强势了而已。

我不负责任得做一些猜测(见图11):虽然重量级的Hybrid 架构与基础设施仍是目前不少场景下最优的解决方案,但是随着移动设备本身的硬件性能提升与新技术的成熟与普及,JS-to-Native 与以 PWA 为首的纯 Web 应用,将分别从两个方向挤压 Hybrid 的生存空间,消化当前 Hybrid 架构主要解决的问题;前者将逐渐演化为类似Xarmarin这样针对跨平台原生应用开发的解决方案;后者将显著降低当前 Hybrid 架构的容器开发与部署成本,将 Hybrid 返璞归真为简单的 Web View 调用。

图11  众多的技术选型,以及我的一种猜测

图11 众多的技术选型,以及我的一种猜测

这当然不是没有依据的瞎猜,比如前者可以参考阿里巴巴集团级别迁移 Weex 的战略与微信小程序的 roadmap;后者则可以参考当前 Cordova 与 Ionic 两大 Hybrid 社区对 PWA 的热烈反响。

PWA 在中国

看看 Google 官方宣传较多的 PWA 案例就会发现,FlipKart、Housing.com 来自印度;Uber、华盛顿邮报来自北美;唯一来自中国的 AliExpress 主要开展的则是海外业务。

由于中国的特殊性,我在第一次聊到 PWA 时难免表现出了一定程度的悲观:

  1. 国内较重视 iOS,而 iOS 目前还不支持 PWA。
  2. 国内的 Android 实为“安卓”,不自带 Chrome 是一,可能还会有其他兼容问题。
  3. 国内厂商可能并不会像三星那样对推动自家浏览器支持 PWA 那么感兴趣。
  4. 依赖 GCM 推送的通知不可用,Web Push Protocol 还没有国内的推送服务实现。
  5. 国内 Web View 环境较为复杂(比如微信),黑科技比较多。

反观印度,由于 Google 服务健全、标配 Chrome 的 Android 手机市占率非常高,PWA 的用户达到率简直直逼100%,也难免获得无数好评与支持了。我奢望着本文能对推动 PWA 的国内环境有一定的贡献。不过无论如何,PWA 在国内的春天可确会来得稍微晚一点了。

结语

“我们信仰 Web ,不仅仅在于软件、软件平台与单纯的技术,还在于‘任何人,在任何时间任何地点,都可以在万维网上发布任何信息,并被世界上的任何一个人所访问到。’而这才是 Web 的最为革命之处,堪称我们人类,作为一个物种的一次进化。”

请不要让 Web 再继续离我们远去,浏览器厂商们已经重新走到了一起,而下一棒将是交到我们 Web 应用开发者的手上。乔布斯曾相信 Web 应用才是移动应用的未来,那就让我们用代码证明给这个世界看吧。

让我们的用户,也像我们这般热爱 Web 吧。


参考

[1] 示例代码 https://github.com/Huxpro/sw-101-gdgdf
[2] 作者个人博客 https://huangxuan.me/
[3] Google Developers 系列文章 https://developers.google.com/ Web /fundamentals/engage-and-retain/push-notifications/

饿了么的 PWA 升级实践

文 / 黄玄

自 Vue.js 在官方推特第一次公开到现在,我们就一直在进行着将饿了么移动端网站升级为 Progressive Web App 的工作。直到近日在 Google I/O 2017上登台亮相,才终于算告一段落。我们非常荣幸能够发布全世界第一个专门面向国内用户的 PWA,但更荣幸的是能与 Google、UC 以及腾讯合作,一起推动国内 Web 与浏览器生态的发展。

多页应用、Vue.js、PWA?

对于构建一个希望达到原生应用级别体验的 PWA,目前社区里的主流做法都是采用 SPA,即单页面应用模型(Single-page App)来组织整个 Web 应用,业内最有名的几个 PWA 案例 Twitter LiteFlipkart LiteHousing Go Polymer Shop 无一例外。

然而饿了么,与很多国内的电商网站一样,青睐多页面应用模型(MPA,Multi-page App)所能带来的一些好处,也因此在一年多前就将移动站从基于 AngularJS 的单页应用重构为目前的多页应用模型。团队最看重的优点莫过于页面与页面之间的隔离与解耦,这使得我们可以将每个页面当做一个独立的“微服务”来看待,这些服务可以被独立迭代,独立提供给各种第三方的入口嵌入,甚至被不同的团队独立维护。而整个网站则只是各种服务的集合而非一个巨大的整体。

与此同时,我们仍然依赖 Vue.js 作为 JavaScript 框架。Vue.js 除了是 React、AngularJS 这种“重型武器”的竞争对手外,其轻量与高性能的优点使得它同样可以作为传统多页应用开发中流行的“jQuery/Zepto/Kissy+模板引擎”技术栈的完美替代。Vue.js 提供的组件系统、声明式与响应式编程更是提升了代码组织、共享、数据流控制、渲染等各个环节的开发效率。Vue.js 还是一个渐进式框架,如果网站的复杂度继续提升,我们可以按需、增量地引入 Vuex 或 Vue-Router 这些模块。万一哪天又要改回单页呢?(谁知道呢……)

2017年,PWA 已经成为 Web 应用新的风潮。我们决定试试,以我们现有的“Vue.js+多页”架构,能在升级 PWA 的道路上走多远,达到怎样的效果。

实现“PRPL”模式

“PRPL”(读作“purple”)是 Google 工程师提出的一种 Web 应用架构模式,它旨在利用现代 Web 平台的新技术以大幅优化移动Web的性能与体验,对如何组织与设计高性能的 PWA 系统提供了一种高层次的抽象。我们并不准备从头重构我们的 Web 应用,不过我们可以把实现“PRPL”模式作为我们的迁移目标。“PRPL”实际上是“Push/Preload、Render、Precache、Lazy-Load”的缩写,我们接下来会展开介绍它们的具体含义。

Push/Preload,推送/预加载初始 URL 路由所需的关键资源

无论是 HTTP2 Server Push 还是<link rel="preload">,其关键都在于,我们希望提前请求一些隐藏在应用依赖关系(Dependency Graph)较深处的资源,以节省 HTTP 往返、浏览器解析文档,或脚本执行的时间。比如说,对于一个基于路由进行 code splitting 的 SPA,如果我们可以在 Webpack 清单、路由等入口代码(entry chunks)被下载与运行之前就把初始 URL,即用户访问的入口 URL 路由所依赖的代码用 Server Push 推送或<link rel="preload">进行提前加载。那么当这些资源被真正请求时,它们可能已经下载好并存在缓存中了,这样就加快了初始路由所有依赖的就绪。

在多页应用中,每一个路由本来就只会请求这个路由所需要的资源,并且通常依赖也都比较扁平。饿了么移动站的大部分脚本依赖都是普通的<script>元素,因此他们可以在文档解析早期就被浏览器的 preloader 扫描出来并且开始请求,其效果其实与显式的<link rel="preload">是一致的,见图1所示。

<img src="https://ipad-cms.csdn.net/cms/attachment/201707/5955f29a5789e.png" alt="图1 有无 <link rel=“preload”> 的效果对比" title="图1 有无 <link rel=“preload”> 的效果对比" />

图1 有无 <link rel=“preload”>的效果对比

我们还将所有关键的静态资源都伺服在同一域名下(不再做域名散列),以更好地利用 HTTP2 带来的多路复用(Multiplexing)。同时,我们也在进行着对 API 进行 Server Push 的实验

Render,渲染初始路由,尽快让应用可被交互

既然所有初始路由的依赖都已经就绪,我们就可以尽快开始初始路由的渲染,这有助于提升应用诸如首次渲染时间、可交互时间等指标。多页应用并不使用基于 JavaScript 的路由,而是传统的 HTML 跳转机制,所以对于这一部分,多页应用其实不用额外做什么。

Precache,用 Service Worker 预缓存剩下的路由

这一部分就需要 Service Worker 的参与了。Service Worker 是一个位于浏览器与网络之间的客户端代理,它已可拦截、处理、响应流经的 HTTP 请求,使得开发者得以从缓存中向 Web 应用提供资源而闻名。不过,Service Worker 其实也可以主动发起 HTTP 请求,在“后台”预请求与预缓存我们未来所需要的资源,见图2所示。

图2  Service Worker预缓存未来所需要的资源

图2 Service Worker 预缓存未来所需要的资源

我们已经使用 Webpack 在构建过程中进行 .vue 编译、文件名哈希等工作,于是我们编写了一个 Webpack 插件来帮助收集需要缓存的依赖到一个“预缓存清单”中,并使用这个清单在每次构建时生成新的 Service Worker 文件。在新的 Service Worker 被激活时,清单里的资源就会被请求与缓存,这其实与 SW-Precache 这个库的运行机制非常接近。

实际上,我们只对标记为“关键路由”的路由进行依赖收集。你可以将这些“关键路由”的依赖理解为我们整个应用的“App Shell”或者说“安装包”。一旦它们都被缓存,或者说成功安装,无论用户是在线离线,我们的 Web 应用都可以从缓存中直接启动。对于那些并不那么重要的路由,我们则采取在运行时增量缓存的方式。我们使用的 SW-Toolbox 提供了 LRU 替换策略与 TTL 失效机制,可以保证应用不会超过浏览器的缓存配额。

Lazy-Load,按需懒加载、懒实例化剩下的路由

懒加载与懒实例化剩下的路由对于 SPA 是一件相对麻烦点儿的事情,你需要实现基于路由的 code splitting 与异步加载。幸运的是,这又是一件不需要多页应用担心的事情,多页应用中的各个路由天生就是分离的。

值得说明的是,无论单页还是多页应用,如果在上一步中,我已经将这些路由的资源都预先下载与缓存好了,那么懒加载就几乎是瞬时完成的了,这时候我们就只需要付出实例化的代价。

至此,我们对 PRPL 的四部分含义做了详细说明。有趣的是,我们发现多页应用在实现 PRPL 这件事甚至比单页还要容易一些。那么结果如何呢?

根据 Google 推出的 Web 性能分析工具 Lighthouse(v1.6),在模拟的 3G 网络下,用户的初次访问(无任何缓存)大约在2秒左右达到“可交互”,可以说非常不错,见图3所示。而对于再次访问,由于所有资源都直接来自于 Service Worker 缓存,页面可以在1秒左右就达到可交互的状态了。

图3  Lighthouse跑分结果

图3 Lighthouse 跑分结果

但是,故事并不是这么简单得就结束了。在实际体验中我们发现,应用在页与页的切换时,仍然存在着非常明显的白屏空隙,见图4所示。由于 PWA 是全屏运行,白屏对用户体验所带来的负面影响甚至比以往在浏览器内更大。不是已经用 Service Worker 缓存了所有资源了吗,怎么还会这样呢?

图4  从首页点击到发现页,跳转过程中的白屏

图4 从首页点击到发现页,跳转过程中的白屏

多页应用的陷阱:重启开销

与 SPA 不同,在多页应用中,路由的切换是原生的浏览器文档跳转(Navigating across documents),这意味着之前的页面会被完全丢弃而浏览器需要为下一个路由的页面重新执行所有的启动步骤:重新下载资源、解析 HTML、运行 JavaScript、解码图片、布局页面、绘制……即使其中的很多步骤本是可以在多个路由之间复用的。这些工作无疑将产生巨大的计算开销,也因此需要付出相当多的时间成本。

图5中为我们的入口页(同时也是最重要的页面)在两倍 CPU 节流模拟下的 Profile 数据。即使可以将“可交互时间”控制在 1 秒左右,我们的用户仍然会觉得这对于“仅仅切换个标签”来说实在是太慢了。

图5  入口页在两倍CPU节流模拟下的Profile数据

图5 入口页在两倍 CPU 节流模拟下的 Profile 数据

巨大的 JavaScript 重启开销

根据 Profile,我们发现在首次渲染(First Paint)发生之前,大量的时间(900ms)都消耗在了 JavaScript 的运行上(Evaluate Script)。几乎所有脚本都是阻塞的(Parser-blocking),不过因为所有的 UI 都是由 JavaScript/Vue.js 驱动的,倒也不会有性能影响。这 900ms 中,约一半是消耗在 Vue.js 运行时、组件、库等依赖的运行上,而另一半则花在了业务组件实例化时 Vue.js 的启动与渲染上。从软件工程角度来说,需要这些抽象,所以这里并不是想责怪 JavaScript 或是 Vue.js 所带来的开销。

但是,在 SPA 中,JavaScript 的启动成本是均摊到整个生命周期的:每个脚本都只需要被解析与编译一次,诸如生成 Virtual DOM 等较重的任务可以只执行一次,像 Vue.js 的 ViewModel 或是 Virtual DOM 这样的大对象也可以被留在内存里复用。可惜在多页应用里就不是这样了,每次切换页面都为 JavaScript 付出了巨大的重启代价。

浏览器的缓存啊,能不能帮帮忙?

能,也不能。

V8 提供了代码缓存(code caching),可以将编译后的机器码在本地拷贝一份,这样就可以在下次请求同一个脚本时一次省略掉请求、解析、编译的所有工作。而且,对于缓存在 Service Worker 配套的 Cache Storage 中的脚本,会在第一次执行后就触发 V8 的代码缓存,这对于多页切换能提供不少帮助。

另外一个你或许听过的浏览器缓存叫做“进退缓存”,Back-Forward Cache,简称 bfcache。浏览器厂商对其的命名各异,Opera 称之为 Fast History Navigation,Webkit 称其为 Page Cache。但是思路都一样,就是可以让浏览器在跳转时把前一页留存在内存中,保留 JavaScript 与 DOM 的状态,而不是全都销毁掉。你可以随便找个传统的多页网站在 iOS Safari 上试试,无论是通过浏览器的前进后退按钮、手势,还是通过超链接(会有一些不同),基本都可以看到瞬间加载的效果。

Bfcache 其实非常适合多页应用。但不幸的是,Chrome 由于内存开销与其多进程架构等原因目前并不支持。Chrome 现阶段仅仅只是用了传统的 HTTP 磁盘缓存,来稍稍简化了一下加载过程而已。对于 Chromium 内核霸占的 Android 生态来说,没法指望了。

为“感知体验”奋斗

尽管多页应用面临着现实中的不少性能问题,我们并不想这么快就妥协。一方面,尝试尽可能减少在页面达到可交互时间前的代码执行量,比如减少/推迟一些依赖脚本的执行,还有减少初次渲染的 DOM 节点数以节省 Virtual DOM 的初始化开销。另一方面,也意识到应用在感知体验上还有更多的优化空间。

Chrome 产品经理 Owen 写过一篇 Reactive Web Design:The secret to building web apps that feel amazing,谈到两种改进感知体验的手段:一是使用骨架屏(Skeleton Screen)来实现瞬间加载;二是预先定义好元素的尺寸来保证加载的稳定。跟我们的做法可以说不谋而合。

为了消除白屏时间,我们同样引入了尺寸稳定的骨架屏来帮助我们实现瞬间的加载与占位。即使是在硬件很弱的设备上,我们也可以在点击切换标签后立刻渲染出目标路由的骨架屏,以保证 UI 是稳定、连续、有响应的。我录了两个视频放在 Youtube 上,不过如果你是国内读者,你可以直接访问饿了么移动网站来体验实地的效果。最终效果如图6所示。

图6  添加骨架屏后,从发现页点回首页的效果

图6 添加骨架屏后,从发现页点回首页的效果

这效果本该很轻松的就能实现,不过实际上我们还费了点功夫。

在构建时使 用 Vue.js 预渲染骨架屏

你可能已经想到了,为了让骨架屏可以被 Service Worker 缓存,瞬间加载并独立于 JavaScript 渲染,我们需要把组成骨架屏的 HTML 标签、CSS 样式与图片资源一并内联至各个路由的静态 *.html 文件中。

不过,我们并不准备手动编写这些骨架屏。你想啊,如果每次真实组件有迭代(每一个路由对我们来说都是一个 Vue.js 组件),都需要手动去同步每一个变化到骨架屏的话,那实在是太繁琐且难以维护了。好在,骨架屏不过是当数据还未加载进来前,页面的一个空白版本而已。如果能将骨架屏实现为真实组件的一个特殊状态——“空状态”的话,从理论上就可以从真实组件中直接渲染出骨架屏来。

而 Vue.js 的多才多艺就在这时体现出来了,我们真的可以用 Vue.js 的服务端渲染模块来实现这个想法,不过不是用在真正的服务器上,而是在构建时用它把组件的空状态预先渲染成字符串并注入到 HTML 模板中。你需要调整 Vue.js 组件代码使得它可以在 Node上 执行,有些页面对 DOM/BOM 的依赖一时无法轻易去除得,目前只好额外编写一个 *.shell.vue 来暂时绕过这个问题。

关于浏览器的绘制(Painting)

HTML 文件中有标签并不意味着这些标签就能立刻被绘制到屏幕上,你必须保证页面的关键渲染路径是为此优化的。很多开发者相信将 Script 标签放在 body 的底部就足以保证内容能在脚本执行之前被绘制,这对于能渲染不完整 DOM 树的浏览器(比如桌面浏览器常见的流式渲染)来说可能是成立的。但移动端的浏览器很可能因为考虑到较慢的硬件、电量消耗等因素并不这么做。不仅如此,即使你曾被告知设为 async 或 defer 的脚本就不会阻塞 HTML 解析了,但这可不意味着浏览器就一定会在执行它们之前进行渲染。

首先我想澄清的是,根据 HTML 规范 Scripting 章节,async 脚本是在其请求完成后立刻运行的,因此它本来就可能阻塞到解析。只有 defer(且非内联)与最新的 type=module 被指定为“一定不会阻塞解析”(不过 defer 目前也有点小问题……我们稍后会再提到),见图7所示。

图7  具有不同属性的Script脚本对HTML解析的阻塞情况

图7 具有不同属性的 Script 脚本对 HTML 解析的阻塞情况

而更重要的是,一个不阻塞 HTML 解析的脚本仍然可能阻塞到绘制。我做了一个简化的“最小多页 PWA”(Minimal Multi-page PWA,或 MMPWA)来测试这个问题:我们在一个 async(且确实不阻塞HTML解析)脚本中,生成并渲染1000个列表项,然后测试骨架屏能否在脚本执行之前渲染出来。图8是通过 USB Debugging 在我的 Nexus 5真机上录制的 Profile。

图8  通过USB Debugging在Nexus 5真机上录制的Profile

图8 通过 USB Debugging 在 Nexus 5 真机上录制的 Profile

是的,出乎意料吗?首次渲染确实被阻塞到脚本执行结束后才发生。究其原因,如果在浏览器还未完成上一次绘制工作之前就过快得进行了 DOM 操作,我们亲爱的浏览器就只好抛弃所有它已经完成的像素,且一直要等待到 DOM 操作引起的所有工作结束之后才能重新进行下一次渲染。而这种情况更容易在拥有较慢 CPU/GPU 的移动设备上出现。

黑魔法:利用 setTimeout() 让绘制提前

不难发现,骨架屏的绘制与脚本执行实际是一个竞态。大概是 Vue.js 太快了,骨架屏还是有非常大的概率绘制不出来。于是我们想着如何能让脚本执行慢点,或者说,“懒”点。我们想到了一个经典的 Hack:setTimeout(callback, 0)。我们试着把 MMPWA 中的 DOM 操作(渲染1000个列表)放进 setTimeout(callback, 0) 里……

当当!首次渲染瞬间就被提前了,见图9所示。如果你熟悉浏览器的事件循环模型(Event Loop)的话,这招 Hack 其实是通过 setTimeout 的回调把 DOM 操作放到了事件循环的任务队列中以避免它在当前循环执行,这样浏览器就得以在主线程空闲时喘息一下(更新一下渲染)了。如果你想亲手试试 MMPWA,可以访问https://github.com/Huxpro/mmpwahttp://huangxuan.me/mmpwa/,查看代码与 Demo。我把 UI 设计成了A/B Tes t的形式并改为渲染5000个列表项来让效果更夸张一些。

图9  利用Hack技术,提前完成骨架屏的绘制

图9 利用 Hack 技术,提前完成骨架屏的绘制

回到饿了么 PWA 上,我们同样试着把 new Vue() 放到了 setTimeout 中。果然,黑魔法再次显灵,骨架屏在每次跳转后都能立刻被渲染。这时的 Profile 看起来是这样的,见图10所示。

图10  为感知体验进行各种优化后的最终Profile

图10 为感知体验进行各种优化后的最终 Profile

现在,我们在400ms时触发首次渲染(骨架屏),在600ms时完成真实 UI 的渲染并达到页面的可交互。你可以详细对比下图9和图10所示的优化前后 Profile 的区别。

被我“defer”的有关 defer 的 Bug

不知道你发现没有,在图10的 Profile 中,仍然有不少脚本是阻塞了 HTML 解析的。好吧,让我解释一下,由于历史原因,我们确实保留了一部分的阻塞脚本,比如侵入性很强的 lib-flexible,没法轻易去除它。不过,Profile 里的大部分阻塞脚本实际上都设置了 defer,本以为它们应该在 HTML 解析完成之后才被执行,结果被 Profile 打了一脸。

我和 Jake Archibald 聊了一下,果然这是 Chrome 的 Bug:defer 的脚本被完全缓存时,并没有遵守规范等待解析结束,反而阻塞了解析与渲染。Jake 已经提交在 crbug 上了,一起给它投票吧。

最后,图11是优化后的 Lighthouse 跑分结果,同样可以看到明显的性能提升。需要说明的是,能影响 Lighthouse 跑分的因素有很多,所以建议你以控制变量(跑分用的设备、跑分时的网络环境等)的方式来进行对照实验。

图11  优化后的Lighthouse跑分结果

图11 优化后的 Lighthouse 跑分结果

最后为大家展示下应用的架构示意图,见图12所示。

图12  应用架构示意图

图12 应用架构示意图

一些感想

多页应用仍然有很长的路要走

Web 是一个极其多样化的平台。从静态的博客,到电商网站,再到桌面级的生产力软件,它们全都是 Web 这个大家庭的第一公民。而我们组织 Web 应用的方式,也同样只会更多而不会更少:多页、单页、Universal JavaScript 应用、WebGL,以及可以预见的 Web Assembly。不同的技术之间没有贵贱,但是适用场景的差距确是客观存在的。

Jake 曾在 Chrome Dev Summit 2016上说过“PWA !== SPA”。可是尽管已经用上了一系列最新的技术(PRPL、Service Worker、App Shell……),我们仍然因为多页应用模型本身的缺陷有着难以逾越的一些障碍。多页应用在未来可能会有“bfcache API”、Navigation Transition 等新的规范以缩小跟 SPA 的距离,不过也必须承认,时至今日,多页应用的局限性也是非常明显的。

而 PWA 终将带领 Web 应用进入新的时代

即使多页应用在升级 PWA 的路上不如单页应用来得那么闪亮,但是 PWA 背后的想法与技术却实实在在地帮助我们在 Web 平台上提供了更好的用户体验。

PWA 作为下一代 Web 应用模型,其尝试解决的是 Web 平台本身的根本性问题:对网络与浏览器UI的硬依赖。因此,任何 Web 应用都可以从中获益,这与你是多页还是单页、面向桌面还是移动端、是用 React 还是 Vue.js 无关。或许,它还终将改变用户对移动 Web 的期待。现如今,谁还觉得桌面端的 Web 只是个看文档的地方呢?

还是那句老话,让我们的用户,也像我们这般热爱 Web 吧。

最后,感谢饿了么的王亦斯、任光辉、题叶,Google 的 Michael Yeung、DevRel 团队,UC 浏览器团队,腾讯 X5 浏览器团队在这次项目中的合作。感谢尤雨溪、陈蒙迪和 Jake Archibald 在写作过程中给予我的帮助。

WebAssembly,Web 的新时代

文/张敏

在浏览器之争中,Chrome 凭借 JavaScript 的卓越性能取得了市场主导地位,然而由于 JavaScript 的无类型特性,导致其运行时消耗大量的性能做为代价,这也是 JavaScript 的瓶颈之一。WebAssembly 旨在解决这一问题。本文从 WebAssembly 的起源到开发实践对其做全面探究,帮助开发者对 WebAssembly 有全面的了解。

缘起

让我们从浏览器大战说起。微软凭借 Windows 系统捆绑 Internet Explorer 的先天优势击溃 Netscape 后,进入了长达数年的静默期。而 Netscape 则于1998年将 Communicator 开源,并由 Mozilla 基金会衍生出 Firefox 浏览器,在2004年发布了1.0版本。从此,第二次浏览器大战拉开帷幕。这场大战由 Firefox 浏览器领衔,Safari、Opera 等浏览器也积极进取,Internet Explorer 的主导地位首次受到挑战。2008年 Google 推出 Chrome 浏览器,不但逐步侵蚀 Firefox 的市场,更是压制了老迈的 Internet Explorer。在此次大战之后的2012年,StatCounter 的数据指出 Chrome 以微弱优势超越 Internet Explorer 成为世界上最流行的浏览器。

分析 Google Chrome 浏览器战胜 Internet Explorer 的原因,除了对 Web 标准更友善的支持外,卓越的性能是其中相当重要的因素,而浏览器性能之争的本质则体现在 JavaScript 引擎。此前,JavaScript 引擎的实现方式经历了遍历语法树到字节码解释器等较为原始的方式,将每条源代码翻译成相应的机器码并执行,并不保存翻译后的机器码,使得解释执行很慢。2008年9月,Google 发布了 V8 JavaScript 引擎。V8被设计用于提高 Web 浏览器中 JavaScript 的执行性能,通过即时编译 JIT(Just-In-Time)技术,在执行时将 JavaScript 代码编译成更为高效的机器代码并保存,下次执行同一代码段时无需再编译,使得 JavaScript 获得了几十倍的性能提升。

然而,JavaScript 是个无类型(untyped,变量没有类型)的语言,这直接导致表达式 c=a+b 有多重含义:

  1. a、b均为数字,则算术运算符+表示值相加;
  2. a、b为字符串,则+运算符表示字符串连接;
  3.  …

表达式执行时,JIT 编译器需要检查 a 和 b 的类型,确定操作行为。若 a、b 均为数字,JIT 编译器则将 a、b 确认为整型,而一旦某一变量变成字符串,JIT 编译器则不得不将之前编译的机器码推倒重来。由此可见,JavaScript 的无类型特性建立在消耗大量性能代价的基础之上。即便 JIT 编译器在对变量类型发生变化时已进行相应优化,但仍然有很多情况 JavaScript 引擎未进行或无法优化,例如 for-of、try-catch、try-finally、with 语句以及复合 let、const 赋值的函数等。

由此可见,JavaScript 的无类型是 JavaScript 引擎的性能瓶颈之一,改进方案有两种:一是设计一门新的强类型语言并强制开发者进行类型指定;二是给现有的 JavaScript 加上变量类型。

微软开发的 TypeScript 属于第一种改进方案。它是扩展了 JavaScript 特性的语言,包含了类型批注,编译时类型检查,类型推断和擦除等功能,TypeScript 开发者在声明变量时指定类型,使得 JavaScript 引擎能够更快将这种强类型的语言编译成弱类型。

看看第二种方案:代码1

代码1表示带有两个参数(a 和 b)的 JavaScript 函数,和通常 JavaScript 代码不同的地方在于 a=a | 0及b=b | 0,以及返回值后面均利用标注进行了按位 OR 操作。这么做的优点是使 JavaScript 引擎强制转换变量的值为整型执行。通过标注加上变量类型,JavaScript 引擎就能更快地编译。既然增加变量类型能够提升 Web 性能,有没有办法将静态类型代码例如 C/C++ 等转换成 JavaScript 指令的子集呢?上面的这段代码恰恰是作为 JavaScript 子集的 asm.js,由代码2的 C 语言编译而来:

代码2

事实上,早在1995年起就已经有 Netscape Plugin API(NPAPI)在内的可以使用浏览器运行 C/C++ 程序的项目在开发。而2013年问世的 asm.js 是目前较为广泛的方案。asm.js 是一种中间编程语言,允许用 C/C++ 语言编写的计算机软件作为 Web 应用程序运行,并保持更好的性能,而 Mozilla Firefox 从版本22起成为第一个为 asm.js 特别优化的网页浏览器。

Google 也同样在为原生代码运行在 Web 端而努力。Google Native Client(NaCl)采用沙盒技术,让 Intel x86、ARM 或 MIPS 子集的机器码直接在沙盒上运行。它能够在无需安装插件的情况下从浏览器直接运行原生可执行代码,使 Web 应用程序可以用接近于机器码运作的速度来运行。而 Google Portable Native Client(PNaCl)则稍有变化,通过一些前端编译器将 C/C++ 源代码编译成 LLVM 的中间字节码而不是 x86 或 ARM 代码,并且进行优化以及链接(如表1所示)。

表1 JavaScript 及原生代码支持对比

表1  JavaScript 及原生代码支持对比

有了类型支持,第二种方案性能提升潜力远远大于第一种。

然而,无论是 asm.js 或现有 PNaCl 的解决方案,都面临着一些缺陷(例如 1KB 的 C 源码编译生成 asm.js 后的大小有480KB)或其他浏览器不支持的窘境,而2016年10月对 Chromium 问题跟踪代码的评论更是表明,Google Native Client 小组已被关闭。作为 Web 浏览器性能和代码重用的解决方案,asm.js 及 PNaCl 都没能被普遍接受,那么有没有上述表格中的特性全部占优,且跨厂商的解决方案呢?

WebAssembly 旨在解决这个问题。

新时代

WebAssembly (简称 Wasm)是一种新的适合于编译到Web的,可移植的,大小和加载时间高效的格式。这是一个新的与平台无关的二进制代码格式,目标是解决 JavaScript 性能问题。这个新的二进制格式远小于 JavaScript,可由浏览器的 JavaScript 引擎直接加载和执行,这样可节省从 JavaScript 到字节码,从字节码到执行前的机器码所花费的即时编译 JIT(Just-In-Time)时间。 作为一种低级语言,它定义了一个抽象语法树(Abstract Syntax Tree,AST),开发人员可以以文本格式进行调试。

WebAssembly 描述了一个内存安全的沙箱执行环境,可以在现有的 JavaScript 虚拟机中实现。 当嵌入到 Web 中时,WebAssembly 将强制执行浏览器的同源和权限安全策略。因此,和经常出现安全漏洞的 Flash 插件相比,WebAssembly 是一个更加安全的解决方案。

WebAssembly 可由 C/C++ 等语言编译而来。此外,WebAssembly 由 Google、Mozilla、微软以及苹果公司牵头的 W3C 社区组共同努力,基本覆盖主流的浏览器厂商,因此其可移植性相较 Silverlight 等有极大提升,平台兼容问题将不复出现。

在 Web 平台的很多项目中,对于原生新功能的支持需要 Web 浏览器或 Runtime 提供复杂的标准化的 API 来实现,但是 JavaScript API 往往较慢。使用 WebAssembly ,这些标准 API 可以更简单,并且操作在更低的水平。例如,对于一个面部识别的 Web 项目,对于访问数据流我们可以由简单的 JavaScript API 实现,而把面部识别原生 SDK 做的事情交由 WebAssembly 实现。

需要了解的是,WebAssembly 不是将 C/C++ 等其他语言编译到 JavaScript ,更不是一种新的编程语言。

探究

asm.js

上文的 C 语言求和代码经由编译器生成 asm.js 后如代码3所示。

代码3

上述代码转换为 WebAssembly 的文本格式稍显复杂,为了理解方便,我们从精简的 asm.js 开始(见代码4)。

代码4

wast 文本文件

将 asm.js 代码转换为 WebAssembly 的文本格式 add.wast(转换工具见本文工具链章节,如代码5所示)。

代码5

WebAssembly 中代码的可装载和可执行单元被称为一个模块(module)。在运行时,一个模块可以被一组 import 值实例化,多个模块实例能够访问相同的共享状态。目前文本格式中的 module 主要用 S 表达式来表示。虽然 S 表达格式不是正式的文本格式,但它易于表示 AST。WebAssembly 也被设计为与 ES6 的 modules 集成。

一个单一的逻辑函数定义包含两个部分:功能部分声明在模块中每个内部函数定义的签名,代码段部分包含由功能部分声明的每个函数的函数体。WebAssembly 是带有返回值的静态类型,并且所有参数都含有类型。上面的 add.wast可以解读为:

  1. 声明了一个名为 $add 的函数;
  2. 包含两个参数 $ a 和 $ b,两者都是32位整型;
  3. 结果是一个32位整型;
  4. 函数体是一个32位的加法:
  5. 上面是局部变量 $a 得到的值;
  6. 下面是局部变量 $b 得到的值;
  7. 由于没有明确的返回节点,因此 return 是该加法函数的最后加载指令。
二进制 Wasm 文件

如图1所示,由 C 语言求和代码经过编译生成二进制文件,通读文件可以找到相应的头部、类型、导入、函数以及代码段等。通过 JavaScript API 载入 Wasm 二进制文件后,最终转换到机器码执行。

图1  经过编译的二进制文件

图1 经过编译的二进制文件

工具链

开发人员现在可以使用相应的工具链从 C/C++ 源文件编译 WebAssembly 模块。WebAssembly 由许多工具支持,以帮助开发人员构建和处理源文件和生成的二进制内容。

Emscripten

Emscripten 是其中无法回避的工具之一,如图2所示。在图2中,Emscripten SDK 管理器(emsdk)用于管理多个 SDK 和工具,并且指定当前正被使用到编译代码的特定 SDK 和工具集。

图2  Emscripten工具链流程图及生成JavaScript (asm.js)流程

图2 Emscripten 工具链流程图及生成 JavaScript (asm.js)流程

Emscripten 的主要工具是 Emscripten 编译器前端(emcc),它是例如 GCC 的标准编译器的简易替代实现。

Emcc 使用 Clang 将 C/C++ 文件转换为 LLVM(源自于底层虚拟机 Low Level Virtual Machine)字节码,使用 Fastcomp(Emscripten 的编译器核心,一个 LLVM 后端)把字节码编译成J avaScript 。输出的 JavaScript 可以由 Node.js 执行,或者嵌入 HTML 在浏览器中运行。这带来的直接结果就是,C 和 C++ 程序经过编译后可在 JavaScript 上运行,无需任何插件。

WABT 和 Binaryen

除此之外,对于想要使用由其他工具(如 Emscripten)生成的 WebAssembly 二进制文件感兴趣的开发者,目前 http:// WebAssembly .org/ 官方额外提供了另外两组不同的工具:

  1. WABT ——WebAssembly 二进制工具包;
  2. Binaryen——编译器和工具链。

WABT 工具包支持将二进制 WebAssembly 格式转换为可读的文本格式。其中 wasm2wast 命令行工具可以将 WebAssembly 二进制文件转换为可读的 S 表达式文本文件。而 wast2wasm 命令行工具则执行完全相反的过程。

Binaryen 则是一套更为全面的工具链,是用 C++ 编写成用于 WebAssembly 的编译器和工具链基础结构库(如图3所示)。WebAssembly 是二进制格式(Binary Format)并且和 Emscripten 集成,因此该工具以 Binary 和 Emscript-en 的末尾合并命名为 Binaryen。它旨在使编译 WebAssembly 容易、快速、有效。它包含且不仅仅包含下面的几个工具。

图3  Binaryen生成 WebAssembly 流程

图3 Binaryen 生成 WebAssembly 流程

  1. wasm-as:将 WebAssembly 由文本格式(当前为S表达式格式)编译成二进制格式;
  2. wasm-dis:将二进制格式的 WebAssembly 反编译成文本格式;
  3. asm2wasm:将 asm.js 编译到 WebAssembly 文本格式,使用 Emscripten 的 asm 优化器;
  4. s2wasm:在 LLVM 中开发,由新 WebAssembly 后端产生的 .s 格式的编译器;
  5. wasm.js:包含编译为 JavaScript 的 Binaryen 组件,包括解释器、asm2wasm、S 表达式解析器等。

Binaryen 目前提供了两个生成 WebAssembly 的流程,由于 emscripten 的 asm.js 生成已经非常稳定,并且 asm2wasm 是一个相当简单的过程,所以这种将 C/C++ 编译为 WebAssembly 的方法已经可用(如图4所示)。

图4  Emscripten+Binaryen生成 WebAssembly 的完整流程

图4 Emscripten+Binaryen 生成 WebAssembly 的完整流程

由此可见,Emscripten 以及 Binaryen 提供了完整的 C/C++ 到 WebAssembly 的解决方案。而 Binaryen 则帮助提升了 WebAssembly 的工具链生态。

提示

由于 WebAssembly 正处于活跃开发阶段,各项编译步骤和编译工具会有大幅变更和改进,相信最终的编译工具和步骤会趋于便捷,开发者需要留意官方网站的最新动态。

实战

Linux 和 mac OS 平台编译原生代码到 WebAssembly 可由如下步骤实现。

编译环境准备

操作系统必须有可以工作的编译器工具链,因此需要安装 GCC、cmake 环境,此外 Python、Node.js 及 Java 环境也是需要的(其中 Java 为可选,如图5所示)。

图5  编译环境安装

图5 编译环境安装

如果是以其他方式安装了 Node.js,可能需要更新 ~/.emscripten 文件的 NODE _ JS 属性。

安装正确的 emscripten 分支

要编译原生代码到 WebAssembly ,我们需要 emscripten 的 incoming 分支。由于 emscripten 不仅仅是用于 WebAssembly 的编译工具链,选择正确的分支尤为重要(如图6所示)。

图6  安装emscripten的incoming分支

图6 安装 emscripten 的 incoming 分支

其中 URLTO 具体的 URL 是 https://s3.amazonaws.com/mozilla-games/emscripten/releases/emsdk-portable.tar.gz。

处理安装异常

可运行 emcc -v 命令进行验证安装。如果遇到如图7所示的错误,表明带有 JavaScript 后端的 LLVM 编译器并未被生成。

图7  emcc -v命令报错

图7 emcc -v 命令报错

图8  emcc -v命令报错解决方案

图8 emcc -v 命令报错解决方案

通过图8步骤,可以解决该问题,并且在 ~/.emscripten 文件中修改如下配置:

开始编译程序

现在一个完整的工具链已经具备,我们可以使用它来编译简单的程序到 WebAssembly 。但是,还有一些其他注意事项:

  1. 必须通过参数 -s Wasm=1 到 emcc(否则默认 emcc 将编译出 asm.js);
  2. 除了 Wasm 二进制文件和 JavaScript wrapper 外,如果还希望 emscripten 生成一个可直接运行的程序的 HTML 页面,则必须指定一个扩展名为 .html 的输出文件。

在编译之前,首先准备一个最基本的 add.c 程序,见代码6。

代码6

按代码7所示的命令编辑好 add.c 程序并编译:

代码7

运行 WebAssembly 应用

以 Chrome 浏览器为例,如果直接在浏览器内本地打开 HTML 文件,会有图9所示的错误:

图9  XMLHttpRequest本地访问的跨域请求错误

图9 XMLHttpRequest 本地访问的跨域请求错误

由于 XMLHttpRequest 跨域请求不支持 file:// 协议,必须经由 HTTP 实际输出,可以由 Python 的 SimplHTTPServer 改进,见代码8:

代码8

在浏览器中输入 http://127.0.0.1:8080 并打开 add.html,就能直接看到转换成 WebAssembly 的应用程序输出结果。

创建独立 WebAssembly

默认情况下,emcc 会创建 JavaScript 文件和 WebAssembly 的组合,其中 JS 加载包含编译代码的 WebAssembly 。对于 C/C++ 开发人员,他们可能更倾向于创建独立的 WebAssembly ,用于 JavaScript 开发人员调用,见代码9。

代码9

上述命令运行后,我们可以得到独立的 Wasm 文件。需要说明的是,该参数仍然在开发中,可能随时发生规范和实现变更。

JavaScript API 调用

从 C/C++ 程序编译获得一个 .wasm 模块之后,JavaScript 开发人员可以通过如下方式进行载入 .wasm 文件并执行。WebAssembly 社区组也有计划通过 Streams 使用 streaming 以及异步编译,见代码10。

代码10

最后一行调用导出的 WebAssembly 函数,它反过来调用我们导入的 JS 函数,最终执行 add(201700, 2),并且在控制台获得期望的结果输出(如图10所示)。

图10   WebAssembly 求和函数在控制台的输出

图10 WebAssembly 求和函数在控制台的输出

性能

那么,WebAssembly 的真实性能如何呢?首先我们用一直被用来作为 CPU 基准测试的斐波那契 (Fibonacci)数列来进行对比,这里使用的是性能较差的递归算法,在 Node.js v7.2.1 环境下,能够看到 WebAssembly 性能优势越发明显(如图11所示)。

图11  CPU基准测试反应 WebAssembly 的真实性能

图11 CPU 基准测试反应 WebAssembly 的真实性能

再看看最基本的1000毫秒时间内,求和计算的运算量统计,在同一台计算机的 Firefox 50.1.0 版本的运算结果如图12所示。

图12  1000毫秒内求和计算的运算量统计

图12 1000毫秒内求和计算的运算量统计

尽管重复测试时结果不尽相同,重启浏览器并多次测试取平均值后依然可以看到 WebAssembly 的运算量比 JavaScript 快了近一个量级。

Demo

图13展示了 Angry Bots Demo,它是由 WebAssembly 项目发布的一个 Demo,由 Unity 游戏移植而来。

图13   Angry Bots Demo / Google Chrome 55.0.2883.87

图13 Angry Bots Demo/Google Chrome 55.0.2883.87

通过如下方式可以体验 WebAssembly 在浏览器中的强大性能。即便 Google Chrome 较新的稳定版也已支持 WebAssembly ,还是推荐使用 canary 版及 Firefox 的 nightly 版进行测试。

  1. 下载浏览器:
    • Google Chrome;
    • Mozilla Firefox;
    • Opera;
    • Vivaldi。
  2. 打开 WebAssembly 支持 :
    • Google Chrome:chrome://flags/#enable- WebAssembly ;
    • Mozilla Firefox:about:config→接受→搜索 JavaScript .options.wasm→设置为 true;
    • Opera:opera://flags/#enable- WebAssembly ;
    • Vivaldi:vivaldi://flags#enable- WebAssembly 。

访问:http:// WebAssembly .org/demo/。使用 W、A、S、D 等键实现移动操作,点击鼠标进行射击。该 WebAssembly 游戏在浏览器中运行相当流畅,媲美原生性能。

除了最新的浏览器开始对 WebAssembly 逐步支持外,Intel 开源技术中心开发的 Crosswalk 项目(https://crosswalk-project.org/)早在2016年11月初的 Crosswalk 22稳定版(Windows 及 Android 平台)即已加入对 WebAssembly 实验性的支持,开发者可以使用该版本体验 Angry Bots Demo。

开发者

WebAssembly 对于 Web 有显著的性能提升,对于开发者尤其是前端或者 JavaScript 开发人员而言,并不意味着 WebAssembly 将会取代 JavaScript (如图14所示)。

图14   WebAssembly 与JavaScript 引擎的关系

图14 WebAssembly 与JavaScript 引擎的关系

WebAssembly 被设计为对 JavaScript 的补充,而不是替代,是为了提供一种方法来获得应用程序的关键部分接近原生性能。随着时间的推移,虽然 WebAssembly 将允许多种语言(不仅仅是 C/C++)被编译到 Web,但是 JavaScript 的发展势头不会因此被削弱,并且仍然将保持 Web 的单一动态语言。此外,由于 WebAssembly 构建在 JavaScript 引擎的基础架构上,JavaScript 和 WebAssembly 将在许多场景中配合使用。

那么 WebAssembly 是不是仅仅面向 C/C++ 开发者呢?答案依旧是否定的。WebAssembly 最初实现的重点是 C/C++,由 Mozilla 主导开发的注重高效、安全和并行的 Rust 也能在2016年末被成功编译到 WebAssembly 了,未来还会继续增加其他语言的支持,见代码11。

代码11

在未来,通过 ES6 模块接口与 JavaScript 集成,Web 开发人员并不需要编写 C++,而是可以直接利用其他人编写的库,重用模块化 C++ 库可以像使用 JavaScript 中的 modules 一样简单。

进展

依据开发路线图,2016年10月31日,WebAssembly 到达浏览器预览的里程碑。Google Chrome V8 引擎及 Mozilla Firefox SpiderMonkey 引擎都已经在 trunk上 支持 WebAssembly 浏览器预览。2016年12月下旬,Microsoft Edge 浏览器使用的JavaScript 引擎 ChakraCore v1.4.0 启用了 WebAssembly 浏览器预览支持。而 Webkit JavaScript Core 引擎对于该支持也在积极进行中。

目前,WebAssembly 社区组已经有初始(MVP)二进制格式发布候选和 JavaScript API 在多个浏览器中实现。作为浏览器预览期间的一部分,WebAssembly 社区组(WebAssembly Community Group)现在正在征求更广泛的社区反馈。社区组的初步目标是浏览器预览在2017年第一季度结束,但在浏览器预览期间的重大发现可能会延长该周期。当浏览器预览结束时,社区组将产生 WebAssembly 的草案规范,并且浏览器厂商可以开始默认提供符合规范的实现。预计在2017年上半年,四大主流浏览器对原生的 WebAssembly 支持将到达稳定版。

具体到 Google V8 引擎的最新进展,asm.js 代码将不再通过 Turbofan JavaScript 编译器而是编译到 WebAssembly 后,在 WebAssembly 的原生执行环境中执行最终的机器码。这种改变带来的好处有,为 asm.js 将预先编译(AOT,Ahead Of Time Compilation)带到了 Chrome,且完全向后兼容。新的 WebAssembly 编译渠道重用了一些 Turbofan JavaScript 编译器后端部分,因此能够在少了很多编译和优化消耗的前提下,产生类似的代码。在 Google Chrome中,WebAssembly 将很快在 Canary 版中默认启用,开发团队也期望能够发布到2017年第一季度末的稳定版中。

社区

包含所有主要浏览器厂商代表的 W3C Web——Assembly社区组于2015年4月底成立。该小组的任务是,在编译到适用于 Web 的新的、便携的、大小和加载时间高效的格式上,促进早期的跨浏览器协作。该社区组也正在将 WebAssembly 设计为 W3C 开放标准。目前,除了文中所述主流浏览器厂商 Mozilla、Google、微软、及苹果公司之外,Opera CTO 及 Intel 的8位该领域专家均参与了该社区组。当然,并不是只有社区组成员才能参与标准的制定,任何人都可以在 https://github.com/ WebAssembly 做出贡献。

展望

由于主要的浏览器厂商对 WebAssembly 支持表现积极,并且都在实现 WebAssembly 的各项功能,因此在 Web 中高性能需求的应用例如在线游戏、音乐、视频流、AR/VR、平台模拟、虚拟机、远程桌面、压缩及加密等都能够获得接近于原生的性能。相信 WebAssembly 将会开创 Web 的新时代。

WebAssembly 初步探索
WebAssembly 在白鹭引擎5.0中的实践
在 Node.js 中看 JavaScript 的引用
Node.js 异步编程之难
58同城 Android 端 HTTPS 实践之旅
微信终端跨平台组件 Mars 在移动网络的探索和实践
携程 React Native 工程实践和优化
原生 JavaScript 模块的现在与未来
详解 HTTP/2 Server Push 进一步提升页面加载速度
Webpack 在现代化前端开发中的作用与未来
使用 WebGL 提升可视化中的布局性能
Redux or Mobx:前端应用状态管理方案的探索与思考
Hybrid Go:去哪儿网 Hybrid 实践
苏宁前端基础工具集
被低估的 Babel
探索 Headless Chrome
CSS 模块化演进
前端工程师为什么要学习编译原理

阅读全文: http://gitbook.cn/gitchat/geekbook/5a62a5775418822a9fb0c223

Logo

一座年轻的奋斗人之城,一个温馨的开发者之家。在这里,代码改变人生,开发创造未来!

更多推荐