Создание прогрессивного веб-приложения Google I/O 2016

Айова дом

Краткое содержание

Узнайте, как мы создали одностраничное приложение с использованием веб-компонентов, Polymer и Material Design и запустили его в производство на Google.com.

Результаты

  • Больше вовлеченности, чем в родном приложении (4:06 минуты в мобильном Интернете против 2:40 минут в Android).
  • Первая отрисовка для вернувшихся пользователей на 450 мс быстрее благодаря кэшированию сервис-воркеров.
  • 84% посетителей поддержали Service Worker
  • Количество сохранений на главном экране выросло на 900% по сравнению с 2015 годом.
  • 3,8% пользователей отключились от сети, но продолжали просматривать 11 тысяч страниц!
  • 50 % зарегистрированных пользователей включили уведомления.
  • Пользователям было отправлено 536 тыс. уведомлений (12% вернули их обратно).
  • 99% браузеров пользователей поддерживают полифилы веб-компонентов.

Обзор

В этом году я имел удовольствие работать над прогрессивным веб-приложением Google I/O 2016 , ласково названным «IOWA». Он в первую очередь мобильный, работает полностью в автономном режиме и во многом вдохновлен Material Design .

IOWA — это одностраничное приложение (SPA), созданное с использованием веб-компонентов , Polymer и Firebase, и имеющее обширную серверную часть, написанную на App Engine (Go). Он предварительно кэширует контент с помощью сервис-воркера , динамически загружает новые страницы, плавно переключается между представлениями и повторно использует контент после первой загрузки.

В этом примере я расскажу о некоторых наиболее интересных архитектурных решениях, которые мы приняли для внешнего интерфейса. Если вас интересует исходный код, проверьте его на Github.

Посмотреть на Github

Создание SPA с использованием веб-компонентов

Каждая страница как компонент

Одним из основных аспектов нашего интерфейса является то, что он сосредоточен вокруг веб-компонентов. Фактически каждая страница в нашем SPA является веб-компонентом:

    <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> , Custom Elements и 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"></video> <!-- doesn't load/start -->
    <script>alert("Not run until the template is stamped");</script>
</template>

Polymer расширяет <template> несколькими пользовательскими элементами расширения типа , а именно <template is="dom-if"> и <template is="dom-repeat"> . Оба являются пользовательскими элементами, расширяющими <template> дополнительными возможностями. А благодаря декларативному характеру веб-компонентов оба делают именно то, что вы ожидаете. Первый компонент штампует разметку на основе условия. Второй повторяет разметку для каждого элемента списка (модель данных).

Как IOWA использует эти элементы расширения типа?

Если вы помните, каждая страница в IOWA является веб-компонентом. Однако было бы глупо объявлять каждый компонент при первой загрузке . Это означало бы создание экземпляра каждой страницы при первой загрузке приложения. Мы не хотели ухудшать нашу первоначальную производительность загрузки, тем более что некоторые пользователи будут переходить только на 1 или 2 страницы.

Нашим решением было обмануть. В IOWA мы оборачиваем элемент каждой страницы в <template is="dom-if"> чтобы его содержимое не загружалось при первой загрузке. Затем мы активируем страницы, когда атрибут name шаблона соответствует URL-адресу. Веб-компонент <lazy-pages> обрабатывает всю эту логику за нас. Разметка выглядит примерно так:

<!-- 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-импорта:

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

IOWA не делает этого, потому что а) мы ленились и б) неясно, какой прирост производительности мы бы увидели. Наша первая краска уже была ~1 с.

Управление жизненным циклом страницы

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 оказалась не менее полезной. Поведения — это просто миксины.

Вместо того, чтобы создавать одну и ту же поверхность API на всех страницах, имело смысл «СУХАТЬ» кодовую базу, создавая общие примеси. Например, PageBehavior определяет общие свойства/методы, необходимые всем страницам нашего приложения:

СтраницаBehavior.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 . При необходимости они также могут переопределить его базовые свойства/методы. В качестве примера, вот что переопределяет «подкласс» нашей домашней страницы:

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 чтобы совместно использовать цвета, типографику и классы макета на страницах и других созданных нами компонентах.

общее-приложение-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».

Делимся состоянием приложения

Теперь вы знаете, что каждая страница нашего приложения является пользовательским элементом. Я говорил это миллион раз. Хорошо, но если каждая страница представляет собой автономный веб-компонент, вы можете спросить себя, как мы делимся состоянием в приложении.

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 вы заметите плавные переходы страниц ( в стиле Material Design ).

Переходы страниц IOWA в действии.
Переходы страниц IOWA в действии.

Когда пользователи переходят на новую страницу, происходит следующая последовательность событий:

  1. Верхняя панель навигации перемещает панель выбора к новой ссылке.
  2. Заголовок страницы исчезает.
  3. Содержимое страницы сползает вниз, а затем исчезает.
  4. Если перевернуть эту анимацию, появятся заголовок и содержимое новой страницы.
  5. (Необязательно) Новая страница выполняет дополнительную работу по инициализации.

Одной из наших задач было выяснить, как осуществить этот плавный переход, не жертвуя при этом производительностью. Происходит очень динамичная работа, и Джанк на нашей вечеринке не был желанным гостем. Наше решение представляло собой комбинацию API веб-анимации и Promises . Их совместное использование дало нам универсальность, систему анимации Plug and Play и детальный контроль, позволяющий свести к минимуму зависания .

Как это работает

Когда пользователи переходят на новую страницу (или нажимают назад/вперед), runPageTransition() нашего маршрутизатора творит чудеса, выполняя серию промисов. Использование обещаний позволило нам тщательно организовать анимацию и помогло рационализировать «асинхронность» 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: общая функциональность на страницах» , страницы прослушивают события DOM page-transition-start и page-transition-done . Теперь вы видите, где запускаются эти события.

Мы использовали 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>

Заключение

Для веб-приложения с прогрессивным вводом-выводом мы смогли создать весь интерфейс за несколько недель благодаря веб-компонентам и готовым виджетам Material Design от Polymer. Функции собственных API (Пользовательские элементы, Shadow DOM, <template> ) естественным образом обеспечивают динамизм SPA. Возможность повторного использования экономит массу времени.

Если вы заинтересованы в создании собственного прогрессивного веб-приложения, воспользуйтесь панелью инструментов приложения . Polymer’s App Toolbox — это набор компонентов, инструментов и шаблонов для создания PWA с помощью Polymer. Это простой способ начать работу.