בניית Progressive Web App של Google I/O 2016

בית באיווה

סיכום

איך יצרנו אפליקציה של דף יחיד באמצעות רכיבי אינטרנט, Polymer ועיצוב חומרים, והשקנו אותה בסביבת הייצור ב-Google.com.

תוצאות

  • יותר התעניינות בהשוואה לאפליקציה המקורית (4:06 דקות באינטרנט לנייד לעומת 2:40 דקות ב-Android).
  • הצגת תמונה ראשונית במהירות של 450 אלפיות השנייה למשתמשים חוזרים באמצעות שמירה במטמון של Service Worker
  • 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.

הצגה ב-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>, ב-Custom Elements וב-Shadow DOM. אלה כלים למפתחים שמובְנים בדפדפן. למה לא לנצל אותם?

על ידי יצירת רכיב מותאם אישית לכל דף, קיבלנו הרבה חינם:

  • ניהול מחזור החיים של הדף.
  • CSS/HTML מוגדרים בדף.
  • כל ה-CSS/HTML/JS שספציפי לדף מקובצים ונטענים יחד לפי הצורך.
  • אפשר להשתמש שוב בתצוגות. מאחר שדפים הם צמתים ב-DOM, הוספה או הסרה שלהם משנים את התצוגה.
  • מנהלים עתידיים יכולים להבין את האפליקציה שלנו פשוט על ידי הוספת תגי עיצוב.
  • אפשר לשפר בהדרגה את התגים שעבר עיבוד בשרת, ככל שהדפדפן רושם ומשודרג את הגדרות הרכיבים.
  • לרכיבים מותאמים אישית יש מודל ירושה. קוד ללא כפילויות הוא קוד טוב.
  • ... עוד הרבה דברים.

השתמשנו במלוא היתרונות האלה ב-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>

הפולימר מרחיב את הערכים של <template> בעזרת מספר רכיבים מותאמים אישית של תוספים, בעיקר <template is="dom-if"> ו-<template is="dom-repeat">. שניהם רכיבים מותאמים אישית שמרחיבים את <template> עם יכולות נוספות. בזכות האופי הדקלרטיבי של רכיבי האינטרנט, שניהם עושים בדיוק את מה שציפיתם. הרכיב הראשון חותמת את הרכיב על סמך תנאי. הפעולה השנייה חוזרת על הסימון לכל פריט ברשימה (מודל נתונים).

איך 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 בכל הדפים, הגיוני לגבש את ה-codebase על ידי יצירת תמהילים משותפים. לדוגמה, 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. בנוסף, הם לא יכולים לשנות את המאפיינים או ה-methods של הבסיס במקרה הצורך. לדוגמה, אלה ההחרגות של '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, תבחינו במעברים החלקיים בין הדפים (à la material Design).

מעברי הדפים של IOWA בפעולה.
מעברי הדפים של IOWA בפעולה.

כשמשתמשים עוברים לדף חדש, מתרחשת רצף של אירועים:

  1. בסרגל הניווט העליון מחליקים סרגל בחירה אל הקישור החדש.
  2. הכותרת של הדף נעלמת.
  3. תוכן הדף מחליק למטה ואז מתפוגג.
  4. על ידי הפיכת האנימציות האלו, הכותרת והתוכן של הדף החדש יופיעו.
  5. (אופציונלי) בדף החדש מבצעים אתחול נוסף.

אחד מהאתגרים שלנו היה להבין איך ליצור את המעבר החלק הזה בלי להקריב את הביצועים. יש הרבה עבודה דינמית שמתבצעת, וג'אנק לא היה מוזמן למסיבה שלנו. הפתרון שלנו היה שילוב של 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 (ה-Masthead ואזור התוכן הראשי), מצהירים על ההתחלה/הסוף של כל אנימציה, ויוצרים 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>

סיכום

ב-I/O Progressive Web App, הצלחנו לבנות ממשק קצה שלם תוך כמה שבועות בעזרת רכיבי אינטרנט וווידג'טים לעיצוב חומרים מוכנים מראש של Polymer. התכונות של ממשקי ה-API המקוריים (Custom Elements, shadow DOM, <template>) פונים באופן טבעי לדינמיקה של SPA. שימוש חוזר חוסך הרבה זמן.

אם אתם רוצים ליצור Progressive Web App משלכם, כדאי לנסות את ארגז הכלים של האפליקציה. ערכת הכלים של אפליקציות Polymer היא אוסף של רכיבים, כלים ותבניות ליצירת אפליקציות PWA באמצעות Polymer. זו דרך קלה להתחיל.