构建 2016 年 Google I/O 大会渐进式 Web 应用

爱荷华州的家

摘要

了解我们如何使用网络组件、Polymer 和 Material Design 构建单页应用,并在 Google.com 上发布正式版应用。

成果

  • 与原生应用相比,互动度更高(移动网站为 4:06,Android 为 2:40)。
  • 借助 Service Worker 缓存,回访用户的首次绘制速度提升了 450 毫秒
  • 84% 的访问者支持 Service Worker
  • 与 2015 年相比,在主屏幕上进行的保存次数增加了 900%。
  • 3.8% 的用户离线了,但是继续带来了 1.1 万次网页浏览!
  • 50% 的登录用户启用了通知功能。
  • 向用户发送了 53.6 万条通知(其中 12% 返回了通知)。
  • 99% 的用户浏览器支持网络组件 polyfill

概览

今年,我很荣幸能够参与 2016 年 Google I/O 大会渐进式 Web 应用(被亲切地命名为“IOWA”)。它优先在移动设备上运行,可完全离线运行,其灵感来源于 Material Design

IOWA 是一个使用网页组件、Polymer 和 Firebase 构建的单页应用 (SPA),并具有用 App Engine (Go) 编写的大量后端。它使用 Service Worker 预缓存内容,动态加载新页面,在视图之间平稳过渡,并在首次加载后重复使用内容。

在本案例研究中,我将介绍我们针对前端做出的一些更有趣的架构决策。如果您对源代码感兴趣,请前往 GitHub 查看

在 GitHub 上查看

使用网络组件构建 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="{ % templatetag openvariable % }app}}"></io-schedule-page>
    <io-attend-page></io-attend-page>
    <io-extended-page></io-extended-page>
    <io-faq-page></io-faq-page>

我们为什么要这么做?第一个原因是此代码可读。如果您是初次使用者,将会非常清楚应用中的每个页面是什么。第二个原因是,网络组件具有一些很棒的属性,可用于构建 SPA。得益于 <template> 元素、自定义元素Shadow DOM 的固有功能,很多常见的问题(状态管理、视图激活、样式范围限定)都消失了。这些是内置于浏览器中的开发者工具。何不充分利用它们呢?

通过为每个网页创建自定义元素,我们获得了很多免费功能:

  • 页面生命周期管理。
  • 将 CSS/HTML 的作用域限定为网页。
  • 所有特定于网页的 CSS/HTML/JS 可根据需要捆绑并加载在一起。
  • 视图可以重复使用。由于网页是 DOM 节点,因此简单地添加或移除它们都会改变视图。
  • 未来的维护人员只需浏览标记即可了解我们的应用。
  • 随着浏览器注册和升级元素定义,服务器渲染的标记可以逐渐增强。
  • 自定义元素具有继承模式。DRY 代码是很好的代码。
  • ...更多东西。

我们在爱荷华州充分利用了这些优势。我们来深入了解一些细节。

动态启用页面

<template> 元素是浏览器创建可重复使用的标记的标准方式。<template> 具有 SPA 可以利用的两个特性。首先,在创建模板的实例之前,<template> 内的所有内容都会休眠。其次,浏览器会解析标记,但无法从主页面访问内容。它是真正的可重复使用的标记块。例如:

<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>

Polymer 通过几个类型扩展自定义元素(即 <template is="dom-if"><template is="dom-repeat">)来extends <template>。两者都是自定义元素,使用额外的功能扩展 <template>。由于 Web 组件具有声明式特性,这两种组件都能实现您预期的效果。第一个组件根据条件标记标记。第二个函数会对列表(数据模型)中的每个项重复标记。

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

您应该还记得,IOWA 中的每个页面都是一个网络组件。不过,在首次加载时声明每个组件就很傻。这意味着,在应用首次加载时,需要为每个页面创建一个实例。我们不希望影响初始加载性能,特别是因为某些用户只会导航到 1 到 2 个网页。

我们的解决方法就是作弊。在 IOWA 中,我们将每个页面的元素封装在 <template is="dom-if"> 中,使其内容不会在首次启动时加载。然后,当模板的 name 属性与网址匹配时,我们会激活页面。<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 只会按需执行(当其父级 <template> 已标记时)。使用网络组件 FTW 的动态视图 + 延迟视图。

未来的改进

当网页首次加载时,我们会一次性加载每个网页的所有 HTML 导入内容。一个显而易见的改进是仅在需要时延迟加载元素定义。Polymer 还有一个用于异步加载 HTML Imports 的实用帮助程序:

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

爱荷华没有这样做,因为 a) 我们有点懒,并且 b) 我们尚不清楚效果提升有多大。我们的首次渲染大约需要 1 秒。

页面生命周期管理

Custom Elements API 定义了用于管理组件状态的“生命周期回调”。实现这些方法时,您可以在组件的生命周期中使用免费钩子:

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 中可以轻松利用这些回调。请注意,每个网页都是一个独立的 DOM 节点。要在 SPA 中导航到“新视图”,只需将一个节点附加到 DOM 并移除另一个节点即可。

我们使用 attachedCallback 执行了设置工作(初始化状态、附加事件监听器)。当用户导航到其他页面时,detachedCallback 会执行清理操作(移除监听器、重置共享状态)。我们还在自己的一些回调的基础上扩展了原生生命周期回调:

onPageTransitionDone() {
    // page transition animations are complete.
},

onSubpageTransitionDone() {
    // sub nav/tab page transitions are complete.
}

这些函数对于延迟工作并最大程度地减少页面转换之间的卡顿非常有用。稍后会详细介绍。

干预各页面中的通用功能

继承是自定义元素的一项强大功能。它为网络提供了标准的继承模式。

遗憾的是,在撰写本文时,Polymer 1.0 尚未实现元素继承。与此同时,Polymer 的行为功能同样有用。行为就是 Mixin。

与其在所有页面上创建相同的 API 接口,不如通过创建共享 mixin 来对代码库进行 DRY。例如,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};

如您所见,PageBehavior 会执行在用户访问新页面时运行的常见任务。例如,更新 document.title、重置滚动位置,以及为滚动和子导航效果设置事件监听器。

各个页面通过将 PageBehavior 作为依赖项加载并使用 behaviors 来使用它。如果需要,它们也可以随意替换其基本属性/方法。例如,以下是我们的首页“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 代码,便可在整个应用中在不同位置重复使用它。对我们而言,“不同位置”意味着不同的组件。

在爱荷华州,我们创建了 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”的模块中。

正在分享应用状态

现在,您已经知道应用中的每个页面都是自定义元素。我已经说过一百万遍了。好的,但如果每个页面都是独立的 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="{ % templatetag openvariable % }app}}"></io-schedule-page>
    </template>
    ...
</lazy-pages>

<google-signin client-id="..." scopes="profile email"
                            user="{ % templatetag openvariable % }app.currentUser}}"></google-signin>

<iron-media-query query="(min-width:320px) and (max-width:768px)"
                                query-matches="{ % templatetag openvariable % }app.isPhoneSize}}"></iron-media-query>

当用户登录我们的应用时,<google-signin> 元素会更新其 user 属性。由于该属性已绑定到 app.currentUser,因此任何想要访问当前用户的页面只需绑定到 app 并读取 currentUser 子属性即可。此方法本身对于在应用中共享状态非常有用。但是,另一个好处是,我们最终创建了单一登录元素并在整个网站上重复使用了其结果。媒体查询也是如此。为每个网页重复登录或创建自己的一组媒体查询将会是浪费行为。相反,负责应用范围的功能/数据的组件存在于应用级别。

页面转换

在浏览 Google I/O 大会 Web 应用时,您会注意到它采用流畅的页面过渡(à la Material Design)。

IOWA 的页面转换效果实例。
IOWA 页面转换的实际效果。

当用户导航到新页面时,会发生一系列操作:

  1. 顶部导航栏可将选择栏滑动至新链接。
  2. 网页标题会逐渐消失。
  3. 网页内容先向下滑动,然后逐渐消失。
  4. 反转这些动画后,系统会显示新页面的标题和内容。
  5. (可选)新页面会执行额外的初始化工作。

我们面临的挑战之一是,了解如何在不牺牲性能的情况下制作这种流畅的过渡。由于需要处理大量动态工作,因此我们不欢迎派对期间出现卡顿现象。我们的解决方案将 Web Animations API 和 Promise 结合使用。将这两者结合使用可为我们提供多功能性、即插即用的动画系统以及精细的控制功能,从而最大限度地减少卡顿卡顿。

运作方式

当用户点击进入新页面(或点击返回/前进按钮)时,路由器的 runPageTransition() 会运行一系列 Promise。我们使用 Promise 来精心编排动画,并帮助使 CSS 动画和动态加载内容的“异步”更加合理。

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 帮助程序。对于 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)
    ]);
}

只需修改数组,即可让视图转换更复杂(或更复杂)!

滚动效果

当您滚动页面时,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 的应用布局元素实现的。它们提供开箱即用的滚动效果,例如粘性/返回顶部导航、阴影、颜色和背景过渡、视差效果和流畅滚动。

    // 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-header> 元素。您可以轻松在应用中实现精美的滚动效果。当然,我们本可以自行实现这些元素,但将细节编码到可重复使用的组件中可以节省大量时间。

声明元素。您可以使用属性对其进行自定义。大功告成!

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

总结

对于 I/O 渐进式 Web 应用,得益于 Web 组件和 Polymer 的预制 Material Design widget,我们在几周内就能够构建出整个前端。原生 API(自定义元素、Shadow DOM、<template>)的功能自然而然地融入到了 SPA 的活力四射中。可重用性可以为您节省大量时间。

如果您对自行创建渐进式 Web 应用感兴趣,请查看 App Toolbox。Polymer 的应用工具箱包含一系列组件、工具和模板,可用于使用 Polymer 构建 PWA。这样,您就可以轻松上手投放广告。