
摘要
瞭解我們如何使用網路元件、Polymer 和 Material Design 建構單頁應用程式,並在 Google.com 上正式推出。
結果
- 比原生應用程式更能吸引使用者互動 (行動版網站 4 分 6 秒,Android 版 2 分 40 秒)。
- 服務工作者快取功能可讓回訪使用者的首次顯示時間提早 450 毫秒
- 84% 的訪客支援 Service Worker
- 與 2015 年相比,新增至主畫面的儲存量增加了 900%。
- 3.8% 的使用者離線,但仍持續產生 11,000 次網頁瀏覽!
- 50% 的已登入使用者啟用了通知功能。
- 向使用者傳送了 536,000 則通知 (12% 的使用者回歸)。
- 99% 的使用者瀏覽器支援網頁元件 polyfill
總覽
今年,我很榮幸參與 Google I/O 2016 漸進式網頁應用程式的開發工作,這項應用程式暱稱為「IOWA」。這項服務以行動裝置為優先,可完全離線運作,並大量受到Material Design 的啟發。
IOWA 是單頁應用程式 (SPA),使用網路元件、Polymer 和 Firebase 建構而成,且具有以 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>
,並提供一些類型擴充自訂元素,例如 <template is="dom-if">
和 <template is="dom-repeat">
。這兩個自訂元素都會擴充 <template>
的額外功能。而且,由於網頁元件的宣告性質,兩者都會按照預期執行。第一個元件會根據條件設定標記標記。第二個方法會針對清單 (資料模型) 中的每個項目重複標記。
IOWA 如何使用這些型別擴充功能元素?
如您所知,IOWA 中的每個網頁都是網頁元件。不過,在第一次載入時宣告每個元件是不明智的做法。也就是說,在應用程式首次載入時,會建立每個頁面的例項。我們不希望影響初始載入效能,尤其是有些使用者只會前往 1 或 2 個網頁。
我們的解決方案是作弊。在 IOWA 中,我們會將每個網頁元素包裝在 <template is="dom-if">
中,以便在首次啟動時不載入內容。接著,當範本的 name
屬性與網址相符時,我們會啟用網頁。<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>
被標記時)。使用網頁元件建立動態 + 延遲檢視畫面,可說是完美結局。
未來改善項目
網頁首次載入時,我們會一次載入每個網頁的所有 HTML 匯入。明顯的改善方式,就是只在需要時延遲載入元素定義。Polymer 也提供方便的輔助工具,可用於非同步載入 HTML 匯入內容:
Polymer.Base.importHref('io-home-page.html', (e) => { ... });
IOWA 沒有這麼做,是因為 (a) 我們懶得這麼做,(b) 我們不確定這麼做能提升多少效能。我們的第一個顯示時間約為 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.
}
這些新增功能可延遲工作,並盡量減少網頁轉換期間的卡頓情形。稍後會再詳細說明。
在各頁面中實踐 DRY 原則,提升常見功能
繼承是自訂元素的強大功能。為網頁提供標準的繼承模式。
很抱歉,Polymer 1.0 在撰寫本文時尚未實作元素繼承功能。在此同時,Polymer 的「行為」功能也同樣實用。行為只是混合物。
與其在所有頁面上建立相同的 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
。如有需要,也可以自由覆寫其基本屬性/方法。舉例來說,以下是首頁「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>
是 Polymer 的語法,表示「在名為『shared-app-styles』的模組中加入樣式」。
共用應用程式狀態
您現在應該知道應用程式中的每個頁面都是自訂元素。我已經說過無數次了。好,但如果每個網頁都是獨立的網頁元件,您可能會想知道如何在應用程式中共用狀態。
IOWA 使用與依附元件插入 (Angular) 或 redux (React) 類似的技術來共用狀態。我們建立了全域 app
屬性,並將共用子屬性掛在此屬性上。我們會將 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)。

使用者前往新頁面時,會發生以下一連串事件:
- 頂端導覽列會將選取列滑動至新連結。
- 頁面標題會淡出。
- 網頁內容會向下滑動,然後淡出。
- 反轉這些動畫後,新頁面的標題和內容就會顯示出來。
- (選用) 新頁面會執行額外的初始化作業。
我們面臨的其中一個挑戰,就是如何在不犧牲效能表現的情況下,打造出這麼流暢的轉場效果。我們需要進行大量動態作業,而「jank」不受歡迎。我們的解決方案結合了 Web Animations API 和 Promises。這兩種技術的結合,讓我們可以實現多樣性、即插即用動畫系統,以及精細控制,盡量減少 das 的頓挫。
運作方式
當使用者點選新頁面 (或按下「前進/後退」按鈕) 時,我們的路由器 runPageTransition()
會執行一系列承諾,發揮神奇功效。使用 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 事件。您現在可以看到這些事件的觸發位置。
我們使用的是 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 漸進式網頁應用程式,我們得以在幾週內建構整個前端,這要歸功於網路元件和 Polymer 預先製作的 Material Design 小工具。原生 API 的功能 (Custom Elements、Shadow DOM、<template>
) 自然適合 SPA 的動態特性。可重複使用,可節省大量時間。
如果您有興趣自行建立漸進式網頁應用程式,請參閱應用程式工具箱。Polymer 的應用程式工具箱包含元件、工具和範本,可用於使用 Polymer 建構 PWA。這麼做可以輕鬆啟用服務。