LitElement - 像 React 一樣寫 WebComponent

LitElement - 像 React 一樣寫 WebComponent

2018/6/21

在 React, Vue 還不存在的時代,純粹透過 jQuery 或是 DOM API 撰寫 User interface ,不過問題就是 html, js 容易進入混亂的狀態,沒有元件的概念,不好模組化,所以許多人一直發(ㄔㄨㄥˊ)明(ㄗㄠˋ)工(ㄔㄜ)具(ㄌㄨㄣˊ)幫助開發

也有另一派人馬希望可以透過改善瀏覽器本身 API 來做到元件化開發,因此 WebComponent 的概念就出現了,WebComponent 包含了許多面向,我覺得最重要的部分是 Custom Elements

什麼是 Custom Element?

簡單來說,透過它可以宣告自定義的 html element

舉個例子,你可以定義一個 hello-custom-element:

class HelloCustomElement extends HTMLElement {}
customElements.define('hello-custom-element', HelloCustomElement);

這樣寫就有一個最基本的 custom element 了,不過沒有任何功能,如果你在 HTML 裡面這樣寫:

<hello-custom-ele>
  <h1>Original H1</h1>
</hello-custom-ele>

看起來什麼事情都沒發生,就像是亂寫一個 HTML tag 而已,那個 h1 還是正常的顯示出來,但是其實 <hello-custom-ele> 已經是一個可以客製行為的 html 元件,我們來驗證一下:

class HelloCustomElement extends HTMLElement {
  constructor() {
    super()
    debugger
  }
}

一個有效的 custom element 最基本要 extends HTMLElement 並且在 constructor() 裡頭先呼叫 super()

進入中斷點之後,嘗試在 console 上輸入 this. ,你會發現有 this.innerHTML, this.getAttribute('some-prop') 等 API 可呼叫

完整實作請參考 Github Repo 或是 commit,可以使用 http-server

真正的隔離 - shadow DOM

HelloCustomElement 建立一個 shadow root (DOM):

constructor() {
  super()
  this.attachShadow({mode: 'open'})
  debugger
}

進入中斷點應該可以發現畫面上的 Original H1 h1 消失了,這時在 console 上輸入 this.shadowRoot.innerHTML = 'hello',去看 HTML 的樣子:

shadow-root

接著有趣的地方來了,如果在 <hello-custom-ele> 外加上隨便的 h1

<hello-custom-ele>
  <h1>original H1</h1>
</hello-custom-ele>
<h1>out side of hello-custom-ele</h1>

並且讓 HelloCustomElement 的 innerHTML 來點 style:

constructor() {
  super()
  this.attachShadow({mode: 'open'});
  this.shadowRoot.innerHTML = `
    <style> h1 { color: #5bbd72; } </style>
    <h1>hello-custom-ele rendered!</h1>
  `
}

你會發現外面的 out side of hello-custom-ele h1 依然還是原本的顏色,這個就是 shadow root 的特性,裡面跟外面的 selector 是互相選取不到的,同時 document.querySelectorAll('h1') 也互相選不到,這樣 css rule 就可以寫得更簡單了,也可以確保內外行為不會互相干擾

完整實作請參考 commit

lit-html

一直用 innerHTML 來建立元件內容也不是個辦法,這也是 React, Vue 等等 framework 想要解決的問題,這邊要來介紹兩個東西

  • HTML template: 其實就是一個預設不會 render 的 HTML DOM,作為模板使用,有點 VDOM 的感覺
  • JS template literals: ES6 用反引號(back-tick)包起來的模板字串,可以寫成 `hello ${name}`,不過還有一個更神奇的 some_fun`some ${blabla}`,稱為 tagged template literal
    • 這個 some_fun 可以拿到這個 template 裡面每個挖空區域的內容 ${...},可以試試看在 console 上輸入 console.log`1 + 1 = ${1 + 1}`
    • 基本上這樣的設計就是讓大家可以在 JS 裡面內嵌其他語言

lit+html

JS template LITeral + HTML template = lit-html,大概是這樣的感覺,這是 2017 年 Google Polymer 的實驗專案,Github repo 在此,直接把 <hello-custom-ele /> 改用 lit-html render 吧:

yarn add lit-html
import { html, render as litRender } from '/node_modules/lit-html/lit-html.js';

class HelloCustomEle extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({mode: 'open'});
    litRender(this.render('#5bbd72'), this.shadowRoot);
  }

  render(color) {
    return html`
      <style> h1 { color: ${color}; } </style>
      <h1>hello-custom-ele rendered!</h1>
    `
  }
}
customElements.define('hello-custom-ele', HelloCustomEle);

有點像 React 了?分析一下發生了什麼事情:

  1. html`` 像是寫 JSX 一樣寫 HTML 結構,建立並回傳 TemplateResult,一個 lit-html 的資料格式
  2. litRender(TemplateResult, this.shadowRoot) (lit-html 提供的 render function)
    • 如果 TemplateResult 是第一個 instance 的第一次 render,建立 HTML template 並且 cache 起來(lit-html 內部有個 template cache)
    • 如果這個 instance this.shadowRoot 的第一次 render,把 HTML template 內容複製到 this.shadowRoot 上面,否則用新的挖空處值 ${...} 值更新 DOM

注意這邊用了 ES6 import 語法, <script /> 要有 type='module' 才能動 完整實作請參考 commit,要先用 yarn 安裝 lit-html

LitElement

既然開始有點像 React 了,那來看一下缺了哪些 React 的主要功能:

  • props, state and updating
  • Event handling

從上面的 <hello-custom-ele /> 看來要自己加 callback 來做到這些事情,而且我們可以開始注意到有許多共通行為應該要包成一個 base class 來做了,像是 shadow DOM 的建立跟 component lifecycle (initialization, rendering, re-rendering) 等等

2018 Google IO 發表了 Polymer 3.0,但是我有興趣的是 lit-html 的完整包裝 LitElement,改用 LitElement 之後:

import { LitElement, html } from '@polymer/lit-element';

const color1 = '#5bbd72'
const color2 = '#42a2c3'

class HelloCustomEle extends LitElement {
  static get properties() { return { color: String } }

  constructor() {
    super();
    this.color = this.color || color1;
  }

  _render({ color }) {
    return html`
      <style> h1 { color: ${color}; } </style>
      <h1 on-click="${() => { this.color = this.color === color1 ? color2 : color1 } }">
        hello-custom-ele rendered!
      </h1>
    `
  }
}
customElements.define('hello-custom-ele', HelloCustomEle);

LitElement 作為 base class 可以幫你:

  • 建立 shadow DOM 以及 render
  • static get properties() 當成 state / props,而且弄成 setter,被設定時會 re-render
  • on-some-event 掛上 listener

去瀏覽器點 hello-custom-ele rendered! 應該可以看到顏色的改變,也可以 <hello-custom-ele color='#42a2c3' /> 指定 property,看起來就像是一個 native html element,component 實作起來跟 React 也有 87% 像了

完整實作請參考 commit,這邊會需要 setup 一下


或許大家都不否認 React JSX 定義 Component 結構的方式簡單又明瞭,lit-html 的出現可能只是順水推舟,核心實作也沒多少東西,但是看到這邊大家一定會想:

瀏覽器支援度呢?

以上的範例只有 import 的路徑有透過 polymer-cli 改寫過,我用 Chrome 67 可以在不加任何 polyfill 的情況下動起來,但是其他瀏覽器就無法保證了,可參考各個 feature 相容性:

基本上 Polymer / WebComponent 都是由 Google 推動的,有點像是把這些 feature 趕上自己的瀏覽器,然後就說現在大部分的瀏覽器都支援了…(Chrome 加 android Chrome 市佔率就超過 50%

現在 Custom Element 已經被寫進 HTML Spec,因此今天來嚐鮮撰寫 Custom Element 的工具,如果順利的話這可能是未來透過瀏覽器內建 API 撰寫網頁元件的好方法之一!