近期的工作用到了 Web Components 的能力,但在學習的過程中還是踩了不少的坑。但學完之後發現其和 Vue、React 有諸多相似之處,所以通過對比 React、Vue 與 Web Components 的相似點和不同點,希望大家在需要的時候能夠快速掌握 Web Components 核心知識,減少學習的時間和少踩坑。
目錄
核心概念
Web Components 給我們給提供了自定義 html 元素的能力,類似 Vue 或者 React 組件,但和他們不同的是,Web Components 定義的組件,就是普通的 html 元素,爲什麼這樣說呢?我們且看下面一個小示例:
<style>
/* 3.設置樣式 */
hello-world {
color: red;
font-size: 100px;
border: 1px solid #eee;
display: inline-block;
}
</style>
<!-- 2.使用組件 -->
<hello-world />
<script>
// 1.定義 Web Components 標籤
customElements.define('hello-world', class HelloWorld extends HTMLElement {
constructor() {
super();
this.innerHTML = `<div>hello world</div><div>你好,世界</div>`
}
})
// 4.定義事件監聽
const helloWorld = document.querySelector('hello-world')
helloWorld.addEventListener('mouseover', (e) => {
console.log('hover', e)
})
</script>
我們看到上述代碼,無論是樣式設置還是事件監聽都和原生標籤無異。Vue 或者 React 組件只是邏輯上的組合,並不是真正的 HTML 標籤。
定義和註冊
Web Components 的定義和 React class 模式的定義很類似,都是繼承基類:
// react 組件定義方式
class HelloWorld extends React.Component {
constructor() {
super();
}
render() {
return <div>hello world</div>;
}
}
// Web Components 組件定義
class HelloWorld extends HTMLElement {
constructor() {
super();
this.innerHTML = `<div>hello world</div>`;
}
}
其註冊方式也很簡單,類似 Vue 的全局組件,註冊後就可以任意處使用(沒註冊也可以先寫上 html 標籤):
// vue2 組件全局組件註冊
Vue.component("hello-world", HelloWorld);
// Web Components 組件註冊
customElements.define("hello-world", HelloWorld);
這裏需要注意的一點是,Web Components 不僅可以繼承基礎的 HTMLElement 還可以繼承任意標籤,例如:
<script>
// 1、繼承 HTMLVideoElement
class VipVideo extends HTMLVideoElement {
constructor() {
super();
this.addEventListener("play", () => {
setTimeout(() => {
this.pause();
alert("請充值 SVIP 會員");
}, 3000);
});
}
}
// 2、這裏需要 extends: video
customElements.define("vip-video", VipVideo, { extends: "video" });
</script>
<!-- 3、使用 is 表明其真實身份 -->
<video is="vip-video" autoplay="autoplay" src="https://www.w3school.com.cn/i/movie.ogg" controls="controls"></video>
繼承其他標籤從定義到註冊到使用都和默認的方式稍有區別,要注意哦。
節點和模板
無論是 React 的 jsx 還是 Vue template 都是虛擬 DOM,但是 Web Components 則是真實 DOM,我們上面 HelloWorld 定義採用了最簡單的 innerHTML 的方式,其還可以用原生的 DOM API 進行創建,例如:
class HelloWorld extends HTMLElement {
constructor() {
super();
// DOM API 創建 div 節點
const div = document.createElement("div");
div.textContent = "hello world";
// 創建 style 節點,添加內部的樣式
const style = document.createElement("style");
style.textContent = "div { font-size: 30px; color: red; }";
// 添加到當前節點
this.appendChild(style);
this.appendChild(div);
}
}
當然上面的定義方式在一些較大組件的定義時,顯然會讓人發瘋,所以我們可以通過更爲簡單的模板克隆,可以先將結構定義好,然後再填充數據,和 Vue 的模板差不多,但是數據填充還是要通過 DOM API 進行操作:
<template id="user-info-template">
<style>
.container {
color: red;
font-size: 100px;
}
</style>
<div class="container">
<div>用戶名:<span id="user-name"></span></div>
<div>年齡:<span id="user-age"></span></div>
</div>
</template>
<user-info></user-info>
<script>
class UserInfo extends HTMLElement {
constructor() {
super();
// 獲取模板
const templateElem = document.getElementById("user-info-template");
// 深度克隆
const deepClonedElem = templateElem.content.cloneNode(true);
// 填充數據
deepClonedElem.querySelector("#user-name").textContent = "zhang";
deepClonedElem.querySelector("#user-age").textContent = 18;
// 添加節點
this.appendChild(deepClonedElem);
}
}
window.customElements.define("user-info", UserInfo);
</script>
查詢
Web Components 組件與 Vue 全局組件不同的是,組件一旦定義,是不能被覆蓋的,如果強行重定義,是拋異常的,例如: 這裏報錯就是告知我們 user-info
組件已經被定義過了。
爲了防止這種報錯,我們就需要判斷其是否被定義過了,API 爲:
customElements.get("user-info");
聲明週期
無論是 Vue 還是 React 都是自己的生命週期鉤子函數讓我們做一些事情,例如組件節點渲染前獲取數據,組件銷燬前做一些清理定時任務或者事件等,同樣的 Web Components 也考慮到了這類場景,提供了一下鉤子函數:
- connectedCallback:當自定義元素第一次被連接到文檔 DOM 時被調用,作用同 Vue3 的 onMounted 或者 React Class 模式的 componentDidMount、React Function 模式的 useEffect 初次調用。
- disconnectedCallback: 當自定義元素與文檔 DOM 斷開連接時被調用,作用同 **Vue3 的 onUnmounted **或者 React Class 的 componentWillUnmount、React Function 模式的 useEffect 返回函數。
- attributeChangedCallback: 當自定義元素增加、刪除、修改自身屬性時,被調用,類似 **Vue3 的 onUpdated **或者 **React Class ** 的 componentDidUpdate(props 屬性小節會再提到)。
我們從上面看似乎 Web Components 組件提供的鉤子相對於 Vue 或者 React 少很多,但其實其他的鉤子都是可以模擬出來的,想要了解更多的參考https://github.com/yyx990803/vue-lit/blob/master/index.js 裏面有關於使用 Web Components 模擬出 Vue3 生成周期方法。
props 屬性
Web Components 屬性與 Vue 或 React 屬性有三點不同:
- 屬性不能爲引用類型,也就是不能是函數、對象;
- 響應式屬性必須提前聲明,否則無法觸發 attributeChangedCallback;
- 如果有響應式屬性,其先觸發 n 次(n = 定義響應式屬性個數)attributeChangedCallback,然後再調用 connectedCallback。
屬性不能傳引用類型 原因: 屬性不能是引用類型比較好理解,我們沒見過哪個 HTML 標籤屬性是傳對象進去的,而自定義組件也是普通 HTML 標籤,所以也遵循了這個基本原則。 解決方案:
- 明修棧道暗度陳倉:將傳遞的先保存到一個全局變量(或模塊變量),然後返回一個字符串標識,在組件內部通過標識拿到真正的值。具體代碼可以參考:magic-microservices;
- 發佈訂閱:既然限制這麼厲害,我們乾脆就不走它的屬性傳遞方式,而是採用發佈訂閱模式在外部傳遞數據,內部接受數據;
響應式屬性必須提前聲明與觸發時機演示
以下是具體代碼:
<input id="user-name" type="text">
<input id="user-age" type="numer">
<input id="user-gender" type="text">
<user-info name="zhang" age="19" gender="man"></user-info>
<script>
class UserInfo extends HTMLElement {
// 1.定義了 2 個響應式屬性
static get observedAttributes() {
return ["name", "age"];
}
constructor() {
super();
const content = `
<div>username: <span id='info-name'>${this.getAttribute(
"name"
)}</span></div>
<div>age: <span id='info-age'>${this.getAttribute("age")}</span></div>
<div>gender: <span id='info-gender'>${this.getAttribute(
"gender"
)}</span></div>
`;
this.innerHTML = content;
}
// 3.然後觸發此鉤子
connectedCallback() {
console.log("connectedCallback");
}
// 2.先觸發 2 次
attributeChangedCallback(name, oldValue, newValue) {
console.count("attributeChangedCallback");
console.log(name, oldValue, newValue);
this.update(name, newValue);
}
update(name, value) {
const el = this.querySelector(`#info-${name}`);
el.textContent = value;
}
}
window.customElements.define("user-info", UserInfo);
</script>
<script>
const userInfo = document.querySelector("user-info");
document.querySelector("#user-name").addEventListener("input", (e) => {
userInfo.setAttribute("name", e.target.value);
});
document.querySelector("#user-age").addEventListener("input", (e) => {
userInfo.setAttribute("age", e.target.value);
});
// 4.就算更新屬性,也不會觸發 attributeChangedCallback
document.querySelector("#user-gender").addEventListener("input", (e) => {
userInfo.setAttribute("gender", e.target.value);
});
</script>
當然想要實現任意屬性的響應式只能採用發佈訂閱的模式,而不是走 Web Components 的屬性體系。
CSS 和 Shadow DOM
首先需要澄清一個我自己一直以來的認知誤區,既 Web Components 很安全,有沙箱功能,能隔離 JS。這句話裏能隔離 JS 是不對的,Web Components 不對 JS 做隔離的,但能做到 HTML 和 CSS 的隔離。
默認情況:html、css 不做隔離
<div>out: hello world</div>
<style>
div {
color: red;
}
</style>
<hello-world></hello-world>
<script>
class HelloWorld extends HTMLElement {
constructor() {
super();
const content = `
<div>inner: hello world</div>
<style>
div {
font-size: 100px;
}
</style>
`;
this.innerHTML = content;
}
}
window.customElements.define("hello-world", HelloWorld);
</script>
<script>
const helloWorld = document.querySelector("hello-world");
console.log(helloWorld.innerHTML);
</script>
Shadow DOM mode 爲 open:樣式隔離、DOM 隱藏
- this.innerHTML = content
+ const shadowDOM = this.attachShadow({ mode: 'open' }) // 開啓 Shadow DOM
+ shadowDOM.innerHTML = content
Shadow DOM mode 爲 closed:樣式隔離、DOM 隱藏
closed 時,不僅無法獲取 HTML,連路徑也無法獲取,這裏不做演示,感興趣可以對比一下兩者當點擊時獲取的路徑 。
document.querySelector("html").addEventListener("click", (e) => {
console.log(e.composed);
console.log(e.composedPath());
});
插槽
Web Components 的插槽功能和 Vue2.5 之前的可以說是一模一樣了(當然 web compnents 是沒有作用域插槽功能的),或者說 Vue2 當初設計時就參考了 Web Components 的規範。
// 1、vue 插槽定義
<template>
<div class="layout">
<!-- 具名插槽定義 & 支持插槽默認值 -->
<slot name="header"><h1>header(插槽默認內容)</h1></slot>
<!-- 默認插槽定義 -->
<slot></slot>
<!-- 具名插槽定義 -->
<slot name="footer">footer</slot>
</div>
</template>
// 2、使用組件和插槽
<template>
<base-layout>
<!-- 覆蓋具名插槽 -->
<h2 slot="header">自定義 header</h2>
<!-- 覆蓋默認插槽內容 -->
<div>覆蓋默認插槽內容</div>
</base-layout>
</template>
<!-- 1.模板定義插槽 -->
<template id="base-layout-temp">
<div class="layout">
<!-- 具名插槽定義 & 支持插槽默認值 -->
<slot name="header">
<h1>header(插槽默認內容)</h1>
</slot>
<!-- 默認插槽定義 -->
<slot></slot>
<!-- 具名插槽定義 -->
<slot name="footer">footer</slot>
</div>
</template>
<!-- 3.使用組件和插槽 -->
<base-layout>
<h2 slot="header">自定義 header</h2>
<div>覆蓋默認插槽內容</div>
</base-layout>
<script>
// 2.註冊組件
class BaseLayout extends HTMLElement {
constructor() {
super();
const temp = document.querySelector("#base-layout-temp");
// 必須是 Shadow DOM 模式 !!!
const shadow = this.attachShadow({ mode: "open" });
shadow.appendChild(temp.content.cloneNode(true));
}
}
customElements.define("base-layout", BaseLayout);
</script>
從上我們看到 Vue 在插槽的定義上確實和 Web Components 一模一樣,在使用上也沒啥差別,需要注意一個坑就是的是插槽只有在 Shadow DOM 模式下才生效。
事件和綜合示例
Vue 通過 $emit
拋出事件,v-bind
接受事件;而 React 則是通過傳遞函數,組件內部調用。Web Components 更像是 Vue 的模式,簡單而言就是內部可以拋出自定義事件,外部通過 addEventListener
進行監聽。 這個自定義事件能力詳見 MDN,我們結合上述所有章節給一個綜合的示例:
<template id="fixed-overlay-temp">
<style>
.fixed-overlay-background {
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
display: flex;
background: #00000088;
justify-content: center;
align-items: center;
}
.fixed-overlay {
position: relative;
z-index: 1000;
pointer-events: auto;
}
</style>
<div class="fixed-overlay-background">
<div class="fixed-overlay">
<slot></slot>
</div>
</div>
</template>
<div class="main">
<fixed-overlay visible="false">
<div style="width: 200px; height: 200px; background: skyblue; display: flex;align-items: center;justify-content: center;">
你好,世界
</div>
</fixed-overlay>
<button id="toggle-btn">切換</button>
</div>
<script>
customElements.define(
"fixed-overlay",
class extends HTMLElement {
static get observedAttributes() {
return ["visible"];
}
constructor() {
super();
// 獲取模板
const temp = document.querySelector("#fixed-overlay-temp");
const dom = temp.content.cloneNode(true);
// 默認隱藏
const overlay = dom.querySelector(".fixed-overlay-background");
overlay.style.display = "none";
// 添加 DOM
const shadow = this.attachShadow({ mode: "open" });
shadow.appendChild(dom);
// 添加監聽事件
this.startListen();
}
// 開始監聽
startListen() {
// 點擊背景,拋出自定義事件
const overlayBG = this.shadowRoot.querySelector(
".fixed-overlay-background"
);
overlayBG.addEventListener("click", () => {
this.emit("visible", false);
});
// 防止內部點擊
const overlay = this.shadowRoot.querySelector(".fixed-overlay");
overlay.addEventListener("click", (e) => {
e.stopPropagation();
});
}
// 模仿 Vue emit(1、重點!!!)
emit(evetName, data) {
const event = new CustomEvent(evetName, { detail: data });
this.dispatchEvent(event);
}
// 屬性變化回調
attributeChangedCallback(attrName, oldValue, newValue) {
// 實際上,監聽的屬性就這一個,可以不做這一步
if (attrName === "visible") {
this.toggleVisible(newValue);
}
}
// 切換顯示
toggleVisible(visible) {
const overlay = this.shadowRoot.querySelector(
".fixed-overlay-background"
);
overlay.style.display = visible === "false" ? "none" : "flex";
}
}
);
const overlayInstance = document.querySelector("fixed-overlay");
// 切換顯示
const toggleVisible = (visible) => {
overlayInstance.setAttribute("visible", visible);
};
// 監聽自定義的 visible 事件(2、重點!!!)
overlayInstance.addEventListener("visible", (e) => {
toggleVisible(e.detail);
});
// 監聽 btn 的切換事件
const btn = document.querySelector("#toggle-btn");
btn.addEventListener("click", () => {
const oldVisible = overlayInstance.getAttribute("visible");
const newVisible = oldVisible === "false" ? true : false;
toggleVisible(newVisible);
});
</script>
生態和工具
- lit:目前最流行的、谷歌開源的,用於快速構建 Web Components 的庫/工具
- omi:騰訊開源的,用於快速構建 Web Components 的庫/工具
- webcomponentsjs:Web Components IE 兼容方案
- magic-microservices:字節跳動開源的,基於 Web Components 的輕量級微組件解決方案
- micro-app:京東零售團隊開源的,基於 Web Components 的輕量、高效、功能強大的微前端解決方案
- omiu:基於 omi 開發的 Web Components 組件庫
- @shoelace-style/shoelace:Web Components 開發的組件庫
- LuLu UI:張鑫旭大佬的作品,Web Components 開發的組件庫
學習資料
後續
本篇算是入門篇,後續如果有時間可以再搞兩篇:
- Web Components 引用類型屬性傳遞解決方案
- 100 行代碼實現 magic-microservices
寫作很辛苦,如果你有所收穫,請幫忙點個贊,如果暫時沒有應用場景,也可以先收藏。