Làm việc với phần tử tuỳ chỉnh

Giới thiệu

Web thiếu biểu cảm nghiêm trọng. Để hiểu ý tôi, hãy xem một ứng dụng web "hiện đại" như Gmail:

Gmail

Món súp <div> không có gì hiện đại. Tuy nhiên, đây là cách chúng ta xây dựng ứng dụng web. Thật đáng buồn. Chúng ta không nên đòi hỏi nhiều hơn từ nền tảng của mình sao?

Mã đánh dấu sexy. Hãy cùng làm điều đó

HTML cung cấp cho chúng ta một công cụ tuyệt vời để tạo cấu trúc cho tài liệu, nhưng từ vựng của HTML chỉ giới hạn ở các phần tử mà tiêu chuẩn HTML xác định.

Nếu mã đánh dấu cho Gmail không quá tệ thì sao? Nếu hình ảnh đó đẹp:

<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>

Thật mới mẻ! Ứng dụng này cũng hoàn toàn hợp lý. Mã này có ý nghĩa, dễ hiểu và quan trọng nhất là có thể duy trì. Tôi/bạn trong tương lai sẽ biết chính xác chức năng của lớp này chỉ bằng cách kiểm tra phần cốt lõi khai báo của lớp.

Bắt đầu

Phần tử tuỳ chỉnh cho phép nhà phát triển web xác định các loại phần tử HTML mới. Thông số kỹ thuật này là một trong số nhiều API gốc mới thuộc phạm vi Thành phần web, nhưng có thể là thông số quan trọng nhất. Thành phần web không tồn tại nếu không có các tính năng do các phần tử tuỳ chỉnh mở khoá:

  1. Xác định các phần tử HTML/DOM mới
  2. Tạo các phần tử mở rộng từ các phần tử khác
  3. Gói chức năng tuỳ chỉnh lại với nhau một cách hợp lý thành một thẻ duy nhất
  4. Mở rộng API của các phần tử DOM hiện có

Đăng ký phần tử mới

Các phần tử tuỳ chỉnh được tạo bằng document.registerElement():

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

Đối số đầu tiên của document.registerElement() là tên thẻ của phần tử. Tên phải chứa dấu gạch ngang (-). Ví dụ: <x-tags>, <my-element><my-awesome-app> đều là tên hợp lệ, còn <tabs><foo_bar> thì không. Quy định hạn chế này cho phép trình phân tích cú pháp phân biệt các phần tử tuỳ chỉnh với các phần tử thông thường, nhưng cũng đảm bảo khả năng tương thích chuyển tiếp khi thêm thẻ mới vào HTML.

Đối số thứ hai là một đối tượng (không bắt buộc) mô tả prototype của phần tử. Đây là nơi để thêm chức năng tuỳ chỉnh (ví dụ: thuộc tính và phương thức công khai) vào các phần tử. Chúng ta sẽ tìm hiểu thêm về vấn đề này sau.

Theo mặc định, các phần tử tuỳ chỉnh kế thừa từ HTMLElement. Do đó, ví dụ trước tương đương với:

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

Lệnh gọi đến document.registerElement('x-foo') sẽ hướng dẫn trình duyệt về phần tử mới và trả về một hàm khởi tạo mà bạn có thể sử dụng để tạo các thực thể của <x-foo>. Ngoài ra, bạn có thể sử dụng các kỹ thuật tạo bản sao thành phần khác nếu không muốn sử dụng hàm khởi tạo.

Mở rộng phần tử

Phần tử tuỳ chỉnh cho phép bạn mở rộng các phần tử HTML (gốc) hiện có cũng như các phần tử tuỳ chỉnh khác. Để mở rộng một phần tử, bạn cần truyền registerElement() tên và prototype của phần tử cần kế thừa.

Mở rộng phần tử gốc

Giả sử bạn không hài lòng với Regular Joe <button>. Bạn muốn tăng cường chức năng của nút này để trở thành "Mega Button" (Nút siêu lớn). Để mở rộng phần tử <button>, hãy tạo một phần tử mới kế thừa prototype của HTMLButtonElementextends tên của phần tử. Trong trường hợp này, "nút":

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

Các phần tử tuỳ chỉnh kế thừa từ phần tử gốc được gọi là phần tử tuỳ chỉnh mở rộng loại. Các lớp này kế thừa từ một phiên bản chuyên biệt của HTMLElement để thể hiện rằng "phần tử X là một Y".

Ví dụ:

<button is="mega-button">

Mở rộng phần tử tuỳ chỉnh

Để tạo một phần tử <x-foo-extended> mở rộng phần tử tuỳ chỉnh <x-foo>, bạn chỉ cần kế thừa nguyên mẫu của phần tử đó và cho biết thẻ mà bạn đang kế thừa:

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

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

Hãy xem phần Thêm thuộc tính và phương thức JS bên dưới để biết thêm thông tin về cách tạo nguyên mẫu phần tử.

Cách nâng cấp phần tử

Bạn có bao giờ thắc mắc tại sao trình phân tích cú pháp HTML không gặp vấn đề với các thẻ không chuẩn không? Ví dụ: chúng ta hoàn toàn có thể khai báo <randomtag> trên trang. Theo quy cách HTML:

Xin lỗi <randomtag>! Bạn không theo tiêu chuẩn và kế thừa từ HTMLUnknownElement.

Điều này không đúng đối với các phần tử tuỳ chỉnh. Các phần tử có tên phần tử tuỳ chỉnh hợp lệ sẽ kế thừa từ HTMLElement. Bạn có thể xác minh thực tế này bằng cách khởi động Console: Ctrl + Shift + J (hoặc Cmd + Opt + J trên máy Mac) và dán các dòng mã sau đây; các dòng mã này sẽ trả về 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

Phần tử chưa được phân giải

Vì các phần tử tuỳ chỉnh được đăng ký bằng tập lệnh sử dụng document.registerElement(), nên bạn có thể khai báo hoặc tạo các phần tử đó trước khi trình duyệt đăng ký định nghĩa của các phần tử đó. Ví dụ: bạn có thể khai báo <x-tabs> trên trang nhưng cuối cùng lại gọi document.registerElement('x-tabs') sau đó rất lâu.

Trước khi được nâng cấp lên định nghĩa, các phần tử được gọi là phần tử chưa được phân giải. Đây là các phần tử HTML có tên phần tử tuỳ chỉnh hợp lệ nhưng chưa được đăng ký.

Bảng sau đây có thể giúp bạn nắm rõ mọi thứ:

Tên Kế thừa từ Ví dụ
Phần tử chưa được phân giải HTMLElement <x-tabs>, <my-element>
Phần tử không xác định HTMLUnknownElement <tabs>, <foo_bar>

Tạo bản sao phần tử

Các kỹ thuật phổ biến để tạo phần tử vẫn áp dụng cho phần tử tuỳ chỉnh. Giống như mọi phần tử chuẩn, bạn có thể khai báo các phần tử này trong HTML hoặc tạo trong DOM bằng JavaScript.

Tạo bản sao thẻ tuỳ chỉnh

Khai báo các quyền đó:

<x-foo></x-foo>

Tạo DOM trong JS:

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

Sử dụng toán tử new:

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

Tạo bản sao các phần tử mở rộng loại

Việc tạo bản sao các phần tử tuỳ chỉnh kiểu phần mở rộng loại rất giống với thẻ tuỳ chỉnh.

Khai báo các quyền đó:

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

Tạo DOM trong JS:

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

Như bạn có thể thấy, hiện có một phiên bản nạp chồng của document.createElement() lấy thuộc tính is="" làm tham số thứ hai.

Sử dụng toán tử new:

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

Cho đến nay, chúng ta đã tìm hiểu cách sử dụng document.registerElement() để cho trình duyệt biết về một thẻ mới…nhưng điều này không giúp ích được gì nhiều. Hãy thêm các thuộc tính và phương thức.

Thêm thuộc tính và phương thức JS

Điểm mạnh của các phần tử tuỳ chỉnh là bạn có thể gói chức năng được điều chỉnh với phần tử bằng cách xác định các thuộc tính và phương thức trên định nghĩa phần tử. Hãy coi đây là cách tạo API công khai cho phần tử của bạn.

Sau đây là ví dụ đầy đủ:

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);

Tất nhiên, có hàng nghìn cách để tạo prototype. Nếu bạn không thích tạo bản minh hoạ như thế này, thì sau đây là một phiên bản rút gọn hơn của cùng một nội dung:

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

Định dạng đầu tiên cho phép sử dụng Object.defineProperty ES5. Phương thức thứ hai cho phép sử dụng get/set.

Phương thức gọi lại trong vòng đời

Các phần tử có thể xác định các phương thức đặc biệt để khai thác những thời điểm thú vị trong sự tồn tại của chúng. Các phương thức này được đặt tên phù hợp là lệnh gọi lại trong vòng đời. Mỗi loại đều có tên và mục đích cụ thể:

Tên lệnh gọi lại Được gọi khi
createdCallback một thực thể của phần tử được tạo
attachedCallback một thực thể đã được chèn vào tài liệu
detachedCallback một thực thể đã bị xoá khỏi tài liệu
attributeChangedCallback(attrName, oldVal, newVal) một thuộc tính đã được thêm, xoá hoặc cập nhật

Ví dụ: xác định createdCallback()attachedCallback() trên <x-foo>:

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

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

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

Tất cả lệnh gọi lại trong vòng đời đều không bắt buộc, nhưng hãy xác định các lệnh gọi lại đó nếu/khi cần thiết. Ví dụ: giả sử phần tử của bạn đủ phức tạp và mở một kết nối đến IndexedDB trong createdCallback(). Trước khi bị xoá khỏi DOM, hãy thực hiện công việc dọn dẹp cần thiết trong detachedCallback(). Lưu ý: bạn không nên dựa vào điều này, ví dụ: nếu người dùng đóng thẻ, nhưng hãy coi đó là một trình bổ trợ tối ưu hoá có thể có.

Một lệnh gọi lại vòng đời trường hợp sử dụng khác là để thiết lập trình nghe sự kiện mặc định trên phần tử:

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

Thêm mã đánh dấu

Chúng ta đã tạo <x-foo>, cung cấp cho nó một API JavaScript, nhưng API này vẫn trống! Chúng ta có nên cung cấp một số HTML để hiển thị không?

Lệnh gọi lại trong vòng đời sẽ hữu ích ở đây. Cụ thể, chúng ta có thể sử dụng createdCallback() để cung cấp cho một phần tử một số HTML mặc định:

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});

Việc tạo bản sao thẻ này và kiểm tra trong DevTools (nhấp chuột phải, chọn Inspect Element (Kiểm tra phần tử)) sẽ hiển thị:

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

Đóng gói nội dung bên trong trong Shadow DOM

Bản thân Shadow DOM là một công cụ mạnh mẽ để đóng gói nội dung. Hãy sử dụng cùng với các phần tử tuỳ chỉnh để tạo ra những hiệu ứng kỳ diệu!

Shadow DOM cung cấp cho các phần tử tuỳ chỉnh:

  1. Một cách để ẩn nội dung bên trong, nhờ đó bảo vệ người dùng khỏi các chi tiết triển khai rườm rà.
  2. Đóng gói kiểu…miễn phí.

Việc tạo một phần tử từ Shadow DOM cũng giống như tạo một phần tử hiển thị mã đánh dấu cơ bản. Sự khác biệt nằm ở 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});

Thay vì đặt .innerHTML của phần tử, tôi đã tạo một Shadow Root (Gốc bóng) cho <x-foo-shadowdom>, sau đó điền vào đó bằng mã đánh dấu. Khi bật chế độ cài đặt "Hiển thị Shadow DOM" trong DevTools, bạn sẽ thấy một #shadow-root có thể mở rộng:

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

Đó là Shadow Root!

Tạo phần tử từ mẫu

Mẫu HTML là một loại API gốc mới khác phù hợp với thế giới của các phần tử tuỳ chỉnh.

Ví dụ: đăng ký một phần tử được tạo từ <template> và 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>

Vài dòng mã này có tác dụng rất lớn. Hãy cùng tìm hiểu mọi thứ đang diễn ra:

  1. Chúng ta đã đăng ký một phần tử mới trong HTML: <x-foo-from-template>
  2. DOM của phần tử được tạo từ <template>
  3. Các chi tiết đáng sợ của phần tử này được ẩn bằng Shadow DOM
  4. Shadow DOM cung cấp tính năng đóng gói kiểu phần tử (ví dụ: p {color: orange;} không làm toàn bộ trang chuyển sang màu cam)

Tuyệt vời!

Định kiểu phần tử tuỳ chỉnh

Giống như mọi thẻ HTML khác, người dùng thẻ tuỳ chỉnh của bạn có thể tạo kiểu cho thẻ đó bằng bộ chọn:

<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>

Định kiểu các phần tử sử dụng Shadow DOM

Bạn sẽ càng đi sâu hơn nhiều khi kết hợp Shadow DOM. Các phần tử tuỳ chỉnh sử dụng Shadow DOM kế thừa những lợi ích tuyệt vời của Shadow DOM.

Shadow DOM truyền một phần tử bằng cách đóng gói kiểu. Các kiểu được xác định trong Shadow Root không bị rò rỉ ra khỏi máy chủ lưu trữ và không bị tràn vào từ trang. Trong trường hợp phần tử tuỳ chỉnh, chính phần tử đó là máy chủ lưu trữ. Các thuộc tính của tính năng đóng gói kiểu cũng cho phép các phần tử tuỳ chỉnh xác định kiểu mặc định cho chính chúng.

Việc tạo kiểu cho Shadow DOM là một chủ đề rất lớn! Nếu bạn muốn tìm hiểu thêm về vấn đề này, bạn nên tham khảo một số bài viết khác của tôi:

Ngăn chặn lỗi FOUC bằng :unresolved

Để giảm thiểu FOUC, các phần tử tuỳ chỉnh sẽ chỉ định một lớp giả CSS mới, :unresolved. Sử dụng thuộc tính này để nhắm đến các phần tử chưa được phân giải, cho đến khi trình duyệt gọi createdCallback() của bạn (xem các phương thức vòng đời). Khi đó, phần tử này không còn là phần tử chưa được phân giải nữa. Quá trình nâng cấp đã hoàn tất và phần tử đã chuyển đổi thành định nghĩa của phần tử đó.

Ví dụ: làm mờ các thẻ "x-foo" khi các thẻ đó được đăng ký:

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

Xin lưu ý rằng :unresolved chỉ áp dụng cho các phần tử chưa được phân giải, chứ không áp dụng cho các phần tử kế thừa từ HTMLUnknownElement (xem phần Cách nâng cấp phần tử).

<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>

Nhật ký và hỗ trợ trình duyệt

Phát hiện tính năng

Việc phát hiện tính năng là kiểm tra xem document.registerElement() có tồn tại hay không:

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

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

Hỗ trợ trình duyệt

document.registerElement() bắt đầu xuất hiện sau một cờ trong Chrome 27 và Firefox ~23. Tuy nhiên, thông số kỹ thuật đã phát triển khá nhiều kể từ đó. Chrome 31 là phiên bản đầu tiên có hỗ trợ thực sự cho thông số kỹ thuật mới cập nhật.

Cho đến khi trình duyệt hỗ trợ tốt hơn, polyfill sẽ được Polymer của Google và X-Tag của Mozilla sử dụng.

Điều gì đã xảy ra với HTMLElementElement?

Đối với những người đã theo dõi công việc chuẩn hoá, bạn sẽ biết rằng từng có <element>. Đó là một sự kiện tuyệt vời. Bạn có thể sử dụng thuộc tính này để đăng ký các phần tử mới theo cách khai báo:

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

Rất tiếc, có quá nhiều vấn đề về thời gian với quá trình nâng cấp, các trường hợp hiếm gặp và các tình huống giống như ngày tận thế để giải quyết tất cả. <element> phải được lưu trữ. Vào tháng 8 năm 2013, Dimitri Glazkov đã đăng trên public-webapps để thông báo về việc xoá ứng dụng này, ít nhất là hiện tại.

Xin lưu ý rằng Polymer triển khai một hình thức khai báo đăng ký phần tử bằng <polymer-element>. Cách thực hiện: Phương thức này sử dụng document.registerElement('polymer-element') và các kỹ thuật mà tôi đã mô tả trong phần Tạo phần tử từ mẫu.

Kết luận

Các phần tử tuỳ chỉnh cung cấp cho chúng ta công cụ để mở rộng từ vựng của HTML, dạy cho nó các thủ thuật mới và vượt qua các lỗ hổng của nền tảng web. Kết hợp các thành phần này với các thành phần gốc mới khác của nền tảng như Shadow DOM và <template>, chúng ta bắt đầu nhận ra bức tranh về Thành phần web. Mã đánh dấu có thể trở lại hấp dẫn!

Nếu bạn quan tâm đến việc bắt đầu sử dụng các thành phần web, bạn nên tham khảo Polymer. Bạn có thể làm được nhiều việc với chiếc điện thoại này.