Web Components 入门分享

开篇

从我们熟知的前端框架 React、Vue 来看,「组件化」 已然成为前端主流开发模式,在代码复用、逻辑拆分、提升团队开发和维护效率等方面,有着无可比拟的优势。

Web Components 是一套可以通过原生 JS 来实现组件化的技术,即从原生层面实现组件化。它允许你创建新的 自定义、可封装HTML 标签,不用加载任何额外的模块就可以使用。概括一句话就是基于原生 JS 可实现一套可适配全框架的组件库。

优势与劣势

相较于 React、Vue 框架,Web Components 的优势在于解决 技术栈差异框架版本不同 之间的组件复用问题。

假如你所在的开发部门,一半的项目用的 Vue 技术栈,一半的项目用的 React 技术栈,会基于这两套技术栈分别搭建了 Vue UI 组件库和 React UI 组件库,尽管它们的功能几乎毫无差异,但要耗费双倍的人力成本。

再比如 新老项目 版本问题,基于 React 16 Hooks 版本封装的组件,无法在 React 15 的老项目中应用。

Web Components 原生层面实现的组件,可以顺利的接入到各个项目中,从而忽略上述两个顾虑。

但它的不足之处也显而易见:跟主流的框架相比,原生的书写较为复杂,需要开发者自己进行原生 DOM 操作,这一点没有框架语法开发上那样灵活,一定程度影响 开发体验 和 开发效率。

所以,尽管原生支持了自定义封装组件,但这并不意味着可以颠覆当前三大框架,每一个框架都有着对应的生态(路由管理、状态管理、DOM 性能优化管理等)。

最好的做法是:我们可以将框架其中的一部分进行替代,使之拥有框架提供的优势,又能避免因框架而导致的缺陷。

一、Web Components 重要组成部分

Web Components 本质是扩展全新的 HTML DOM 元素,一切 HTML 元素标准它都会具备。如何编写一个 Web Components ? 需要掌握以下知识点:

  1. Custom elements,创建自定义 Web Components 组件,可以像 HTML DOM 标签那样使用;
  2. 生命周期,类似于框架中组件的生命周期,在属性变化、或组件销毁时可以执行处理逻辑;
  3. Shadow DOM,被称为「影子 DOM」,类似于一个独立的沙盒,让组件内部的内容与外界独立,起到隔离作用。
  4. HTML Template,用于定义一组 DOM 结构,作为自定义组件的内容使用、一般会结合 <slot /> 实现内容分发。

Custom elements、Shadow DOM、HTML template,主流浏览器都已支持(不支持 IE),属于 W3C Web 标准。

二、Custom elements

window 全局对象 customElements 提供了 define() 方法来定义 Web Components。

  • 第一参数:自定义组件标签名称,如:custom-button
  • 第二参数:自定义组件的具体实现,它是一个 class 类,可以继承自一个 HTMLElement,从而拥有 HTML 属性和方法;

一个简单的 Web Components 定义如下:

class CustomButton extends HTMLElement {
  constructor() {
    // super() 的无参数调用必须是构造函数主体中的第一条语句
    super();
    // 构造函数通常用于设置 初始状态 和 默认值,以及设置事件侦听器和可能的影子根。
  }
  // DOM 操作尽量推迟放在 connectedCallback 中( mount )
  connectedCallback() {
    const button = document.createElement("button");
    button.innerText = this.getAttribute("name") || "custom button";
    this.appendChild(button);
  }
}
window.customElements.define("custom-button", CustomButton);

<custom-button name="自定义按钮" />

注意:默认自定义的元素属于 display: inline 行内元素。

除了定义元素,customElements 还提供了查找指定元素的方法:get()

window.customElements.get("custom-button");

三、生命周期

类似于框架组件的生命周期:组件从被创建到挂载到页面中运行,再到组件不用时卸载的过程。Web Components 提供了以下常用生命周期:

  • constructor,构造函数,在被 document.createElement() 创建初始化时调用,可在这里设置 初始状态 和 默认值;
  • connectedCallback,挂载函数,在组件被成功添加到主文档时触发的生命周期,发生在 constructor 之后;
  • disconnectedCallback,销毁函数,在组件被成功从主文档移除时触发。
  • attributeChangedCallback,监听属性变化的回调事件,如果是在页面使用自定义组件时设置的属性,会在 connectedCallback 之前触发,如果是组件挂载后动态设置属性值(如:ele.setAttribute()),在 connectedCallback 执行之后触发。

attributeChangedCallback 需要配合静态方法 observedAttributes 来使用,只有注册在 observedAttributes 中的属性才会被监听。

class CustomElement extends HTMLElement {
  constructor() {
    super();
    console.log('constructor!');
  }
  
  // mount(初始化属性)
  connectedCallback() {
    console.log('connectedCallback!');
  }
  
  // unmount
  disconnectedCallback() {
    console.log('disconnectedCallback!');
  }
  
  // update
  static get observedAttributes() { return ['text']; }
  attributeChangedCallback(name, oldValue, newValue) {
    console.log('attributeChangedCallback!', name, oldValue, newValue);
  }
}

window.customElements.define("custom-element", CustomElement);

setTimeout(() => {
  document.querySelector('custom-element').remove();
}, 3000);

<custom-element text="custom-element" />

输出顺序如下:

constructor!
attributeChangedCallback! text null custom-element
connectedCallback!
disconnectedCallback!

四、Shadow DOM

Shadow DOM 可以看做是一个沙箱,外部可以使用该元素,元素内部的具体结构在默认外部看不到看不到的,也不能进行访问和操作。

常见的 HTML 原生 Shadow DOM 有:<input /><video />,它们在使用时看似很简单,但却能够呈现出丰富的布局,这是因为它们使用了 Shadow DOM,对内部的元素构造部分进行了隔离和隐藏。

使用 Shadow DOM 另一方面是为了结合 <template><slot> 一起使用。

默认情况下,浏览器调试 Element 是看不到 <input /><video /> 内部结构。我们可以通过浏览器控制台 – Elements – Settings Icon – Preferences – Elements,找到并勾选 Show user agent shadow dom,来查看沙箱内部结构:

Web Components 入门分享

Web Components 入门分享

1. 使用 Shadow DOM

通过 Element.attachShadow() 方法将元素变为 Shadow DOM,它会返回 ShadowRoot 的引用。后续可通过 this.shadowRoot 访问和使用。

class CustomShadowDOM extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });

    const text = document.createElement('span');
    text.innerHTML = 'shadow dom.';
    this.shadowRoot.appendChild(text);
  }
}
customElements.define('custom-shadow-dom', CustomShadowDOM);

attachShadow 接受一个对象参数,只需关注一个配置属性 mode,设置为 open 时,可以操作 this.shadowRoot 添加子元素;如果设置为 closed,则不可操作 this.shadowRoot 添加子元素。

2. Shadow DOM CSS 选择器

Shadow DOM 提供了伪类选择器 :host 来访问自定义元素本身,使用方式如下:

constructor() {
  ...
  const style = document.createElement('style');
  style.innerHTML = `
    :host{ // 设置元素本身
      display: inline-block;
      width: 200px;
      height: 100px;
      border: 2px solid gray;
    }
    :host span{ // 设置元素的子元素
      color: lime;
    }
  `;
  this.shadowRoot.appendChild(style);
}

五、HTML Template 与 Slot

HTML 提供了 template 标签,可以作为模板来组合多个标签元素,使用如下:

constructor() {
  ...
  const template = document.createElement("template");
  template.innerHTML = `
    <style>
      span{
        color: gray;
      }
    </style>

    <div class="template-container">
      <span>text</span>
      <slot name="content"></slot>
    </div>
  `;
  this.shadowRoot.appendChild(template.content.cloneNode(true));
}

Slot 和 Vue 中的插槽作用一致,将于将内容渲染到组件内指定的位置。在 template 中我们定义了 slot name="content" 的插槽,下面我们将 p 标签渲染到该插槽下,使用如下:

<custom-shadow-dom class="custom-shadow-dom">
  <p slot="content">内容</p>
</custom-shadow-dom>

注意,template 和 slot 只有在 Shadow DOM 下 才可正常使用。

六、CSS 样式编写方式

  1. 外部使用时定义元素样式:

和使用原生 HTML 标签一样,为自定义元素设置样式。

<style>
  .custom-shadow-dom{
    display: inline-block;
    width: 200px;
    height: 100px;
    border: 2px solid gray;
  }
  .custom-shadow-dom span{ // `Shadow DOM` 元素类型将无效
    color: lime;
  }
</style>

<custom-shadow-dom class="custom-shadow-dom" />

注意,如果自定义元素属于 Shadow DOM 类型,则这种方式无法为其自元素设置样式。

  1. 内部创建 syle 标签定义样式:
constructor() {
  ...
  const style = document.createElement('style');
  style.innerHTML = `
    :host{ // 设置元素本身
      display: inline-block;
      width: 200px;
      height: 100px;
      border: 2px solid gray;
    }
    :host span{ // 设置元素的子元素
      color: lime;
    }
  `;
  this.shadowRoot.appendChild(style);
}
  1. 内部创建 css 样式文件定义样式:
constructor() {
  ...
  const link = document.createElement('link');
  link.rel = 'stylesheet';
  link.href = './custom-shadow-dom.css';
  this.shadowRoot.appendChild(link);
}

// custom-shadow-dom.css
:host{
  display: inline-block;
  width: 200px;
  height: 100px;
  border: 2px solid gray;
}
:host span{
  color: lime;
}

七、实现一个自定义组件

下面我们结合上述介绍的知识点,通过自定义组件实现业务需求:博客文章列表。效果如下图:

Web Components 入门分享

其中,第一个自定义组件 post-list 用于管理数据,每条数据的内容呈现由第二个自定义组件 blog-post 来实现。

post-list 组件中主要是请求 API 获取列表数据,遍历数据渲染 blog-post,并将 title 和 content 分别以 propslot 两种方式传递给 blog-post。这里我们通过 Promise 模拟数据请求:

class PostList extends HTMLElement {
  constructor() {
    super();
    // 1. 创建 template
    const postListTemplate = document.createElement('template');
    postListTemplate.innerHTML = `
      <style>
        .post-list {
          width: 375px;
          margin: 0 auto;
          display: grid;
          grid-template-columns: 1fr;
          gap: 48px;
        }
        .article {
          font-family: Verdana;
          font-size: 14px;
          line-height: 22px;
          color: hsl(0deg, 0%, 40%);
        }
      </style>
      <div class="post-list"></div>
    `;

    // 2. 初始化 Shadow DOM,填充模版内容
    this.attachShadow({ mode: 'open' }).appendChild(postListTemplate.content.cloneNode(true));
  }
  
  // 模拟 api 请求
  async connectedCallback() {
    const posts = await new Promise(resolve => resolve([
      { title: 'This is the first title', content: 'If our time is usefully employed, it will either turn out some useful and important piece of work which will fetch its price in the market, or it will add to our experience and increase our capacities so as to enable us to earn money when the proper opportunity comes. Let those, who think nothing of wasting time, remember this.' },
      { title: 'This is the second title', content: 'But nearly one third of this has to be spent in sleep; some years have to be spent over our meals; some in making journeys on land and voyages by sea; some in merry-making; some in watching over the sick-beds of our nearest and dearest relatives.' },
    ]));
    this.renderPosts(posts);
  }

  renderPosts(posts) {
    const postList = this.shadowRoot.querySelector('.post-list');
    posts.forEach(post => {
      const blogPostEle = postList.appendChild(document.createElement('blog-post'));
      // 标题
      blogPostEle.setAttribute('title', post.title);
      // 内容
      const article = document.createElement('div');
      article.className = '.article';
      article.slot = 'content';
      article.innerHTML = post.content;
      blogPostEle.appendChild(article);
    });
  }
}

customElements.define('post-list', PostList);

blog-post 组件中,通过监听生命周期 attributeChangedCallback 和 slot 元素事件 slotchange 来接收传递的属性,最后为 button 绑定事件来切换内容的全文展示。具体实现如下:

class BlogPost extends HTMLElement {
constructor() {
super();
// 1. 创建 template 和 slot
const blogPostTemplate = document.createElement('template');
blogPostTemplate.innerHTML = `
<style>
.blog-post-button {
padding: 12px 48px;
border: none;
margin-top: 24px;
border-radius: 4px;
cursor: pointer;
}
</style>
<div class="blog-post">
<h1 class="blog-post-title"></h1>
<slot class="blog-post-slot" name="content"></slot>
<button class="blog-post-button">查看全文</button>
</div>
`;
// 2. 初始化 Shadow DOM,填充模版内容
this.attachShadow({ mode: 'open' }).appendChild(blogPostTemplate.content.cloneNode(true));
// 3. DOM 绑定
this.titleEle = this.shadowRoot.querySelector('.blog-post-title');
this.buttonEle = this.shadowRoot.querySelector('.blog-post-button');
this.slotEle = this.shadowRoot.querySelector('.blog-post-slot');
// 4. 状态初始化
this.state = {
title: '',
content: '',
showFull: false,
article: null, // 外部传递给 slot 的元素
}
}
// 事件绑定
connectedCallback() {
this.buttonEle.addEventListener('click', this.toggleFull.bind(this));
this.slotEle.addEventListener('slotchange', this.slotChange.bind(this)); // slot 接收到外部使用插值时触发
}
// 事件销毁
disconnectedCallback() {
this.buttonEle.removeEventListener('click', this.toggleFull.bind(this));
this.slotEle.removeEventListener('slotchange', this.slotChange.bind(this));
}
// 属性监听
static get observedAttributes() {
return ['title'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'title') {
this.state.title = newValue;
this.titleEle.textContent = newValue;
}
}
slotChange() {
this.state.article = this.slotEle.assignedElements()[0]; // 获取外部传递的 slot 元素
this.content = this.state.article.innerHTML; // 第一次记录 content
this.state.article.innerHTML = this.getExcept();
}
toggleFull() {
this.state.showFull = !this.state.showFull;
if (this.state.showFull) {
this.state.article.innerHTML = this.content;
this.buttonEle.textContent = '隐藏全文';
} else {
this.state.article.innerHTML = this.getExcept();
this.buttonEle.textContent = '查看全文';
}
}
getExcept() {
return this.content.slice(0, 60) + "...";
}
}
customElements.define('blog-post', BlogPost);

最后,我们使用时只需调用 post-list 自定义组件使用即可。

<post-list />

文末

感谢读者阅读,如有不足之处,欢迎在评论区补充。

借鉴:

五分钟带你了解 Web Components
你真的了解Web Component吗?
Web Component 探索之旅

原文链接:https://juejin.cn/post/7216357824883310651 作者:明里人

(0)
上一篇 2023年3月31日 上午10:00
下一篇 2023年3月31日 上午10:37

相关推荐

发表回复

登录后才能评论