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

Giới thiệu

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

Gmail

Không có gì hiện đại về món súp <div>. Chưa hết, đây là cách chúng tôi phát triển các ứng dụng web. Thật buồn. Chẳng phải chúng ta đò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 biến điều đó thành một điều

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

Điều gì sẽ xảy ra nếu việc đánh dấu cho Gmail không độc hại? Nếu ảnh đẹp thì sao:

<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ý. Đây là câu hỏi có ý nghĩa, dễ hiểu và hơn hết là dễ bảo trì. Trong tương lai, tôi/bạn sẽ biết chính xác chức năng của hàm này chỉ bằng cách kiểm tra xương sống khai báo của hàm.

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 một số dữ liệu gốc mới của API xuất hiện trong ô Thành phần web, nhưng có thể đây 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 được phần tử tuỳ chỉnh mở khoá:

  1. Xác định 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. Nhóm chức năng tuỳ chỉnh vào một thẻ theo logic
  4. Mở rộng API của các phần tử DOM hiện có

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

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ì vậy, ví dụ: <x-tags>, <my-element><my-awesome-app> đều là tên hợp lệ, trong khi <tabs><foo_bar> thì không. Hạn chế này cho phép trình phân tích cú pháp phân tích cú pháp phần tử tuỳ chỉnh với 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 các thẻ mới được thêm 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ử của bạn. Tìm hiểu thêm về điều đó ở 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ể dùng để tạo các thực thể của <x-foo>. Ngoài ra, bạn có thể sử dụng kỹ thuật tạo thực thể phần tử khác nếu không muốn dùng hàm khởi tạo.

Phần tử mở rộng

Phần tử tuỳ chỉnh cho phép bạn mở rộng 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 Thông thường Joe <button>. Bạn muốn tăng khả năng thành "Mega Button" (Nút 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'
});

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 của phần mở rộng về loại. Các đối tượng này kế thừa từ một phiên bản chuyên biệt của HTMLElement để nói "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ẻ bạn đang kế thừa:

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

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

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

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

Bạn có bao giờ tự hỏi tại sao trình phân tích cú pháp HTML không điều chỉnh được cho các thẻ không chuẩn không? Ví dụ: bạn hoàn toàn có thể khai báo <randomtag> trên trang. Theo thông số kỹ thuật HTML:

Xin lỗi <randomtag>! Bạn không đạt 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 tính xác thực 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 vào các dòng mã sau; các dòng mã này 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 giải quyết

Vì các phần tử tuỳ chỉnh được đăng ký bằng tập lệnh bằng document.registerElement(), nên các phần tử này có thể được khai báo hoặc tạo trước khi trình duyệt đăng ký. 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 đó.

Trước khi nâng cấp các phần tử lên định nghĩa, chúng được gọi là phần tử chưa được giải quyết. Đâ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 này có thể giúp giữ mọi thứ thẳng thắn:

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ể cho phần tử

Các kỹ thuật tạo phần tử phổ biến 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, bạn có thể khai báo các phần tử chuẩn trong HTML hoặc tạo trong DOM bằng JavaScript.

Tạo thực thể cho thẻ tuỳ chỉnh

Khai báo các mã đó:

<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ể cho các phần tử của phần mở rộng về loại

Việc tạo thực thể cho các phần tử tuỳ chỉnh kiểu tiện ích rất giống với thẻ tuỳ chỉnh.

Khai báo các mã đó:

<!-- <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 quá tải 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() để thông báo cho trình duyệt về thẻ mới...nhưng cách này không có gì nhiều. Hãy thêm thuộc tính và phương thức.

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

Điểm mạnh mẽ của phần tử tuỳ chỉnh là bạn có thể nhóm chức năng được điều chỉnh cho 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 đị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ó vô số 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à một phiên bản ngắn gọn hơn của tính năng 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 ES5 Object.defineProperty. Lý do 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ị khi chúng tồn tại. 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 phương thức có một 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ả 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 các phương thức này nếu phù 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 bị xoá khỏi DOM, hãy thực hiện các bướ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, chẳng hạn như nếu người dùng đóng thẻ, hãy coi đây là một phương thức tối ưu hoá khả thi.

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 mã đánh dấu

Chúng tôi đã tạo <x-foo>, cung cấp một API JavaScript nhưng giao diện này trống! Chúng ta có cần cung cấp một số HTML để kết xuất không?

Phương thức gọi lại trong vòng đời rất hữu ích trong trường hợp này. Cụ thể, chúng ta có thể sử dụng createdCallback() để 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 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 nội dung bên trong trong DOM bóng

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 kết hợp với các phần tử tuỳ chỉnh để tạo nên những điều kỳ diệu!

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

  1. Một cách giấu linh cảm, qua đó bảo vệ người dùng khỏi những chi tiết triển khai đẫm máu.
  2. Đóng gói kiểu... tuỳ ý.

Việc tạo một phần tử từ Shadow DOM giống như tạo một phần tử kết xuất 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 cho <x-foo-shadowdom> rồi điền mã đánh dấu vào phần tử đó. Khi bật chế độ cài đặt "Show Shadow DOM" 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à Shadow Root!

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

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

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

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

Một vài dòng mã này có rất nhiều điểm nhấn. Hãy cùng nắm bắt những điều đang xảy 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ử này được tạo từ <template>
  3. Những chi tiết đáng sợ của yếu tố này được ẩn đi bằng mô hình DOM bóng
  4. DOM tối cho phép đóng gói kiểu phần tử (ví dụ: p {color: orange;} không chuyển toàn bộ trang thành màu cam)

Rất tốt!

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

Giống như bất kỳ thẻ HTML nào, người dùng thẻ tùy 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 các phần tử sử dụng DOM bóng

Lỗ thỏ sẽ đi sâu hơn nhiều khi bạn đưa Shadow DOM vào hỗn hợp. Các phần tử tuỳ chỉnh sử dụng DOM bóng kế thừa những lợi ích to lớn của nó.

DOM bóng kết hợp một phần tử có đóng gói kiểu. Các kiểu được xác định trong Gốc bóng sẽ không bị rò rỉ ra khỏi máy chủ lưu trữ và không tràn vào từ trang. Trong trường hợp một phần tử tuỳ chỉnh, phần tử đó chính 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 tự xác định kiểu mặc định.

Kiểu DOM bóng là một chủ đề rất lớn! Nếu bạn muốn tìm hiểu thêm về chủ đề này, tôi đề xuất một số bài viết khác của tôi:

Ngăn chặn FOUC bằng cách sử dụ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. Hãy sử dụng đối tượng này để nhắm mục tiêu các phần tử chưa được giải quyết, từ trước cho đến thời điểm trình duyệt gọi createdCallback() của bạn (xem các phương thức vòng đời). Sau khi điều đó xảy ra, phần tử này 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 nó.

Ví dụ: rõ dần trong thẻ "x-foo" khi đă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 giải quyết, chứ không áp dụng cho các phần tử kế thừa từ HTMLUnknownElement (xem bài viết Cách nâng cấp các 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>

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

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

Việc phát hiện tính năng là việc 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() lần đầu tiên bắt đầu hạ cánh sau một lá cờ trong Chrome 27 và Firefox ~23. Tuy nhiên, kể từ đó, thông số kỹ thuật đã phát triển khá nhiều. Chrome 31 là phiên bản đầu tiên có hỗ trợ thực cho thông số kỹ thuật mới cập nhật.

Cho đến khi khả năng hỗ trợ trình duyệt trở nên vượt trội, có một polyfill được sử dụng bởi Polymer của Google và X-Tag của Mozilla.

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

Đối với những ai đã tuân thủ quy trình tiêu chuẩn hoá, chắc hẳn bạn đã từng dùng <element>. Đó là đầu ong. 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 góc và các tình huống tương tự như Armageddon nên không giải quyết được vấn đề. <element> phải được xếp lên kệ. Vào tháng 8 năm 2013, Dimitri Glazkov đã đăng lên các ứng dụng web công khai để thông báo việc xóa ứng dụng này, ít nhất là cho đến bây giờ.

Điểm đáng chú ý 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 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 tôi công cụ để mở rộng vốn từ của HTML, dạy cho HTML này các thủ thuật mới và truy cập các lỗ hổng của nền tảng web. Kết hợp chúng với các nền tảng gốc mới khác như Shadow DOM và <template>, chúng ta bắt đầu nhận ra bức tranh về Thành phần web. Thẻ đánh dấu đã trở nên hấp dẫn hơn bao giờ hết!

Nếu muốn bắt đầu sử dụng các thành phần web, bạn nên xem Polymer. Vậy là bạn đã có thể bắt đầu rồi.