요약
웹 구성요소, Polymer, Material Design을 사용하여 단일 페이지 앱을 빌드하고 Google.com에서 프로덕션으로 출시한 방법을 알아보세요.
결과
- 네이티브 앱보다 높은 참여도(모바일 웹 4분 6초, Android 2분 40초)
- 서비스 워커 캐싱을 통해 재방문자의 첫 페인트가 450ms 더 빨라짐
- 방문자의 84% 가 서비스 워커 지원
- 2015년과 비교해 홈 화면에 추가 저장이 900% 증가했습니다.
- 3.8% 의 사용자는 오프라인으로 전환했지만 계속해서 11,000회의 페이지 조회수를 기록했습니다.
- 로그인한 사용자의 50% 가 알림을 사용 설정했습니다.
- 사용자에게 536,000개의 알림이 전송되었으며, 이 중 12%가 다시 돌아왔습니다.
- 사용자 브라우저의 99%가 웹 구성요소 폴리필을 지원함
개요
올해는 'IOWA'라는 애칭으로 불리는 Google I/O 2016 프로그레시브 웹 앱을 작업할 수 있었습니다. 모바일에 최적화되어 있으며 완전히 오프라인에서 작동하며 Material Design에서 많은 영감을 받았습니다.
IOWA는 웹 구성요소, Polymer, Firebase를 사용하여 빌드된 단일 페이지 애플리케이션 (SPA)이며 App Engine (Go)으로 작성된 광범위한 백엔드가 있습니다. 또한 서비스 워커를 사용하여 콘텐츠를 사전 캐시하고, 새 페이지를 동적으로 로드하고, 뷰 간에 원활하게 전환하고, 첫 로드 후 콘텐츠를 재사용합니다.
이 사례에서는 프런트엔드에 대해 내린 몇 가지 더 흥미로운 아키텍처 결정을 살펴보겠습니다. 소스 코드에 관심이 있으면 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"></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>
를 확장하는 맞춤 요소입니다. 웹 구성요소의 선언적 특성 덕분에 두 방법 모두 예상대로 작동합니다.
첫 번째 구성요소는 조건부를 기반으로 마크업을 스탬프합니다. 두 번째는 목록(데이터 모델)의 모든 항목에 마크업을 반복합니다.
IOWA는 이러한 유형 확장 요소를 어떻게 사용하나요?
기억하시겠지만 IOWA의 모든 페이지는 웹 구성요소입니다. 하지만 첫 로드 시 모든 구성요소를 선언하는 것은 어리석은 일입니다. 앱이 처음 로드될 때 모든 페이지의 인스턴스를 만들어야 한다는 의미입니다. 특히 일부 사용자는 한두 페이지만 이동하기 때문에 초기 로드 성능을 저하시키고 싶지 않았습니다.
해결책은 부정행위였습니다. 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) => { ... });
아이오와는 이렇게 하지 않습니다. 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 처리
상속은 맞춤 요소의 강력한 기능입니다. 웹에 표준 상속 모델을 제공합니다.
안타깝게도 Polymer 1.0은 작성 시점에 요소 상속을 아직 구현하지 않았습니다. 그동안 Polymer의 동작 기능도 마찬가지로 유용했습니다. 동작은 믹스인일 뿐입니다.
모든 페이지에 동일한 API 노출 영역을 생성하는 대신 공유 믹스인을 만들어 코드베이스를 정리하는 것이 합리적입니다. 예를 들어 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
를 사용합니다.
필요한 경우 기본 속성/메서드를 재정의할 수도 있습니다. 예를 들어 다음은 홈페이지 '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>
는 '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 웹 앱을 탐색할 때 Material Design 스타일의 멋진 페이지 전환을 확인할 수 있습니다.
사용자가 새 페이지로 이동하면 다음과 같은 일련의 작업이 실행됩니다.
- 상단 탐색 메뉴에서 선택 바가 새 링크로 슬라이드됩니다.
- 페이지의 제목이 서서히 사라집니다.
- 페이지의 콘텐츠가 아래로 미끄러진 후 사라집니다.
- 이러한 애니메이션을 역전시켜 새 페이지의 제목과 콘텐츠를 표시합니다.
- (선택사항) 새 페이지에서 추가 초기화 작업을 실행합니다.
실적을 떨어뜨리지 않으면서 매끄러운 전환을 만드는 방법을 찾는 것이 과제 중 하나였습니다. 동적 작업이 많이 이루어지며 버벅거림은 환영받지 못했습니다. 솔루션은 Web Animations API와 Promises를 조합한 것입니다. 이 두 가지를 함께 사용하면 다재다능함, 플러그 앤 플레이 애니메이션 시스템, 세밀한 제어를 통해 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-start
및 page-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의 앱 레이아웃 요소를 사용하여 구현됩니다. 고정/반환 상단 탐색 메뉴, 그림자, 색상 및 배경 전환, 시차 효과, 부드러운 스크롤과 같은 기본 제공 스크롤 효과를 제공합니다.
// 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의 앱 도구 상자는 Polymer로 PWA를 빌드하기 위한 구성요소, 도구, 템플릿 모음입니다. 간편하게 시작할 수 있습니다.