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

爱荷华州住宅

摘要

了解我们如何使用 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% 的用户浏览器支持 Web 组件 polyfill

概览

今年,我很荣幸能参与 2016 年 Google I/O 大会的渐进式 Web 应用的开发,该应用被亲切地称为“IOWA”。它以移动设备为先,完全离线运行,并深受 Material Design 的启发。

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

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

在 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="{ % templatetag openvariable % }app}}"></io-schedule-page>
    <io-attend-page></io-attend-page>
    <io-extended-page></io-extended-page>
    <io-faq-page></io-faq-page>

我们为何要这样做?第一个原因是此代码可读。作为首次阅读者,应用中的每个页面都非常明显。第二个原因是,Web 组件具有一些用于构建 SPA 的实用属性。得益于 <template> 元素、自定义元素Shadow DOM 的固有特性,许多常见的令人沮丧的问题(状态管理、视图激活、样式作用域)都迎刃而解。这些是内置于浏览器中的开发者工具。为什么不利用这些优势?

通过为每个网页创建自定义元素,我们获得了许多好处:

  • 页面生命周期管理。
  • 特定于网页的 CSS/HTML。
  • 系统会根据需要捆绑并一起加载特定于网页的所有 CSS/HTML/JS。
  • 视图是可重复使用的。由于页面是 DOM 节点,因此只需添加或移除页面即可更改视图。
  • 未来的维护人员只需了解标记代码,即可了解我们的应用。
  • 随着浏览器注册和升级元素定义,服务器呈现的标记可以逐步增强。
  • 自定义元素具有继承模型。DRY 代码是优质代码。
  • …还有更多内容。

我们在 IOWA 中充分利用了这些优势。我们来详细了解一下。

动态激活页面

<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"></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">扩展<template>。这两种自定义元素都可以通过额外的功能扩展 <template>。得益于 Web 组件的声明式特性,这两种方法都能按预期执行操作。第一个组件会根据条件添加标记。第二种会对列表(数据模型)中的每个项重复标记。

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

您还记得吗?IOWA 中的每个网页都是 Web 组件。不过,在首次加载时声明每个组件是愚蠢的做法。这意味着,在应用首次加载时,系统会创建每个页面的实例。我们不希望影响初始加载性能,尤其是因为有些用户只会浏览 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> 被盖章时)才会执行。使用 Web 组件实现动态 + 延迟视图。

未来的改进

网页首次加载时,我们会一次性加载每个网页的所有 HTML 导入。一个明显的改进是,仅在需要时延迟加载元素定义。Polymer 还提供了一个用于异步加载 HTML 导入的便捷帮助程序:

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

IOWA 没有这样做,原因如下:(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.
}

这些新增内容对于延迟工作和最大限度地减少页面转换之间的卡顿非常有用。稍后我们会详细介绍这部分内容。

对各个页面上的常见功能进行 DRY 处理

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

很遗憾,在撰写本文时,Polymer 1.0 尚未实现元素继承。与此同时,Polymer 的行为功能同样非常实用。行为只是混入。

与其在所有页面上创建相同的 API Surface,不如通过创建共享的混入容器来简化代码库。例如,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 来使用 PageBehavior。他们还可以根据需要替换其基本属性/方法。例如,以下是我们的首页“子类”替换项:

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”的模块中添加样式”。

共享应用状态

现在,您已经知道我们应用中的每个页面都是一个自定义元素。我已经说过无数次了。好的,但如果每个网页都是自包含的 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 网站应用时,您会发现其流畅的页面转换效果(采用 材料设计)。

IOWA 的页面转换效果。
IOWA 的页面转换效果。

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

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

我们面临的一个挑战是,如何在不牺牲性能的情况下打造这种流畅的转换效果。我们需要完成大量动态工作,而卡顿是绝对不允许的。我们的解决方案是 Web Animations API 和Promise 的组合。将这两者结合使用,我们获得了多样性、即插即用的动画系统以及精细的控制功能,从而最大限度地减少了das 卡顿。

运作方式

当用户点击新页面(或按返回/前进键)时,路由器的 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));
    }

}

回想一下“保持 DRY:跨页面共享常用功能”部分,页面会监听 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 的 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-header> 元素。它很容易添加,并可在应用中获得精美的滚动效果。当然,我们也可以自行实现这些效果,但将这些细节编码到可重复使用的组件中可以节省大量时间。

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

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

总结

对于 I/O 大会的渐进式 Web 应用,我们得益于 Web 组件和 Polymer 的预制 Material Design 微件,仅用几周时间就构建了整个前端。原生 API(自定义元素、Shadow DOM、<template>)的功能非常适合 SPA 的动态特性。可重复使用功能可节省大量时间。

如果您有兴趣自行创建渐进式 Web 应用,请参阅 App Toolbox。Polymer 的 App Toolbox 是一组组件、工具和模板,可用于使用 Polymer 构建 PWA。这种方式简单易行。