עבודה עם רכיבים מותאמים אישית

מבוא

באינטרנט יש מחסור חמור בביטוי עצמי. כדי להבין למה הכוונה, אפשר להעיף מבט באפליקציית אינטרנט "מודרנית" כמו Gmail:

Gmail

אין שום דבר מודרני במרק <div>. עם זאת, זו הדרך שבה אנחנו מפתחים אפליקציות אינטרנט. זה עצוב. האם לא כדאי לדרוש יותר מהפלטפורמה שלנו?

תגי עיצוב סקסיים. קדימה, נתחיל

HTML הוא כלי מצוין ליצירת מבנה למסמך, אבל אוצר המילים שלו מוגבל לרכיבים שמוגדרים בתקן HTML.

מה קורה אם תגי העיצוב של Gmail לא נוראיים נוראיים? מה אם התמונה הייתה יפה:

<hangout-module>
    <hangout-chat from="Paul, Addy">
    <hangout-discussion>
        <hangout-message from="Paul" profile="profile.png"
            profile="118075919496626375791" datetime="2013-07-17T12:02">
        <p>Feelin' this Web Components thing.
        <p>Heard of it?
        </hangout-message>
    </hangout-discussion>
    </hangout-chat>
    <hangout-chat>...</hangout-chat>
</hangout-module>

מרענן! גם האפליקציה הזו הגיונית לגמרי. הוא משמעותי, קל להבנה, והכי חשוב – קל לתחזק אותו. אני או אתם בעתיד נדע בדיוק מה הוא עושה רק על ידי בדיקת עמוד השדרה העדכני שלו.

תחילת העבודה

רכיבים מותאמים אישית מאפשרים למפתחי אתרים להגדיר סוגים חדשים של רכיבי HTML. המפרט הוא אחד מכמה רכיבים בסיסיים חדשים של ממשקי API שנכללים בקטגוריה רכיבי אינטרנט, אבל הוא כנראה החשוב ביותר. רכיבי Web לא קיימים בלי התכונות שמתקבלות באמצעות רכיבים מותאמים אישית:

  1. הגדרת רכיבי HTML/DOM חדשים
  2. יצירת רכיבים שמתרחבים מרכיבים אחרים
  3. לארוז באופן לוגי פונקציונליות בהתאמה אישית בתג אחד
  4. הרחבת ה-API של רכיבי DOM קיימים

רישום רכיבים חדשים

רכיבים מותאמים אישית נוצרים באמצעות document.registerElement():

var XFoo = document.registerElement('x-foo');
document.body.appendChild(new XFoo());

הארגומנט הראשון של document.registerElement() הוא שם התג של הרכיב. השם חייב להכיל מקף (-). לדוגמה, <x-tags>,‏ <my-element> ו-<my-awesome-app> הם שמות תקינים, אבל <tabs> ו-<foo_bar> הם לא. ההגבלה הזו מאפשרת למנתח להבדיל בין רכיבים מותאמים אישית לבין רכיבים רגילים, אבל גם מבטיחה תאימות עתידית כשמוסיפים תגים חדשים ל-HTML.

הארגומנט השני הוא אובייקט (אופציונלי) שמתאר את prototype של הרכיב. כאן אפשר להוסיף פונקציונליות מותאמת אישית (למשל, מאפיינים ושיטות ציבוריים) לרכיבים. מידע נוסף בנושא יפורט בהמשך.

כברירת מחדל, רכיבים מותאמים אישית עוברים בירושה מ-HTMLElement. לכן, הדוגמה הקודמת זהה לביטוי:

var XFoo = document.registerElement('x-foo', {
    prototype: Object.create(HTMLElement.prototype)
});

קריאה ל-document.registerElement('x-foo') מלמדת את הדפדפן על הרכיב החדש, ומחזירה קונסטרוקטור שאפשר להשתמש בו כדי ליצור מכונות של <x-foo>. לחלופין, אם אתם לא רוצים להשתמש ב-constructor, תוכלו להשתמש בשיטות אחרות ליצירת רכיבים.

הרחבת רכיבים

רכיבים מותאמים אישית מאפשרים להרחיב רכיבי HTML קיימים (מקומיים) ורכיבים מותאמים אישית אחרים. כדי להרחיב אלמנט, צריך להעביר ל-registerElement() את השם ואת prototype של האלמנט שממנו רוצים לרשת.

הרחבת רכיבים מקומיים

נניח שאתם לא מרוצים מ-Joe הפשוט <button>. אתם רוצים לשפר את היכולות שלו ולהפוך אותו ל'לחצן-על'. כדי להרחיב את האלמנט <button>, יוצרים אלמנט חדש שעובר בירושה את prototype של HTMLButtonElement ואת extends של שם האלמנט. במקרה הזה, 'button':

var MegaButton = document.registerElement('mega-button', {
    prototype: Object.create(HTMLButtonElement.prototype),
    extends: 'button'
});

רכיבים מותאמים אישית שעוברים בירושה מרכיבים מקומיים נקראים רכיבים מותאמים אישית של תוסף סוג. הם יורשים מגרסה מיוחדת של HTMLElement, כדרך לומר "רכיב X הוא Y".

דוגמה:

<button is="mega-button">

הרחבת רכיב מותאם אישית

כדי ליצור רכיב <x-foo-extended> שמרחיב את הרכיב המותאם אישית <x-foo>, פשוט עוברים בירושה על אב הטיפוס שלו ומציינים את התג שממנו עוברים בירושה:

var XFooProto = Object.create(HTMLElement.prototype);
...

var XFooExtended = document.registerElement('x-foo-extended', {
    prototype: XFooProto,
    extends: 'x-foo'
});

מידע נוסף על יצירת אב טיפוס של רכיבים זמין בקטע הוספת מאפיינים ושיטות של JS בהמשך.

איך מתבצע השדרוג של רכיבים

תהיתם פעם למה מנתח ה-HTML לא מתפרץ כשנתקל בתגים לא סטנדרטיים? לדוגמה, אין בעיה אם נצהיר על <randomtag> בדף. בהתאם למפרט HTML:

סליחה <randomtag>! אתם לא סטנדרטיים ומקבלים בירושה מ-HTMLUnknownElement.

זה לא נכון לגבי רכיבים מותאמים אישית. רכיבים עם שמות חוקיים של רכיבים מותאמים אישית יורשים מ-HTMLElement. אפשר לאמת את העובדה הזו על ידי הפעלת מסוף: Ctrl + Shift + J (או Cmd + Opt + J ב-Mac), והדבקת שורות הקוד הבאות. הן מחזירות את הערך true:

// "tabs" is not a valid custom element name
document.createElement('tabs').__proto__ === HTMLUnknownElement.prototype

// "x-tabs" is a valid custom element name
document.createElement('x-tabs').__proto__ == HTMLElement.prototype

אלמנטים שלא נפתרו

מכיוון שרכיבים מותאמים אישית נרשמים באמצעות סקריפט באמצעות document.registerElement(), אפשר להצהיר עליהם או ליצור אותם לפני שהדפדפן ירשום את ההגדרה שלהם. לדוגמה, אפשר להצהיר על <x-tabs> בדף, אבל לבסוף להפעיל את document.registerElement('x-tabs') הרבה יותר מאוחר.

לפני שרכיבים משודרגים להגדרה שלהם, הם נקראים רכיבים לא משוחררים. אלה אלמנטים של HTML שיש להם שם תקין של אלמנט מותאם אישית, אבל הם לא רשומים.

בטבלה הבאה מוסבר מה ההבדל:

שם קבלה בירושה מ- דוגמאות
רכיב לא נפתר HTMLElement <x-tabs>, <my-element>
רכיב לא ידוע HTMLUnknownElement <tabs>, <foo_bar>

יצירה של רכיבים

השיטות הנפוצות ליצירת רכיבים עדיין רלוונטיות לרכיבים מותאמים אישית. כמו כל רכיב רגיל, אפשר להצהיר עליהם ב-HTML או ליצור אותם ב-DOM באמצעות JavaScript.

יצירת אובייקטים של תגים בהתאמה אישית

מגדירים אותן:

<x-foo></x-foo>

יצירת DOM ב-JS:

var xFoo = document.createElement('x-foo');
xFoo.addEventListener('click', function(e) {
    alert('Thanks!');
});

משתמשים באופרטור new:

var xFoo = new XFoo();
document.body.appendChild(xFoo);

יצירת רכיבי תוסף מסוגים

יצירת רכיבים מותאמים אישית בסגנון תוסף מסוג טיפוס דומה מאוד ליצירת תגים מותאמים אישית.

מגדירים אותן:

<!-- <button> "is a" mega button -->
<button is="mega-button">

יצירת DOM ב-JS:

var megaButton = document.createElement('button', 'mega-button');
// megaButton instanceof MegaButton === true

כפי שאפשר לראות, עכשיו יש גרסה עם עומס יתר של document.createElement() שמקבלת את המאפיין is="" כפרמטר השני שלה.

משתמשים באופרטור new:

var megaButton = new MegaButton();
document.body.appendChild(megaButton);

עד עכשיו למדנו איך להשתמש ב-document.registerElement() כדי להודיע לדפדפן על תג חדש…אבל זה לא עוזר הרבה. נוסיף מאפיינים ושיטות.

הוספת מאפיינים ושיטות של JS

היתרון של אלמנטים מותאמים אישית הוא שאפשר לצרף לאלמנט פונקציונליות מותאמת אישית על ידי הגדרת מאפיינים ושיטות בהגדרת האלמנט. אפשר להתייחס לזה כאל דרך ליצור ממשק API ציבורי לאלמנט.

דוגמה מלאה:

var XFooProto = Object.create(HTMLElement.prototype);

// 1. Give x-foo a foo() method.
XFooProto.foo = function() {
    alert('foo() called');
};

// 2. Define a property read-only "bar".
Object.defineProperty(XFooProto, "bar", {value: 5});

// 3. Register x-foo's definition.
var XFoo = document.registerElement('x-foo', {prototype: XFooProto});

// 4. Instantiate an x-foo.
var xfoo = document.createElement('x-foo');

// 5. Add it to the page.
document.body.appendChild(xfoo);

כמובן שיש אלפי דרכים לבנות prototype. אם אתם לא אוהבים ליצור אב טיפוס כזה, הנה גרסה מרוכזת יותר של אותו הדבר:

var XFoo = document.registerElement('x-foo', {
  prototype: Object.create(HTMLElement.prototype, {
    bar: {
      get: function () {
        return 5;
      }
    },
    foo: {
      value: function () {
        alert('foo() called');
      }
    }
  })
});

הפורמט הראשון מאפשר להשתמש ב-ES5 Object.defineProperty. השני מאפשר להשתמש ב-get/set.

שיטות קריאה חוזרת של מחזור חיים

רכיבים יכולים להגדיר שיטות מיוחדות כדי לנצל זמנים מעניינים במהלך החיים שלהם. השיטות האלה נקראות קריאות חזרה למחזור חיים. לכל אחד מהם יש שם ומטרה ספציפיים:

שם הקריאה החוזרת הקריאה מתבצעת כאשר
createdCallback נוצרת מופע של האלמנט
attachedCallback מופע הוטמע במסמך
detachedCallback מופע הוסר מהמסמך
attributeChangedCallback(attrName, oldVal, newVal) מאפיין נוסף, הוסר או עודכן

דוגמה: הגדרת createdCallback() ו-attachedCallback() ב-<x-foo>:

var proto = Object.create(HTMLElement.prototype);

proto.createdCallback = function() {...};
proto.attachedCallback = function() {...};

var XFoo = document.registerElement('x-foo', {prototype: proto});

כל פונקציות ה-call back של מחזור החיים הן אופציונליות, אבל כדאי להגדיר אותן אם או כשזה הגיוני. לדוגמה, נניח שהרכיב שלכם מורכב מספיק ופותח חיבור ל-IndexedDB ב-createdCallback(). לפני שהיא תוסר מה-DOM, צריך לבצע את פעולות הניקוי הנדרשות ב-detachedCallback(). הערה: לא כדאי להסתמך על האירוע הזה, למשל אם המשתמש סוגר את הכרטיסייה, אלא להתייחס אליו כאל וו אופטימיזציה אפשרי.

תרחיש לדוגמה נוסף לקריאות חוזרות (callbacks) במחזור החיים הוא הגדרת מאזיני אירועים שמוגדרים כברירת מחדל באלמנט:

proto.createdCallback = function() {
  this.addEventListener('click', function(e) {
    alert('Thanks!');
  });
};

הוספת תגי עיצוב

יצרנו את <x-foo>, הקצינו לו ממשק API ל-JavaScript, אבל הוא ריק! רוצה שנשלח לו קצת HTML לעיבוד?

קריאות חזרה ל-Lifecycle יכולות לעזור כאן. באופן ספציפי, אפשר להשתמש ב-createdCallback() כדי להעניק לאלמנט קוד HTML מסוים כברירת מחדל:

var XFooProto = Object.create(HTMLElement.prototype);

XFooProto.createdCallback = function() {
    this.innerHTML = "**I'm an x-foo-with-markup!**";
};

var XFoo = document.registerElement('x-foo-with-markup', {prototype: XFooProto});

יצירת מופע של התג הזה ובדיקה שלו בכלי הפיתוח (לחיצה ימנית ובחירה באפשרות 'בדיקת רכיב') אמורים להציג את הפרטים הבאים:

▾<x-foo-with-markup>
  **I'm an x-foo-with-markup!**
</x-foo-with-markup>

אנקפסולציה של הרכיבים הפנימיים ב-Shadow DOM

Shadow DOM הוא כלי רב עוצמה בפני עצמו לאנקפסולציה של תוכן. אפשר להשתמש בו בשילוב עם רכיבים מותאמים אישית, והתוצאות יהיו קסומות!

Shadow DOM מעניק לרכיבים מותאמים אישית:

  1. דרך להסתיר את הנתונים הפנימיים, וכך להגן על המשתמשים מפני פרטי הטמעה מפורטים.
  2. אנקפסולציה של סגנון… בחינם.

יצירת רכיב מ-Shadow DOM דומה ליצירת רכיב שמרינדור את הרכיב הבסיסי. ההבדל הוא ב-createdCallback():

var XFooProto = Object.create(HTMLElement.prototype);

XFooProto.createdCallback = function() {
    // 1. Attach a shadow root on the element.
    var shadow = this.createShadowRoot();

    // 2. Fill it with markup goodness.
    shadow.innerHTML = "**I'm in the element's Shadow DOM!**";
};

var XFoo = document.registerElement('x-foo-shadowdom', {prototype: XFooProto});

במקום להגדיר את .innerHTML של האלמנט, יצרתי Root בצל ל-<x-foo-shadowdom> ולאחר מכן מילאתי אותו בסימון. כשההגדרה 'הצגת DOM בצל' מופעלת בכלי הפיתוח, מופיע #shadow-root שניתן להרחיב:

<x-foo-shadowdom>
  #shadow-root
    **I'm in the element's Shadow DOM!**
</x-foo-shadowdom>

זהו שורש הצל!

יצירת רכיבים מתבנית

תבניות HTML הן רכיב API פרימיטיבי חדש נוסף שמתאים היטב לעולם של רכיבים מותאמים אישית.

דוגמה: רישום של אלמנט שנוצר מ-<template> ומ-Shadow DOM:

<template id="sdtemplate">
  <style>
    p { color: orange; }
  </style>
  <p>I'm in Shadow DOM. My markup was stamped from a <template&gt;.
</template>

<script>
  var proto = Object.create(HTMLElement.prototype, {
    createdCallback: {
      value: function() {
        var t = document.querySelector('#sdtemplate');
        var clone = document.importNode(t.content, true);
        this.createShadowRoot().appendChild(clone);
      }
    }
  });
  document.registerElement('x-foo-from-template', {prototype: proto});
</script>

<template id="sdtemplate">
  <style>:host p { color: orange; }</style>
  <p>I'm in Shadow DOM. My markup was stamped from a <template&gt;.
</template>

<div class="demoarea">
  <x-foo-from-template></x-foo-from-template>
</div>

כמה שורות הקוד האלה מכילות הרבה עוצמה. בואו נבין מה קורה:

  1. רשמנו רכיב חדש ב-HTML: <x-foo-from-template>
  2. ה-DOM של הרכיב נוצר מ-<template>
  3. הפרטים המפחידים של הרכיב מוסתרים באמצעות Shadow DOM
  4. Shadow DOM מאפשר אנקפסולציה של סגנון האלמנט (למשל, p {color: orange;} לא הופך את כל הדף כתום)

מצוין!

עיצוב רכיבים בהתאמה אישית

כמו כל תג HTML, משתמשים בתג בהתאמה אישית יכולים להגדיר לו סגנון באמצעות בוחרים:

<style>
  app-panel {
    display: flex;
  }
  [is="x-item"] {
    transition: opacity 400ms ease-in-out;
    opacity: 0.3;
    flex: 1;
    text-align: center;
    border-radius: 50%;
  }
  [is="x-item"]:hover {
    opacity: 1.0;
    background: rgb(255, 0, 255);
    color: white;
  }
  app-panel > [is="x-item"] {
    padding: 5px;
    list-style: none;
    margin: 0 7px;
  }
</style>

<app-panel>
    <li is="x-item">Do</li>
    <li is="x-item">Re</li>
    <li is="x-item">Mi</li>
</app-panel>

עיצוב רכיבים שמשתמשים ב-Shadow DOM

כשמצרפים את Shadow DOM לתמונה, העניין הופך להיות הרבה יותר מורכב. רכיבים מותאמים אישית שמשתמשים ב-Shadow DOM יורשים את היתרונות הגדולים שלו.

Shadow DOM מחדיר לרכיב אנקפסולציה של סגנון. סגנונות שהוגדרו ב-Shadow Root לא נשפכים מהמארח ולא נשפכים מהדף. במקרה של רכיב מותאם אישית, הרכיב עצמו הוא המארח. המאפיינים של אנקפסולציית סגנונות מאפשרים גם לרכיבים מותאמים אישית להגדיר לעצמם סגנונות ברירת מחדל.

עיצוב של Shadow DOM הוא נושא רחב מאוד. למידע נוסף, מומלץ לקרוא כמה מהמאמרים האחרים שלי:

מניעת FOUC באמצעות ‎ :unresolved

כדי לצמצם את FOUC, רכיבים מותאמים אישית מציינים פסאודו-מחלקה חדשה של CSS, :unresolved. אפשר להשתמש בו כדי לטרגט רכיבים לא פתורים, עד לנקודה שבה הדפדפן מפעיל את createdCallback() (ראו שיטות מחזור חיים). לאחר מכן, האלמנט לא יהיה יותר אלמנט לא מטופל. תהליך השדרוג הושלם והרכיב השתנה בהתאם להגדרה שלו.

דוגמה: הוספת תגים מסוג 'x-foo' כשהם נרשמים:

<style>
  x-foo {
    opacity: 1;
    transition: opacity 300ms;
  }
  x-foo:unresolved {
    opacity: 0;
  }
</style>

חשוב לזכור ש-:unresolved רלוונטי רק לרכיבים לא פתורים, ולא לרכיבים שעוברים בירושה מ-HTMLUnknownElement (ראו איך מתבצע שדרוג של רכיבים).

<style>
  /* apply a dashed border to all unresolved elements */
  :unresolved {
    border: 1px dashed red;
    display: inline-block;
  }
  /* x-panel's that are unresolved are red */
  x-panel:unresolved {
    color: red;
  }
  /* once the definition of x-panel is registered, it becomes green */
  x-panel {
    color: green;
    display: block;
    padding: 5px;
    display: block;
  }
</style>

<panel>
    I'm black because :unresolved doesn't apply to "panel".
    It's not a valid custom element name.
</panel>

<x-panel>I'm red because I match x-panel:unresolved.</x-panel>

היסטוריה ותמיכה בדפדפנים

זיהוי תכונות

זיהוי התכונה הוא עניין של בדיקה אם document.registerElement() קיים:

function supportsCustomElements() {
    return 'registerElement' in document;
}

if (supportsCustomElements()) {
    // Good to go!
} else {
    // Use other libraries to create components.
}

תמיכה בדפדפנים

document.registerElement() התחיל להופיע מאחורי דגל ב-Chrome 27 וב-Firefox 23. עם זאת, המפרט התפתח משמעותית מאז. Chrome 31 הוא הגרסה הראשונה עם תמיכה מלאה במפרט המעודכן.

עד שתהיה תמיכה מלאה בדפדפנים, יש polyfill שמשמש את Polymer של Google ואת X-Tag של Mozilla.

מה קרה ל-HTMLElementElement?

מי שעקב אחרי עבודת התקינה יודע שהיה פעם <element>. זה היה פשוט נהדר. אפשר להשתמש בה כדי לרשום באופן דקלרטיבי רכיבים חדשים:

<element name="my-element">
    ...
</element>

לצערנו, היו יותר מדי בעיות תזמון בתהליך השדרוג, מקרים קיצוניים ותרחישים של יום הדין, כך שלא הצלחנו לפתור את הבעיה. <element> נאלצנו להשהות. באוגוסט 2013, דמיטרי גלזקוב (Dimitri Glazkov) פרסם ב-public-webapps הודעה על הסרת הספרייה, לפחות בינתיים.

חשוב לציין ש-Polymer מטמיע טופס מצהיר של רישום רכיבים באמצעות <polymer-element>. איך? הוא משתמש ב-document.registerElement('polymer-element') ובשיטות שתיארתי במאמר יצירת רכיבים מתבנית.

סיכום

רכיבים מותאמים אישית נותנים לנו את הכלי להרחיב את אוצר המילים של HTML, ללמד אותו טריקים חדשים ולעבור דרך מנהרות הזמן של פלטפורמת האינטרנט. כשמשלבים אותם עם הרכיבים הבסיסיים האחרים של הפלטפורמה, כמו Shadow DOM ו-<template>, מתחילים להבין את התמונה של Web Components. עכשיו אפשר ליהנות מסימני ה-Markup שוב!

אם אתם רוצים להתחיל להשתמש ברכיבי אינטרנט, מומלץ לבדוק את Polymer. יש בו מספיק תכונות כדי להתחיל.