通过 Vue、React ,快速学习 web components 核心知识

近期的工作用到了 Web Components 的能力,但在学习的过程中还是踩了不少的坑。但学完之后发现其和 Vue、React 有诸多相似之处,所以通过对比 React、Vue 与 Web Components 的相似点不同点,希望大家在需要的时候能够快速掌握 Web Components 核心知识,减少学习的时间和少踩坑。

目录

核心概念

Web Components 给我们给提供了自定义 html 元素的能力,类似 Vue 或者 React 组件,但和他们不同的是,Web Components 定义的组件,就是普通的 html 元素,为什么这样说呢?我们且看下面一个小示例:

<head>
  <style>
    /* 3.设置样式 */
    hello-world {
      color: red;
      font-size: 100px;
      border: 1px solid #eee;
      display: inline-block;
    }
  </style>
</head>
<body>
  <!-- 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>
</body>
</html>
复制代码

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 少很多,但其实其他的钩子都是可以模拟出来的,想要了解更多的参考github.com/yyx990803/v… 里面有关于使用 Web Components 模拟出 Vue3 生成周期方法。

props 属性

Web Components 属性与 Vue 或 React 属性有三点不同:

  • 属性不能为引用类型,也就是不能是函数、对象;
  • 响应式属性必须提前声明,否则无法触发 attributeChangedCallback
  • 如果有响应式属性,其先触发 n 次(n = 定义响应式属性个数)attributeChangedCallback,然后再调用 connectedCallback。

属性不能传引用类型 原因: 属性不能是引用类型比较好理解,我们没见过哪个 HTML 标签属性是传对象进去的,而自定义组件也是普通 HTML 标签,所以也遵循了这个基本原则。 解决方案:

  • 明修栈道暗度陈仓:将传递的先保存到一个全局变量(或模块变量),然后返回一个字符串标识,在组件内部通过标识拿到真正的值。具体代码可以参考:magic-microservices
  • 发布订阅:既然限制这么厉害,我们干脆就不走它的属性传递方式,而是采用发布订阅模式在外部传递数据,内部接受数据;

响应式属性必须提前声明与触发时机演示 image.png image.png

以下是具体代码:

<!DOCTYPE html>
<html lang="en">
  <head></head>
  <body>
    <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>
  </body>
</html>
复制代码

当然想要实现任意属性的响应式只能采用发布订阅的模式,而不是走 Web Components 的属性体系。

CSS 和 Shadow DOM

首先需要澄清一个我自己一直以来的认知误区,既 Web Components 很安全,有沙箱功能,能隔离 JS。这句话里能隔离 JS 是不对的,Web Components 不对 JS 做隔离的,但能做到 HTML 和 CSS 的隔离。

默认情况:html、css 不做隔离

image.png

<body>
  <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>
</body>
复制代码

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) => {
  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 开发的组件库

学习资料

后续

本篇算是入门篇,后续如果有时间可以再搞两篇:

写作很辛苦,如果你有所收获,请帮忙点个赞,如果暂时没有应用场景,也可以先收藏。

猜你喜欢

转载自juejin.im/post/7049724696522129439
今日推荐