Web 組件構建庫 - Lit
認識 Lit
抽象與封裝
在《你真的瞭解 Web Component 嗎 [1]》的分享中,我們在介紹 web 組件前,從理解框架和職責範圍的出發點與角度,探究了框架存在和發展的意義及目標,瞭解到了框架可以使快速開發和基礎性能之間達成平衡,進而讓開發者的開發體驗得到較大的提升。
而一個框架的組成,離不開優秀的設計思想,和對這些設計思想的最終實現。實現的整個過程,其實就是一個抽象與封裝的過程。但是這個過程並非是框架獨屬的,我們可以回憶一下,日常的開發中,可以對某些頻繁、重複使用的邏輯進行函數式封裝;可以將某個到處使用的模版進行組件式封裝;甚至我們會引用一些高質量的庫去支持開發,而這些庫,也是一個抽象與封裝的結果。既然抽象與封裝應用如此廣泛,那麼 web component 的創建與使用是不是也可以形成一個抽象與封裝的產物呢?
Lit 的介紹
Lit 是一個輕量的庫,用來快速構建 web 組件。其核心是 LitElement 基類,可以提供響應式狀態、作用域樣式以及高效靈活的模版系統。儘管它也是一個基於原生 web 組件而封裝的庫,但它依然保留了 web 組件的所有特性。不必依賴框架便可以實現組件化,並且它的使用不受框架的制約,甚至可以沒有框架。
基本特點
-
類 jsx/tsx 語法。
-
模版語法類似模版字符串的寫法。
-
只支持原生 css,但預編譯的樣式,需藉助打包器編譯實現後,通過特定方式引入使用。
-
支持 ts。
-
編程模型:OOP。
-
單向數據流,支持 MVVM,但無雙向綁定。
-
組件狀態管理:使用內置的 @state 與 @property 實現。
-
職責範圍小,單純處理 web 組件的創建與使用。
-
跨平臺跨框架。
-
庫的體積較小,據官網說明,gzip 壓縮混淆後只有 5k 左右。
-
Lit 的 html 模版中,可以通過 (.[屬性]) 的方式,進行自定義回調的傳遞(官網未說明,屬於 hack 方式)
Lit 的應用及基本原理
Demo 示例
暫時無法在文檔外展示此內容
基本組成及應用
基類
基類 (LitElement) 是 lit 最核心的組成,它繼承了原生的 HTMLElement 類,並在此基礎上進行了豐富的擴展。包括響應式狀態、生命週期以及一些諸如控制器和混入等高級用法的提供。
裝飾器
可以理解爲一些語法糖,用於修改類、類方法及屬性的特殊函數。
- @customElement('my-element'),註冊自定義組件,相當於 js 中的
window.customElements.define('my-element')。
- @eventOptions({capture: true,passive:true,once:true}),事件監聽配置,相當於 js 中的
dom.addEventListener(eventName,func,{capture: true,passive:true,once:true})。
- @property(options?) test:string= 'Somebody',公共屬性狀態的聲明; 相當於 js 中的
constructor() {
super();
this.test = 'Somebody';
}
static get properties() {
return {
test: {type: String},
}
}
options 是一個配置對象,其中包含:
-
attribute: 表示聲明的 property 是否與組件中元素的 attribute 建立連接,false 表示不建立,true 表示建立,並且 attribute 的名字與 property 同名。爲 string 時,表示建立並且 attribute 的名字爲該字符串。(建立連接,表示該 property 與 attribute 相互映射。)
-
converter:表示處理 property 與 attribute 之間的轉換規則。
-
默認時,attribute=》property,property 爲聲明時類型,attribute 爲 string;
-
爲 function 時,處理 attribute=》property;
-
爲 object 時,fromAttribute 處理 attribute=》property;toAttribute 處理 property=》attribute。
-
noAccessor:表示是否監聽該屬性變化並自動更新,默認爲 false,表示監聽並自動更新。爲 true 時,表示不自動更新,需要開發者調用 this.requestUpdate(propertyName, oldValue),來進行視圖更新。
-
reflect:表示是否將 property 的變化同步到 attribute, 默認 false。false 時,不會同步,儘管你設置了 converter;爲 true 時,會同步,根據 converter 的設置轉換。
-
type:表示類型聲明。
-
hasChanged:是一個函數,參數爲 value 與 oldValue,分別是屬性的新值與舊值。如果返回 false,表示屬性相關的視圖不需更新;返回 true 則相反。
-
@state(options) protected _active = false, 內部屬性狀態的聲明,本質上也是一個 property,同樣會觸發視圖的更新機制; 相當於 js 中的
constructor() {
super();
this._active = false;
}
static get properties() {
return {
_active: {state: true}
}
}
options 是一個配置對象,其中包含:
-
hasChanged:同上。
-
@query('todo-list') todoList!: TodoList; 獲取單個 dom 元素,相當於 js 中的
document.querySelector('todo-list');
- @queryAll('todo-lists') todoLists!: TodoLists; 獲取批量 dom 元素,相當於 js 中的
document.querySelectorAll('todo-lists');
-
@queryAssignedNodes(slotName, flatten, selector), 用來獲取對應 slot 元素的。
-
slotName:slot 的 name,string 類型。
-
flatten:是否平鋪,boolean 類型。
-
selector:是否過濾出當前選擇器的 slot。
-
@queryAsync('todo-list'),同 @query 一樣,用來獲取單個 dom 的,只是 @query 是同步獲取,返回的是 dom 對象;@queryAsync 是異步獲取,執行時機在 dom 更新完成以後,執行 updateComplete 的 promise 後執行,返回的是一個 promise 對象。
html 模版
我們來看這樣一段關於 html 模版的代碼
。。。
render() {
if(this.listItems.filter(item => !item.completed).length === 0) {
return html`<p>anything was done!</p>`;
}
return html`
<ul>
${this.listItems.map((item) => html`
<li
class=${item.completed ? 'completed' : ''}
@click=${() => this.toggleCompleted(item)}>
${item.text}
</li>`,
)}
</ul>
`;
}
。。。
可以看到 Lit 的 html 渲染是在 render 函數中進行的。通過 html 方法和模版字符串的結合,實現渲染。並且語法類似 jsx/tsx。可以直接在 render 函數和 html 模版中寫 js 邏輯。非常的靈活與方便。
樣式
- Lit 本身只支持原生 css,使用 css 方法添加樣式,方式如下:
//只有一組style
import {customElement, css} from 'lit-element';
@customElement('my-element')
export class MyElement extends LitElement {
static styles = css`
p {
color: green;
}
`;
。。。
}
//多組style
import {css} from 'lit-element';
static styles = [
css`h1 {
color: green;
} `,
css`h2 {
color: red;
}`
];
//引入樣式文件
import {css,unsafeCSS} from 'lit-element';
import style from './my-elements.less';//需要使用編譯工具編譯後倒入
static styles = [
css`:host {
width:500px;
}`,
css`${unsafeCSS(style)}`
];
- Lit 允許在 css 中書寫表達式
這就意味着你可以在某個 ts 文件中聲明一組樣式,然後引入到多個文件中使用:
//樣式中使用表達式
static get styles() {
const mainColor = 'red';
return css`
div { color: ${unsafeCSS(mainColor)} }
`;
}
- 動態樣式
可以想 vue 或 react 中一樣,動態的使用 class 和 style。
import {customElement, property,LitElement, html, css} from 'lit-element';
import {classMap} from 'lit/directives/class-map.js';
import {styleMap} from 'lit/directives/style-map.js';
@customElement('my-element')
export class MyElement extends LitElement {
@property()
classes = { someclass: true, anotherclass: true };
@property()
styles = { color: 'lightgreen', fontFamily: 'Roboto' };
protected render() {
return html`
<div class=${classMap(this.classes)} style=${styleMap(this.styles)}>
content
</div>
`;
}
}
slot 插槽
- 概念
關於插槽的概念理解,其實可以直接類比 vue 中的 slot 插槽,因爲 lit 本身是一個純 js 庫,所以 lit 的插槽完全是來源於原生 web component 技術所提供的規範,而 vue 的 slot 功能,參考來源正是原生的 slot。
-
注意⚠️
-
默認情況下,如果一個自定義元素有存在 shadow dom,那麼它的子元素是不會渲染的。
<my-element test-word="test-word" testWord="testWord">
<div>我是子元素</div>
</my-element>
-
預置 slot 中的內容,可以在對應子元素渲染前,起到兜底的作用。
-
slot 的使用
-
匿名 slot,只需要預置一個 slot,那麼所有的子元素都可以被安排呈現。
//html
<my-element test-word="test-word" testWord="testWord">
<div>我是子元素1</div>
<div>我是子元素2</div>
<div>我是子元素3</div>
</my-element>
//ts
render() {
return html`
<slot></slot>
`;
}
- 具名 slot,只根據對應的 name 呈現,其他的被忽略
//html
<my-element test-word="test-word" testWord="testWord">
<div slot="child1">我是子元素1</div>
<div>我是子元素2</div>
<div>我是子元素3</div>
</my-element>
//ts
render() {
return html`
<slot ></slot>
`;
}
- slot 的生命週期
可以通過在 slot 元素上面綁定 slotchange 事件,來獲取 slot 被插入或刪除的時機,以及對應的事件對象。
//html
<my-element test-word="test-word" testWord="testWord">
<div slot="child1">我是子元素1</div>
</my-element>
//ts
handleSlotchange(e:Event) {
console.log(e);
}
render() {
return html`
<slot @slotchange=${this.handleSlotchange}></slot>
`;
}
}
事件通信
在 Lit 中,也是內置了事件通信的邏輯。事件通信主要是兩部分組成,註冊監聽和調度觸發。
- 註冊監聽,採用 @[事件名] 的方式,在組件上註冊事件;使用 e.detail 來回去通信內容。
。。。
private addList(e: CustomEvent){
this.listItems = [...this.listItems,{
text:e.detail,
completed: false,
} as ToDoItem];
this.todoList.requestUpdate();
}
。。。
render() {
return html`
。。。
<controls-area @addList=${this.addList}></controls-area>
。。。
`;
}
。。。
- 調度觸發,使用 dispatchEvent 進行調度,使用 CustomEvent 構造函數及固定的結構來實例化通信傳輸的事件對象。
...
private sendText(){
const options = {
detail: this.inputText,
};
this.dispatchEvent(new CustomEvent('addList', options));
this.inputText = '';
}
...
生命週期及更新流程
Lit 採用批量更新的方式來提高性能和效率。一次設置多個屬性只會觸發一次更新,然後在微任務定時異步中執行。
狀態更新時,只渲染 DOM 中發生改變的部分。由於 Lit 只解析並創建一次靜態 HTML ,並且後續只更新表達式中更改的值,所以更新非常高效。
-
更新流程
-
當屬性被 set 時,屬性的 setter 被觸發 (屬性的監聽通過 defineProperty 完成)。
-
然後將觸發組件的 requestUpdate。
-
此時若該屬性設置了 hasChanged 函數,那麼等待該函數返回值來決定是否繼續更新。若沒有設置 hasChanged 函數,則直接對比新舊值。
-
新舊值不一致時,觸發異步更新,調用組件的 update 方法。如果發現已經觸發了一次更新,那麼執行最後一個更新。
-
觸發更新後,將更新的 property 再次映射到 attribute 中,並渲染 html。
-
生命週期
lit 中的生命週期分爲兩類,一類是原生組件化提供的生命週期,一般不需要開發者主動去使用。另一類是 lit 的狀態更新提供的生命週期,如下:
- requestUpdate
執行了 requestUpdateInternal 方法,並返回了更新結果的 promise。requestUpdateInternal 方法中主要進行了兩個操作,一個是將更新的屬性存在一個 map 中,以備後續使用。另一個是進行一些比較判斷,決定是否調用_enqueueUpdate 方法。而_enqueueUpdate 調用了 performUpdate 方法。
- performUpdate
performUpdate 方法中執行了 shouldUpdate,shouldUpdate 返回 false 則中斷,若返回 true,則依次執行 willUpdate、update、firstUpdated 和 updated。
- willUpdate
ts 中不存在 willUpdate,是 js 的 polyfill-support 的覆蓋點,源碼中函數內容爲空。
- Update
主要執行了_propertyToAttribute 函數,將 property 向 attribute 映射,覆蓋 render 渲染出來的 html。
- Render
render 函數只執行一次, 用來解析並創建靜態 HTML。
- firstUpdated
是一個覆蓋點,源碼中爲空。元素首次更新完畢觸發,只執行一次。此方法中設置屬性,會在本次更新完畢後再次觸發更新。
- updated
是一個覆蓋點,源碼中爲空。元素每次更新完畢觸發。此方法中設置屬性,會在本次更新完畢後再次觸發更新。
- updateComplete
是函數_getUpdateComplete 的返回值,本質上是一個 promise 對象,表示當前更新全部完畢。
protected _getUpdateComplete() {
return this.getUpdateComplete();
}
protected getUpdateComplete() {
return this._updatePromise;
}
高階應用
指令
如上面的動態樣式一樣,classMap 和 styleMap 屬於應用在 html 模版中的指令。開發者可以直接使用內置指令進行開發;也可以根據自己的需要,進行自定義指令的開發。
-
內置指令
-
classMap - 將類列表設置爲基於對象的元素
-
styleMap - 將樣式屬性列表設置爲基於對象的元素
-
repeat - 將值從可迭代對象渲染到 DOM 中
-
templageContent- 呈現
<template>
元素的內容 -
unsafeHTML - 將字符串呈現爲 HTML
-
unsafeSVG - 將字符串呈現爲 SVG
-
cache - 更改模板時緩存呈現的 DOM
-
guard - 僅在其依賴項之一發生變化時重新更新模板
-
ifDefined - 如果值已定義,則設置該屬性,如果未定義,則刪除該屬性
-
live - 採用嚴格的‘===’檢查實時 DOM 值與表達式的值,不相等便觸發更新。
-
until - 呈現佔位符內容,直到一個或多個 Promise 解決
-
asyncAppend- 將
AsyncIterable
的 promise 結果插入到 DOM 中 -
asyncReplace- 將
AsyncIterable
的 promise 結果替換到 DOM 中 -
ref - 獲取 dom 節點
-
自定義指令
自定義指令的功能很強大,儘管在使用中看起來只是調用了一個函數,但實際上,內部包含了自己的生命週期 (constructor、render、update),不僅如此,指令還能獲得與它關聯的底層 DOM 的特殊訪問。
這裏我們實現一個簡單的指令:
-
首先聲明一個繼承了 Directive 類的自定義指令類。
-
內部定義生命週期的鉤子函數 render,將傳入的字符串修改後返回。
-
使用 directive 實例化,並向外暴露。
//自定義指令的文件
import {Directive, directive} from 'lit/directive.js';
class FormatStr extends Directive {
render(test:string) {
return `${test}!!!`;
}
}
export const formatStr = directive(FormatStr);
//使用自定義指令的文件
import {formatStr} from '../directives/formatStr';
import {html} from 'lit';
。。。
render(){
html`<div>${formatStr('hellow')}</div>`
}
。。。
混和
混合本身的作用,是爲了在類之間共享代碼。而類混合,本質上是屬於原生類的一種行爲,由於 Lit 是一個原生的 js 庫,所以它可以拿來直接使用。目的也很簡單,爲了封裝抽象。在 Lit 中使用類混合,我們可以在複用代碼的同時,做一些定製化的擴展。下面我們實現一個混合。
這裏我們聲明一個混合類的方法,使得調用這個函數後生成的類,擁有公共的方法,就是在組件連接主文檔後進行一次打印,打印的結果,是當前組件自己的 name 屬性。
/* eslint-disable no-unused-vars */
import {LitElement} from 'lit';
type Constructor<T = {}> = new (...args: any[]) => T;
export const TestMixin = <S extends Constructor<LitElement>>(superClass: S) => {
class MyMixinClass extends superClass {
constructor(...args: any[]) {
super();
this.name = 'TestMixin';
}
name:string;
connectedCallback() {
super.connectedCallback();
setTimeout(()=>{
console.log(this.name);
},3000)
}
}
return MyMixinClass as S;
}
這裏是使用的文件:
//TodoList.ts
export class TodoList extends TestMixin(LitElement) {
constructor() {
super();
this.name = 'TodoList';
}
name:string;
}
//ControlsArea.ts
export class ControlsArea extends TestMixin(LitElement) {
constructor() {
super();
this.name = 'ControlsArea';
}
name:string;
}
這裏是打印結果,分別會在連接到主文檔 3s 後打印各自的 name。
控制器
控制器是 Lit 中,又一個封裝抽象的概念,它區別於組件和混合。它沒有視圖,也不封裝視圖;它擁有同宿主綁定的生命週期,但是不存在狀態更新的機制。相對於混合的代碼共用,它更像是實現功能共用。
與宿主交互的相關方法:
-
addController:同宿主綁定。
-
removeController:同宿主解除綁定。
-
requestUpdate:更新宿主視圖。
-
updateComplete:獲取宿主更新完畢的 promise。
有四個可以與宿主綁定的生命週期:
-
hostConnected:宿主連接主文檔時執行。
-
hostUpdate:宿主將 property 向 attribute 映射完畢 (update) 後,render 之前執行。
-
hostUpdated:在組件每次更新完畢 (updated) 後執行。
-
hostDisconnected:宿主與主文檔斷開連接時執行。
實現一個簡單的控制器:
//聲明控制器
import {ReactiveControllerHost} from 'lit';
export class MouseController {
private host: ReactiveControllerHost;
pos = {x: 0, y: 0};
_onMouseMove = ({clientX, clientY}: MouseEvent) => {
this.pos = {x: clientX, y: clientY};
this.host.requestUpdate();
};
constructor(host: ReactiveControllerHost) {
this.host = host;
host.addController(this);
}
hostConnected() {
window.addEventListener('mousemove', this._onMouseMove);
}
hostDisconnected() {
window.removeEventListener('mousemove', this._onMouseMove);
}
}
//使用控制器
import {MouseController} from '../controller/mouseController';
export class ControlsArea extends TestMixin(LitElement) {
constructor() {
super();
}
private mouse = new MouseController(this);
render() {
return html`
<pre>
x: ${this.mouse.pos.x as number}
y: ${this.mouse.pos.y as number}
</pre>
`;
}
}
生態相關
路由
Lit 官方並未提供路由,但是社區提供了:
lit-element-router 傳送門:https://www.npmjs.com/package/lit-element-router
共享狀態管理
Lit 官方並未提供共享狀態管理,但是社區提供了:
lit-element-state 傳送門:https://www.npmjs.com/package/lit-element-state
開發插件 (vscode)
語法高亮(lit-plugin)
高亮前 高亮後
代碼片段(LitElement Snippet)
js 下代碼片段提示 ts 下代碼片段提示
SSR
支持 ssr,並且官方提供了對應的工具包:
@lit-labs/ssr :https://www.npmjs.com/package/@lit-labs/ssr
github 地址
https://github.com/lit/lit/
測試工具
lit 是標準的 js 工具庫,可以使用任何 js 測試工具。
https://lit.dev/docs/tools/testing/
依賴 Lit 的組織及項目
同類框架 / 庫比較
可以看到 lit 的下載量遙遙領先。當然,這並不意味着 lit 就是最好的,畢竟不同的場景,有不同的選型。但至少可以確定一點,lit 的應用場景相對是比較多的,這也是下載量不斷飆升的原因。
依賴 Lit 的開源組件庫
以上只提供了部分背書公司較知名的組件庫 (均爲國外組件庫,國內目前暫無知名的相關組件庫)。用以觀察借鑑,規避踩坑。
其他相關參考
lit 項目所有者:https://github.com/orgs/lit/people =》https://github.com/e111077
lit 項目被依賴關係:https://github.com/lit/lit/network/dependents
Lit-element npm 包地址:https://www.npmjs.com/package/lit-element
框架對比網站:https://www.npmtrends.com/lit-element-vs-svelte-vs-@stencil/core
參考資料
[1] 你真的瞭解 Web Component 嗎: https://juejin.cn/post/7010580819895844878
歡迎關注公衆號 ELab 團隊 收貨大廠一手好文章~
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/FIPCzKfxDZrs_g97AFBpgg