
סיכום
איך יצרנו אפליקציה של דף יחיד באמצעות רכיבי אינטרנט, Polymer ועיצוב חומרים, והשקנו אותה בסביבת הייצור ב-Google.com.
תוצאות
- יותר התעניינות מאשר באפליקציה המקורית (4:06 דקות באתר לנייד לעומת 2:40 דקות ב-Android).
- זמן עיבוד ראשון מהיר יותר ב-450 אלפיות השנייה למשתמשים חוזרים, בזכות שמירת נתונים במטמון של שירותי העבודה
- 84% מהמבקרים תמכו ב-Service Worker
- מספר השמירות של 'הוספה למסך הבית' עלה ב-900% בהשוואה לשנת 2015.
- 3.8% מהמשתמשים עברו למצב אופליין, אבל המשיכו לייצר 11,000 צפיות בדפים!
- 50% מהמשתמשים המחוברים הפעילו את ההתראות.
- נשלחו 536,000 התראות למשתמשים (12% מהם חזרו).
- 99% מהדפדפנים של המשתמשים תמכו ב-polyfills של רכיבי ה-Web
סקירה כללית
השנה הייתה לי הזכות לעבוד על אפליקציית האינטרנט המתקדמת של Google I/O 2016, שנקראת בחיבה 'IOWA'. הוא מבוסס על עיצוב לנייד, פועל במצב אופליין מלא ומבוסס במידה רבה על עיצוב חומר.
IOWA היא אפליקציה בדף יחיד (SPA) שנוצרה באמצעות רכיבי אינטרנט, Polymer ו-Firebase, ויש לה קצה עורפי נרחב שנכתב ב-App Engine (Go). המערכת מאחסנת תוכן במטמון מראש באמצעות service worker, טוענת דפים חדשים באופן דינמי, עוברת בצורה חלקה בין תצוגות ומשתמשת שוב בתוכן אחרי הטעינה הראשונה.
בניתוח המקרה הזה, אציג כמה מההחלטות הארכיטקטוניות המעניינות יותר שקיבלנו לגבי הקצה הקדמי. אם אתם רוצים לבדוק את קוד המקור, אתם יכולים לעיין בו ב-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, הוספה או הסרה שלהם משנים את התצוגה.
- אנשי תחזוקה עתידיים יוכלו להבין את האפליקציה שלנו פשוט על ידי הבנת הרכיבים של ה-Markup.
- אפשר לשפר את הרכיבים של ה-Markup שעבר עיבוד בשרת בהדרגה, ככל שהדפדפן רושם ומשודרג את הגדרות הרכיבים.
- לרכיבים מותאמים אישית יש מודל ירושה. קוד ללא כפילויות הוא קוד טוב.
- …עוד הרבה דברים.
השתמשנו בהטבות האלה במלואן ב-IOWA. נבחן כמה מהפרטים.
הפעלה דינמית של דפים
הרכיב <template>
הוא הדרך הרגילה שבה הדפדפן יוצר סימון לשימוש חוזר. ל-<template>
יש שני מאפיינים שספקי ה-SPA יכולים להפיק מהם תועלת. קודם כול, כל מה שנמצא בתוך <template>
לא פעיל עד שיוצרים מופע של התבנית. השנייה: הדפדפן מפרק את ה-Markup, אבל אי אפשר לגשת לתוכן מהדף הראשי. זהו מקטע של רכיבי עיבוד טקסט שאפשר לעשות בו שימוש חוזר. לדוגמה:
<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>
עם יכולות נוספות. בזכות האופי הדקלרטיבי של רכיבי האינטרנט, שניהם עושים בדיוק את מה שציפיתם.
הרכיב הראשון חותמת את הרכיב על סמך תנאי. השני חוזר על ה-markup לכל פריט ברשימה (מודל נתונים).
איך IOWA משתמשת ברכיבי התוסף האלה של סוגים?
אם זכור לכם, כל דף ב-IOWA הוא רכיב אינטרנט. עם זאת, לא כדאי להצהיר על כל הרכיבים בעומס הראשון. המשמעות היא שיהיה צורך ליצור מופע של כל דף כשהאפליקציה נטענת בפעם הראשונה. לא רצינו לפגוע בביצועי הטעינה הראשונית, במיוחד מכיוון שחלק מהמשתמשים ינווטו רק לדף אחד או לשניים.
הפתרון שלנו היה לרמות. ב-IOWA, אנחנו עוטפים כל אלמנט של דף ב-<template is="dom-if">
כדי שהתוכן שלו לא יטוען בהפעלה הראשונה. לאחר מכן אנחנו מפעילים דפים כשהמאפיין name
של התבנית תואם לכתובת ה-URL. רכיב האינטרנט <lazy-pages>
מטפל בכל הלוגיקה הזו בשבילנו. ה-Markup נראה כך:
<!-- 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 של כל דף בבת אחת. שיפור ברור יהיה טעינת הלקיש (lazy load) של הגדרות הרכיבים רק כשהן נחוצות. ב-Polymer יש גם כלי עזר נחמד לטעינה אסינכררונית של ייבוא HTML:
Polymer.Base.importHref('io-home-page.html', (e) => { ... });
אנחנו לא עושים את זה ב-IOWA כי: א) היינו עצלים, ב) לא ברור כמה שיפור בביצועים היינו רואים. הצגת התוכן הראשונה (paint) כבר הייתה כ-1 שניות.
ניהול מחזור החיים של דפים
ב-Custom Elements API מוגדרים אירועי קריאה חוזרת (callbacks) של מחזור חיים לניהול המצב של רכיב. כשמטמיעים את השיטות האלה, מקבלים ווקים בחינם לחיי הרכיב:
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
מבצע פעולות ניקוי (הסרת מאזינים, איפוס המצב המשותף). כמו כן, הרחבנו את הקריאות החוזרות (callbacks) של מחזור החיים המקומי עם כמה קריאות חוזרות משלימות:
onPageTransitionDone() {
// page transition animations are complete.
},
onSubpageTransitionDone() {
// sub nav/tab page transitions are complete.
}
אלה היו תוספות שימושיות שאפשרו לעכב את העבודה ולצמצם את התנודות בין מעברי הדפים. בהמשך נסביר על כך.
שימוש ב-DRY לפונקציונליות משותפת בדפים שונים
ירושה היא תכונה חזקה של רכיבים בהתאמה אישית. הוא מספק מודל ירושה רגיל לאינטרנט.
לצערנו, בזמן כתיבת המאמר, עדיין לא הטמענו את ירושת הרכיבים ב-Polymer 1.0. בינתיים, התכונה Behaviors ב-Polymer הייתה שימושית באותה מידה. התנהגויות הן פשוט רכיבים משולבים.
במקום ליצור את אותה ממשק API בכל הדפים, החלטנו להשתמש ב-mixins משותפים כדי להפחית את הכמות של הקוד הלא שימושי. לדוגמה, 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
מועבר באפליקציה שלנו על ידי הזרקה לכל רכיב שזקוק לנתונים שלו. בעזרת תכונות קישור הנתונים של 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, אתם יכולים להבחין במעברים החלקים בין הדפים (בסגנון עיצוב חומר).

כשמשתמשים עוברים לדף חדש, מתרחשת רצף של אירועים:
- בסרגל הניווט העליון, סרגל הבחירה מחליק אל הקישור החדש.
- הכותרת של הדף נעלמת.
- התוכן של הדף מחליק למטה ואז מתפוגג.
- כשמפעילים את האנימציות האלה בכיוון ההפוך, מופיעים הכותרת והתוכן של הדף החדש.
- (אופציונלי) הדף החדש מבצע פעולות נוספות של איפוס.
אחד מהאתגרים שלנו היה להבין איך ליצור את המעבר החלק הזה בלי להקריב את הביצועים. יש הרבה עבודה דינמית שמתבצעת, וג'אנק לא היה מוזמן למסיבה שלנו. הפתרון שלנו היה שילוב של Web Animations API ו-Promises. השילוב של שניהם נתן לנו גמישות, מערכת אנימציה מסוג 'חבר ושחק' ובקרה פרטנית כדי למזער את התנודות.
איך זה עובד
כשמשתמשים לוחצים על דף חדש (או מקישים על 'הקודם'/'הבא'), ה-runPageTransition()
של הנתב שלנו מבצע את הקסם שלו על ידי הפעלה של סדרה של הבטחות (Promises). השימוש ב-Promises אפשר לנו לתזמר בקפידה את האנימציות ועזר לנו להסביר את ה'לא-סינכרוניזציה' של אנימציות 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));
}
}
כזכור מהקטע 'שמירה על קוד יבש: פונקציונליות משותפת בדפים', הדפים מקשיבים לאירועי ה-DOM page-transition-start
ו-page-transition-done
. עכשיו אפשר לראות איפה האירועים האלה מופעלים.
השתמשנו ב-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>
הגלילה החלקה מיושמת באמצעות רכיבי app-layout של 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>
סיכום
לאפליקציית ה-PWA של I/O, הצלחנו ליצור ממשק חזיתי שלם תוך כמה שבועות, הודות לרכיבי אינטרנט ולווידג'טים מוכנים מראש של עיצוב חומר ב-Polymer. התכונות של ממשקי ה-API הנתמכים (Custom Elements, Shadow DOM, <template>
) מתאימות באופן טבעי לדינמיות של SPA. שימוש חוזר חוסך המון זמן.
רוצים ליצור אפליקציית Progressive Web App משלכם? כדאי לעיין בערכת הכלים לפיתוח אפליקציות. ערכת הכלים של אפליקציות Polymer היא אוסף של רכיבים, כלים ותבניות ליצירת אפליקציות PWA באמצעות Polymer. זו דרך קלה להתחיל.