【原生组件】一文带你入门 Web Components

4,276 阅读5分钟

前言

本文将介绍一些 Web Components 的相关知识,最后通过一个组件封装的案例讲解带你入门 Web Components。

什么是 Web Components

MDN:Web Components 是一套不同的技术,允许您创建可重用的定制元素(它们的功能封装在您的代码之外)并且在您的web应用中使用它们。

Web Components 不是单一的某个规范,而是由3组不同的技术标准组成的组件模型,它旨在提升组件封装和代码复用能力。

  • Custom Elements - 自定义元素
  • Shadow DOM - 影子 DOM
  • HTML Template - HTML 模版

下面咱们通过一个简单的例子来逐步了解 Web Components 的知识以及使用。

一个简单的例子

创建模版

<body>
  <!-- 创建模版 -->
  <template id="my-template">
    <p>hello~<slot name="user"></slot></p>
  </template>
</body>

这可以看作是我们的组件内容,由template包裹,此时该标签及其内部元素是不可见的,还需要我们用 JavaScript 去操作并将其添加到 DOM 里才能看得见。

可以看到,除了template标签之外还有slot标签,对于用惯了框架的我们应该对它不陌生了,插槽有助于提升我们在开发中的灵活度,用法跟我们平时的使用差不多,这里就不多赘述了。

定义组件的类对象

<body>
  <!-- 模版 -->
  <template id="my-template">
    <p>hello~<slot name="user"></slot></p>
  </template>
  
  <script>
    // 定义类对象
    class HelloComponent extends HTMLElement {
      // 构造函数
      constructor() {
        // 必须先调用 super 方法
        super();
        // 获取<template>
        const template = document.getElementById('my-template');
        // 创建影子 DOM 并将 template 添加到其子节点下
        const _shadowRoot = this.attachShadow({ mode: 'open' });
        const content = template.content.cloneNode(true)
        _shadowRoot.appendChild(content);
      }
    }
    // 自定义标签
    window.customElements.define('hello-component', HelloComponent);
  </script>
</body>

类 HelloComponent 继承自 HTMLElement,我们在构造函数中去挂载 DOM 元素。

获取<template>节点以后,克隆了它的所有子元素,这是因为可能有多个自定义元素的实例,这个模板还要留给其他实例使用,所以不能直接移动它的子元素。

shadow DOM

上面我们用 attachShadow 方法创建了一个 shadow DOM,影子 DOM,如其名一样,它可以将一个隐藏的、独立的 DOM 附加到一个元素上

参数 mode 有两个可选值:open 和 closed。

open 表示可以通过页面内的 JavaScript 方法来获取 shadow DOM。

let shadowroot = element.shadowRoot;

相反地,closed 表示不可以从外部获取 shadow DOM,此时 element.shadowRoot 将返回null。

shadow DOM 其实离我们很近,HTML一些内置的标签就包含了 shadow DOM,如inputvideo等。

在开发者工具中,打开设置面板,勾选 Show user agent shadow DOM。

image.png

查看 input 元素。

image.png

可以看到,input 多了一些我们平时没注意到的东西。现在你应该知道 input 的内容和 placeholder 从哪里来了,同样的,video 默认的那些播放按钮也都是藏在这里面的。

自定义标签

window.customElements.define('hello-component', HelloComponent)

window.customElements.define 方法的第一个参数是自定义标签的名称,第二个参数是用于定义元素行为的类对象。

注意:自定义标签的名称不能是单个单词,且必须有短横线

使用

<body>
  <!-- 模版 -->
  <template id="my-template">
    <p>hello~<slot name="user"></slot></p>
  </template>
  <script>
    // 定义类对象
    class HelloComponent extends HTMLElement {
      // 构造函数
      constructor() {
        // 必须先调用 super 方法
        super();
        // 获取<template>
        const template = document.getElementById('my-template');
        // 创建影子 DOM 并将 template 添加到其子节点下
        const _shadowRoot = this.attachShadow({ mode: 'open' });
        const content = template.content.cloneNode(true)
        _shadowRoot.appendChild(content);
      }
    }
    // 自定义标签
    window.customElements.define('hello-component', HelloComponent);
  </script>
  <!-- 使用 -->
  <hello-component>
    <span slot="user">张三</span>
  </hello-component>
</body>

生命周期

除了构造函数之外,custom elements 还有4个生命周期函数:

  • connectedCallback:当 custom element首次被插入文档DOM时,被调用。
  • disconnectedCallback:当 custom element从文档DOM中删除时,被调用。
  • adoptedCallback:当 custom element被移动到新的文档时,被调用。
  • attributeChangedCallback: 当 custom element增加、删除、修改自身属性时,被调用。
class HelloComponent extends HTMLElement {
  // 构造函数
  constructor() {
    super();
  }
  
  connectedCallback() {
    console.log('当自定义元素第一次被连接到文档DOM时被调用')
  }

  disconnectedCallback() {
    console.log('当自定义元素与文档DOM断开连接时被调用')
  }

  adoptedCallback() {
    console.log('当自定义元素被移动到新文档时被调用')
  }

  attributeChangedCallback() {
    console.log('当自定义元素的一个属性被增加、移除或更改时被调用')
  }
}

实战:封装一个商品卡片组件

image.png

我们期望的使用方式:

<body>
  <!-- 引入 -->
  <script type="module">
    import './goods-card.js'
  </script>
  <!-- 使用 -->
  <goods-card
    img="https://img1.baidu.com/it/u=2613325730,275475287&fm=224&fmt=auto&gp=0.jpg"
    goodsName="跑鞋"
  ></goods-card>
</body>

组件内容实现

// goods-card.js
class GoodsCard extends HTMLElement {
  constructor() {
    super();
    const template = document.createElement('template')
    template.innerHTML = `
      <style>
        .goods-card-container {
          width: 200px;
          border: 1px solid #ddd;
        }
        .goods-img {
          width: 100%;
          height: 200px;
        }
        .goods-name {
          padding: 10px 4px;
          margin: 0;
          text-align: center;
        }
        .add-cart-btn {
          width: 100%;
        }
      </style>
      <div class="goods-card-container">
        <img class="goods-img" />
        <p class="goods-name"></p>
        <button class="add-cart-btn">加入购物车</button>
      </div>
    `;
    const _shadowRoot = this.attachShadow({ mode: 'open' })
    const content = template.content.cloneNode(true)
    _shadowRoot.appendChild(content)
  }
}

window.customElements.define('goods-card', GoodsCard)

现在我们已经成功把 DOM 渲染出来了,只不过现在商品图片和商品名称还是空的,接下来我们需要去获取父组件传递过来的值并将其展示在视图上。

class GoodsCard extends HTMLElement {
  constructor() {
    super();
    // ...省略部分代码
    // 获取并更新视图
    const _goodsNameDom = _shadowRoot.querySelector('.goods-name')
    const _goodsImgDom = _shadowRoot.querySelector('.goods-img')
    _goodsNameDom.innerHTML = this.name
    _goodsImgDom.src = this.img
  }
  
  get name() {
    return this.getAttribute('name')
  }

  get img() {
    return this.getAttribute('img')
  }
}

到这里我们就把一个商品卡片渲染出来了。

响应式视图更新

<goods-card
  img="http://zs-oa.oss-cn-shenzhen.aliyuncs.com/zsoa/goods/v1v2/1021161140018/spu1/349334579590860800.jpg"
  name="跑鞋"
></goods-card>
<script type="module">
  import './goods-card.js'
</script>
<script>
  setTimeout(() => {
    document.querySelector('goods-card').setAttribute('name', '篮球鞋')
  }, 2000);
</script>

这里我们会发现一个问题,如果组件的传值发生了变化,此时视图上是不会更新的,商品名称仍然显示跑鞋而不是篮球鞋

这时候我们需要用到前面讲到的生命周期,用attributeChangedCallback来解决这个问题,对代码进行改造。

class GoodsCard extends HTMLElement {
  constructor() {
    super();
    // ...省略部分代码
    const _shadowRoot = this.attachShadow({ mode: 'open' })
    const content = template.content.cloneNode(true)
    _shadowRoot.appendChild(content)
    this._goodsNameDom = _shadowRoot.querySelector('.goods-name')
    this._goodsImgDom = _shadowRoot.querySelector('.goods-img')
  }

  attributeChangedCallback(key, oldVal, newVal) {
    console.log(key, oldVal, newVal)
    this.render()
  }

  static get observedAttributes() {
    return ['img', 'name']
  }
  
  get name() {
    return this.getAttribute('name')
  }

  get img() {
    return this.getAttribute('img')
  }

  render() {
    this._goodsNameDom.innerHTML = this.name
    this._goodsImgDom.src = this.img
  }
}

通过attributeChangedCallback,我们可以在属性发生改变时执行 render 方法从而让视图得到更新。

这里还有一个陌生的函数observedAttributes,该函数是与attributeChangedCallback配套使用的,如果没写observedAttributes或属性值发生改变的参数名称没有在observedAttributes函数返回的数组里,attributeChangedCallback是不会触发。

static get observedAttributes() {
  return ['img']
}

// 不会执行,因为商品名称 name 没有在 observedAttributes 返回的数组里
attributeChangedCallback(key, oldVal, newVal) {
  console.log(key, oldVal, newVal)
  this.render()
}

事件交互

最后我们来给按钮添加一个点击事件,这是一个很必要的需求,方便组件调用者去处理自己的逻辑。

constructor() {
  // ...省略部分代码
  this._button = _shadowRoot.querySelector('.add-cart-btn')
  this._button.addEventListener('click', () => {
    this.dispatchEvent(new CustomEvent('onButton', { detail: 'button' }))
  })
}
<body>
  <script>
    document.querySelector('goods-card').addEventListener('onButton', (e) => {
      console.log('添加购物车', e.detail) // button
    })
  </script>
</body>

完整代码

<body>
  <!-- 使用 -->
  <goods-card
    img="https://img1.baidu.com/it/u=2613325730,275475287&fm=224&fmt=auto&gp=0.jpg"
    name="跑鞋"
  ></goods-card>

  <!-- 引入 -->
  <script type="module">
    import './goods-card.js'
  </script>
  <script>
    document.querySelector('goods-card').addEventListener('onButton', (e) => {
      console.log('按钮事件', e.detail)
    })
  </script>
</body>
// goods-card.js
class GoodsCard extends HTMLElement {
  constructor() {
    super();
    const template = document.createElement('template')
    template.innerHTML = `
      <style>
        .goods-card-container {
          width: 200px;
          border: 1px solid #ddd;
        }
        .goods-img {
          width: 100%;
          height: 200px;
        }
        .goods-name {
          padding: 10px 4px;
          margin: 0;
          text-align: center;
        }
        .add-cart-btn {
          width: 100%;
        }
      </style>
      <div class="goods-card-container">
        <img class="goods-img" />
        <p class="goods-name"></p>
        <button class="add-cart-btn">加入购物车</button>
      </div>
    `;
    const _shadowRoot = this.attachShadow({ mode: 'open' })
    const content = template.content.cloneNode(true)
    _shadowRoot.appendChild(content)
    this._goodsNameDom = _shadowRoot.querySelector('.goods-name')
    this._goodsImgDom = _shadowRoot.querySelector('.goods-img')
    this._button = _shadowRoot.querySelector('.add-cart-btn')
    this._button.addEventListener('click', () => {
      this.dispatchEvent(new CustomEvent('onButton', { detail: 'button' }))
    })
  }

  attributeChangedCallback(key, oldVal, newVal) {
    console.log(key, oldVal, newVal)
    this.render()
  }

  static get observedAttributes() {
    return ['img', 'name']
  }
  
  get name() {
    return this.getAttribute('name')
  }

  get img() {
    return this.getAttribute('img')
  }

  render() {
    this._goodsNameDom.innerHTML = this.name
    this._goodsImgDom.src = this.img
  }
}

window.customElements.define('goods-card', GoodsCard);

最后

以上,通过一个组件的封装,相信你已经对Web Components有一定的认识和了解了,当然,本文只是带你入门,讲解一些基础的开发实践知识,并非Web Components的全部,Web Components各个部分还有很多值得深入的地方,感兴趣的同学可自行深入了解一下。

感谢

本次分享到这里就结束了,感谢你的阅读,如果对你有什么帮助的话,欢迎点赞支持一下❤️。

如果有什么错误或不足,欢迎评论区指正、交流❤️。

参考资料:

阮一峰 - Web Components 入门实例教程

MDN - Web Componnents