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

מבוא

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

Gmail

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

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

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

מה קורה אם ה-Markup של 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>

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

תחילת העבודה

Custom Elements מאפשר למפתחי אתרים להגדיר סוגים חדשים של רכיבי 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.

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

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

שם הקריאה החוזרת התקשרת כאשר
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(). הערה: לא כדאי להסתמך על כך, למשל אם המשתמש סוגר את הכרטיסייה, אלא להתייחס לכך כאל וו אופטימיזציה אפשרי.

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

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

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

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

קריאות חוזרות (callback) של מחזור החיים שימושיות כאן. במיוחד, אפשר להשתמש ב-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});

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

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

הפיכת הרכיבים הפנימיים ב-Sshadow DOM

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

DOM של Shadow מספק רכיבים מותאמים אישית:

  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> ולאחר מכן מילאתי אותו בסימני markup. כשההגדרה 'הצגת DOM בצל' מופעלת בכלי הפיתוח, מופיע #shadow-root שאפשר להרחיב:

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

זהו Root ההצללה.

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

תבניות 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, משתמשים בתג המותאם אישית יכולים להגדיר לו סגנון באמצעות בוחרים (selectors):

<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 ו-X-Tag של Mozilla.

מה קרה ל-HTMLElementElement?

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

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

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

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

סיכום

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

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