Web Components — Web 组件

Source

前言

在重学 JavaScript 过程中,了解到了 Web 组件,而其中的一些知识点总感觉和 vuejs 中的某些概念很相似,比如 Web 组件中涉及的内容:

  • HTML 模板,即 template 标签
  • 自定义元素
  • 影子 DOM 和 slot 标签
  • 影子 DOM 实现样式隔离

那么下面就一起看看 Web 组件的这些内容和 vue 中的某些概念相似在哪吧!

Web Components

Web 组件到底是什么?

Web 组件其实就是一套用于增强 DOM 行为的工具,其内容包括 影子 DOM自定义元素HTML 模板 等.

目前 Web Components 没有得到广泛应用,主要是因为存在以下问题:

  • 没有统一的 “Web Components” 规范
  • Web 组件存在向后不兼容的版本问题
  • 浏览器实现极其不一致

由于存在这些问题,因此使用 Web 组件通常需要引入一个 Web 组件库,用于模拟浏览器中缺失的 Web 组件. 比如 PolymerLitElement,新项目更推荐使用 LitElement.

HTML 模板 —— template 标签

我们可以先思考下面的问题,并尝试给出一些解决方法。

问题:如何把对应的三个背景颜色分别为红绿蓝的 p 标签,3s 后动态渲染在指定的位置中,如 <div id="root"></div>
方案一: 使用 innerHtml

    let divRoot =  document.querySelector("#root");
    setTimeout(()=>{
    
      
        divRoot.innerHTML = `
         <p class="red">Make me red!</p> 
         <p class="blue">Make me blue!</p> 
         <p class="green">Make me green!</p> 
       `
    },3000);

方案二: 使用 createElement + createDocumentFragment + appendChild

    let divRoot =  document.querySelector("#root");
    let colors = ['red','blue','green'];
    
    let fragment =  document.createDocumentFragment();
    for (const color of colors) {
    
      
      const p = document.createElement('p');
      p.className = color;
      p.innerText = `Make me ${
      
        color}!`
      fragment.appendChild(p);
    }
    setTimeout(()=>{
    
      
        divRoot.appendChild(fragment);
     },3000);

通过 方案一 和 方案二 都能实现对应的效果,但是对于书写 HTML 结构来讲都是不友好的,首先就是它们都无法做到像直接书写 HTML 时的友好提示,其次就是它们都只适用结构非常简单的内容,一旦结构内容有多层嵌套时,单纯设计 html 结构就变得很复杂。

使用 <template> 标签

PS: <template> 标签是 HTML 中存在的,并不是 vue 中特有的,不要混淆.

在有 Web 组件之前,一直缺少基于 HTML 解析构建 DOM 子树,然后在需要时再把这个子树渲染出来的机制.

Web 组件中,可以通过使用 <template> 标签提前在页面中写出特殊标记,让浏览器自动将其解析为 DOM 子树,但跳过渲染. 如下:

<body>
  <template id="tpl">
    <p>I'm inside a custom element template!</p>
  </template>
</body>

在浏览器中通过开发者工具检查网页内容时,可以看到 <template> 标签中渲染的节点内容 是基于 DocumentFragment,而 DocumentFragment 也是批量向 HTML 中添加元素的高效工具,此时的 DocumentFragment 就像一个对应子树的最小化 document 对象,也就是说,如果需要操作 <template> 标签中节点,必须要先获取对应 DocumentFragment 的引用,即 document.querySelector('#tpl').content.

下面是通过 <template> 标签实现上面问题的解决方案:

<body>

  <div id="root"></div>

  <template id="tpl">
    <p class="red">Make me red!</p>
    <p class="blue">Make me blue!</p>
    <p class="green">Make me green!</p>
  </template>

  <script>
    let divRoot = document.querySelector("#root");
    let tpl = document.querySelector("#tpl").content;
    setTimeout(() => {
      
        
      divRoot.appendChild(tpl);
    }, 3000);
  </script>
</body>

template 模板脚本

如果在 template 标签中存在对应的 js 脚本,那么脚本执行可以推迟到将 DocumentFragment 的内容实际添加到 DOM 树,即 延迟执行 js 脚本.

直接看下面的例子:

<body>
    <div id="foo"></div>
    <template id="bar">
     <script>
         console.log('Template script executed');
     </script>
    </template>

    <script>
    const fooElement = document.querySelector('#foo');
    const barTemplate = document.querySelector('#bar');
    const barFragment = barTemplate.content;

    console.log('About to add template');// 1. About to add template
    fooElement.appendChild(barFragment);//2. Template script executed
    console.log('Added template');// 3. Added template
    </script>
</body>

影子 DOM —— shadow DOM

首先来看下面的问题,然后思考一下:

如何给 HTML 中众多相似的结构去渲染不同的样式呢?
通常情况下,为了给每个子树应用唯一的样式,又不使用 style 属性,就需要给每个子树添加一个唯一的类名,然后通过相应的选择符为它们添加样式。

存在的问题:

  • 必须通过 唯一样式选择器 决定渲染对应的样式渲染
  • 样式全部作用于顶级 DOM 树中,即使当前展示的内容需要使用很少的样式
  • 没有真正实现 CSS 样式的隔离,很容易因为书写问题导致 样式冲突

理想情况下,应该能够把 CSS 限制在使用它们的 DOM 上。

影子 DOM 是什么?

通过影子 DOM 就可以将一个 完整的 DOM 树 作为节点添加到 父 DOM 树

即可以实现 DOM 封装,意味着 CSS 样式和 CSS 选择符可以限制在影子 DOM 子树中,而不是作用于整个顶级 DOM 树。

创建影子 DOM

影子 DOM 是通过 attachShadow() 方法创建并添加给有效 HTML 元素的:

  • 影子宿主(shadow host),即容纳影子 DOM 的元素
  • 影子根(shadow root),即影子 DOM 的根节点
  • attachShadow() 方法需要一个 shadowRootInit 对象,即这个对象必须包含一个 mode 属性,值为 “open” 或 “closed”
  • mode 属性值为 “open” 的影子 DOM 的引用可通过 shadowRoot 属性在 HTML 元素上获得,属性值 “closed” 影子 DOM 的引用则无法获取
document.body.innerHTML = `
    <div id="foo"></div>
    <div id="bar"></div>
`;

const foo = document.querySelector('#foo');
const bar = document.querySelector('#bar');

// 创建不同 dom 元素的影子节点,一个 dom 节点只能有一个 影子 DOM
const openShadowDOM = foo.attachShadow({
    
       mode: 'open' });
const closedShadowDOM = bar.attachShadow({
    
       mode: 'closed' });

// 直接访问影子根节点
console.log(openShadowDOM); // #shadow-root (open)
console.log(closedShadowDOM); // #shadow-root (closed)

// 为影子 DOM 添加内容和样式,这里的样式是完全隔离的,并不会发生样式冲突
openShadowDOM.innerHTML = `
 <p>this is red</p>
 <style>
  p{
    background: red;
  }
 </style>
`
closedShadowDOM.innerHTML = `
 <p>this is blue</p>
 <style>
  p{
    background: blue;
  }
 </style>
`

// 通过影子宿主访问影子根节点 
console.log(foo.shadowRoot); // #shadow-root (open)
console.log(bar.shadowRoot); // null

合成与影子 DOM 槽位 slot

影子 DOM 是为自定义 Web 组件设计的,为此需要支持嵌套 DOM 片段,也就是说位于 影子宿主 中的 HTML 需要一种机制以渲染到影子 DOM中去,但这些 HTML 又不需要存在于影子 DOM 树中.

[ 影子 DOM 具有最高优先级 ]

正常情况下,影子 DOM 一添加到元素中,浏览器就会赋予它 最高优先级,优先渲染它的内容而不是原来的 dom 内容,比如下面的例子:

document.body.innerHTML = `
    <div id="foo">
      <h1>I'm foo's child</h1>
    </div>
`;
const foo = document.querySelector('#foo');
const openShadowDOM = foo.attachShadow({
    
      
  mode: 'open'
});
// 为影子 DOM 添加内容
openShadowDOM.innerHTML = `
  <p>this is openShadowDOM content</p>
`

[ <slot> 标签 ]

为了显示影子宿主中原本存在的 HTML 内容,我们需要使用 <slot> 标签指示浏览器在哪里放置原来的 HTML 内容,将上面的例子修改成如下形式:

document.body.innerHTML = `
    <div id="foo">
      <h1>I'm foo's child</h1>
    </div>
`;
const foo = document.querySelector('#foo');
const openShadowDOM = foo.attachShadow({
    
      
  mode: 'open'
});
// 为影子 DOM 添加内容
openShadowDOM.innerHTML = `
  <p>this is openShadowDOM content</p>
   <slot></slot>
`

[ 命名槽位(named slot)]

通过匹配的 slot/name 属性对实现的,比如下面的例子中,带有 slot=“foo” 属性的元素会被投射到带有 name=“foo” 的 <slot> 上.

    document.body.innerHTML = ` 
    <div> 
    <p slot="foo">Foo</p> 
    <p slot="bar">Bar</p> 
    </div> 
  `;

    let shadowDom = document.querySelector('div').attachShadow({
    
      
      mode: 'open'
    });

    shadowDom.innerHTML = ` 
    <slot name="bar"></slot> 
    <slot name="foo"></slot> 
   `;

影子 DOM 中的事件重定向

如果影子 DOM 中发生了浏览器事件(如 click),那么浏览器需要一种方式以让父 DOM 处理事件.

注意:事件重定向只会发生在影子 DOM 中实际存在的元素上,使用 <slot> 标签从外部投射进来的元素不会发生事件重定向,因为从技术上讲,这些元素仍然存在于影子 DOM 外部。

// 创建一个元素作为影子宿主
document.body.innerHTML = `
 <div οnclick="console.log('Handled outside:', event.target)"></div>
`;

// 添加影子 DOM 并向其中插入 HTML
document.querySelector('div')
.attachShadow({
    
       mode: 'open' })
.innerHTML = `
 <button οnclick="console.log('Handled inside:', event.target)">Foo</button>
`;

// 点击按钮时:
// Handled inside: <button οnclick="..."></button>
// Handled outside: <div οnclick="..."></div>

创建影子 DOM 的限制

考虑到安全及避免影子 DOM 冲突,并非所有元素都可以包含影子 DOM,尝试给无效元素或者已经有了影子 DOM 的元素添加影子 DOM 会导致抛出错误。

以下是可以容纳影子 DOM 的元素:

<任何以有效名称创建的自定义元素>
<article>
<aside>
<blockquote>
<body>
<div>
<footer>
<h1>
<h2>
<h3>
<h4>
<h5>
<h6>
<header>
<main>
<nav>
<p>
<section>
<span>

自定义元素

自定义元素为 HTML 元素引入了面向对象编程的风格,基于这种风格,可以创建自定义的、复杂的和可重用的元素,而且只要使用简单的 HTML 标签或属性就可以创建相应的实例。

创建自定义元素

浏览器会尝试将无法识别的元素作为通用元素整合进 DOM,如下面的例子所示.

自定义元素在此基础上更进一步,利用自定义元素,可以在 <x-box> 标签出现时为它定义复杂的行为,同样也可以在 DOM 中将其纳入元素生命周期管理。

document.body.innerHTML = ` 
 <x-box>I'm inside a custom element.</x-box> 
`;
console.log(document.querySelector('x-box') instanceof HTMLElement); // true

customElements.define()

调用 customElements.define() 方法可以创建自定义元素,自定义元素的强大之处在于类定义.

class BoxElement extends HTMLElement {
    
      
    constructor() {
    
      
        super();
        console.log('x-box')
    }
}

customElements.define('x-box', BoxElement);
document.body.innerHTML = `
    <x-box></x-box>
    <x-box></x-box>
    <x-box></x-box>
`;

// x-box
// x-box
// x-box

is 和 extents

如果自定义元素继承了一个元素类,那么可以使用 is 属性和 extends 选项将标签指定为该自定义元素的实例.

class BoxElement extends HTMLDivElement {
    
      
    constructor() {
    
      
        super();
        console.log('x-box')
    }
}

customElements.define('x-box', BoxElement, {
    
       extends: 'div' });
document.body.innerHTML = `
    <div is="x-box"></div>
    <div is="x-box"></div>
    <div is="x-box"></div>
`;

// x-box
// x-box
// x-box

添加自定义元素内容

class BoxElement extends HTMLElement {
    
      
  constructor() {
    
      
    super();
     // 方式一
     this.innerHTML = `<div>this is content1!</div>`;
     // 方式二
     let div = document.createElement('div');
     div.innerText = `this is content2!`;
     this.appendChild(div);
     // 方式三
     this.attachShadow({
    
       mode: 'open' });
     this.shadowRoot.innerHTML = `
      <p>I'm inside a custom element!</p>
     `;
  }
}
customElements.define('x-box', BoxElement);
document.body.innerHTML += `<x-box></x-box`;

使用自定义元素生命周期方法

自定义元素有以下 5 个生命周期方法:

  • constructor():在创建元素实例或将已有 DOM 元素升级为自定义元素时调用
  • connectedCallback():在每次将这个自定义元素实例添加到 DOM 中时调用
  • disconnectedCallback():在每次将这个自定义元素实例从 DOM 中移除时调用
  • attributeChangedCallback():在每次可观察属性的值发生变化时调用,在元素实例初始化时,初始值的定义也算一次变化
  • adoptedCallback():在通过 document.adoptNode()将这个自定义元素实例移动到新文档
class BoxElement extends HTMLElement {
    
      
  constructor() {
    
      
    super();
    console.log('ctor');
  }
  connectedCallback() {
    
      
    console.log('connected');
  }
  disconnectedCallback() {
    
      
    console.log('disconnected');
  }
}
customElements.define('x-box', BoxElement);
const BoxElement = document.createElement('x-box');

// ctor 
document.body.appendChild(BoxElement);
// connected 
document.body.removeChild(BoxElement);
// disconnected

升级自定义元素

并非始终可以先定义自定义元素,然后再在 DOM 中使用相应的元素标签。可以通过 Web 组件在 CustomElementRegistry 上的一些方法,用来检测自定义元素是否定义完成,然后可以用它来升级已有元素。

  • 如果自定义元素已经有定义,那么 CustomElementRegistry.get() 方法会返回相应自定义元素的类
  • CustomElementRegistry.whenDefined() 方法会返回一个期约,当相应自定义元素有定义之后解决
customElements.whenDefined('x-box').then(() => console.log('defined!'));

console.log(customElements.get('x-box')); // undefined 

customElements.define('x-box', class {
    
      });// defined! 

console.log(customElements.get('x-box'));// class BoxElement {}

连接到 DOM 的元素在自定义元素有定义时会自动升级,如果想在元素连接到 DOM 之前强制升级,可以使用 CustomElementRegistry.upgrade() 方法.

// 在自定义元素有定义之前会创建 HTMLUnknownElement 对象
const boxElement = document.createElement('x-box');

// 创建自定义元素
class BoxElement extends HTMLElement {
    
      }
customElements.define('x-box', BoxElement);
console.log(boxElement instanceof BoxElement); // false

// 强制升级
customElements.upgrade(boxElement);
console.log(boxElement instanceof BoxElement); // true

总结

到现在,你应该发现了 web 组件中的一些内容和 vue 中的某些内容是不是很相似,如:

vuejs

  • <template> 标签
  • <slot> 插槽
  • <style scope> 局部样式
  • vue 组件,即 .vue 文件
  • <component is="name"> 动态组件

web 组件

  • template 模板
  • 影子 DOM 中的 <slot> 插槽
  • 影子 DOM 中的 <style> 隔离样式
  • 自定义标签,即 自定义组件
  • 自定义组件中的 is 属性和 extends 选项

注意:当然相似归相似,其本质还是不同的,所以千万不要进行混淆,但是可以认为概念上是有所借鉴的,至此不难发现很多知识内容其实是相通的。