通過 Vue、React ,快速學習 web components 核心知識

近期的工作用到了 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>


image.png 我們看到上述代碼,無論是樣式設置還是事件監聽都和原生標籤無異。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>

demo1.gif 繼承其他標籤從定義到註冊到使用都和默認的方式稍有區別,要注意哦。

節點和模板

無論是 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>

image.png

查詢

Web Components 組件與 Vue 全局組件不同的是,組件一旦定義,是不能被覆蓋的,如果強行重定義,是拋異常的,例如: image.png 這裏報錯就是告知我們 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
  • 發佈訂閱:既然限制這麼厲害,我們乾脆就不走它的屬性傳遞方式,而是採用發佈訂閱模式在外部傳遞數據,內部接受數據;

響應式屬性必須提前聲明與觸發時機演示 image.png image.png

以下是具體代碼:



  
  
    <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 不做隔離

image.png


  <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 隱藏

image.png

- 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) =&gt; {
  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>

image.png

從上我們看到 Vue 在插槽的定義上確實和 Web Components 一模一樣,在使用上也沒啥差別,需要注意一個坑就是的是插槽只有在 Shadow DOM 模式下才生效

事件和綜合示例

Vue 通過 $emit 拋出事件,v-bind 接受事件;而 React 則是通過傳遞函數,組件內部調用。Web Components 更像是 Vue 的模式,簡單而言就是內部可以拋出自定義事件,外部通過 addEventListener 進行監聽。 這個自定義事件能力詳見 MDN,我們結合上述所有章節給一個綜合的示例: demo2.gif

<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 開發的組件庫

學習資料

後續

本篇算是入門篇,後續如果有時間可以再搞兩篇:

寫作很辛苦,如果你有所收穫,請幫忙點個贊,如果暫時沒有應用場景,也可以先收藏。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章