了解Web Components,在现代框架中使用它

4,111 阅读15分钟

web compoennts会取代现在流行的MVVM框架?

在我看来肯定是不能的,他和MVVM框架是可以共存的,在框架中都提供了解决方案。他就像css预处理器中的变量和css var一样共存。

再者就是我们使用MVVM框架并不是只是用他的组件化,还是用他们提供的丰富功能,方便我们开发。

学一门技术,我们可以先来看看他的实现效果,这样可以让我们更有兴趣去了解学习。

一些web components库

css-doodle

css涂鸦,帮助我们创建炫酷的背景。

通过内置的语法,来编写css。

<css-doodle>
  :doodle {
    @grid: 20 / 100vmax;
    background: #12152f;
  }

  ::after {
    content: "\@hex(@rand(0x2500, 0x257f))";
    font-size: 5vmax;
    color: hsla(@rand(360), 70%, 70%, @rand(.9));
  }
</css-doodle>

image.png 使用他提供的语法可以实现很多复杂的动画背景,这里就不过多介绍了。这里写了三个案例可以参考

water.gif

fancy-components

基于web components封装一些常规的组件,但是很hack。

这里给出一个FcChina展示效果

<fc-china></fc-china>

<script type="module">
    import { FcChina } from 'https://unpkg.com/fancy-components@0.0.23/index.js'

    // new 就相当于全局注册了这个组件,相当于 Vue 的 Vue.component('FcChina', 组件)
    new FcChina()
</script>

chain.gif

这里还有些案例可以参考

经过上面的案例了解,我们可以来学习web components了。

web components介绍

Web Components是一种Web开发技术,它通过自定义元素、Shadow DOM、模板和HTML导入等标准技术,允许开发人员创建可重用和独立的组件化UI元素,这些组件可以同时在各种现代Web浏览器中使用。Web Components的实现可以借助于一些相关的标准和API,如HTML Templates、Custom Elements、Shadow DOM、Fetch API等。

Web Components的主要优势在于提高了Web开发的可组合性和代码复用性,降低了应用程序的复杂性和维护成本。通过将业务逻辑和界面元素相互分离,Web Components能够使开发人员更好地管理和维护复杂的UI组件,同时提高应用程序的可扩展性和灵活性。

Web Components技术的发展历程非常具有特色。早在2004年,Mozilla基金会就提出了XUL(XML User interface Language)的概念,旨在使用XML语言来定义通用的用户界面组件。之后,Google通过GWT(Google Web Toolkit)再次推出了UI编写框架,其中有关于组件化的一些实践,例如通过UI Binder来实现UI设计与业务逻辑的分离,处理数据绑定等前端开发问题。在此之后,一些流行的Web框架,例如Angular、React和Vue.js都提供了自定义组件化的新特性,从而为Web Components的实现和推广铺平了道路。 Web Components目前已经成为Web应用程序开发技术的主流之一,越来越多的开发者开始关注和应用这种技术。虽然在早期的浏览器中并未提供原生支持,但是通过Web Components的相关polyfill库,开发者可以在任何现代浏览器上实现相同的开发体验。

vue.js等框架与web components之间的关系

Vue.js 是一个流行的 JavaScript 前端框架,提供了一种基于组件化的开发体验,开发者可以使用Vue.js定义自己的组件,实现将一个页面划分成多个独立的、可复用的组件,这些组件可以通过props接收父组件传递的数据和函数,也可以向父组件发送事件。

与此类似的,Web Components 本身也提供了一种组件化的开发模式,通过定义自定义元素、Shadow DOM、模板和HTML导入等技术,开发者可以定义自己的组件并在 Web 中复用。

因此,Vue.js 的组件化开发与 Web Components 本身的组件化思想是相似的,从这个角度看,Vue.js 可以被看作是一种基于 Web Components 的前端框架。而且,Vue.js 也提供了编写 Web Components 的功能,可以将 Vue.js 组件打包发布为可复用的 Custom Element,并且在任何支持 Web Components 的环境下运行。 总之,Vue.js 和 Web Components 都推崇组件化开发的思想,它们之间有着相似的设计理念,可以互相补充,Vue.js 可以与 Web Components 协同工作,共同推进 Web 应用程序的发展。

在vue和react框架中使用web comoponents自定义组件

vue中使用的配置(vite, vue cli)

通过vue-cli创建的项目中使用jsx时,使用web components的配置

image.png

// babel.config.js
module.exports = {
  presets: [
    '@vue/cli-plugin-babel/preset'
  ],
  plugins: [
    [
      "@vue/babel-plugin-jsx",
      {
        // 当以fc-开头的组件被当做是web components自定义组件,而不是vue中注册的组件。
        // 如果直接放回
        isCustomElement: tag => tag.startsWith('fc-')
      }
    ]
  ]
}

如果直接将isCustomElement设置为true时,vue中注册的组件也会被当成web components组件,不会被编译。

image.png

通过vite创建的项目中使用jsx时,使用web components的配置

// vite.config.js
    import { defineConfig } from 'vite'
    import vue from '@vitejs/plugin-vue'
    import jsx from '@vitejs/plugin-vue-jsx'

    // https://vitejs.dev/config/
    export default defineConfig({
      plugins: [vue({
        template: {
          compilerOptions: {
            isCustomElement: tag => tag.startsWith('fc-')
          }
        }
      }), jsx({
        isCustomElement: tag => tag.startsWith('fc-')
      })]
    })

在react中可以直接使用,不需要做额外的配置。

web components学习

web components的语法结构和vue很像,其实vue在设计之初就是参考了web components设计的。

web components的组成

Custom Elements

共有两种 custom elements:

  • Autonomous custom elements 是独立的元素,它不继承其他内建的 HTML 元素,总是继承HTMLElement。你可以直接把它们写成 HTML 标签的形式,来在页面上使用。例如 <popup-info>,或者是document.createElement("popup-info")这样。

  • Customized built-in elements 继承自基本的 HTML 元素。在创建时,你必须指定所需扩展的元素(正如上面例子所示),使用时,需要先写出基本的元素标签,并通过 is 属性指定 custom element 的名称。例如<p is="word-count">, 或者 document.createElement("p", { is: "word-count" })


class WordCount extends HTMLParagraphElement {
  constructor() {
    // 必须首先调用 super 方法
    super();

    // 元素的功能代码写在这里

    ...
  }
}

customElements.define('word-count', WordCount, { extends: 'p' });

define

我们可以通过customElements.define来定义一个主定义组件。我们定义组件时,需要继承自一个组件类。

注意:自定义组件名称需要使用-相连,目的是区分原生html标签。

   customElements.define("custom-component", class extends HTMLElement {
    constructor() {
      super()
    }
  })

get

我们可以通过customElements.get方法获取到指定组件定义的组件类。这样我们既可以在该组件的基础上扩展一些内容。

下面我们来扩展一下fc-bubbles组件。

<extend-bubbles>点击宽展fc-bubbles</extend-bubbles>
  <script type="module">
    // 内部是es module导出
    import { FcBubbles  } from "https://unpkg.com/fancy-components@0.0.23/index.js"
    new FcBubbles()

    customElements.define("extend-bubbles", class extends customElements.get("fc-bubbles") {
      constructor() {
        super()
        // 当前this表示该主定义组件dom本身
        this.addEventListener("click", () => {
          alert("点击,扩展")
        })
      }
    })
  </script>

image.png

whenDefined

该方法返回一个promise对象,当组件被定义后,将会决定promise状态。

由于组件有未定义和定义两种状态。所以可以通过选择器来选中这两种状态定制css。

  • :defined伪类来选中定义后的元素
  • :not(:defined) 来选中定义之前的元素

下面就是一个小案例

  <!-- 我们还可以配合 :defined伪类来选中定义后的元素  :not(:defined) 来选中定义之前的元素 -->
  <!-- 
    为啥会有这两种状态呢?
    当浏览器在解析dom时,对于自定义的组件,他不认识,就会被标识为未定义,当执行js代码后,定义完组件,他就会被解析。所以有两种状态
  -->
  <style>

    /* 未定义 */
    custom-component:not(:defined) {
      width: 100px;
      height: 30px;
      background: blue;
    }
    /* 定义完成 */
    custom-component:defined {
      /* width: 100px;
      height: 30px; */
      background: red;
    }
  </style>
<body>
  <custom-component>loading....</custom-component>
  <script>
    // 延迟两秒,然后将会出现“加载完成时”
    setTimeout(() => {
      customElements.define("custom-component", class extends HTMLElement {
        constructor() {
          super()
        }
      })
    }, 2000)

    // 主要是当组件创建出来后返回一个promise对象
    customElements.whenDefined("custom-component").then(() => {
      document.querySelector("custom-component").innerHTML = "加载完成时"
    }).catch((err) => {
      // 当组件名称写错误,将会抛出错误
      console.log(err)
    })
  </script>
</body>

defined.gif

upgrade

这个方法没看懂啥意思。大概就是让自定义元素未被注册表收集的元素可以在浏览器中被使用。

 <script>
    // 在组件定义之前,创建一个组件元素
    const dom = document.createElement("custom-component")

    class CustomComponent extends HTMLElement {
      constructor() {
        super()
      }
    }
    customElements.define("custom-component", CustomComponent)

    console.log(dom instanceof CustomComponent) // false
    // 升级组件后
    customElements.upgrade(dom)
    console.log(dom instanceof CustomComponent) // true
  </script>

那我们就让gpt来回答一下吧

customElements.upgrade 是 Web Components API 提供的一个方法,用于将文档中的旧版自定义元素升级为定义自己的元素,具体使用场景如下:

  1. 允许旧版自定义元素向自定义元素靠拢:在旧版浏览器中,可能存在自定义元素的实现并不完全符合标准的情况,使用 customElements.upgrade 方法可以将旧版自定义元素升级为标准自定义元素,让它们具备更好的兼容性和可维护性。

  2. 与其他框架集成:某些框架或库在内部实现中使用了自定义元素来封装特定的 UI 组件,但是这些自定义元素并没有被注册到文档的自定义元素注册表中,因此在浏览器渲染时无法被正确识别和使用。使用 customElements.upgrade 方法可以将这些自定义元素升级为文档中的自定义元素,让它们可以正常工作。

  3. 动态插入新的自定义元素:在文档运行时,可以通过 JavaScript 动态生成自定义元素,并将它们插入到文档中。使用 customElements.upgrade 方法可以确保这些动态生成的自定义元素被正确注册到文档的自定义元素注册表中,并可以被浏览器正确识别和使用。

需要注意的是,customElements.upgrade 方法只能被用于旧版浏览器,对于支持自定义元素的现代浏览器来说,自定义元素会自动被注册到自定义元素注册表中,无需手动升级或注册。

生命周期

  • connectedCallback:当 custom element 首次被插入文档 DOM 时,被调用。

  • disconnectedCallback:当 custom element 从文档 DOM 中删除时,被调用。

  • adoptedCallback:当 custom element 被移动到新的文档时,被调用。

  • attributeChangedCallback: 当 custom element 增加、删除、修改自身属性时,被调用。

connectedCallback

当 custom element 首次被插入文档 DOM 时,被调用。

  <custom-component>
    111
  </custom-component>
  <script>
    const btn = document.getElementById("btn")
    customElements.define("custom-component", class extends HTMLElement {
      constructor() { // 相当于vue的setup
        super()
      }

      connectedCallback() { // 相当于vue的mounted
        console.log("当 custom element 首次被插入文档 DOM 时,被调用")
      }
    })
  </script>

disconnectedCallback

当 custom element 从文档 DOM 中删除时,被调用。

  <custom-component>
    111
  </custom-component>
  <button id="btn">移除custom-component</button>
  <script>
    const btn = document.getElementById("btn")
    customElements.define("custom-component", class extends HTMLElement {
      constructor() { // 相当于vue的setup
        super()
      }

      connectedCallback() { // 相当于vue的mounted
        console.log("当 custom element 首次被插入文档 DOM 时,被调用")
        
        // 点击按钮移除触发disconnectedCallback
        btn.onclick = () => {
          document.body.removeChild(this)
        }
      }

      disconnectedCallback() { // 相当于vue的unmounted
        console.log("当 custom element 从文档 DOM 中删除时,被调用")
      }
    })
  </script>

生命周期.gif

adoptCallbeck

当 custom element 被移动到新的文档时,被调用。

注意在同一个文档中剪切是不会触发该钩子的。

在介绍这个api的作用时,这里需要首先了解一下 adoptNode api

在同一个文档中,不生效。

  <custom-component>
    111
  </custom-component>
  <button id="btn">移除custom-component</button>
  <button id="btn2">剪切</button>
  <script>
    const btn = document.getElementById("btn")
    const btn2 = document.getElementById("btn2")
    customElements.define("custom-component", class extends HTMLElement {
      constructor() { // 相当于vue的setup
        super()
      }

      connectedCallback() {  // 相当于vue的mounted
        console.log("当 custom element 首次被插入文档 DOM 时,被调用")
        
        // 点击按钮移除触发disconnectedCallback
        btn.onclick = () => {
          document.body.removeChild(this)
        }
        btn2.onclick = () => {
          document.body.appendChild(document.adoptNode(this))
        }
      }

      disconnectedCallback() {  // 相当于vue的unmounted
        console.log("当 custom element 从文档 DOM 中删除时,被调用")
      }

      // 当组件被剪切时调用。 adoptNode()
      // 只有移动到新的文档才会被调用,在同一个文档中剪切时不生效的。
      adoptedCallback() {
        console.log("当 custom element 被移动到新的文档时,被调用")
      }
    })
  </script>

声明周期1.gif

那么我们添加iframe来测试一下。

// iframe.html

  <custom-component>
    111
  </custom-component>
  <script>
    customElements.define("custom-component", class extends HTMLElement {
      constructor() { // 相当于vue的setup
        super()
      }

      // 当组件被剪切时调用。 adoptNode()
      // 只有移动到新的文档才会被调用,在同一个文档中剪切时不生效的。
      adoptedCallback() {
        console.log("当 custom element 被移动到新的文档时,被调用")
      }
    })
  </script>
  <iframe src="./07.iframe.html" frameborder="10"></iframe>
  <button id="btn2">剪切</button>
  <script>
    const btn2 = document.getElementById("btn2")

    btn2.onclick = () => {

      const iframe = document.getElementsByTagName("iframe")[0]
      const ele = iframe.contentWindow.document.querySelector("custom-component")
      // 剪切
      document.body.appendChild(document.adoptNode(ele))
    }
  </script>

声明周期2.gif

attributeChangedCallback

当 custom element 增加、删除、修改自身属性时,被调用。

当我们想要监听属性变化,我们需要指定哪些属性需要被监听。

需要注意的是,如果需要在元素属性变化后,触发attributeChangedCallback()回调函数,你必须监听这个属性。这可以通过定义observedAttributes() get 函数来实现,observedAttributes()函数体内包含一个 return 语句,返回一个数组,包含了需要监听的属性名称:

static get observedAttributes() {return ['w', 'l']; }

一个demo

  <custom-component color="red" font="20px">
    111
  </custom-component>
  <button id="btn">移除custom-component</button>
  <button id="btn2">剪切</button>
  <button id="btn3">修改color</button>
  <button id="btn4">修改font</button>
  <script>
    const btn = document.getElementById("btn")
    const btn2 = document.getElementById("btn2")
    const btn3 = document.getElementById("btn3")
    const btn4 = document.getElementById("btn4")
    customElements.define("custom-component", class extends HTMLElement {
      // 需要事先指定监听的那些属性
      // 相当于vue中的data
      static get observedAttributes() {return ['color', 'font']; }
      constructor() { // 相当于vue的setup
        super()
      }

      connectedCallback() {  // 相当于vue的mounted
        console.log("当 custom element 首次被插入文档 DOM 时,被调用")
        
        // 点击按钮移除触发disconnectedCallback
        btn.onclick = () => {
          document.body.removeChild(this)
        }
        btn2.onclick = () => {
          document.body.appendChild(document.adoptNode(this))
        }
        btn3.onclick = () => {
          this.setAttribute("color", "blue")
        }
        btn4.onclick = () => {
          this.setAttribute("font", "30px")
        }
        
      }

      disconnectedCallback() {  // 相当于vue的unmounted
        console.log("当 custom element 从文档 DOM 中删除时,被调用")
      }

      // 当组件被剪切时调用。 adoptNode()
      // 只有移动到新的文档才会被调用,在同一个文档中剪切时不生效的。
      adoptedCallback() {
        console.log("当 custom element 被移动到新的文档时,被调用")
      }

      // 初次挂载的时候也会调用,主要实现指定监听那行属性就行
      // 注意旧值在前,新值在后
      attributeChangedCallback(name, oval, nval) { // 相当于vue中的watch
        console.log("当 custom element 增加、删除、修改自身属性")
        console.log(name, oval, nval)
        if(name == "color") { // 即使修改的值一样也会触发
          if(oval !== nval) {
            this.style.color = nval
          }
        }
        if(name == "font") {
          this.style.fontSize = nval
        }
      }
    })
  </script>

声明周期3.gif

attribute 与 property关系

我们知道通过js获取dom对象时,直接通过.来获取属性并赋值自定义的属性时,dom本身和js dom对象是不会联动的。

只有内置的一些属性才会被联动。例如id, class等等。

  <div id="div" color="red">111</div>
  <script>
    const dom = document.getElementById("div")
    console.log("%O", div)
    // 额外定义的属性获取不到,在js dom对象中修改color也不会响应到html dom中
    console.log("获取dom.color", dom.color) // undefined
    // 我们只有使用setAttribute才可以修改额外定义的html dom attribute
    dom.setAttribute("color", "skyblue")
    
    // 内置的属性可以获取到
    console.log("获取dom.id", dom.id) // div
  </script>

下面我们来看一下在web components中如何进行联动的

 
  <custom-component color="red" font="20px">
    111
  </custom-component>
  <button id="btn3">修改color</button>
  <div id="div">
    custom-component中color属性值:
  </div>
  <script>
    const btn3 = document.getElementById("btn3")
    const colors = ["red", "blue", "green"]
    customElements.define("custom-component", class extends HTMLElement {
      // 需要事先指定监听的那些属性
      // 相当于vue中的data
      static get observedAttributes() {return ['color', 'font']; }
      // 联动attribute与property
      get color() {
        return this.getAttribute("color")
      }
      set color(value) {
        this.setAttribute("color", value)
      }

      constructor() { // 相当于vue的setup
        super()
      }

      connectedCallback() {  // 相当于vue的mounted
        btn3.onclick = () => {
          this.color = colors[Math.floor(Math.random() * 3)]
          document.getElementById("div").innerHTML = `custom-component中color属性值:${document.querySelector("custom-component").color}`
        }
      }

      // 初次挂载的时候也会调用,主要实现指定监听那行属性就行
      // 注意旧值在前,新值在后
      attributeChangedCallback(name, oval, nval) { 
        if(name == "color") { // 即使修改的值一样也会触发
          if(oval !== nval) {
            this.style.color = nval
          }
        }
      }
    })
  </script>

声明周期4.gif

template, slot 模板和插槽

涉及的一些方法的属性

  • slot, 返回已插入元素所在的 Shadow DOM slot 的名称。即我们替换slot的插槽名称。
  • shadowRoot, 表示获取shadow root 的mode设置为open时的shadow dom根元素。

template

定义一个模板结构,该模板中的内容不会展示到页面中。用于模板结构的重复利用。一般为自定义组件提供一个html结构。类似于vue中的模板。内部可以定义插槽(slot)

并且内部定义的style标签也不会作用域全局文档,其他任何元素中定义的style都会作用域全局文档。他只会在被shadow dom使用时作用于shadow root内部的元素。

image.png

slot

用于在 Shadow DOM 中插入内容的占位符,也可以定义默认内容。注意换行符和空格等也会替换无名插槽,所以使用插槽时最好设置名称。

<template id="my-paragraph">
    <!-- template中定义的style不会作用于任何元素,如果使用shadow dom的话,它将作用于shadow dom,但是不会作用于全局文档 -->
    <style>
      p {
        color: white;
        background-color: #666;
        padding: 5px;
      }
    </style>
    <!-- <p>My paragraph</p> -->
    <!-- 使用slot 。 在替换插槽时,我们需要通过slot属性定义具名插槽的名称-->
    <p>
      <slot name="my-text">My default text</slot>
      <slot>默认无名插槽</slot>
    </p>
  </template>
  <my-paragraph>
    // 注意换行符和空格等也会替换无名插槽,所以使用插槽时最好设置名称
    <a href="">###</a>
    <span slot="my-text">替换掉my-text具名插槽</span>
  </my-paragraph>
  // template中的style不会作用于外部
  <p>外部p标签=======</p>
  <script>
    customElements.define(
      "my-paragraph",
      class extends HTMLElement {
        constructor() {
          super();
          let template=document.getElementById("my-paragraph");
          // 获取模板内容
          let templateContent=template.content;
          // 表示可以通过js获取到shadow dom中的元素。
          const shadowRoot=this.attachShadow({mode: "open"});
          // const shadowRoot = this.attachShadow({ mode: "closed" });
          shadowRoot.appendChild(templateContent.cloneNode(true));
          // 这个也会直接将内容全部剪切过来,而不是复制一份。
          // shadowRoot.appendChild(templateContent);
        }
      },
    );

  </script>

image.png 我们查看插槽替换结构可以发现,slot内部替换元素是定义在根元素上的,所以我们在通过选择器获取元素时是不可以获取到的。

image.png

下面我们来看看HTMLSlotElement提供的api吧。

  • assign 动态给插槽分配占位内容。测试发现,并没有什么效果。
  • assignedElements 获取该插槽替换后的所有直接子元素。如果给出option.flatten: true那么也会获取后代插槽的所有直接子元素 其实他获取的是插槽提供的默认元素。

image.png

image.png

  • assignedNodes 获取该插槽替换后的所有直接子节点(包括文本和注释)。如果给出option.flatten: true那么也会获取后代插槽的所有直接子节点 其实他获取的是插槽提供的默认节点。

image.png

  • slotchange 一个事件,当该插槽提供的内容发生变化时触发。

所有案例代码

参考