Web Components 入門實戰(zhàn)學(xué)習(xí)

Web Components 入門實戰(zhàn)學(xué)習(xí).png

前言:這周完成了兩場技術(shù)分享會,下周還有一場,就完成了這階段的一個重大任務(wù)。分享會是關(guān)于 TS 的,我這兩場分享會的主題分別是:

  • TS 初級入門
  • TS 高級語法

下周的主題是,如何在 React 中優(yōu)雅的書寫 TS。

我對技術(shù)也是有那么點喜歡,所以我平時喜歡學(xué)習(xí)些新技術(shù),但是同時我認為最好的學(xué)習(xí),應(yīng)該是來自于實踐,所以除了大量做項目,對剛學(xué)的技術(shù)最好的幫助就是分享,把別人給教會,而這也是一種能力的體現(xiàn)。所以我對分享并不是很排斥,反而有種強烈的喜歡。

而且分享還能打破封閉,對個人能力有很大的加成作用,不過難就難在跨出第一步,我剛開始分享也是有點慌,但等到第二場就開始駕輕就熟了,真的鼓勵大家要不斷的去嘗試,不要重復(fù)自己,要敢于突破自己。

Web Components 這個技術(shù)是我在 「TS 高級語法」主題分享前給團隊小伙伴的一個開胃小菜。


以下正文:

前端組件化

無論你用什么流行框架去寫前端,本質(zhì)上你都是在使用前端三劍客即: HTML、CSS 和 JavaScript。那這三劍客在自己的領(lǐng)域組件化/模塊化做的怎么樣了呢?

  • 對于 CSS,我們有 @impot
  • 對于 JS 現(xiàn)在也有模塊化方案。

那么對于 HTML 呢?我們知道樣式和腳本都是集成到 HTML 中,所以所以單獨的去做 HTML 模塊化,沒有任何意義。

既然如此,我們看看 HTML 在編程過程中遇到了什么問題。

  1. 因為 CSS 樣式作用在全局,就會造成樣式覆蓋。
  2. 因為在頁面中只有一個 DOM,任何地方都可以直接讀取和修改 DOM。

可以看到我們的痛點就是解決 CSS 和 DOM 這兩個阻礙組件化的因素,于是 Web Components 孕育而生。

Web Components

Web Components 由三項主要技術(shù)組成:

Web Components 整體知識點不多,內(nèi)容也不復(fù)雜,我認為核心就是 Shadow DOM(影子 DOM),為什么我這么認為呢?看下 Shadow DOM 的作用你就明白了:

  • 影子 DOM 中的元素對于整個網(wǎng)頁是不可見的;
  • 影子 DOM 的 CSS 不會影響到整個網(wǎng)頁的 CSSOM,影子 DOM 內(nèi)部的 CSS 只對內(nèi)部的元素起作用。

看完,你發(fā)沒發(fā)現(xiàn)它剛好解決了,我們開頭前端組件遇到的問題,所以 Shadow DOM 才是 Web Components 的核心。

自定義元素(Custom elements)

如何自定義元素或叫如何自定義標(biāo)簽

自定義元素就像 Vue 和 React 中的類組件,首先我們需要使用 ES2015 語法來定義一個類,接著,使用瀏覽器原生的 customElements.define() 方法,告訴瀏覽器我要注冊一個元素/標(biāo)簽 user-text,(自定義元素的名稱必須包含連詞線,用與區(qū)別原生的 HTML 元素,就像 React 的自定義組件名使用時必須大寫一樣)。

class UserText extends HTMLElement {
    constructor() {
        super();
    }
}

上面代碼中,UserText 是自定義元素的類,這個類繼承了 HTMLElement 父類。

我們現(xiàn)在把 user-text 作為標(biāo)簽使用,放到頁面上去:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <user-text></user-text>
    <script>
        class UserText extends HTMLElement {
            constructor () {
                super();
                this.innerHTML = "我是內(nèi)容";
            }
        }
        globalThis.customElements.define("user-text", UserText);
    </script>
</body>
</html>

我們看到頁面成功渲染:

user-text.png

組件會有生命周期,所以這個類還有些方法:

  • connectedCallback:當(dāng) custom element 首次被插入文檔 DOM 時,被調(diào)用,俗稱組件上樹。
  • disconnectedCallback:當(dāng) custom element 從文檔 DOM 中刪除時,被調(diào)用,俗稱組件下樹或組件消亡。
  • adoptedCallback:當(dāng) custom element 被移動到新的文檔時,被調(diào)用,這個 API 常和 document.adoptNode 配合使用。
  • attributeChangedCallback: 當(dāng) custom element 增加、刪除、修改自身屬性時,被調(diào)用,俗稱組件更新。

模板 (Templates)

頁面上的元素最終是要給用戶呈現(xiàn)內(nèi)容,在自定義組件里,我們通過字符串的方式來接受要展現(xiàn)給用戶的內(nèi)容,這種方式非常不利于組織我們的 HTML,我們需要一個寫 HTML 的地方,這個技術(shù)就是模板 (Templates),非常像 Vue 的模版渲染,如果你熟悉 Vue ,完全可以無障礙切換。

我們隨便來弄點數(shù)據(jù)組織下代碼,在瀏覽器展示給用戶:


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <template id="user-text-template">
        你好,我是模版!
    </template>
    <user-text></user-text>
    <script>
        class UserText extends HTMLElement {
            constructor () {
                super();
            }
            connectedCallback () {
                const oldNode = document.getElementById("user-text-template").content;
                const newNode = oldNode.cloneNode(true);
                this.appendChild(newNode);
            }
        }
        globalThis.customElements.define("user-text", UserText);
    </script>
</body>
</html>

我們看到頁面成功渲染:

template-render.png

如果,自定義元素需要動態(tài)傳值給我們的自定義組件,可以使用插槽 slot,語法基本同 Vue,但是此時還無法演示,因為 slot 標(biāo)簽對標(biāo)準的 DOM(更專業(yè)點叫 light DOM)無效,只對 shadow DOM 是有效的,看下使用示例。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <template id="user-text-template">
        <style>
            p {
                color: red;
            }
        </style>
        <p id="templateDOM">你好,我是模版!</p>
        <p><slot>因為我是無效的,我也會默認展示</slot></p>
    </template>
    <user-text>
        <p>light DOM 環(huán)境下,slot 標(biāo)簽沒用</p>
    </user-text>
    <script>
        class UserText extends HTMLElement {
            constructor () {
                super();
            }
            connectedCallback () {
                const oldNode = document.getElementById("user-text-template").content;
                const newNode = oldNode.cloneNode(true);
                this.appendChild(newNode);
            }
        }
        globalThis.customElements.define("user-text", UserText);
        console.log(document.getElementById("templateDOM"));
    </script>
</body>
</html>

看下頁面加載顯示:

slot-invaild-light-dom.png

除了,slot 無法使用,我們還觀察到 template 元素及其內(nèi)容不會在 DOM 中呈現(xiàn),必須通過 JS 的方式去訪問、style 標(biāo)簽內(nèi)的樣式是作用到全局的、template 里面的 DOM 也可以被全局訪問。

影子 DOM(shadow DOM)

影子 DOM 是 Web Components 核心中的核心,可以一舉解決我們前面提到的,CSS 和 DOM 作用全局的問題。

看下使用示例:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <template id="user-text-template">
        <style>
            p {
                color: red;
            }
        </style>
        <p id="templateDOM">你好,我是模版!</p>
        <p><slot>因為我是無效的,我也會默認展示</slot></p>
    </template>
    <user-text>
        <p>light DOM 環(huán)境下,slot 標(biāo)簽沒用</p>
    </user-text>
    <p>測試 shadow DOM 樣式不作用全局</p>
    <script>
        class UserText extends HTMLElement {
            constructor () {
                super();
            }
            connectedCallback () {
                this.attachShadow({ mode: "open" });
                const oldNode = document.getElementById("user-text-template").content;
                const newNode = oldNode.cloneNode(true);
                this.shadowRoot.appendChild(newNode);
            }
        }
        globalThis.customElements.define("user-text", UserText);
        console.log(document.getElementById("templateDOM"));
    </script>
</body>
</html>

現(xiàn)在完成了,組件的樣式應(yīng)該與代碼封裝在一起,只對自定義元素生效,不影響外部的全局樣式、DOM 默認與外部 DOM 隔離,內(nèi)部任何代碼都無法影響外部,同時 slot 也生效了,看下頁面加載顯示:

obstacle-style-dom.png

影子 DOM 的 mode 參數(shù)除了有 open,之外還有 closed,兩者的區(qū)別在于此影子 DOM 是否能被訪問外界訪問,即是否能通過 JS 獲取影子 DOM 讀取 影子 DOM 里面的內(nèi)容。

style 穿越 影子 DOM

任何項目為了統(tǒng)一風(fēng)格,肯定需要有公共樣式,而且為了方面是統(tǒng)一引入的,這就涉及到外部樣式影響到內(nèi)部樣式,那怎么突破影子 DOM 呢?

CSS 變量

可以使用 CSS 變量來穿透 DOM:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>CSS 變量樣式穿透</title>
    <style>
        [type="primary"] {
            --ui-button-border: 1px solid transparent;
            --ui-button-background: deepskyblue;
            --ui-button-color: #fff;
        }
    </style>
</head>
<body>
    <template id="ui-button-template">
        <style>
            button {
                cursor: pointer;
                padding: 9px 1em;
                border: var(--ui-button-border, 1px solid #ccc);
                border-radius: var(--ui-button-radius, 4px);
                background-color: var(--ui-button-background, #fff);
                color:  var(--ui-button-color, #333);
            }
        </style>
        <button ><slot></slot></button>
    </template>
    <ui-button type="primary">按鈕</ui-button>
    <script>
        class UiButton extends HTMLElement {
            constructor () {
                super();
            }
            connectedCallback () {
                this.attachShadow( { mode: "open" });
                const oldNode = document.getElementById("ui-button-template").content;
                const newNode = oldNode.cloneNode(true);
                this.shadowRoot.appendChild(newNode);
            }
        }
        globalThis.customElements.define("ui-button", UiButton);
    </script>
</body>
</html>

頁面展示效果圖:

::part 偽元素

::part 偽元素的用法有點像具名插槽 slot。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>::part 樣式穿透</title>
    <style>
        [type="primary"]::part(button) {
            cursor: pointer;
                padding: 9px 1em;
                border: 1px solid #ccc;
                border-radius: 4px;
                background-color: skyblue;;
                color:  #987;
        }
    </style>
</head>
<body>
    <template id="ui-button-template">
        <button part="button"><slot></slot></button>
    </template>
    <ui-button type="primary">按鈕</ui-button>
    <script>
        class UiButton extends HTMLElement {
            constructor () {
                super();
            }
            connectedCallback () {
                this.attachShadow( { mode: "open" });
                const oldNode = document.getElementById("ui-button-template").content;
                const newNode = oldNode.cloneNode(true);
                this.shadowRoot.appendChild(newNode);
            }
        }
        globalThis.customElements.define("ui-button", UiButton);
    </script>
</body>
</html>

HTML 原生組件支持 Web Components

我們知道 HTML5 有很多的原生組件,例如:input,video,textarea,select,audio 等。

如果你審查元素會發(fā)現(xiàn),這個組件并不是純正的原生組件,而是基于 Web Components 來封裝的。

如果你審查元素沒有顯示影子 DOM,請打開控制臺,同時檢查瀏覽器設(shè)置 Settings -> Preferences -> Elements 中把 Show user agent shadow DOM 打上勾。

落地應(yīng)用有哪些?

首先,github 網(wǎng)址是完全基于 Web Components 來開發(fā)的,其次 Vue 和 小程序 也是基于 Web Components 來做組件化的,而且 Web Components 作為最底層的技術(shù)完全可配合 Vue 和 React 等框架,直接使用的。

光學(xué)不練那不是假把式嗎,我來給大家整個 demo,自定義一個對話框,這個對話框只滿足最基本的使用需求,先看下最終的成品。

對話框

源代碼,可能比較難得兩個思路:

  • 數(shù)據(jù)更新,采用的是類的 get 和 set
  • 關(guān)閉的回調(diào)事件,用的是自定義事件
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>自定義彈框</title>
</head>
<body>
    <style>
        .open-button {
            cursor: pointer;
            padding: 9px 1em;
            border: 1px solid transparent;
            border-radius: 4px;
            background-color: deepskyblue;
            color: #fff;
        }
        ul > li {
            margin: 20px;
        }
    </style>

    <section>
        <ul>
            <li><button id="launch-dialog-one" class="open-button">open-one</button>
            <li><button id="launch-dialog-two" class="open-button">open-two</button></li>
            <li><button id="launch-dialog-three" class="open-button">open-three</button></li></li>
        </ul>
    </section>

    <shanshu-dialog title="title-one" id="shanshu-dialog-one">
        <span slot="my-text">Let's have some different text!</span>
        <p>Some contents Some contents......</p>
        <p>Some contents Some contents......</p>
        <p>Some contents Some contents......</p>
    </shanshu-dialog>
    
    <shanshu-dialog title="title-two" id="shanshu-dialog-two">
        <span slot="my-text">Let's have some different text!</span>
        <p>Some contents Some contents......</p>
        <p>Some contents Some contents......</p>
        <p>Some contents Some contents......</p>
    </shanshu-dialog>
    
    <shanshu-dialog title="title-three" id="shanshu-dialog-three">
        <span slot="my-text">Let's have some different text!</span>
        <p>Some contents Some contents......</p>
        <p>Some contents Some contents......</p>
        <p>Some contents Some contents......</p>
    </shanshu-dialog>

    <template id="shanshu-dialog-template">
        <style>
            .wrapper {
                opacity: 0;
                transition: visibility 0s, opacity 0.25s ease-in;
            }

            .wrapper:not(.open) {
                visibility: hidden;
            }

            .wrapper.open {
                align-items: center;
                display: flex;
                justify-content: center;
                height: 100vh;
                position: fixed;
                top: 0;
                left: 0;
                right: 0;
                bottom: 0;
                opacity: 1;
                visibility: visible;
            }

            .overlay {
                background: rgba(0, 0, 0, 0.3);
                height: 100%;
                position: fixed;
                top: 0;
                right: 0;
                bottom: 0;
                left: 0;
                width: 100%;
            }

            .dialog {
                background: #ffffff;
                max-width: 600px;
                min-width: 400px;
                text-align: center;
                padding: 1rem;
                position: fixed;
                border-radius: 4px;
            }

            button {
                all: unset;
                cursor: pointer;
                font-size: 1.25rem;
                position: absolute;
                top: 1rem;
                right: 1rem;
            }

            button:focus {
                border: 1px solid skyblue;
            }
            h1 {
                color: #4c5161;
            }
            .content {
                color: #34495e;
                position: relative;
            }
            .btn {
                background: none;
                outline: 0;
                border: 0;
                position: absolute;
                right: 1em;
                top: 1em;
                width: 20px;
                height: 20px;
                padding: 0;
                user-select: none;
                cursor: unset;
            }
            .btn::before {
                content: "";
                display: block;
                border: 1px solid green;
                height: 20px;
                width: 0;
                border-radius: 2px;
                /*transition: .1s;*/
                transform: translate(9px) rotate(45deg);
                background: #fff;
            }
            .btn::after {
                content: "";
                display: block;
                border: 1px solid green;
                height: 20px;
                border-radius: 2px;
                width: 0;
                /*transition: .1s;*/
                transform: translate(9px, -100%) rotate(-45deg);
                background: #fff;
            }
        </style>
        <div class="wrapper">
            <div class="overlay"></div>
            <div class="dialog" role="dialog" aria-labelledby="title" aria-describedby="content">
                <button aria-label="Close" class="btn"></button>
                <h1 id="title">Hello world</h1>
                <div id="content" class="content">
                    <slot></slot>
                    <slot name="my-text"></slot>
                </div>
            </div>
        </div>
    </template>
    
    <script type="text/javascript">
        "use strict";
        class ShanshuDialog extends HTMLElement {
            static get observedAttributes() {
                return ["open"];
            }
            constructor() {
                super();
                this.attachShadow({ mode: "open" });
                this.close = this.close.bind(this);
            }
            connectedCallback() {
                const { shadowRoot } = this;
                const templateElem = document.getElementById("shanshu-dialog-template");
                const oldNode = templateElem.content;
                // const newNode = oldNode.cloneNode(true);
                const newNode = document.importNode(oldNode, true);
                shadowRoot.appendChild(newNode);
                shadowRoot.getElementById("title").innerHTML = this.title;
                shadowRoot.querySelector("button").addEventListener("click", this.close);
                shadowRoot.querySelector(".overlay").addEventListener("click", this.close);
            }
            disconnectedCallback() {
                this.shadowRoot.querySelector("button").removeEventListener("click", this.close);
                this.shadowRoot.querySelector(".overlay").removeEventListener("click", this.close);
            }
            get open() {
                return this.hasAttribute("open");
            }
            set open(isOpen) {
                console.log("isOpen", isOpen);
                const { shadowRoot } = this;
                shadowRoot.querySelector(".wrapper").classList.toggle("open", isOpen);
                shadowRoot.querySelector(".wrapper").setAttribute("aria-hidden", !isOpen);
                if (isOpen) {
                    this._wasFocused = document.activeElement;
                    this.setAttribute("open", false);
                    this.focus();
                    shadowRoot.querySelector("button").focus();
                } else {
                    this._wasFocused && this._wasFocused.focus && this._wasFocused.focus();
                    this.removeAttribute("open");
                    this.close();
                }
            }
            close() {
                this.open !== false && (this.open = false);
                const closeEvent = new CustomEvent("dialog-closed");
                this.dispatchEvent(closeEvent);
            }
        }
        customElements.define("shanshu-dialog", ShanshuDialog);


        const buttonOneDOM = document.getElementById("launch-dialog-one");
        const buttonTwoDOM = document.getElementById("launch-dialog-two");
        const buttonThreeDOM = document.getElementById("launch-dialog-three");
        const shanshuDialogOne = document.querySelector("#shanshu-dialog-one");

        buttonOneDOM.addEventListener("click", () => {
            document.querySelector("#shanshu-dialog-one").open = true;
        });
        shanshuDialogOne.addEventListener("dialog-closed", () => {
            alert("對話框關(guān)閉回調(diào)函數(shù)");
        });

        buttonTwoDOM.addEventListener("click", () => {
            document.querySelector("#shanshu-dialog-two").open = true;
        });
        buttonThreeDOM.addEventListener("click", () => {
            document.querySelector("#shanshu-dialog-three").open = true;
        });
        
    </script>
</body>
</html>

組件庫

當(dāng)我們談到在項目中如何應(yīng)用,我們首先需要兩個東西,選個 UI 組件庫,同時有比較好的工具來操作這個 UI 庫,我提供兩個給你參考。

參考

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • Web Components,我們簡稱WC。 Custom Element customElements是瀏覽器內(nèi)...
    brandonxiang閱讀 487評論 0 1
  • 組件的概念 組件,是數(shù)據(jù)和方法的一個封裝,其定義了一個可重用的軟件元素的功能,展示和使用,通常表現(xiàn)為一個或一組可重...
    zx_lau閱讀 2,529評論 0 3
  • 在使用Web Components之前,我們先看看上一篇文章Web Components簡介[https://ww...
    張中華閱讀 647評論 0 1
  • 前言 不知不覺,2019年即將接近尾聲,現(xiàn)有前端三大框架也各自建立著自己的生態(tài)、自己的使用群體。從angular1...
    Kaku_fe閱讀 3,003評論 0 19
  • 組件化,標(biāo)簽語義化,是前端發(fā)展的趨勢。現(xiàn)在流行的組件化框架有React、Vue等,標(biāo)簽語義化在H5中添加的arti...
    張中華閱讀 440評論 0 1

友情鏈接更多精彩內(nèi)容