Web Components

998 阅读6分钟

定义:面向标准封装 可重用定制元素 ;

1 WebComponent 自定义元素

shadowDom 将封装的定制元素挂载到元素节点上,可以理解为内部具有一个隔离环境的沙箱,其中的 shadow Tree集合 与外部的其他DOM互不干扰,具有天然的样式隔离(对比vue中的scoped;

Component节点: 自定义元素的根节点,元素内部可以通过 this获取节点实例,可以通过 :host 设置样式,或者外部通过选择器直接修改;

slot Tree集合: slot节点集合;

具有样式隔离的只有 shadow Tree 部分;

image.png

1.1 声明元素 - 注册可使用

🤯问题场景:站在浏览器的角度,在遇到一些自定义元素后,我们是如何知道哪些元素是自定义的?应该如何去渲染的呢?

为此Window对象提供了一个只读属性customElements ,可用于注册新的自定义元素,或者获取之前定义过的自定义元素的信息。

// 定义一个新的自定义元素
customElements.define(name, constructor, options);
// 返回指定自定义元素的构造函数,如果未定义自定义元素,则返回undefined
customElements.get(name);
// 更新节点子树中所有包含阴影的自定义元素
customElements.upgrade(root);
// 当使用给定名称定义自定义元素时将会执行的回调
customElements.whenDefined(name);

1.1.1 注册可使用

我们可以根据继承的元素不同来创建两种类型的自定义元素:定制元素、扩展内置元素;

// 定制元素
class CustomLifecycle extends HTMLElement {}
// 扩展内置元素
class WordCount extends HTMLParagraphElement {}

// 判断元素是否注册
if (!customElements.get('custom-lifecycle')) {
  // 注册元素
  customElements.define('custom-lifecycle', CustomLifecycle);
}

1.1.2 监听元素注册回调事件(返回promise

在首次加载、元素异步注册时,为了避免页面异常,需要确保元素注册完成后再进行UI的渲染;

customElements.whenDefined('custom-lifecycle').then(callback);

🤯问题1:如果元素已经注册,那么 resolve 会立即执行(但仍旧是异步事件;

🤯问题2:如果传入的 componentName 不是一个 有效的自定义元素名,那么将会触发 reject 回调;

1.2 生命周期

自定义元素的在挂载期间,会经过一系列的初始化过程:

生命周期的执行顺序对比react 或者vue中的执行顺序是类似的:constructor -> attributeChangedCallback -> connectedCallback

image.png

class CustomLifecycle extends HTMLElement {
  // 自定义元素初始化;
  // 对比 vue中的created;
  constructor() {
    // HTMLElement.prototype.constructor.call(this);
    super();
    console.log('constructor');
  }
  /**
   * connectedCallback
   * 当元素插入到 DOM 中时,将调用 connectedCallback;
   * 在这里就可以通过 this.shadowRoot 拿到shadowDOM元素实例;
   * 对比React的componentDidMount、vue的mount方法;
   */
  connectedCallback() {
    console.log('connectedCallback', this.shadowRoot);
    this.mainEl = this.shadowRoot.querySelector('.main');
  }
  /**
   * disconnectedCallback
   * 只要从 DOM 中移除元素,就会调用 disconnectedCallback;
   * 在这里可以清楚自定义元素内的定时器、事件监听等等;
   *
   * 但是,当用户直接关闭浏览器或浏览器标签时,这个方法将不会被调用;
   * 可以用window.unload beforeunload或者widow.close 去触发在浏览器关闭是的回调;
   *
   * 对比React的componentWillUnmount、vue的destory方法;
   */
  disconnectedCallback() {
    console.log('disconnected!');
  }

  /**
   * 设置哪些属性将会被监听变动,一般结合attributeChangedCallback使用;
   * 对比React中的getDerivedStateFromProps、vue中的watch方法;
   */
  static get observedAttributes() { return ['my-attr']; }
  /**
   * @param {*} name 属性名
   * @param {*} oldVal 旧值
   * @param {*} newVal 新值
   */
  attributeChangedCallback(name, oldVal, newVal) {
    console.log(`Attribute: ${name} changed!`);
    if (name == 'open' && newValue == 'true') {
      // 生命周期问题
      if (this.mainEl) this.mainEl.xxx;
    }
  }
}

需要注意的是 attributeChangedCallback 执行在 connectedCallback 之前,当元素 observedAttributes 的监听属性存在默认值时,会触发;

这就意味着,attributeChangedCallback 中的实例方法可能不存在,需要逻辑判断兼容下; (tips:如果默认值会对UI有影响,可以在 constructor 中进行处理;

1.2.1 constructor

在这里完成自定义元素的内容定义(包含 shadowRoot 实例初始化、样式、DOM内容等);

如果默认属性值会对UI有影响,可以在 constructor 中进行处理;

场景:按钮默认是disabled状态;

constructor() {
  super();
  const shadowRoot = this.attachShadow({ mode: 'open' });
  shadowRoot.innerHTML = `
    <style>
      :host{}
    </style>
    <div>${this.defaultValue}</div>
  `;
}

get defaultValue() {
  return this.getAttribute('defaultValue');
}

1、根元素样式选择器

可以通过 :host 标识根元素,结合 元素属性值,可以进行各种条件下的 样式控制(如下图,:host控制的是 elmation-delay-list-item 节点样式;

<style>
  :host{}
  :host([disabled]) {}
  :host([type="primary"]) {}
  :host(:not([disabled])) {}
</style>

// 元素外部样式,仍旧可以覆盖 :host
elmation-delay-list-item {}

image.png

2、mode - shadowRoot的创建模式

可选值为 “open” 或者 “closed”;

当设置为 closed 时,意味着 shadowRoot 内部实现将完全封闭,无法被外部 JavaScript 修改(主要是属性值),例如 <video> 标签;

1.2.2 connectedCallback、disconnectedCallback

元素 连接 到DOM树时触发,这个时机包含 插入节点、移动节点,所以可能被触发多次

在这期间可以通过 shadowRoot 实例设置事件监听、私有数据初始化

如果需要像 created 这样只需要在生命周期触发一次的方法,可以在 constructor 或者 通过 this._isReady = true; 添加实例属性标识来完成;

connectedCallback() {
  this.btn = this.shadowRoot.getElementById('btn');
  this.btn.addEventListener('click', () => {});
}
disconnectedCallback() {
  this.btn.removeEventListener('click');
}

.2.3 attributeChangedCallback

配合 observedAttributes 使用,实现属性值变更后的逻辑处理(默认值也会触发

1.3 对外通信

自定义元素跟外部交流可以通过两种基本的途径:属性值、自定义事件、slot插槽;

1.3.1 属性值

1、通过 getter、setter 的方式获取、修改属性值(通过 getAttributesetAttribute )

class ElfinTransition extends HTMLElement {
  get name() {
    return this.getAttribute('name');
  }
  set name(value) {
    if (value) {
      this.setAttribute('name', value);
    } else {
      this.removeAttribute('value');
    }
  }
}

2、不同场景下获取到的属性值

<name> ==> this.getAttribute('name') => null
<name open> ==> this.getAttribute('name') => ''
<name open="true"> ==> this.getAttribute('name') => 'true'

1.3.2 自定义事件

在正常使用中,外部经常需要感知到内部的某些行为,这个应该如何实现呢?

方案是在元素内部自定义事件 new CustomEvent(),通过 this.dispatchEvent 将事件广播出,外部用 addEventListener监听(注意除了标准事件(如click、change...)不能直接使用类似 onEvent 的形式监听);

this.dispatchEvent(new CustomEvent('play', {
  detail: {
    slow: this._slow,
  },
}));

获取传递出来的属性值 ==> event.detail.slow;

1.3.3 slot插槽

除了通过属性值传入普通的值,也可以通过slot传入 HTML内容;(对比vue中的slot

1、具名插槽;

 元素有一个特殊的 attribute:name。这个 attribute 可以用来定义额外的插槽,一个不带 name 的  出口将会作为默认插槽(类比于default;

2、特殊的display值;

slot的默认display值为 contents ,意味这没有任何的布局效果(无法设置宽高、背景色等,但是可以设置字体相关,将该元素移除DOM树对布局也不会有影响;

image.png

3、特殊的HTML结构;

在视觉上,插槽元素是替换了 slot 的占位,但是两者在HTML结构中的位置并没有发生变化;

也就是插槽元素仍旧是存在于 shadowDOM 之外的,无法通过 this.shadowRoot.querySelector 匹配到(可以通过 this.querySelector

在 shadowDOM 中可以对 slot元素 上添加事件,这和在插槽上添加是一样的效果,不过一个是内部控制,一个是外部控制;

image.png

4、如果获取到 slot DOM 实例?

通过 slotchange 事件确定slot挂载的时机,建议通过 关联元素(item元素)、添加特殊标识(添加特殊属性值) 的办法,然后用 this.querySelector 获取到slot节点;

this.shadowRoot.addEventListener('slotchange', () => {
  let nodes = this.querySelectorAll('elmation-delay-list-item');
});

1.4 元素样式

1、样式隔离

前面提到,可以通过 :host 设置根元素样式;

自定义元素内的样式(#shadow-root内),将不会影响到外部元素,外部的样式同样无法影响到内部;(样式隔离性

也就是说,在 :hostslot插槽内节点 的样式,仍旧是可以被外部覆盖的;

image.png

2、样式覆盖

🤯场景:需要覆盖内部的元素样式呢?

除了通过 :host 和 slot,还可以使用 var变量;

内部JS

shadowRoot.innerHTML = `
  <style>
    button {
      background: var(--background, #000);
    }
  </style>
  <div>Sup</div>
`;

外部CSS

component {
  --background: #fff;
}

或者

component.style.setProperty('--x', '100px');

缺点:无法随心所欲的覆盖 shadowDOM 元素中的样式,存在一定的局限性;