摘要
瞭解我們如何使用網路元件、Polymer 和 Material Design 建構單頁應用程式,並在 Google.com 上正式推出。
結果
- 參與度比原生應用程式還高 (行動版網站的載入時間是 4 分 40 秒,Android 則為 2 分 40 秒)。
- 藉助 Service Worker 快取功能,對回訪者首次繪製的速度加快 450 毫秒
- 84% 的訪客支持 Service Worker
- 與 2015 年相比,新增至主畫面的儲存量增加了 900%。
- 3.8% 的使用者離線,但仍持續產生 11,000 次網頁瀏覽!
- 50% 的已登入使用者啟用了通知功能。
- 已向使用者發送 53.6 萬則通知 (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 中的每個網頁都是網頁元件。不過,在第一次載入時宣告每個元件是不明智的做法。這表示系統會在應用程式首次載入時,建立一個頁面的執行個體。我們不希望妨礙初始載入效能,特別是某些使用者只會瀏覽一或兩個網頁。
我們的解決方案是作弊。在 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 秒。
頁面生命週期管理
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.
}
這些額外功能有助於延後工作,以及盡量減少頁面轉換之間的卡頓。稍後會再詳細討論。
將各個頁面的常見功能放在一起
繼承是自訂元素的強大功能。為網頁提供標準的繼承模式。
很抱歉,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
。如有需要,也可以自由覆寫其基本屬性/方法。以下為首頁「子類別」覆寫的範例:
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
會透過將其插入需要其資料的每個元件,在應用程式中傳遞。使用 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 和 Promise 組合而成。兩者結合後,我們就能擁有多功能、隨插即播放動畫系統,以及精細的控制選項,盡可能減少「達標」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 的功能 (自訂元素、陰影 DOM、<template>
) 與 SPA 的構成直接作用相仿。可重複使用,可節省大量時間。
如果您有興趣自行建立漸進式網頁應用程式,請參閱應用程式工具箱。Polymer 的 App Toolbox 提供一系列元件、工具和範本,可讓您透過 Polymer 建構 PWA。這麼做可以輕鬆啟用服務。