返回 登录
0

构建Google I/O 2016的Progressive Web App

原文: Building the Google I/O 2016 Progressive Web App
译者: 孙薇,本译文基于原文版权声明翻译。
审校: 唐小引(@唐门教主),欢迎技术投稿、约稿,给文章纠错,请发送邮件tangxy#csdn.net(请将#更换为@)。

摘要

Google I/O 2016 web app(网站)开源了,本文将带你了解我们是如何通过web组件、Polymer还有材料设计来构建SPA(单页应用)并在Google.com上发布。

效果

  • 用户参与度较本地程序更高(移动web app 4.06分钟 vs 安卓 app 2.40分钟);
  • 有了服务worker的缓存,首次渲染的返回速度比之前要快450ms;
  • 服务worker对84%的访问者提供支持;
  • 添加到主屏幕的使用率比2015年多了逾900%;
  • 3.8%的用户在离线时继续生成了1.1万的PV;
  • 登录用户有一半开启了通知;
  • 发送给用户的通知多达53.6万条(其中有12%带来了回访率);
  • 99%的用户使用的浏览器支持web组件polyfills。

图片描述

概述

笔者今年有幸参与了Google I/O 2016 progressive web app(又称IOWA)研究工作,它是完全离线运作的移动应用,其创意很大程度上来自于Google I/O的material design

IOWA是一个使用web组件、Polymer、Firebase构建的单页应用(SPA),在App Engine中有大量后端支持。它通过服务worker预先缓存内容,动态加载新页面,以优雅的方式切换页面,并会在首次加载后复用内容。

本文会整理一些我们曾在确定前端架构时,作出的一些比较有意思的决策。如果对完整的源代码有兴趣,请点击这里在Github上查看。

通过web组件构建SPA

每页都是一个组件

我们前端的核心之一就是:它是以web组件为中心的。事实上,在我们的SPA中每个页面都是一个web组件:

<io-home-page date="2016-05-18T17:00:00Z" app="[[app]]"></io-home-page>
<io-schedule-page date="2016-05-18T17:00:00Z" app="{{app}}"></io-schedule-page>
<io-attend-page></io-attend-page>
<io-extended-page></io-extended-page>
<io-faq-page></io-faq-page>

这种做法的原因在于:1. 这样的代码具有可读性,一眼就能完全清楚地了解应用每页的内容;2. 针对构建SPA,web组件具有一些非常优秀的属性:<template> element、Custom Elements以及Shadow DOM的内在特性解决了很多常见的故障问题,包括状态管理、激活视图、风格范围等。这些都是浏览器内置的开发工具,所以为什么不用呢?

通过为各个页面创建Custom Element,我们无需其它代价便可获益良多:

  • 页面的生命周期管理;
  • 针对特定页面限定Scoped属性的CSS/HTML;
  • 特定页面的CSS/HTML/JS会打包在一起,按需加载;
  • 视图可复用:由于页面具有DOM节点,通过简单地添加或移除就能改变视图;
  • 标记能让应用易于理解,便于维护;
  • 随着浏览器注册与更新元素定义,服务器渲染标记也能逐渐获得加强;
  • Custom Element拥有继承模型,没有水分的代码(DRY code)就是好代码;
  • ……更多其它

在IOWA中我们完全用到了这些优势,下面就来深入探究一下其中的细节吧。

动态激活页面

嵌入<template> element是浏览器创建可复用标记的标准方式,<template>有两个SPA可以用到的特性,包括:1. 在<template>内的任意内容除非创建有实例,否则都是无效的;2. 浏览器会解析这个标记,但在主页上是看不到这部分内容的,它是真实、可复用的大块标记。

例如:

<template id="t">
<div>This markup is inert and not part of the main page's DOM.</div>
<img src="profile.png"> <!-- not loaded by the browser -->
<video id="vid" src="vid.mp4" autoplay></video> <!-- doesn't load/start -->
<script>alert("Not run until the template is stamped");</script>
</template>

注意: document.querySelector('#vid') === null but t.content.querySelector('#vid') !== null.

Polymer通过一些Custom Element的类型扩展为<template>提供了拓展,比如<template is="dom-if"><template is="dom-repeat">这两个Custom Element都为<template>添加了额外功能。由于web组件是声明模式的,这些Custom Element能完全实现使用者的想法:第一个组件根据条件作出标记,第二个则为列表中的每个项目(数据模型)重复标记。

IOWA要如何使用这些类型扩展元素呢?

在IOWA中的每个页面都是web组件。然而首次加载时就声明所有组件是愚蠢的做法,意味着需要在应用首次加载时就创建各个页面的实例。我们不希望对首次加载的性能造成不利影响,尤其是一些用户可能只会查看1到2页。

我们的解决方案是“作弊”。在IOWA中,我们将每页的元素封装在<template is="dom-if">之中,其中的内容一开始时并不会加载。然后在template的名称属性与URL符合时,我们会对页面进行激活。<lazy-pages>的web组件会处理所有这些逻辑,标记看起来就像下面这样:

<!-- Lazy pages manages the template stamping. It watches for route changes
and sets `template.if = true` on the appropriate template. -->
<lazy-pages>
<template is="dom-if" name="home">
<io-home-page date="2016-05-18T17:00:00Z"></io-home-page>
</template>
<template is="dom-if" name="schedule">
<io-schedule-page date="2016-05-18T17:00:00Z"></io-schedule-page>
</template>
<template is="dom-if" name="attend">
<io-attend-page></io-attend-page>
</template>
</lazy-pages>

其中让人喜爱的功能在于,在页面加载时各页面就已解析完成并准备好了,但其中的CSS/HTML/JS只会按需执行,并通过web组件FTW的动态+延迟加载视图。

未来的改进

页面初次加载时,我们会立刻为每个页面加载所有的HTML Imports,其中明显的一点改进就是延迟加载元素定义,只按需加载。Polymer对于HTML Imports的异步加载有很好的协助效果:

Polymer.Base.importHref('io-home-page.html', (e) => { ... });

IOWA没有使用这种方式的原因在于: a) 我们很懒;b) 目前还不清楚性能会有多少提升。我们首次渲染大约用了1秒时间。

页面的生命周期管理

Custom Element API定义了管理某个组件状态的“lifecycle callbacks”,实现这些方法时会与组件的生命周期产生关联。

createdCallback() {
// automatically called when an instance of the element is created.
}
attachedCallback() {
// automatically called when the element is attached to the DOM.
}
detachedCallback() {
// automatically called when the element is removed from the DOM.
}
attributeChangedCallback() {
// automatically called when an HTML attribute changes.
}

在IOWA中使用这些callback非常容易。要记得,每一页都是独立的DOM节点。在我们的SPA中,导航到一个“新视图”就是将一个节点添加到DOM上,并移除另外一个。

我们通过attachedCallback来执行安装任务,如初始化状态、附加事件监听。每当用户导航到新页面,detachedCallback便执行清理任务,如移除监听器、重置共享状态。我们还添加了几个本地的lifecycle callback:

onPageTransitionDone() {
// page transition animations are complete.
},
onSubpageTransitionDone() {
// sub nav/tab page transitions are complete.
}

这些扩展代码有利于显示效果,并将页面切换时的渲染卡顿减到最少。稍后会有更多这方面的解释。

将跨页面常用功能的代码精简

继承是Custom Element的强大功能之一,它为web提供了标准的继承模型。

可惜的是Polymer 1.0尚未实现元素继承,同时Polymer的Behavior功能也很有用。

Behavior并未在所有页面上创建相同的API界面,而是通过创建共享mixin来精简代码,例如PageBehavior定义了我们应用所需的每个页面的通用属性/方法:

PageBehavior.html
let PageBehavior = {
// Common properties all pages need.
properties: {
name: { type: String }, // Slug name of the page.
...
},
attached() {
// If the page defines a `onPageTransitionDone`, call it when the router
// fires 'page-transition-done'.
if (this.onPageTransitionDone) {
this.listen(document.body, 'page-transition-done', 'onPageTransitionDone');
}
// Update page meta data when new page is navigated to.
document.body.id = `page-${this.name}`;
document.title = this.title || 'Google I/O 2016';
// Scroll to top of new page.
if (IOWA.Elements.Scroller) {
IOWA.Elements.Scroller.scrollTop = 0;
}
this.setupSubnavEffects();
},
detached() {
this.unlisten(document.body, 'page-transition-done', 'onPageTransitionDone');
this.teardownSubnavEffects();
}
};
IOWA.IOBehaviors = IOWA.IOBehaviors || {PageBehavior: PageBehavior};

注意: Polymer精简了添加/移除使用的attachedCallback与detachedCallback生命周期方法。

PageBehavior会执行与访问新页面时相同的通用任务,像是更新document.title、重置滚动位置、设置滚动与子导航效果的事件监听器等。

单个页面通过加载PageBehavior作为依赖,并嵌入behavior来使用PageBehavior。如果需要,也可以按需覆盖其基本属性/方法。例如,下面是我们主页“subclass”所覆盖的:

io-home-page.html

<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="PageBehavior.html">
<!-- rest of the import dependencies used by the page. -->
<dom-module id="io-home-page">
<template>
<!-- PAGE'S MARKUP -->
</template>
<script>
Polymer({
is: 'io-home-page',
behaviors: [IOBehaviors.PageBehavior], // All pages have common functionality.
// Pages define their own title and slug for the router.
title: 'Schedule - Google I/O 2016',
name: 'home',
// The home page has custom setup work when it's added navigated to.
// Note: PageBehavior's attached also gets called.
attached() {
if (this.app.isPhoneSize) {
this.listen(IOWA.Elements.ScrollContainer, 'scroll', '_onPageScroll');
}
},
// The home page does its own cleanup when a new page is navigated to.
// Note: PageBehavior's detached also gets called.
detached() {
this.unlisten(IOWA.Elements.ScrollContainer, 'scroll', '_onPageScroll');
},
// The home page can define onPageTransitionDone to do extra work
// when page transitions are done, and thus preventing janky animations.
onPageTransitionDone() {
...
}
});
</script>
</dom-module>

共享样式

为了在应用中跨组件共享样式,我们使用了Polymer的共享样式组件,从而得以一次定义大量CSS内容,并复用在应用中的不同地方,也就是不同的组件中。

在IOWA中,我们创建了shared-app-styles(共享-应用-样式),以便在不同的页面与组件中共享颜色、字体、布局类型。

shared-app-styles.html

<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="../bower_components/iron-flex-layout/iron-flex-layout.html">
<link rel="import" href="../bower_components/paper-styles/color.html">
<dom-module id="shared-app-styles">
<template>
<style>
[layout] {
@apply(--layout);
}
[layout][horizontal] {
@apply(--layout-horizontal);
}
.scrollable {
@apply(--layout-scroll);
}
.noscroll {
overflow: hidden;
}
/* Style radio buttons and tabs the same throughout the app */
paper-tabs {
--paper-tabs-selection-bar-color: currentcolor;
}
paper-radio-button {
--paper-radio-button-checked-color: var(--paper-cyan-600);
--paper-radio-button-checked-ink-color: var(--paper-cyan-600);
}
...
</style>
</template>
</dom-module>

io-home-page.html

<link rel="import" href="shared-app-styles.html">
<!-- Rest of import dependencies used by the page. -->
<dom-module id="io-home-page">
<template>
<style include="shared-app-styles">
:host { display: block} /* Other element styles can go here. */
</style>
<!-- PAGE'S MARKUP -->
</template>
<script>Polymer({...});</script>
</dom-module>

这里的<style include="shared-app-styles"></style>在Polymer语法中表示“包含shared-app-styles模块中的样式”的意思。

共享应用状态

到目前为止,我们应用中每个页面都是一个Custom Element,这一点本文多次重申。但如果每个页面都是独立的web组件,可能会让人疑惑该怎样跨应用分享状态。

IOWA在共享状态时,运用了与依赖注入(Angular)或redux (React)类似的技术。我们创建了全局性的app属性,并放开了分享的子属性,app通过注入所有需要数据的组件而进行传递。由于无需编写代码就可以进行连接,使用Polymer的数据绑定功能会让这类任务更简单。

<lazy-pages>
<template is="dom-if" name="home">
<io-home-page date="2016-05-18T17:00:00Z" app="[[app]]"></io-home-page>
</template>
<template is="dom-if" name="schedule">
<io-schedule-page date="2016-05-18T17:00:00Z" app="{{app}}"></io-schedule-page>
</template>
...
</lazy-pages>
<google-signin client-id="..." scopes="profile email"
user="{{app.currentUser}}"></google-signin>
<iron-media-query query="(min-width:320px) and (max-width:768px)"
query-matches="{{app.isPhoneSize}}"></iron-media-query>

在用户登录应用时,<google-signin>元素会更新用户属性。由于该属性与app.currentUser绑定,想要访问当前用户的任何页面都需要绑定app并读取currentUser的子属性,这项技术对于在应用内共享状态很有用处。此外,最终我们也得以创建了一个单独的登录元素,可以在网站中复用登录结果。媒体请求也是一样的,每个页面重复登录或者创建单独的媒体请求都是一种浪费,因此我们运用了在应用层面内负责整个应用功能/数据的组件。

注意:在此例中,我特意用到了双向绑定({{)与单向绑定 ([[]])。当变更(内部变更)需要写入元素,并传播出去时,使用({{)绑定;当仅需读取数据时,在元素中使用([[]])绑定。

页面切换

在Google I/O web应用中浏览时,我们会发现:它是一款能够平滑切换页面的SPA。

IOWA的页面切换动作

当用户导航到新页面时,会发生以下一系列事件:

  • 顶部导航滑到一个选择标签,开新页面;
  • 页面顶部渐隐;
  • 页面内容向下滑动并逐渐隐藏;
  • 使用反向动画,新页面的顶部与内容出现。
    -(可选)新页面会执行额外的初始化工作。

挑战之一在于,如何在不牺牲性能的前提下,让页面过渡平滑执行——会有很多动态任务,同时我们还要避免卡顿。我们的解决方案是一套Web Animations API再加上Promise,两者并用赋予了我们多功能性,再加上一个嵌入与动画播放系统以及减少卡顿的颗粒度控制。

工作原理

当用户点击打开新页面(或点击后退/前进)时,我们选择的runPageTransition()通过执行一系列Promise而产生神奇的效果。使用Promise让我们可以仔细编排动画,并协助CSS动画“async-ness”更加合理化,同时支持动态加载内容。

class Router {
init() {
window.addEventListener('popstate', e => this.runPageTransition());
}
runPageTransition() {
let endPage = this.state.end.page;
this.fire('page-transition-start');              // 1. Let current page know it’s starting.
IOWA.PageAnimation.runExitAnimation()            // 2. Play exist animation sequence.
.then(() => {
IOWA.Elements.LazyPages.selected = endPage;  // 3. Activate new page in <lazy-pages>.
this.state.current = this.parseUrl(this.state.end.href);
})
.then(() => IOWA.PageAnimation.runEnterAnimation())  // 4. Play entry animation sequence.
.then(() => this.fire('page-transition-done')) // 5. Tell new page transitions are done.
.catch(e => IOWA.Util.reportError(e));
}
}

回忆一下精简代码那一节:页面监听着page-transition-startpage-transition-done的DOM事件,现在我们来看一下具体实现:

我们使用Web Animations API来代替runEnterAnimation/runExitAnimation helper,在runExitAnimation案例中,我们捕获了一些DOM节点(报头和主要内容区),声明了各个动画的起始/结束,并创建了一个GroupEffect以便其并行运行:

function runExitAnimation(section) {
let main = section.querySelector('.slide-up');
let masthead = section.querySelector('.masthead');
let start = {transform: 'translate(0,0)', opacity: 1};
let end = {transform: 'translate(0,-100px)', opacity: 0};
let opts = {duration: 400, easing: ‘cubic-bezier(.4, 0, .2, 1)''};
let opts_delay = {duration: 400, delay: 200};
return new GroupEffect([
new KeyframeEffect(masthead, [start, end], opts),
new KeyframeEffect(main, [{opacity: 1}, {opacity: 0}], opts_delay)
]);
}

**注意:**GroupEffect是web-animations-next polyfill的一部分,但属于一级规范

滚动效果

在滚动页面时,IOWA会有一些有趣的效果,第一个就是我们的浮动操作按钮(FAB),它可以让用户返回页面顶部:

<a href="#" tabindex="-1" aria-hidden="true" aria-label="back to top" onclick="backToTop">
<paper-fab icon="io:expand-less" noink tabindex="-1"></paper-fab>
</a>

我们通过Polymer的app-layout元素实现了平滑滚动——这些元素提供了现成的滚动效果,像是悬浮/返回顶部导航、阴影效果、颜色与背景过度、视差效果以及平滑滚动等。

// Smooth scrolling the back to top FAB.
function backToTop(e) {
e.preventDefault();
Polymer.AppLayout.scroll({top: 0, behavior: 'smooth',
target: document.documentElement});
e.target.blur();  // Kick focus back to the page so user starts from the top of the doc.
}

我们用到<app-layout>元素的另一个地方是在悬浮导航栏中。在用户向下滚动页面时这个导航栏会消失,而在向上回滚时又会出现。

用到<app-layout>的悬浮导航栏

图片描述

我们基本按原样嵌入了<app-header>元素,添加非常简单,在应用中的滚动效果也很炫。当然,我们也可以自行实现这些效果,但利用细节已经定义好的可复用组件能够节省大量的时间。

只要声明元素,自定义属性,然后就搞定了。

<app-header reveals condenses effects="fade-background waterfall"></app-header>

结论

对于I/O progressive web应用来说,我们已经可以在数周内构建完整的前端了,这也多亏了web组件与Polymer的material design部件。这些本地API功能,比如Custom ElementShadow DOM<template>等都为SPA提供了巨大的支持,再加上可复用效果又节省了大量的时间。

如果对创建progressive web应用有兴趣的话,大家可以看下App Toolbox。Polymer的App Toolbox是一套组件、工具与模板集合,专为通过Polymer构建PWA而设计,很容易上手运行。

原文版权声明: 除非特别注明,本页面采用创作共用署名3.0许可证(Creative Commons Attribution 3.0 License)授权,本页代码样例采用Apache 2.0许可协议授权。如要查看全文,请访问此网址。Java是Oracle及其附属公司的注册商标和注册服务标志。


2016 年 9 月 23-24 日,由 CSDN 和创新工场联合主办的“MDCC 2016 移动开发者大会• 中国”(Mobile Developer Conference China)将在北京• 国家会议中心召开,来自iOS、Android、跨平台开发、产品设计、VR开发、移动直播、人工智能、物联网、硬件开发、信息无障碍10个领域的技术专家将分享他们在各自行业的真知灼见。

8月7日24点前仍处于5折优惠票价阶段,五人以上团购更有特惠,限量供应,预购从速。票务详情链接5折优惠仅此一周!

评论