Hello

咱们好,我是田同学,咱们能够加我微信 VirgoTyh 一同一起学习。

前言

我是 vue3 开源组件库 fighting-design 的保护者。

最近我正在研讨 Web Components 的组件库,由于现在社区还没老练,各种例子也少,网上的一些其它文章都太过于简略,优化太差,仅仅是完成根底。许多的 web components 库也是运用了第三方的支持。可是为了搞清楚其中的原理,仍是直接来手写一波原生比较好,所以近期踩尽了坑,翻遍了 MDN,也撕了一些第三方库的源码,用了两天时间,从无到有总结了以下经验,前来分享一波~

什么是 Web Components?

Web Components 其实便是一套组件库。

咱们平时在运用 vue 或者 react 的时分,关于不同的结构,就需求运用结构所支持的组件库来进行开发,许多团队都会别离开发 vue 和 react 两套组件库。可是这样保护本钱的很高的,更何况结构还不仅仅是这两个。

Web components 便是为了解决这一痛点,建立在 Web 标准之上的下一代的 UI 组件库。也便是说,开发了这一套组件,不论在任何的结构中都能够运用。关于前端来说,任何的结构,最终都会被打包成 html、css、js,web components 便是根据原生 js 来完成的一套可适配全结构的组件库。

更多概况可参阅 MDN 的 MDN Web_Components。

第一步

开发的第一步,要先了解一下 web components 是怎么完成的,它根据以下几个部分:

  • Custom elements(自界说元素):原生 js 供给了自界说元素的办法
  • Shadow DOM(影子节点):也便是自己封装的组件,它是一个特别的 dom 节点,和外部 dom 的完全阻隔的
  • HTML templates(HTML 模板):也便是组件的 dom 结构
  • adoptedStyleSheets(采用的款式表):针对 Shadow DOM 的 css 款式处理

下面别离来介绍一些各个部分的细节,本文将以一个按钮组件来进行演示

⚓ 根底完成

  1. 首要新建一个 index.htmlindex.js,让在 html 中引入 js 文件
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
  </head>
  <body>
    <script src="./index.js"></script>
  </body>
</html>
  1. 接下来开端 js 部分。首选需求新建一个类,类名便是你要烘托出来的标签名,它需求继承至浏览器原生的 HTMLElement,然后在 constructor 中需求创立一个 attachShadow,并传递一个目标 { mode: 'open' },就会得到一个影子节点
class FButton extends HTMLElement {
  constructor() {
    super()
    const shadowRoot = this.attachShadow({ mode: 'open' })
  }
}
  1. 有了影子节点,就需求即将完成的组件、款式、插槽增加进去了,这儿直接运用 innerHTML 简略粗暴的完成:

留意,在 dom 中要预留出供给 button 内容的插槽,原生 slot 元素可参阅 slot

class FButton extends HTMLElement {
  constructor() {
    super()
    const shadowRoot = this.attachShadow({ mode: 'open' })
    shadowRoot.innerHTML = `
      <style>
        .f-button {
          display: inline-flex;
          width: 100px;
          height: 35px;
          background: rgb(45, 90, 241);
          color: rgb(255, 255, 255);
          border: none;
          outline: none;
          cursor: pointer;
          justify-content: center;
          align-items: center;
        }
      </style>
      <button class="f-button">
        <slot></slot>
      </button>
    `
  }
}
  1. 最终运用 CustomElementRegistry.define() 办法界说了一个自界说元素,即可完成一个简略的 web components

customElements.define 办法接收两个参数:标签名(有必要是以小写字母,有必要写一个短横线连接)和自界说元素构造器

customElements.define('f-button', FButton)
  1. 完好代码

js 部分:

class FButton extends HTMLElement {
  constructor() {
    super()
    const shadowRoot = this.attachShadow({ mode: 'open' })
    shadowRoot.innerHTML = `
      <style>
        .f-button {
          display: inline-flex;
          width: 100px;
          height: 35px;
          background: rgb(45, 90, 241);
          color: rgb(255, 255, 255);
          border: none;
          outline: none;
          cursor: pointer;
          justify-content: center;
          align-items: center;
        }
      </style>
      <button class="f-button">
        <slot></slot>
      </button>
    `
  }
}
customElements.define('f-button', FButton)

html 部分:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Document</title>
  </head>
  <body>
    <f-button>主要按钮</f-button>
    <script src="./index.js"></script>
  </body>
</html>

现在存在的问题

上面代码已经完成了根底的 web components,可是存在许多的问题,比如:

  • 假如开发了其它的组件,每次都要新建一个影子节点,每次都要设置模板和款式,这部分可封装起来
  • 外部修正不了 css 的款式,就算运用 !important 也覆盖不了影子节点 中的款式
  • innerHTML 的功能是很差的,所以运用 innerHTML 并不是一个好主意
  • dom 结构也不能运用纯字符串的办法

公共类

关于公共类的抽离,我想到的是运用一种叫 模板办法形式 的 js 规划形式,这个规划形式我是在 JavaScript 规划模与开发实践 这本书中学到的,简略的案例可参阅我的看书笔记 模板办法形式。

关于规划形式,咱们可自行学习,这个形式也不是很难,可是效果确不错,运用重写的办法,可完成父类的统一性。如后续有些特别组件不是一切函数都调用的话,也能够采用钩子函数的办法进行重写,也有相对好的拓展性。

首要新建一个 RenderShadow 类,继承至 HTMLElement,内部的 setupShadow 办法用来实例化影子节点,别的 csshtml 办法,需求子类进行重写,也便是说这两个办法针对不同的组件,回来值也是不一样的,可是父类需求也需求供给这个办法,一旦子类没有重写父类的办法,就会报错

class RenderShadow extends HTMLElement {
  constructor() {
    super()
    // 初始化调用 setupShadow 办法
    this.setupShadow()
  }
  // 初始化影子节点
  setupShadow() {
    const shadowRoot = this.attachShadow({ mode: 'open' })
  }
  // 处理 css
  css() {
    throw new Error('有必要重写父类 css 办法')
  }
  // 处理 html
  html() {
    throw new Error('有必要重写父类 html 办法')
  }
}

这样的话 FButton 类也需求更改了,就直接继承至 RenderShadow 公共类即可,并重写 csshtml 办法:

class FButton extends RenderShadow {
  constructor() {
    super()
  }
  css() {
    return `
      <style>
        .f-button {
          display: inline-flex;
          width: 100px;
          height: 35px;
          background: rgb(45, 90, 241);
          color: rgb(255, 255, 255);
          border: none;
          outline: none;
          cursor: pointer;
          justify-content: center;
          align-items: center;
        }
      </style>
    `
  }
  html() {
    return `
      <button class="f-button">
        <slot></slot>
      </button>
    `
  }
}

RenderShadow 类就可获取到子类重写的办法,给影子节点设置元素和款式。

可是先不要急着增加,还有更多的问题!!!

处理 CSS

现在的款式在外部的不能修正的,由于影子节点会把组件放在一个和外部完全阻隔的环境,所以不管多大的权重,都不会对内部的 dom 产生影响,就好比原生 iframe 标签一样,是一个阻隔的环境。

而且,现在在影子节点中有两个标签,一个是 style,一个是 button,这样的组件其实也是不美观的,假如款式很冗长,查看也不办法,所以上面的 css 处理办法,是不引荐的,我希望的款式增加是:外部能够自在修正,内部还不会嵌套 style 标签。

关于款式的处理,我找了许多的源码,最终在 lit 库的 css-tag.ts 文件中找到了一些要害办法,也便是 CSSStyleSheet,该办法于查看和修正当前网页的 css。

别的关于挑选器,也不能仅仅的运用 class 类名进行挑选了,针对影子节点,供给了 :host() 的伪类挑选器,处理 css 的代码如下:

class RenderShadow extends HTMLElement {
  constructor() {
    super()
    // 初始化调用 setupShadow 办法
    this.setupShadow()
  }
  // 初始化影子节点
  setupShadow() {
    // 创立影子节点
    const shadowRoot = this.attachShadow({ mode: 'open' })
    // 创立一个空的构造款式表
    const sheet = new CSSStyleSheet()
    // 将规矩应用于作业表
    sheet.replaceSync(this.css())
    // 将款式应用于影子节点
    shadowRoot.adoptedStyleSheets = [sheet]
  }
  // 处理 css
  css() {
    throw new Error('有必要重写父类 css 办法')
  }
  // ……
}
class FButton extends RenderShadow {
  constructor() {
    super()
  }
  css() {
    // 回来运用 :host 伪类的款式
    return `
      :host {
        display: inline-flex;
        width: 100px;
        height: 35px;
        background: rgb(45, 90, 241);
        color: rgb(255, 255, 255);
        border: none;
        outline: none;
        cursor: pointer;
        justify-content: center;
        align-items: center;
      }
    `
  }
  // ……
}
customElements.define('f-button', FButton)

这样的款式处理,就能够完成既隐藏了 style 标签,而且外部的款式也可进行了修正

处理 HTML

关于 HTML 的处理,直接挑选 innerHTML,关于功能、安全方面考虑,都是很差的。

所以最优的解决方案,仍是 Document.createElement(),那么假如组件内部许多的 html 节点,别离创立出来标签,再追加节点,不免有些冗余,
针对这一点,我想到了 vue3 中的虚拟 dom,这儿能够直接回来一个虚拟 dom 的树形结构,那么在真正回来运用的时分,再遍历这棵树,别离进行递归追加不就好了吗?

这儿关于手写虚拟 dom 节点,绝不是最优的解决方案,现在我先这样写,后续有想到更好的解决方案再进行更新

写这样的一个函数并不难,如下 render 函数:

const render = (obj, node) => {
  const el = document.createElement(obj.tag)
  if (obj.class) {
    el.className = obj.class
  }
  if (typeof obj.children === 'string') {
    const text = document.createTextNode(obj.children)
    el.appendChild(text)
  } else if (obj.children) {
    obj.children.forEach((item) => render(item, el))
  }
  node.appendChild(el)
}

针关于按钮组件的 dom 结构,就能够传入一个这样的目标:

const btn = {
  tag: 'button',
  children: [{ tag: 'slot' }]
}

但其实呢,dom 结构还能够更简化些,直接仅仅烘托个 slot 就好了:

const btn = {
  tag: 'slot'
}

这样一来,重写了父类的 html 办法就能够直接调用 render 函数来完成关于 dom 结构的烘托,只需求将子类重写的办法回来值,和影子节点传给 render 函数即可,完好代码如下:

// 烘托函数
const render = (obj, node) => {
  const el = document.createElement(obj.tag)
  if (obj.class) {
    el.className = obj.class
  }
  if (typeof obj.children === 'string') {
    const text = document.createTextNode(obj.children)
    el.appendChild(text)
  } else if (obj.children) {
    obj.children.forEach((item) => render(item, el))
  }
  node.appendChild(el)
}
// 烘托影子节点公共类
class RenderShadow extends HTMLElement {
  constructor() {
    super()
    // 初始化调用 setupShadow 办法
    this.setupShadow()
  }
  // 初始化影子节点
  setupShadow() {
    // 创立影子节点
    const shadowRoot = this.attachShadow({ mode: 'open' })
    // 创立一个空的构造款式表
    const sheet = new CSSStyleSheet()
    // 将规矩应用于作业表
    sheet.replaceSync(this.css())
    // 将款式应用于影子节点
    shadowRoot.adoptedStyleSheets = [sheet]
    // 烘托 html 节点
    render(this.html(), shadowRoot)
  }
  // 处理 css
  css() {
    throw new Error('有必要重写父类 css 办法')
  }
  // 处理 html
  html() {
    throw new Error('有必要重写父类 html 办法')
  }
}
// 自界说按钮类
class FButton extends RenderShadow {
  constructor() {
    super()
  }
  css() {
    // 回来运用 :host 伪类的款式
    return `
      :host {
        display: inline-flex;
        width: 100px;
        height: 35px;
        background: rgb(45, 90, 241);
        color: rgb(255, 255, 255);
        border: none;
        outline: none;
        cursor: pointer;
        justify-content: center;
        align-items: center;
      }
    `
  }
  html() {
    return {
      tag: 'slot'
    }
  }
}
customElements.define('f-button', FButton)

这样,就将烘托影子节点公共类进行了抽离,款式和 dom 节点也有了相对友好的处理。

最终

以上一切源码,可参阅仓库 web-components

最近预备开发 Web Component 的组件库,感兴趣的同学也能够参加一波

Vue3 件库 fighting-design 也仍在更新中

联系我

增加微信请补白 Github 用户名,加老友邀请进群

  • 微信:VirgoTyh
  • Github: https://github.com/Tyh2001