Web Components 入门实战(上篇)
在前端快速发展的今天,组件贯彻着我们日常开发的方方面面,不管是针对业务封装的业务组件,还是项目中依赖的第三方基础 UI 组件(Ant Design、Element、Iviewui...),亦或是依赖的前端框架(Angular、Inferno、Preact、Vue、React、snabbdom、virtual-dom...),它们都贯彻着「组件化」的概念,「组件化」开发毅然成为前端主流开发方式,因为「组件化」开发在代码复用、提升团队效率方面有着无可比拟的优势,现在流行的 Vue、React、Angular 等等框架都是组件框架。所以毫不夸张的说 「组件化将会是前端的发展方向」。
最早在 2011 年的时候 Google 就推出了 Web Components 的概念。那时候前端还处于百废待兴的一个状态,前端甚至都没有「组件化」的概念,但是就是这个时候 Google 就已经凭明锐的嗅觉察觉到「组件化」是未来发展的趋势,所以 Google 一直在推动浏览器的原生组件的发展,即 Web Components API。
相比第三方框架,原生组件简单直接,符合直觉,不用加载任何外部模块,代码量小。目前,它还在不断发展,但已经可用于生产环境。
Web Components API
内容比较多,本文并不会全面的实战演示,只会做简单演示,带大家入门看看怎么用它开发组件。本文将教您如何构建第一个 Web 组件
。
近年来,Web组件
-- Custom Elements
已成为多个浏览器的标准API,允许开发人员将 HTML
、CSS
和JavaScript
进行封装成为可重用组件,注意我们并不需要依赖如 React、Angular 或者 Vue 的第三方框架。例如,假设我们需要实现一个类似这样的下拉组件:
<web-dropdown
label="Dropdown"
option="option"
options='[]'
></my-dropdown>
Web Components 入门实战,分为上下两篇,通过上下两篇,我们将通过从头开始一步一步实现下拉组件,并在应用程序中使用它。
实现一个按钮
首先下拉菜单需要一个触发对象
,这个触发对象可以是一个文本也可以是一个按钮,我们实现一个按钮来充当下拉菜单的触发对象。让我们一步一步来完成所有的事情。
第一步
首先利用 template
来写一个按钮的代码片段。
template
: 本质是一种用于保存客户端内容机制,该内容在加载页面时不会呈现,但随后可以在运行时使用 JavaScript 实例化。当我们必须在网页上重复使用相同的标记结构时,使用某种模板而不是一遍又一遍地重复相同的结构是有意义的。
<template id="my-button">
<style>
.container {
padding: 8px;
}
button {
display: inline-block;
line-height: 1;
white-space: nowrap;
cursor: pointer;
background: #fff;
border: 1px solid #dcdfe6;
color: #606266;
-webkit-appearance: none;
text-align: center;
box-sizing: border-box;
outline: none;
margin: 0;
transition: .1s;
font-weight: 500;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
padding: 12px 20px;
font-size: 14px;
border-radius: 4px;
color: #fff;
background-color: #409eff;
border-color: #409eff;
}
</style>
<div class="container">
<button>default text</button>
</div>
</template>
第二步
然后注册一个按钮的类,通过 Element.attachShadow()
方法给指定的元素
挂载一个 Shadow DOM
,并且返回对 ShadowRoot
的引用。
创建按钮类时这里我们继承 HTMLElement ,使用独立元素,使用 HTML 可以直接定义的标签。当然你也可以使用继承元素,继承 HTMLParagraphElement。
class Button extends HTMLElement {
constructor() {
super();
// 返回对 ShadowRoot 的引用
this._shadowRoot = this.attachShadow({ mode: 'open' });
this._shadowRoot.appendChild(template.content.cloneNode(true));
}
}
这里需要注意,并不是每一种类型的元素都可以附加到 Shadow Root
上。出于安全的考虑,一些元素不能使用 Shadow DOM
。这里列举一些可以挂载Shadow Root
的元素:
- 任何带有
有效的名称
且可独立存在的(autonomous)自定义元素 - article
- aside
- blockquote
- body
- div
- footer
- h1 ~h6
- header
- main
- nav
- p
- section
- span
当我们Element.attachShadow()
方法时,可以给它传递两个参数。
一个是 mode
,表示模式。模式有两种状态:
- open shadow root:元素可以从 js 外部访问根节点
this.attachShadow({ mode: 'open' }); // 返回一个 ShadowRoot 对象
- closed 拒绝从 js 外部访问关闭的 shadow root 节点
this.attachShadow({ mode: 'closed' }); // 返回一个 null
另一个参数是 delegatesFocus
,表示焦点委托。
当设置为 true 时,指定减轻自定义元素的聚焦性能问题行为。 当 shadow DOM 中不可聚焦的部分被点击时,让第一个可聚焦的部分成为焦点,并且 shadow host(影子主机)将提供所有可用的 :focus 样式。
第三步
接着创建 Custom Elements(自定义元素)
。通过在窗口上定义自定义元素,将其定义为我们的 HTML 的有效元素。而第一个参数是我们的可重用自定义元素的名称,如 HTML——它必须有一个连字符——第二个参数是我们的自定义元素的定义,包括渲染的模板。
window.customElements.define('my-button', Button);
第四步
最后就可以在 HTML 中的某处使用我们新的自定义元素<my-button></my-button>
。
请注意,自定义元素不能/不应用作自闭合标签。
<my-button></my-button>
完整代码如下:
<template id="my-button">
<style>
.container {
padding: 8px;
}
button {
display: inline-block;
line-height: 1;
white-space: nowrap;
cursor: pointer;
background: #fff;
border: 1px solid #dcdfe6;
color: #606266;
-webkit-appearance: none;
text-align: center;
box-sizing: border-box;
outline: none;
margin: 0;
transition: .1s;
font-weight: 500;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
padding: 12px 20px;
font-size: 14px;
border-radius: 4px;
color: #fff;
background-color: #409eff;
border-color: #409eff;
}
</style>
<div class="container">
<button>default text</button>
</div>
</template>
<my-button></my-button>
<script>
let template = document.getElementById('my-button');
class Button extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: 'open' });
this._shadowRoot.appendChild(template.content.cloneNode(true));
}
}
window.customElements.define('my-button', Button);
</script>
到这里我们就实现了一个下拉菜单的触达对象
,一个自定义元素的按钮。但是这个自定义元素除了拥有自己的样式和结构,我们没有做更多其他的事情。
为按钮设置属性
将属性传递
我们封装的触发对象是一个公共的代码片段,那必然在不同的场景,按钮的默认文案是不一样的,为了让按钮可以自定义文案内容,我们需要将不同场景的文案传递给按钮。但是我们并不能直接这样使用。
<my-button>更多菜单</my-button>
因为这样并不能生效。我们需要将文案内容当做一个属性传入。如这样:
<my-button text="更多菜单"></my-button>
为了使我们自定义的按钮元素对这个新属性做出反应,我们需要观察它,并使用来自扩展 HTMLElement 类的类方法对它做一些事情。这里我们会用到钩子函数attributeChangedCallback
。
每当元素的属性变化时(当自定义元素的属性被增加、移除或更改时被调用),attributeChangedCallback
回调函数会执行。正如它的属性所示,我们可以查看属性的名称、旧值与新值,以此来对元素属性做单独的操作。
attributeChangedCallback(name, oldVal, newVal) {
this[name] = newVal;
...
}
需要注意的是,如果需要在元素属性变化后,触发attributeChangedCallback
回调函数,我们必须监听这个属性。这可以通过定义observedAttributes() get
函数来实现,observedAttributes()
函数体内包含一个 return 语句,返回一个数组,包含了需要监听的属性名称:
static get observedAttributes() {
return ['text'];
}
这里需要明确的是,observedAttributes 被定义为 static,因此它将被从类中调用,而不是实例。
通过结合 attributeChangedCallback
和 observedAttributes
我们就可以实现属性的自定义。
<my-button text="更多菜单"></my-button>
<script>
let template = document.getElementById('my-button');
class Button extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: 'open' });
this._shadowRoot.appendChild(template.content.cloneNode(true));
this.$button = this._shadowRoot.querySelector('button');
}
static get observedAttributes() {
return ['text'];
}
render() {
this.$button.innerHTML = this.text;
}
attributeChangedCallback(name, oldVal, newVal) {
this[name] = newVal;
this.render();
}
}
window.customElements.define('my-button', Button);
</script>
这样自定义元素的内容就初始化完成了,当然你也可以用户相同的方式实现其他属性。到这里我们基本就将属性传递给了自定义元素,每当属性更改时,我们都会在回调函数中将此属性设置为 Web Component 实例中的属性。
将属性映射
在上面我们通过设置自定义元素组件的属性,来动态更新元素的内容,但是我们也可以通过 get set
方法来进行属性的映射。什么意思了?
我们也可以将信息设置为元素的属性,而不是使用属性来渲染我们的按钮。通常在将对象和数组等信息分配给我们的元素时使用这种方式:
<my-button text="更多菜单"></my-button>
===>
<my-button></my-button>
const element = document.querySelector('my-button');
element.text = '更多菜单';
这种方式就和上面的方式不太一样了,使用get 方法
将属性
反映到 property
。这样一来我们确保总是获得最新的值,而不是我们自己在回调函数中分配它。然后,this.text
总是从我们的 getter 函数
返回最近的属性。然后通过元素的 setter 方法
通过将元素的属性
设置为反射的属性值
,确保将属性反射到属性。之后,我们的属性回调再次运行,因为属性已更改,因此我们恢复了渲染机制。
get text() {
return this.getAttribute('text');
}
set text(value) {
this.setAttribute('text', value);
}
完整代码如下:
<my-button></my-button>
<script>
let template = document.getElementById('my-button');
class Button extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: 'open' });
this._shadowRoot.appendChild(template.content.cloneNode(true));
this.$button = this._shadowRoot.querySelector('button');
}
static get observedAttributes() {
return ['text'];
}
get text() {
return this.getAttribute('text');
}
set text(value) {
this.setAttribute('text', value);
}
render() {
this.$button.innerHTML = this.text;
}
attributeChangedCallback(name, oldVal, newVal) {
this.render();
}
}
window.customElements.define('my-button', Button);
const element = document.querySelector('my-button');
element.text = '更多菜单';
</script>
这样一来,就将属性直接进行了映射。
通过控制台的输入,我们也可以看到整个流程的执行过程,了解到方法的触发顺序,更加深入的理解整个过程。
为按钮添加交互
注册事件
上面的步骤我们已经完成了自定义元素属性的传递和设置
。接下来我们需要为自定义元素注册事件
来对用户的操作做出响应。例如,我们可以获取按钮并向其添加事件侦听器:
// 方法1:
const element = document.querySelector('my-button');
element.addEventListener('click', () => {
// do something
console.log(1);
});
// 方法2
class Button extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: 'open' });
this._shadowRoot.appendChild(template.content.cloneNode(true));
this.$button = this._shadowRoot.querySelector('button');
this.$button.addEventListener('click', () => {
// do something
console.log(2);
});
}
}
注意:方法1和方法2都可以给元素添加事件侦听器,方法1是在元素外部,方法2是在自定义元素内部,两个方法没有太大的差别,只是方法2可以让你更好的控制自定义元素的侦听注册。
并且我们还可以将事件作为属性传入元素内部,以此来作为监听回调。
class Button extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: 'open' });
this._shadowRoot.appendChild(template.content.cloneNode(true));
this.$button = this._shadowRoot.querySelector('button');
this.$button.addEventListener('click', () => {
this.onClick('设置的点击回调被触发~~~');
});
}
}
const element = document.querySelector('my-button');
element.onClick = (value) => {
console.log(value);
}
通过这种方式,也可以将自定义元素内部的信息传递给外部。
支持自定义事件
如果还希望为组件提供自定义事件(例如 onXXX)作为 API。我们也可以手动将自定义元素的点击事件映射到 onXXX 函数上。这里需要借助dispatchEvent()
和new CustomEvent()
。
dispatchEvent() 方法会向一个指定的事件目标派发一个 Event,并以合适的顺序(同步地)调用所有受影响的 EventListener。标准事件处理规则(包括事件捕获和可选的冒泡过程)同样适用于通过手动使用 dispatchEvent() 方法派发的事件。
new CustomEvent() 方法创建一个新的 CustomEvent 对象。
class Button extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: 'open' });
this._shadowRoot.appendChild(template.content.cloneNode(true));
this.$button = this._shadowRoot.querySelector('button');
this.$button.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('onCustomClick', {
detail: '设置的点击回调被触发~~~'
}))
});
}
}
const element = document.querySelector('my-button');
element.addEventListener('onCustomClick', e => console.log(e));
element.addEventListener('onCustomClick', e => console.log(e));
不过在使用new CustomEvent()
的时候需要注意了,需要把想要传递的参数包裹在一个包含detail属性
的对象,否则传递的参数不会被挂载。
为了方便我们后续的操作,我们将这个按钮的封装,抽离到一个 js 文件中,当做一个模块。完整代码如下:
const template = document.createElement('template');
template.innerHTML = `
<style>
.container {
padding: 8px;
}
button {
display: inline-block;
line-height: 1;
white-space: nowrap;
cursor: pointer;
background: #fff;
border: 1px solid #dcdfe6;
color: #606266;
-webkit-appearance: none;
text-align: center;
box-sizing: border-box;
outline: none;
margin: 0;
transition: .1s;
font-weight: 500;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
padding: 12px 20px;
font-size: 14px;
border-radius: 4px;
color: #fff;
background-color: #409eff;
border-color: #409eff;
}
</style>
<div class="container">
<button>default text</button>
</div>
`;
class Button extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: 'open' });
this._shadowRoot.appendChild(template.content.cloneNode(true));
this.$container = this._shadowRoot.querySelector('.container');
this.$button = this._shadowRoot.querySelector('button');
this.$button.addEventListener('click', () => {
this.dispatchEvent(
new CustomEvent('onCustomClick', {
detail: '设置的点击回调被触发~~~',
})
);
});
}
get text() {
return this.getAttribute('text');
}
set text(value) {
this.setAttribute('text', value);
}
static get observedAttributes() {
return ['text'];
}
attributeChangedCallback(name, oldVal, newVal) {
this.render();
}
render() {
this.$button.innerHTML = this.text;
}
}
window.customElements.define('my-button', Button);
到这里下拉菜单的触发对象
自定义按钮就完成了。
总结
我们在从头回忆一下上面的实现过程。
- 第一步:通过
Custom Elements 和 template
实现自定义元素的样式和结构。 - 第二步:通过
attributeChangedCallback
和observedAttributes
实现属性的传递,在通过getter
和setter
实现属性的映射。 - 最后一步:响应用户操作,为自定义元素注册事件。
整个过程,不是很复杂,那么 Web Components 入门实战的(上篇)到这里就结束了。第二篇将结合已实现的自定义按钮,在此基础上,实现自定义下拉菜单,敬请期待。
参考
转载自:https://juejin.cn/post/7157955953776820254