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

בית באיווה

סיכום

ראו כיצד בנינו אפליקציית דף אחת באמצעות רכיבי אינטרנט, פולימר ועיצוב חדשני תלת-ממדי והשקנו אותה לייצור ב-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 של רכיבי האינטרנט

סקירה

השנה נהניתי מאוד לעבוד על אפליקציית Google I/O 2016 מסוג Progressive Web App, שנקראת בחיבה "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>, רכיבים מותאמים אישית ו-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>

פולימר extends את ערכי <template> באמצעות כמה אלמנטים מותאמים אישית של תוסף מסוג תוסף, כלומר <template is="dom-if"> ו-<template is="dom-repeat">. שניהם הם רכיבים מותאמים אישית שמרחיבים את <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) => { ... });

IOWA לא עושה את זה כי א) היו עצלנים וב) לא ברור עד כמה שיפור בביצועים היינו יכולים לראות. הצבע הראשון שלנו כבר היה באורך של כשנייה.

ניהול מחזור החיים של דף

ב-Custom Elements API מוגדרים קריאות חוזרות במחזור החיים לניהול המצב של רכיב. כאשר מטמיעים את השיטות האלה, מוסיפים קטעי הוק (hooks) בחינם לחיים של רכיב:

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 לביצוע עבודת הגדרה (מצב כניסה, צירוף פונקציות event listener). כשמשתמשים עוברים לדף אחר, מתבצע ניקוי ב-detachedCallback (הסרת המאזינים, איפוס המצב המשותף). בנוסף, הרחבנו את הקריאה החוזרת (callback) במחזור החיים המקורי עם כמה קריאות חוזרות משלנו:

onPageTransitionDone() {
    // page transition animations are complete.
},

onSubpageTransitionDone() {
    // sub nav/tab page transitions are complete.
}

אלו היו תוספות שימושיות לעיכוב העבודה ולצמצום ה-jank בין מעברי הדפים. נרחיב בנושא מאוחר יותר.

ייבוש פונקציונליות משותפת בדפים שונים

ירושה היא תכונה מתקדמת של רכיבים מותאמים אישית. הוא מספק מודל ירושה סטנדרטי לאינטרנט.

לצערנו, גרסת 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, איפוס מיקום הגלילה והגדרת פונקציות event listener לאפקטים של גלילה וניווט משני.

דפים נפרדים משתמשים ב-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 כך: 'includes the styles במודול בשם '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 עיצוב חדשני תלת-ממדי).

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

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

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

אחד האתגרים שלנו היה להבין איך ליצור את המעבר החלק הזה בלי לפגוע בביצועים. מתבצעת כאן הרבה עבודה דינמית, וjank לא התקבל במסיבה שלנו. הפתרון שלנו היה שילוב של Web Animations API ו-Promises. השימוש בשתי הדרכים ביחד סיפק לנו מגוון רחב של אפשרויות, מערכת אנימציה בשיטת 'פלאגין הפעלה' ואפשרות שליטה פרטנית כדי למזער את הבעיות הגרפיות (jank) של das.

איך זה עובד

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

כדי לבצע גלילה חלקה, אתם יכולים להשתמש ברכיבים של פריסת האפליקציות של 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>

סיכום

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

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