Web Components 入门实战学习

Web Components 入门实战学习.png

前言:这周完成了两场技术分享会,下周还有一场,就完成了这阶段的一个重大任务。分享会是关于 TS 的,我这两场分享会的主题分别是:

  • TS 初级入门
  • TS 高级语法

下周的主题是,如何在 React 中优雅的书写 TS。

我对技术也是有那么点喜欢,所以我平时喜欢学习些新技术,但是同时我认为最好的学习,应该是来自于实践,所以除了大量做项目,对刚学的技术最好的帮助就是分享,把别人给教会,而这也是一种能力的体现。所以我对分享并不是很排斥,反而有种强烈的喜欢。

而且分享还能打破封闭,对个人能力有很大的加成作用,不过难就难在跨出第一步,我刚开始分享也是有点慌,但等到第二场就开始驾轻就熟了,真的鼓励大家要不断的去尝试,不要重复自己,要敢于突破自己。

Web Components 这个技术是我在 「TS 高级语法」主题分享前给团队小伙伴的一个开胃小菜。


以下正文:

前端组件化

无论你用什么流行框架去写前端,本质上你都是在使用前端三剑客即: HTML、CSS 和 JavaScript。那这三剑客在自己的领域组件化/模块化做的怎么样了呢?

  • 对于 CSS,我们有 @impot
  • 对于 JS 现在也有模块化方案。

那么对于 HTML 呢?我们知道样式和脚本都是集成到 HTML 中,所以所以单独的去做 HTML 模块化,没有任何意义。

既然如此,我们看看 HTML 在编程过程中遇到了什么问题。

  1. 因为 CSS 样式作用在全局,就会造成样式覆盖。
  2. 因为在页面中只有一个 DOM,任何地方都可以直接读取和修改 DOM。

可以看到我们的痛点就是解决 CSS 和 DOM 这两个阻碍组件化的因素,于是 Web Components 孕育而生。

Web Components

Web Components 由三项主要技术组成:

Web Components 整体知识点不多,内容也不复杂,我认为核心就是 Shadow DOM(影子 DOM),为什么我这么认为呢?看下 Shadow DOM 的作用你就明白了:

  • 影子 DOM 中的元素对于整个网页是不可见的;
  • 影子 DOM 的 CSS 不会影响到整个网页的 CSSOM,影子 DOM 内部的 CSS 只对内部的元素起作用。

看完,你发没发现它刚好解决了,我们开头前端组件遇到的问题,所以 Shadow DOM 才是 Web Components 的核心。

自定义元素(Custom elements)

如何自定义元素或叫如何自定义标签

自定义元素就像 Vue 和 React 中的类组件,首先我们需要使用 ES2015 语法来定义一个类,接着,使用浏览器原生的 customElements.define() 方法,告诉浏览器我要注册一个元素/标签 user-text,(自定义元素的名称必须包含连词线,用与区别原生的 HTML 元素,就像 React 的自定义组件名使用时必须大写一样)。

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

上面代码中,UserText 是自定义元素的类,这个类继承了 HTMLElement 父类。

我们现在把 user-text 作为标签使用,放到页面上去:

<!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 = "我是内容";
            }
        }
        globalThis.customElements.define("user-text", UserText);
    </script>
</body>
</html>

我们看到页面成功渲染:

user-text.png

组件会有生命周期,所以这个类还有些方法:

  • connectedCallback:当 custom element 首次被插入文档 DOM 时,被调用,俗称组件上树。
  • disconnectedCallback:当 custom element 从文档 DOM 中删除时,被调用,俗称组件下树或组件消亡。
  • adoptedCallback:当 custom element 被移动到新的文档时,被调用,这个 API 常和 document.adoptNode 配合使用。
  • attributeChangedCallback: 当 custom element 增加、删除、修改自身属性时,被调用,俗称组件更新。

模板 (Templates)

页面上的元素最终是要给用户呈现内容,在自定义组件里,我们通过字符串的方式来接受要展现给用户的内容,这种方式非常不利于组织我们的 HTML,我们需要一个写 HTML 的地方,这个技术就是模板 (Templates),非常像 Vue 的模版渲染,如果你熟悉 Vue ,完全可以无障碍切换。

我们随便来弄点数据组织下代码,在浏览器展示给用户:


<!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

如果,自定义元素需要动态传值给我们的自定义组件,可以使用插槽 slot,语法基本同 Vue,但是此时还无法演示,因为 slot 标签对标准的 DOM(更专业点叫 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 环境下,slot 标签没用</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 元素及其内容不会在 DOM 中呈现,必须通过 JS 的方式去访问、style 标签内的样式是作用到全局的、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 环境下,slot 标签没用</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>

现在完成了,组件的样式应该与代码封装在一起,只对自定义元素生效,不影响外部的全局样式、DOM 默认与外部 DOM 隔离,内部任何代码都无法影响外部,同时 slot 也生效了,看下页面加载显示:

obstacle-style-dom.png

影子 DOM 的 mode 参数除了有 open,之外还有 closed,两者的区别在于此影子 DOM 是否能被访问外界访问,即是否能通过 JS 获取影子 DOM 读取 影子 DOM 里面的内容。

style 穿越 影子 DOM

任何项目为了统一风格,肯定需要有公共样式,而且为了方面是统一引入的,这就涉及到外部样式影响到内部样式,那怎么突破影子 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 等。

如果你审查元素会发现,这个组件并不是纯正的原生组件,而是基于 Web Components 来封装的。

如果你审查元素没有显示影子 DOM,请打开控制台,同时检查浏览器设置 Settings -> Preferences -> Elements 中把 Show user agent shadow DOM 打上勾。

落地应用有哪些?

首先,github 网址是完全基于 Web Components 来开发的,其次 Vue 和 小程序 也是基于 Web Components 来做组件化的,而且 Web Components 作为最底层的技术完全可配合 Vue 和 React 等框架,直接使用的。

光学不练那不是假把式吗,我来给大家整个 demo,自定义一个对话框,这个对话框只满足最基本的使用需求,先看下最终的成品。

对话框

源代码,可能比较难得两个思路:

  • 数据更新,采用的是类的 get 和 set
  • 关闭的回调事件,用的是自定义事件
<!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("对话框关闭回调函数");
        });

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

组件库

当我们谈到在项目中如何应用,我们首先需要两个东西,选个 UI 组件库,同时有比较好的工具来操作这个 UI 库,我提供两个给你参考。

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345

推荐阅读更多精彩内容

  • Web Components,我们简称WC。 Custom Element customElements是浏览器内...
    brandonxiang阅读 416评论 0 1
  • 组件的概念 组件,是数据和方法的一个封装,其定义了一个可重用的软件元素的功能,展示和使用,通常表现为一个或一组可重...
    zx_lau阅读 2,429评论 0 3
  • 在使用Web Components之前,我们先看看上一篇文章Web Components简介[https://ww...
    张中华阅读 457评论 0 1
  • 前言 不知不觉,2019年即将接近尾声,现有前端三大框架也各自建立着自己的生态、自己的使用群体。从angular1...
    Kaku_fe阅读 2,751评论 0 19
  • 组件化,标签语义化,是前端发展的趋势。现在流行的组件化框架有React、Vue等,标签语义化在H5中添加的arti...
    张中华阅读 333评论 0 1