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 款式处理
下面别离来介绍一些各个部分的细节,本文将以一个按钮组件来进行演示
⚓ 根底完成
- 首要新建一个
index.html
和index.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>
- 接下来开端 js 部分。首选需求新建一个类,类名便是你要烘托出来的标签名,它需求继承至浏览器原生的
HTMLElement
,然后在constructor
中需求创立一个 attachShadow,并传递一个目标{ mode: 'open' }
,就会得到一个影子节点
class FButton extends HTMLElement {
constructor() {
super()
const shadowRoot = this.attachShadow({ mode: 'open' })
}
}
- 有了影子节点,就需求即将完成的组件、款式、插槽增加进去了,这儿直接运用
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>
`
}
}
- 最终运用 CustomElementRegistry.define() 办法界说了一个自界说元素,即可完成一个简略的 web components
customElements.define
办法接收两个参数:标签名(有必要是以小写字母,有必要写一个短横线连接)和自界说元素构造器
customElements.define('f-button', FButton)
- 完好代码
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
办法用来实例化影子节点,别的 css
和 html
办法,需求子类进行重写,也便是说这两个办法针对不同的组件,回来值也是不一样的,可是父类需求也需求供给这个办法,一旦子类没有重写父类的办法,就会报错
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
公共类即可,并重写 css
和 html
办法:
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