Google I/O 2016 프로그레시브 웹 앱 빌드

아이오와 홈

요약

Google이 웹 구성요소, Polymer, 머티리얼 디자인을 사용하여 단일 페이지 앱을 빌드하고 Google.com에서 프로덕션에 출시한 방법을 알아보세요.

결과

  • 네이티브 앱 대비 참여도가 높습니다 (모바일 웹은 4분 6초, Android는 2분 40초).
  • 서비스 워커 캐싱 덕분에 재방문 사용자를 위한 450ms 더 빨라진 첫 페인트 속도
  • 방문자의 84% 가 서비스 워커를 지원했습니다.
  • 홈 화면에 추가 저장 횟수는 2015년에 비해 900% 증가했습니다.
  • 3.8% 의 사용자가 오프라인으로 전환했지만 계속해서 11,000회의 페이지 조회수를 기록했습니다.
  • 로그인한 사용자의 50% 가 알림을 사용 설정했습니다.
  • 사용자에게 53만 6천 개의 알림이 전송되었습니다 (12% 가 이를 다시 수신함).
  • 사용자 브라우저의 99% 가 웹 구성요소 폴리필을 지원함

개요

올해 저는 'IOWA'라는 애칭을 가진 Google I/O 2016 프로그레시브 웹 앱작업하는 즐거운 시간을 보냈습니다. 모바일을 우선으로 하고 완전히 오프라인으로 작동하며 머티리얼 디자인의 영향을 많이 받았습니다.

IOWA는 웹 구성요소, Polymer, Firebase를 사용하여 빌드된 단일 페이지 애플리케이션 (SPA)으로 App Engine (Go)으로 작성된 광범위한 백엔드를 보유하고 있습니다. 또한 서비스 워커를 사용하여 콘텐츠를 사전 캐시하고, 새 페이지를 동적으로 로드하고, 뷰 간에 매끄럽게 전환하고, 첫 번째 로드 후 콘텐츠를 재사용합니다.

이 우수사례에서는 프런트엔드와 관련하여 Google에서 결정한 몇 가지 흥미로운 아키텍처 관련 결정을 살펴보겠습니다. 소스 코드에 관심이 있다면 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> 요소, 맞춤 요소, 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" 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">를 사용하여 <template>extends합니다. 둘 다 추가 기능으로 <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가 이 방법을 하지 않는 이유는 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를 사용하여 설정 작업 (init 상태, 이벤트 리스너 연결)을 실행했습니다. 사용자가 다른 페이지로 이동하면 detachedCallback가 정리 (리스너 삭제, 공유 상태 재설정)합니다. 또한 다음과 같은 몇 가지 자체로 네이티브 수명 주기 콜백을 확장했습니다.

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

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

이는 작업을 지연시키고 페이지 전환 사이의 버벅거림을 최소화하는 데 유용한 추가 기능이었습니다. 이건 나중에 다시 설명하죠

여러 페이지에 걸쳐 공통 기능 변경

상속은 맞춤 요소의 강력한 기능입니다. 웹에 대한 표준 상속 모델을 제공합니다.

안타깝게도 이 문서 작성 시점에 Polymer 1.0에서는 요소 상속을 아직 구현하지 않았습니다. 그동안 Polymer의 Behaviors 기능도 유용했습니다. 동작은 단순히 혼선에 불과합니다.

모든 페이지에 동일한 API 노출 영역을 만드는 대신 공유 믹스인을 만들어 코드베이스를 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를 사용하여 사용합니다. 필요한 경우 기본 속성/메서드를 자유롭게 재정의할 수도 있습니다. 예를 들어 홈페이지 '서브클래스'가 재정의하는 항목은 다음과 같습니다.

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에서는 페이지 및 Google에서 만든 다른 구성요소에서 색상, 서체, 레이아웃 클래스를 공유하기 위해 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>는 'shared-app-styles'라는 모듈에 스타일을 포함하기 위한 Polymer의 구문입니다.

애플리케이션 상태 공유

지금까지 앱의 모든 페이지가 맞춤 요소라는 것을 알고 계실 것입니다. 백만 번은 말했죠. 모든 페이지가 독립적인 웹 구성 요소라면 앱 전체에서 상태를 어떻게 공유하는지 궁금하실 것입니다.

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 웹 앱을 탐색하면 매끄러운 페이지 전환 (à la 머티리얼 디자인)을 확인할 수 있습니다.

IOWA에서 페이지 전환을 실행합니다.
IOWA의 페이지 전환 작동 방식

사용자가 새 페이지로 이동하면 다음과 같은 시퀀스가 발생합니다.

  1. 상단 탐색 메뉴가 선택 막대를 새 링크로 슬라이드합니다.
  2. 페이지의 제목이 사라집니다.
  3. 페이지의 콘텐츠가 아래로 슬라이드된 다음 사라집니다.
  4. 애니메이션을 반대로 하면 새 페이지의 제목과 콘텐츠가 표시됩니다.
  5. (선택사항) 새 페이지에서 추가 초기화 작업을 실행합니다.

실적을 떨어뜨리지 않으면서 이 매끄러운 전환을 실현하는 방법을 찾는 것이 저희의 과제 중 하나였습니다. 많은 동적 작업이 발생하므로 파티에서는 버벅거림이 환영되지 않았습니다. 이 솔루션은 Web Animations API와 Promises의 조합이었습니다. 두 가지를 함께 사용하여 다양한 기능, 플러그 앤 플레이 애니메이션 시스템, 세밀한 제어 기능을 제공하여 das 버벅거림을 최소화할 수 있었습니다.

보고서를 확인하는 방법

사용자가 새 페이지를 클릭하거나 뒤로/앞으로 키를 누르면 라우터의 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 유지: 페이지 전반에서 공통 기능' 섹션의 재현율: 페이지는 page-transition-startpage-transition-done DOM 이벤트를 수신 대기합니다. 이제 해당 이벤트가 실행되는 위치가 표시됩니다.

runEnterAnimation/runExitAnimation 도우미 대신 Web Animations API를 사용했습니다. 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 프로그레시브 웹 앱의 경우 웹 구성요소와 Polymer의 사전 제작 머티리얼 디자인 위젯 덕분에 몇 주 만에 전체 프런트엔드를 빌드할 수 있었습니다. 네이티브 API의 기능 (맞춤 요소, Shadow DOM, <template>)은 SPA의 역동적인 기능과 자연스럽게 어우러집니다. 재사용 가능성으로 엄청난 시간이 절약됩니다.

프로그레시브 웹 앱을 직접 만드는 데 관심이 있다면 앱 도구 상자를 확인하세요. Polymer의 App Toolbox는 Polymer로 PWA를 빌드하기 위한 구성요소, 도구, 템플릿 모음입니다. 간편하게 설정하고 운영할 수 있습니다.