Web Components-LitElement 實踐

本文首發於政採雲前端團隊博客:Web Components-LitElement 實踐

https://www.zoo.team/article/webcomponents

前言

Google 在 2011 年首次正式提出 Web Components 組件化概念時,它主要依賴三個技術:Custom Element、Shadow Dom、HTML Templates。直到 2015 年 Google 才真正投入生產進行使用,那時其他瀏覽器廠商還沒有大規模支持這個特性,應用起來存在很大的兼容問題。

在這期間,Angular、React 和 Vue 三大框架崛起,並且都有 “組件化” 這個功能,也形成了各自的生態圈,但都與框架強關聯。由於這個原因,開發者對於 Web Components 的呼聲一直是隻增不減。

直到今天,由於各大瀏覽器廠商的支持並結合 polyfills,在使用 Web Components 時,兼容性已經不是問題,開發者開始積極探索並實踐 Web Components 技術。

如何更好地應用 Web Components 技術呢?有輕便的框架可以簡化原生較爲複雜的寫法嗎?那麼我們來看看 LitElement 做了什麼,能不能讓 Web Components 變得更好用些。

回顧

通過閱讀上篇文章《如何基於 Web Components 封裝 UI 組件庫》(https://juejin.cn/post/7096265630466670606),我們掌握了原生 Web Components 的一些內容,包括:

  1. 三要素和生命週期;

  2. 基本的組件通信,包括如何利用 observedAttributes 屬性監聽和 attributeChangedCallback 生命週期獲取最新屬性和通過 CustomEvent 拋出自定義事件來模擬實現狀態的 “雙向綁定”;

  3. 如何設計組件庫;

  4. 如何在原生、React 和 Vue 中優雅地使用我們封裝的組件。

但使用 Web Components 的原生寫法確實存在一些不簡潔的地方:

  1. 屬性監聽:observedAttributes API 需要結合 attributeChangedCallback 生命週期,寫起來代碼量大;

  2. 組件通信時傳入複雜數據類型:只能通過 stringify 後的 attribute 傳遞,特殊對象格式如 Date,Function 等傳遞起來會非常複雜,和現在的組件庫能力上相比功能會比較弱,使用場景相對單一;

  3. 組件通信時雙向綁定:需要結合自定義事件,寫法會比較複雜。

爲了更豐富的開發場景和更好的開發體驗,LitElement 把以上問題進行了歸納轉化,即:

  1. 如何響應 reactive properties 的變化,並應用到 UI 上。

  2. 如何解決模板語法。

它用了兩個核心庫來解決這個問題,分別是 lit-element 和 lit-html。

LitElement 介紹

基本內容

Lit 的核心是一個組件基類,它提供響應式、scoped 樣式和一個小巧、快速且富有表現力的聲明性模板系統,且支持 TypeScript 類型聲明。Lit 在開發過程中不需要編譯或構建,幾乎可以在無工具的情況下使用。

我們知道 HTMLElement 是瀏覽器內置的類,LitElement 基類則是 HTMLElement 的子類,因此 Lit 組件繼承了所有標準 HTMLElement 屬性和方法。更具體來說,LitElement 繼承自 ReactiveElement,後者實現了響應式屬性,而後者又繼承自 HTMLElement。

創建 Lit 組件還涉及許多概念,我們一一瞭解。

定義一個組件

Lit 組件作爲 Custom Element 的實現,並在瀏覽器中註冊。

原生的寫法主要是繼承 HTMLElement 類並重寫它的方法。而 LitElement 框架則是基於 HTMLElement 類二次封裝了 LitElement 類,它將很多的寫法通過一些語法糖的封裝變得更簡單了,極大地簡化了這些代碼。開發者只需繼承 LitElement 類開發自己的組件然後通過瀏覽器原生方法 customElements.define 註冊即可。

export class LitButton extends LitElement { /* ... */  }
customElements.define('lit-button', LitButton);

當定義一個 Lit 組件時,就是定義了一個自定義 HTML 元素。因此,可以像使用任何內置元素一樣使用新元素。

<lit-button type="primary"></lit-button>

渲染

組件具有 render 方法,該方法被調用以渲染組件的內容。

雖然 Lit 模板看起來像字符串插值,但 Lit 解析並創建一次靜態 HTML,然後只更新表達式中需要更改的值。

export class LitButton extends LitElement {
 /* ... */
 
 render() {
    // 使用模板字符串,可以包含表達式
    return html`
      <div><slot ></slot></div>
    `;
  }
}

通常,組件的 render() 方法返回單個 TemplateResult 對象(與 html 標記函數返回的類型相同)。

TemplateResult 對象:是 lit-html 接收模板字符串並經過它的 html 標記函數處理得到的一個純值對象。

但是,它可以返回 Lit 可以渲染的任何內容,包括:

響應式 properties

DOM 中 property 與 attribute 的區別:

  • attribute 是 HTML 標籤上的特性,可以理解爲標籤屬性,它的值只能夠是 String 類型,並且會自動添加同名 DOM 屬性作爲 property 的初始值;

  • property 是 DOM 中的屬性,是 JavaScript 裏的對象,有同名 attribiute 標籤屬性的 property 屬性值的改變也並不會同步引起 attribute 標籤屬性值的改變;

Lit 組件接收標籤屬性 attribute 並將其狀態存儲爲 JavaScript 的 class 字段屬性或 properties。響應式 properties 是可以在更改時觸發響應式更新週期、重新渲染組件以及可選地讀取或重新寫入 attribute 的屬性。每一個 properties 屬性都可以配置它的選項對象。

export class LitButton extends LitElement {
 // 在靜態屬性類字段中聲明屬性,Lit 會處理爲響應式屬性
  static properties = {
    type: {
      type: String,
      reflect: true,
      /*...其他選項屬性...*/
    },
    other: {
      type: Object
    }
  };
  
  /* ... */
}

它的選項對象可以具有以下屬性:

省略選項對象或指定一個空的選項對象等效於爲所有選項指定默認值。

另外,Lit 爲每個響應式屬性生成一個 getter/setter 對。當響應式屬性發生變化時,組件會安排更新。Lit 也會自動應用 super 類聲明的屬性選項。除非需要更改選項,否則不需要重新聲明該屬性。

樣式

組件模板被渲染到它的 shadow root。添加到組件的樣式會自動作用於 shadow root,並且只會影響組件 shadow root 中的元素。

Shadow DOM 爲樣式提供了強大的封裝。如果 Lit 沒有使用 Shadow DOM,則必須非常小心不要意外地爲組件之外的元素設置樣式,無論是組件的父組件還是子組件。這可能涉及編寫冗長而繁瑣的類名。通過使用 Shadow DOM,Lit 確保編寫的任何選擇器僅適用於 Lit 組件的 shadow root 中的元素。

可以使用標記的模板 css 函數在靜態 styles 類字段中定義 scoped 樣式。

export class LitButton extends LitElement {
 // 使用純 CSS 爲組件定義 scoped 樣式
  static styles = css`
    .lit-button {
      display: inline-block;
      padding: 4px 20px;
      font-size: 14px;
      line-height: 1.5715;
      font-weight: 400;
      border: 1px solid #1890ff;
      border-radius: 2px;
      background-color: #1890ff;
      color: #fff;
      box-shadow: 0 2px #00000004;
      cursor: pointer;
    }
  `;
  
  /* ... */
}

如圖同樣應用了 lit-button 樣式,但樣式只對 shodow root 中的部分起作用。

靜態 styles 類字段的值可以是:

此外,styles 也支持在樣式中使用表達式、使用語句、繼承父類樣式、共享樣式、使用 unicode  escapes 以及在模板 template 中使用樣式等功能。Lit 也提供了兩個指令,classMap 和 styleMap,可以方便地在 HTML 模板中條件式的應用 class 和 style。

import {LitElement, html, css} from 'lit';
import {classMap} from 'lit/directives/class-map.js';
import {styleMap} from 'lit/directives/style-map.js';

export class LitButton extends LitElement {
  static properties = {
    classes: {},
    styles: {},
  };
  static styles = css`
   .lit-button {
      display: inline-block;
      padding: 4px 20px;
      font-size: 14px;
      line-height: 1.5715;
      font-weight: 400;
      border: 1px solid #1890ff;
      border-radius: 2px;
      background-color: #1890ff;
      color: #fff;
      box-shadow: 0 2px #00000004;
      cursor: pointer;
    }
    .someclass {
      color: #000;
    }
    .anotherclass {
      font-size: 16px;
    }
  `;

  constructor() {
    super();
    this.classes = {'lit-button': true, someclass: true, anotherclass: true};
    this.styles = {fontFamily: 'Roboto'};
  }
  render() {
    return html`
      <div class=${classMap(this.classes)} style=${styleMap(this.styles)}>
        <slot ></slot>
      </div>
    `;
  }
}
customElements.define('lit-button', LitButton);

生命週期

Lit 組件可以繼承原生的自定義元素生命週期方法。但如果需要使用自定義元素生命週期方法,確保調用 super 類的生命週期,以保證父子組件生命週期的一致。

標準的自定義組件生命週期

connectedCallback() {
  super.connectedCallback()
  addEventListener('keydown', this._handleKeydown);
}

disconnectedCallback() {
  super.disconnectedCallback()
  window.removeEventListener('keydown', this._handleKeydown);
}

除了標準的自定義元素生命週期之外,Lit 組件還實現了響應式更新週期。Lit 異步執行更新,因此屬性更改是批處理的,如果在請求更新後但在更新開始之前發生了更多屬性更改,則所有更改都將在同一個更新中進行。當響應式 prpperties 屬性發生變化或顯式調用 requestUpdate() 方法時,將觸發響應更新週期,它會將更改呈現給 DOM。

響應式更新週期

第一階段:觸發更新

第二階段:執行更新

第三階段:完成更新

其他:

整個流程圖示如下:

瞭解了基本的概念和內容,如果你做過任何現代的、基於組件的 Web 開發,你應該對 Lit 的系列概念和用法感到似曾相識並且容易上手。下面通過一些案例瞭解 LitElement 的其他特性。

傳入複雜數據類型

對於複雜數據的處理,爲什麼會存在這個問題,根本原因還是因爲 attribute 標籤屬性值只能是 String 類型,其他類型需要進行序列化。在 LitElement 中,只需要在父組件模板的屬性值前使用 (.) 操作符,這樣子組件內部 properties 就可以正確序列化爲目標類型。

/**
 * 父組件-複雜數據類型
 */
 import { html, LitElement } from 'lit';
 import './person';

 class LitComplex extends LitElement {

  constructor() {
    super();
    this.person= {'name':'cai'};
    this.friends = [{'name':'zheng'},{'name':'yun'}];
  }

   render() {
     return html`
     <div>複雜數據類型</div>
     <lit-person .person=${this.person} .friends=${this.friends}></lit-person>
     `
   }
 }

 customElements.define('lit-complex', LitComplex);

 export default LitComplex;
/**
 * 基礎組件
 */
 import { html, LitElement } from 'lit';

 class LitPerson extends LitElement {
   static properties = {
     person: {
       type: Object
     },
     friends: {
       type: Array,
     },
     date: {
       type: Date,
     }
   }

   firstUpdated() {
     console.log(this.person instanceof Object, this.friends instanceof Array, this.date instanceof Date); 
     // true true true
   }

   render() {
     return html`
     <div>${this.person.name}${this.friends.length}個朋友</div>
     `
   }
 }

 customElements.define('lit-person', LitPerson);

 export default LitPerson;

這樣可以支持各種類型數據的傳遞使用。

數據的雙向綁定

/**
 * 數據綁定- father
 */
 import { html, LitElement } from 'lit';
 import './lit-input';

class LitInputFather extends LitElement {
  static properties = {
    data: {
      type: String
    }
  }

  constructor() {
    super();
    this.data = 'default';
  }

  render() {
    return html`
    <lit-input value=${this.data}></lit-input>
    `;
  }
}

customElements.define('lit-input-father', LitInputFather);

 export default LitInputFather;
/**
 * 數據綁定
 */
 import { html, LitElement } from 'lit';

 class LitInput extends LitElement {
   static properties = {
     value: {
       type: String,
       reflect: true
     }
   }

   change = (e) ={
     this.value = e.target.value;
   }

   render() {
     return html`
     <div>輸入:<input value=${this.value} @input=${this.change}/></div>
     `
   }
 }

 customElements.define('lit-input', LitInput);

 export default LitInput;

這裏子組件接收了父組件的 value 屬性,默認值設爲了 'default',在子組件內通過監聽輸入事件更新了 value 值,因爲 value 屬性配置了 reflect 爲 true,即可將屬性值的改變反映回關聯的 attribute 屬性。

如圖:input 組件默認值爲 'default'並在緊接着輸入'123'後,組件的標籤屬性 value 同時發生了變化。

這時在父組件通過獲取子組件的 attribute 即可獲得子組件同步改動的值。以此實現數據的雙向綁定,但 LitElement 本身是單向的數據流。

指令使用

指令是可以通過自定義表達式呈現方式來擴展 Lit 的函數。Lit 包含許多內置指令,可幫助滿足各種渲染需求:以組件緩存爲例。

在更改模板而不是丟棄 DOM 時緩存渲染的 DOM。在大型模板之間頻繁切換時,可以使用此指令優化渲染性能。

/**
 * cache 內置指令使用
 */
 import {LitElement, html} from 'lit';
 import {cache} from 'lit/directives/cache.js';

 class LitCache extends LitElement {
  static properties = {
    show: false,
    data: {},
  };

  constructor() {
    super();
    this.data = {
      detail: 'detail',
      sumary: 'sumary'
    };
  }

  detailView = (data) => html`<div>${data.detail}</div>`;

  summaryView = (data) => html`<div>${data.sumary}</div>`

  changeTab = () ={
    this.show = !this.show;
  }

  render() {
    return html`${cache(this.show
      ? this.detailView(this.data)
      : this.summaryView(this.data)
    )}
    <button @click=${this.changeTab}>切換</button>
    `;
  }
}
customElements.define('lit-cache', LitCache);

這個例子在模板中使用了語句表達式,再通過 click 事件切換組件時展示不同的模板內容;引入了 cache 指令函數,實現了 DOM 的緩存。

LitElement 內置了大量的指令函數可以使用。

此外,它還有豐富的 Mixins 和 Decoratrs 等內容值得細細學習,在此不再做過多展開。

總結

總的來說,LitElement 在 Web Components 開發方面有着很多比原生的優勢,它具有以下特點:

結合這些點,基本可以滿足項目開發中的大部分場景。

以上就是關於 LitElement 介紹的主要內容,更多內容可以前往官網學習瞭解,文中案例地址可以在此獲得 (https://github.com/CYLpursuit/lit-element-ui),同時推薦安裝  lit-plugin (https://marketplace.visualstudio.com/items?itemName=runem.lit-plugin) VS Code 插件來更好的預覽和改動代碼。

尾聲

我們知道,W3C 仿照 jQuery 的 $ 函數,實現了 querySelector()querySelectorAll() 方法並逐漸取代了 jQuery 快速選擇 DOM 元素的功能,加速了 jQuery 的沒落,帶着前端邁向了新的階段。那麼隨着 Web Components 的不斷髮展,它會取代現有的前端框架嗎?

現階段來看,還並不會,因爲 Web Components 與各前端框架之間的關係是 “共存” 而非互斥,兩者可以完美地互補。雖然前端框架 React 和 Vue 中組件化是其中非常重要的功能,但它們還有頁面路由,數據綁定,模塊化,CSS 預處理器,虛擬 DOM,Diff 算法以及各種龐大的生態等功能。而 Web components 所解決的僅僅是組件化這麼一項功能。不論是 React 還是 Vue,從它們的官方文檔有關於 Web Components 的說明中,都可以更好幫助我們理解它們與 Web Components 之間的關係。

UI 組件庫

參考資料

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/BL3PBShor4LV_wxdf4qctA