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 rõ ý tôi, hãy xem qua ứng dụng web "hiện đại" như Gmail:

Gmail

Không có gì hiện đại về súp <div>. 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 gợi cảm. Hãy quyết định thành công

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

Điều gì sẽ xảy ra nếu thẻ đánh dấu cho Gmail không có hành vi xấu? Nếu đẹ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ì. Trong tương lai, tôi/bạn sẽ biết chính xác chức năng của nó bằng cách kiểm tra nội dung khai báo cốt lõi của ứng dụng.

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 là một trong nhiều thông số cơ bản của API mới thuộc lớp Thành phần web, nhưng nó có thể là 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 được mở khoá bằng các phần tử tuỳ chỉnh:

  1. Xác định các phần tử HTML/DOM mới
  2. Tạo 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 tắc 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, đồng thời đả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ụ: các thuộc tính và phương thức công khai) vào các phần tử của bạn. Chúng ta sẽ tìm hiểu thêm về vấn đề này ở phần sau.

Theo mặc định, các phần tử tuỳ chỉnh sẽ 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 thực thể phần tử 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 tên và prototype của phần tử đó vào registerElement() để kế thừa.

Mở rộng các thành phần gốc

Giả sử bạn không hài lòng với Regular Joe <button>. Bạn muốn tăng thêm khả năng của ứng dụng để trở thành "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 là "button":

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 "thành phần 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 các 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 đã bao giờ tự hỏi tại sao trình phân tích cú pháp HTML không gửi vừa khớp trên các thẻ không chuẩn chưa? Ví dụ: chúng ta hoàn toàn hài lòng nếu chúng ta khai báo <randomtag> trên trang. Theo thông số kỹ thuật HTML:

Rất tiếc <randomtag>! Bạn không thuộc nhóm 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 điều này bằng cách kích hoạt Console: Ctrl + Shift + J (hoặc Cmd + Opt + J trên máy Mac) rồi dán các dòng mã sau; chúng 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

Các 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 sau đó lại gọi ra document.registerElement('x-tabs').

Trước khi các phần tử được nâng cấp lên định nghĩa của mình, chúng đượ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 giải quyết HTMLElement <x-tabs>, <my-element>
Phần tử không xác định HTMLUnknownElement <tabs>, <foo_bar>

Tạo thực thể 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ư bất kỳ phần tử chuẩn nào, các phần tử tiêu chuẩn có thể được khai báo trong HTML hoặc được tạo trong DOM bằng JavaScript.

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

Khai báo các thuộc tính này:

<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 thực thể của phần tử tiện ích 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 lợi ích:

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

Dùng toán tử new:

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

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

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

Ưu điểm lớn của các phần tử tuỳ chỉnh là bạn có thể gói các chức năng phù hợp 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 phần định nghĩa phần tử. Hãy xem đây là một cách để tạo API công khai cho phần tử của bạn.

Dưới đây là một 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 nguyên mẫu như thế này, thì sau đây là phiên bản rút gọn hơn của nội dung này:

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ị tồn tại của chúng. Các phương thức này được đặt tên phù hợp là phương thức gọi lại trong vòng đời. Mỗi chiến dịch 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 bản sao đã bị xoá khỏi tài liệu
attributeChangedCallback(attrName, oldVal, newVal) thuộc tính đã được thêm, xóa 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ả các phương thức gọi lại trong vòng đời là không bắt buộc, nhưng hãy xác định chúng nếu/khi thích hợp. 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 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ể xảy ra.

Một phương thức gọi lại trong 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 nội dung đá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 cho nó một số HTML để kết xuất 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});

Tạo thực thể thẻ này và kiểm tra trong Công cụ cho nhà phát triển (nhấp chuột phải, chọn Kiểm tra phần tử) sẽ cho thấy:

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

Đóng gói các thành phần 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!

DOM bóng cung cấp các phần tử tuỳ chỉnh:

  1. Đây là một cách che giấu nội dung, giúp người dùng tránh được những chi tiết triển khai đẫm máu.
  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ử kết xuất mã đánh dấu cơ bản. Mức chênh lệch 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 cho <x-foo-shadowdom> rồi điền mã đánh dấu vào phần tử này. Khi bật chế độ cài đặt "Show Shadow DOM" (Hiển thị DOM bóng) trong Công cụ cho nhà phát triển, 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>

Đó chính là Căn cứ bóng tối!

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

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

Ví dụ: đăng ký một phần tử được tạo từ <template> và DOM tối:

<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ã như vậy có rất nhiều công cụ. Hãy tìm hiểu mọi thứ đang diễn ra:

  1. Chúng tôi đã đă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 qua <template>
  3. Chi tiết đáng sợ của nguyên tố được ẩn đi bằng Shadow DOM
  4. Shadow DOM cung cấp cách đóng gói kiểu phần tử (ví dụ: p {color: orange;} không chuyển toàn bộ trang sang màu cam)

Rất tốt!

Tạo kiểu phần tử tuỳ chỉnh

Giống như mọi thẻ HTML, 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>

Tạo kiểu phần tử sử dụng Shadow DOM

Hố thỏ sẽ sâu hơn rất nhiều khi bạn đưa Shadow DOM vào danh sách kết hợp. Các phần tử tuỳ chỉnh sử dụng Shadow DOM thừa hưởng những lợi ích tuyệt vời này.

Shadow DOM kết hợp một phần tử có đóng gói kiểu. Các kiểu được xác định trong Shadow Root sẽ không rò rỉ ra khỏi máy chủ lưu trữ và không tràn ra khỏi 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 đó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.

Tạo kiểu DOM tối là một chủ đề lớn! Nếu bạn muốn tìm hiểu thêm về chủ đề này, tôi giới thiệu 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ả lập 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() (xem các phương thức vòng đời). Khi điều đó xảy ra, phần tử không còn là phần tử chưa được giải quyết 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ờ trong thẻ "x-foo" khi các thẻ này đượ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>

Lịch sử và hỗ trợ trình duyệt

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

Phát hiện tính năng là vấn đề 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

Lần đầu tiên document.registerElement() bắt đầu sử dụng một cờ trong Chrome 27 và Firefox ~23. Tuy nhiên, quy cách này đã 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 cập nhật.

Cho đến khi trình duyệt hỗ trợ xuất sắc, vẫn có một polyfill được dùng cho Polymer của Google và X-Tag của Mozilla.

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

Đối với những nhà xuất bản đã tuân thủ công việc tiêu chuẩn hoá, thì bạn biết rằng từng có <element>. Đó là một nơi 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>

Thật không may là có quá nhiều vấn đề về thời gian với quy trình nâng cấp, các trường hợp góc và các tình huống giống như Armageddon để giải quyết vấn đề. <element> phải được xếp vào giá khác. Vào tháng 8 năm 2013, Dimitri Glazkov đã đăng lên các ứng dụng web công khai, trong đó thông báo về việc xóa ứng dụng, ít nhất là cho đến thời điểm này.

Điều đáng lưu ý là Polymer triển khai hình thức khai báo đăng ký phần tử bằng <polymer-element>. Cách thực hiện: Công cụ này sử dụng document.registerElement('polymer-element') và các kỹ thuật mà tôi mô tả trong bài viết 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 tôi công cụ để mở rộng vốn từ của HTML, dạy HTML5 các thủ thuật mới và khám phá các lỗ hổng lớn của nền tảng web. Hãy 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 sẽ bắt đầu nhận ra bức tranh về Thành phần web. Đánh dấu có thể trở lại gợi cảm!

Nếu bạn muốn bắt đầu sử dụng các thành phần web, bạn nên tham khảo Polymer. Thế là đủ để giúp bạn tiếp tục.