Component phần 4: Custom HTML Element aka Web component

Như đề cập ở bài trước, 1 số các tag html quan trọng là <input />, <button />, <div />, <form />, <iframe />, <img />,... Với UI Builder chúng ta đã học được cách lắp ghép các tổ hợp tag này thành 1 Component và mang nó để dùng cho phù hợp với logic của app. Có 1 vấn đề trong video bài trước đó là khi chèn các Component này vào web thì khi bật Inspect lên soi thì khá khó để dò ra parent của component (do có rất nhiều lớp div lồng ghép vào với nhau). Nếu như có 1 cách nào đó để gộp hết tất cả các component đó lại thành 1 tag thì thật tuyệt, sẽ không còn khó khăn khi inspect nữa.

Và đây, xin giới thiệu Custom HTML Element, aka Web component. Nó là việc tạo 1 tag mới do bạn đặt tên, có thể dùng như các tag html mặc định khác, và nó sẽ chứa giao diện và tính năng theo như bạn chỉ định.

Xem VD dưới đây:

Code js


class HelloWorld extends HTMLElement {
    constructor() {
      super();
      this.shadow = this.attachShadow({ mode: "open" });
    }

    connectedCallback() {
      this.render();
    }

    render() {
      this.shadow.innerHTML = `
          <p>Hello World</>
      `
    }
}

customElements.define("hello-world", HelloWorld);
  • với Custom element thì sẽ làm việc chủ yếu với Class một trong các loại cấu trúc biến của JS. Bạn nên tham khảo thêm các bài viết trên mạng để hiểu về cấu trúc và cách viết của nó.

  • Custom element được tạo bằng cách tạo 1 class mới extends từ HTMLElement, và được khai báo bằng customElements.define("hello-world", HelloWorld);

  • Custom element sẽ phải dùng tên là 1 từ có ít nhất 2 chữ. VD <hello-world />. Không được đặt 1 chữ.

  • Custom element sẽ tạo ra 1 "shadow" để chứa các mã HTML con, shadow này ẩn và không chịu tác động của các thứ bên ngoài component (VD không bị CSS bên ngoài ảnh hưởng, không bị JS selector từ bên ngoài gọi đến). Chỉ có thể gọi đến bằng this.shadowRoot.querySelector và tạo thẻ style bên trong.

Quản lý state và attribute

Quản lý attribute đã có sẵn trong Custom Element, chúng ta chỉ việc khai báo để lắng nghe thay đổi và xử lý khi có thay đổi

static get observedAttributes() {
  return []; // khai báo các attribute muốn listen ở đây
}
attributeChangedCallback(attrName, oldVal, newVal) {

}

Với state thì chúng ta sẽ cần khai báo thêm

state = {  };
setState = (newValue) => {
  this.state = Object.assign(this.state, newValue);
  this.render();
}

Lưu ý rằng cách sử dùng setState này khác với setState ở bài UI Builder. setState này sẽ render bất kể biến mới có giống biến cũ hay không. Và setState này cũng không cần phải nhận toàn bộ biến mà chỉ cần 1 key thôi.

// UI Builder
setState({ ...getState(), text: 'abc' })
// Custom Element
this.setState({ text: 'abc' });

cả 2 function setState này đều là do mình tự đặt ra (khác với việc extends HTMLElement là có sẵn và phải làm theo. lý do tạo ra 2 kiểu khác nhau là do bắt chước React vì React cũng có 2 phiên bản chạy class và chạy function (tuy hiện hiện nay chủ yếu dùng function).

Tổng hợp lại, chúng ta sẽ tạo 1 common CoreComponent để tái sử dụng sau này:

class CoreElement extends HTMLElement {
    constructor() {
      super();
      this.shadow = this.attachShadow({ mode: "open" });
    }

    static get observedAttributes() {
      return [];
    }

    state = {};
    setState = (newValue) => {
      this.state = Object.assign(this.state, newValue);
      this.render();
    }

    attributeChangedCallback(attrName, oldVal, newVal) {
      this.setState({ [attrName]: newVal });
    }

    connectedCallback() {
      this.render();
    }
  }

Và khi sử dụng chỉ cần extends CoreElement là xong. VD làm 1 cái couter:

customElements.define(
  "my-counter",
  class extends CoreElement {
    state={ count: 0, };
    render() {

       this.shadow.innerHTML = `
          <p>${this.state.count}</p>
          <button>+</button>
       `;

       const button = this.shadowRoot.querySelector('button');
       if (button) button.onclick = () => {
         this.setState({ count: this.state.count + 1 });
       }
    } 
  }
);

Lưu ý: ở ví dụ này ta sẽ thấy rõ sự khác biệt giữa arrow function và function truyền thông. thử thay đoạn button.onclick thành function bình thường: button.onclick = function() { bạn sẽ không thấy nó chạy. Muốn sửa cho nó chạy thì phải thêm .bind(this) vào cuối. như sau:

if (button) button.onclick = function() {
  this.setState({ count: this.state.count + 1 });
}.bind(this);

Do đó, trong hầu hết các trường hợp, bạn nên dùng arrow function.

Bắt tay vào làm component

Quay trở lại ví dụ về làm Todo list ở bài trước, chúng ta sẽ làm luôn trên Wordpress để không phải làm lại bước khai báo giá font, color.

Thêm CoreElement vào Custom JS để có thể truy cập ở mọi page:

Xem video:

Đoạn này dừng lại 1 chút, bạn sẽ để ý thấy nó bị nháy khi đổi state. đây là do DOM bị xóa và tạo liên tục. Khi dùng innerHTML = là đã coi như xóa DOM cũ. Do đó chúng ta cần 1 cách khác, đó là chỉ dùng innerHTML 1 lần đầu, sau đó những cái j cần update mới update.

Bên React họ giới thiệu 1 thứ gọi là Virtual DOM và sẽ render state trên Virtual DOM trước và sau đó nếu có thay đổi so với DOM thật thì mới update đúng cái DOM đó. Concept và Virtual DOM khá là complex, chúng ta sẽ không nhảy cóc đến Virtual DOM vội.

Chúng ta sẽ tìm cách khác để xử lý trước, đó là:

  • chỉ dùng innerHTML lần đầu, để setup cấu trúc của element

  • viết 1 func mapStateRender và sẽ dùng các method để update DOM chứ k xóa và tạo lại DOM

// arr -> [{ selector, updateType, fn, value, queryAll }]
    mapStateRender(arr) {
      arr.forEach(({ selector, updateType, fn, value, queryAll }) => {
          const elements = queryAll
              ? this.shadowRoot.querySelectorAll(selector)
              : [this.shadowRoot.querySelector(selector)].filter(Boolean);

          elements.forEach(el => {
              switch(updateType) {
                 case 'class':
                     if (value) el.classList.add(value);
                     else fn(el.classList, el);
                  break;
                 case 'style':
                      fn(el.style, el);
                 break;
                 case 'attr':
                  const getAttr = (attr) => el.getAttribute(attr);
                  const setAttr = (attr, value) => el.setAttribute(attr, value);
                  fn(getAttr, setAttr, el);
                 break;
                 case 'innerHTML':
                  el.innerHTML = value;
                 break;
                 default:
                     if (updateType.includes(".") && value) {
                        const [elKey, nestedKey] = updateType.split('.');
                        if (elKey === 'style') el.style[nestedKey] = value;
                        else if (elKey === 'attr') el.setAttribute(nestedKey, value);
                        else if (elKey === 'prop') el[nestedKey] = value;
                    }
                }
          })

      });
    }

function mapStateRender này sẽ nhận vào 1 array, mỗi phần tử sẽ chứa các thông tin để render riêng lẻ các thành phần. Dựa vào updateType, có các loại như thay đổi class, thay đổi attribute, thay đổi innerHTML. Ngoài ra có thể set value trực tiếp, hoặc là trả về function để xử lý logic. 1 số cách sử dụng như sau:

[
{
  selector: '.something',
  updateType: 'class',
  value: 'active', // -> thêm class active
},
{
  selector: '.something',
  updateType: 'class',
  fn: classList => shouldActive
    ? classList.add('active') : classList.remove('active'),
  // -> toggle class active dựa theo biến shouldActive
},
{
  selector: '.something',
  updateType: 'style',
  fn: style => style.backgroundColor = 'red',
  // -> set backgroundColor
},
{
  selector: '.something',
  updateType: 'style.backgroundColor',
  value: 'red'
  // -> set backgroundColor (tương tự như trên, 2 cách viết)
},
{
  selector: 'button',
  updateType: 'attr',
  fn: (get, set) => set('type', 'submit'),
  // -> thêm attribute
},
{
  selector: 'button',
  updateType: 'attr.type',
  value: 'submit',
  // -> thêm attribute (tương tự như trên, 2 cách viết)
},
{
  selector: 'button',
  updateType: 'innerHTML',
  value: 'Send',
  // -> thay đổi innerHTML
},
{
  selector: 'input',
  updateType: 'prop.value',
  value: 'Hello',
  // -> thay đổi value của input
},
]

Áp dụng vào component <add-new-task /> thì chúng ta đổi lại như sau:

customElements.define('add-new-task', class extends CoreElement {
        state = {
            important: false,
            subtask: false,
            sectionTitle: false,
        };

        ref = {
            text: '',
        };

        renderContainerCSS = () => `
            display: flex;
            flex-direction: row;
            align-items: center;
            column-gap: 1rem;
            padding-top: 4rem;
            position: relative;
        `;

        renderStyle = () => `
            <style>
                .input {
                    flex: 1;
                }
                .button {
                    width: 5.25rem;
                }
                .toggle-bar {
                    position: absolute;
                    top: 0;
                    left: 0;
                    display: flex;
                    flex-direction: row;
                    align-items: center;
                }
                .toggle-bar span {
                   font-size: 1.5rem; 
                   colro: #CECECE;
                   padding: 0.5rem;
                   margin-right: 0.5rem;
                   cursor: pointer;
                }
                .toggle-bar span.active {
                   color: var(--color-blue);
                }
            </style>
        `;

        onAddNewTask() {
            if (this.onSubmit) this.onSubmit({
                text: this.ref.text,

            });
        }

        render() {
            this.style.cssText = this.renderContainerCSS();
            if (!this.shadowRoot.innerHTML) {
                this.shadowRoot.innerHTML = `
                    ${this.renderStyle()}
                    <div class="input">
                        <input-component></input-component>
                    </div>
                    <div class="button">
                        <button-component>+</button-component>
                    </div>
                    <div class="toggle-bar">
                        <span
                            class="title"
                        >title</span>
                        <span
                            class="important"
                        >important</span>
                        <span
                            class="subtask"
                        >sub-task</span>
                    </div>
                `;
            } else {
                this.mapStateRender([
                    {
                        selector: '.title', 
                        updateType: 'class',
                        fn: classList => {
                            if (this.state.sectionTitle) classList.add('active');
                            else classList.remove('active');
                        }
                    },
                    {
                        selector: '.important', 
                        updateType: 'class',
                        fn: classList => {
                            if (this.state.important) classList.add('active');
                            else classList.remove('active');
                        }
                    },
                    {
                        selector: '.subtask', 
                        updateType: 'class',
                        fn: classList => {
                            if (this.state.subtask) classList.add('active');
                            else classList.remove('active');
                        }
                    }
                ])
            }


            const button = this.shadowRoot.querySelector('button-component');
            if (button) button.onclick = () => {
                this.onAddNewTask();
            }
            const input = this.shadowRoot.querySelector('input-component');
            if (input) {
                input.onChange = (value) => {
                    this.ref.text = value;
                };
                input.onEnter = () => {
                    this.onAddNewTask();
                }
            }
            const toggleEls = this.shadowRoot.querySelectorAll('.toggle-bar span');
            toggleEls.forEach((el, index) => {
                const keyMatch = ['sectionTitle', 'important', 'subtask'];
                const key = keyMatch[index];
                el.onclick = () => {
                    this.setState({
                        [key]: !this.state[key],
                    });
                }
            }); 
        }
    });

Và đây là kết quả Before -> After:

Tương tự chúng ta sẽ sửa để <input-component /> có thể dùng attribute text để render mà không sợ bị nháy.

customElements.define('input-component', class extends CoreElement {
        state = {
            text: '',
        };

        static get observedAttributes() {
          return ['text'];
        }

        renderContainerCSS = () => `
            display: flex;
            padding: 1.5rem;
            border-radius: 2.25rem;
            background-color: white;
            border: 1px solid rgba(53, 56, 62, 0.2);
        `;

        renderStyle = () => `
            <style>
                .input {
                    border: none;
                    outline: none;
                    width: 100%;
                    font-size: 2rem;
                }
            </style>
        `

        render() {
            this.style.cssText = this.renderContainerCSS();
            if (!this.shadowRoot.innerHTML) {
                this.shadowRoot.innerHTML = `
                    ${this.renderStyle()}
                    <input class="input" value="${this.state.text || ''}" />
                `;
            } else {
                this.mapStateRender([
                    {
                        selector: 'input',
                        updateType: 'prop.value',
                        value: this.state.text,
                    }
                ]);
            }

            const input = this.shadowRoot.querySelector('input');
            if (input) {
                input.oninput = e => {
                    const newValue = e.target.value;
                    this.setAttribute('text', newValue);
                    if (this.onChange) this.onChange(newValue);
                }
                input.onkeydown = e => {
                    if (e.key === "Enter") {
                        if (this.onEnter) this.onEnter();
                    }
                }
            }
        }
    });