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

2022年01月13日 阅读数:6
这篇文章主要向大家介绍经过 Vue、React ,快速学习 web components 核心知识,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

近期的工做用到了 Web Components 的能力,但在学习的过程当中仍是踩了很多的坑。但学完以后发现其和 Vue、React 有诸多类似之处,因此经过对比 React、Vue 与 Web Components 的类似点不一样点,但愿你们在须要的时候可以快速掌握 Web Components 核心知识,减小学习的时间和少踩坑。javascript

目录

核心概念

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


  <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 标签。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 标签):vue

// vue2 组件全局组件注册
Vue.component("hello-world", HelloWorld);

// Web Components 组件注册
customElements.define("hello-world", HelloWorld);

这里须要注意的一点是,Web Components 不只能够继承基础的 HTMLElement 还能够继承任意标签,例如:java

<script>
  // 一、继承 HTMLVideoElement
  class VipVideo extends HTMLVideoElement {
    constructor() {
      super();

      this.addEventListener("play", () => {
        setTimeout(() => {
          this.pause();
          alert("请充值 SVIP 会员");
        }, 3000);
      });
    }
  }

  // 二、这里须要 extends: video
  customElements.define("vip-video", VipVideo, { extends: "video" });
</script>

<!-- 三、使用 is 代表其真实身份 -->
<video is="vip-video" autoplay="autoplay" src="https://www.w3school.com.cn/i/movie.ogg" controls="controls"></video>

demo1.gif 继承其余标签从定义到注册到使用都和默认的方式稍有区别,要注意哦。react

节点和模板

不管是 React 的 jsx 仍是 Vue template 都是虚拟 DOM,可是 Web Components 则是真实 DOM,咱们上面 HelloWorld 定义采用了最简单的 innerHTML 的方式,其还能够用原生的 DOM API 进行建立,例如:git

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 进行操做:github

<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 组件已经被定义过了。web

为了防止这种报错,咱们就须要判断其是否被定义过了,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 的规范。

// 一、vue 插槽定义
<template>
  <div class="layout">
    <!-- 具名插槽定义 & 支持插槽默认值 -->
    <slot name="header"><h1>header(插槽默认内容)</h1></slot>

    <!-- 默认插槽定义 -->
    <slot></slot>

    <!-- 具名插槽定义 -->
    <slot name="footer">footer</slot>
  </div>
</template>

// 二、使用组件和插槽
<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(一、重点!!!)
      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 事件(二、重点!!!)
  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 开发的组件库

学习资料

后续

本篇算是入门篇,后续若是有时间能够再搞两篇:

写做很辛苦,若是你有所收获,请帮忙点个赞,若是暂时没有应用场景,也能够先收藏。