Web Components 入门实战(下篇)
在上一篇实战中,我们完成了下拉菜单
自定义组件的触发对象
部分。
我们再简单回忆一下自定义组件的触发对象
自定义按钮 button
的实现过程:
- 第一步:通过
Custom Elements 和 template
实现自定义元素的样式和结构。 - 第二步:通过
attributeChangedCadllback
和observedAttributes
实现属性的传递,在通过getter
和setter
实现属性的映射。 - 最后一步:响应用户操作,为自定义元素注册事件。
自定义按钮 button
的实现整个过程,不是很复杂,这里也贴一下上篇文章的实现代码。
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);
自定义按钮元素对于下拉组件来说有点无关紧要,因为它没有实现任何特殊行为。我们也可以可以使用带有 CSS 样式的普通 HTML 按钮元素。但是,自定义按钮元素通过一个简单的示例帮助我们掌握了 Web Component 的概念。这就是为什么我会将实战的自定义按钮元素单独分为一篇,目的也就是打好基础。
这篇文章我们接着完成下拉菜单。我们想要的效果是这样的。
现在只有自定义按钮元素,所以这篇实战将下拉部分
和触发对象
结合起来,完成整个下拉菜单
。
设计
下拉菜单,功能很简单,就是将动作或菜单折叠到下拉菜单中。
整体设计
整个下拉菜单就两部分组成,一个是触发对象 button,一个是下拉的列表。我们要实现的功能也很清晰:
- click 或者 hover button,展示下拉的列表。
- 点击下拉列表选项将选中的项展示到 button 中,并关闭下拉列表。
- 下拉项目被选中将选中项数据回调到外部。
API 设计
- 展示的 label:label
- 默认选中,如果没有,默认以列表第一项展示:option
- 下拉列表数组:options。
<web-dropdown
label="下拉菜单组件"
option="1"
options='[{ "label": "Option 1", "value": 1 }, { "label": "Option 2", "value": 2 }]'
></web-dropdown>
功能设计很简单,API 也很简单,话不多说,开始来完成我们的下拉组件。
Dropdown
首先我们先搭起一个整体的架构,创建一个 Dropdown 类,这个类就是我们下拉菜单的总类,它将 button 和下拉列表包含。
import './button.js';
const template = document.createElement('template');
template.innerHTML = `
<div class="dropdown">
<span class="label">Label</span>
<my-button></my-button>
<div class="dropdown-list-container">
<ul class="dropdown-list"></ul>
</div>
</div>
`;
class Dropdown extends HTMLElement {
constructor() {
super();
this._ropdownRoot = this.attachShadow({ mode: 'open' });
this._ropdownRoot.appendChild(template.content.cloneNode(true));
this.$label = this._ropdownRoot.querySelector('.label');
this.$button = this._ropdownRoot.querySelector('my-button');
this.$dropdown = this._ropdownRoot.querySelector('.dropdown');
this.$dropdownList = this._ropdownRoot.querySelector('.dropdown-list');
}
}
window.customElements.define('road-dropdown', Dropdown);
整体来说,很简单,结构也很清楚。效果如下:
属性映射监听
接着为 Dropdwon 组件 API 属性的监听。
使用 getter 方法
将属性反映到 property。通过元素的 setter 方法
通过将元素的属性设置为反射的属性值,确保将属性反射到属性。
然后通过结合attributeChangedCallback
和 observedAttributes
实现属性的自定义监听。observedAttributes
用于定义我们简要监听的属性,attributeChangedCallback
用于属性改变之后的回调。
class Dropdown extends HTMLElement {
...
static get observedAttributes() {
return ['label', 'option', 'options'];
}
get label() {
return this.getAttribute('label');
}
set label(value) {
this.setAttribute('label', value);
}
get option() {
return this.getAttribute('option');
}
set option(value) {
this.setAttribute('option', value);
}
get options() {
return JSON.parse(this.getAttribute('options'));
}
set options(value) {
this.setAttribute('options', JSON.stringify(value));
}
attributeChangedCallback(name, oldVal, newVal) {
this.render();
}
render() {
}
}
这里需要注意的是,在 web components 中自定义元素的属性类型是对象时,需要通过 JSON 来进行传递,这样一来我们在getter 方法
和 setter 方法
中也需要进行 JSON 的转义。
现在我们在 Dropdown 类中就可以接收到组件传过来的参数了。只要我们监听的属性发生改变都能触发attributeChangedCallback
,所以我们可以在这里进行我们自定义的操作。并且我们也将我们的属性挂载到了当前的 this ,在记下来的操作我们就可以很方便的进行参数的调用和赋值了。
页面初始化
到目前为止,尚未使用所有传递的属性。我们只是用一个空的渲染方法对它们做出反应。让我们通过将它们分配给下拉和按钮元素来使用它们,我们接收到参数之后,就需要将我们的一些默认参数赋值到页面上中,比如:
- label 赋值。
- 按钮的默认文案展示。如果有 option 参数就赋值 option,如果没有就用下拉列表的第一条文案作为按钮的默认文案。
class Dropdown extends HTMLElement {
...
attributeChangedCallback(name, oldVal, newVal) {
this.render();
}
render() {
if (!Array.isArray(this.options)) {
console.warn('Options must be an array...')
return;
};
this.$label.innerHTML = this.label;
this.$button.setAttribute(
'text',
(this.options.find((item) =>
item.value === parseInt(this.option)) || (this.options && this.options[0])).label
);
}
}
渲染下拉列表
接着我们将自定义参数的中列表数据渲染成我们的 DOM 结构。操作很简单,循环列表,创建下拉元素,添加到下拉容器中。
下拉菜单从外部获取其标签作为要设置为内部 HTML 的属性,而按钮现在将任意标签设置为属性。我们稍后将根据从下拉列表中选择的选项设置此标签。此外,我们可以使用这些选项来为我们的下拉菜单呈现实际的可选项目:
class Dropdown extends HTMLElement {
...
attributeChangedCallback(name, oldVal, newVal) {
this.render();
}
render() {
...
this.options.forEach((item) => {
let option = item;
let $option = document.createElement('li');
this.$dropdownList.innerHTML = '';
$option.innerHTML = option.label;
this.$dropdownList.appendChild($option);
});
}
}
默认设置了下拉列表元素样式
display: none
。
下拉列表的 DOM 渲染完成之后,我们就要想着如何通过触发对象 button 来控制我们下拉列表的显示和影藏。
下拉列表的显示和影藏
我们想要展示下拉列表就需要结合 button。需要给 button 注册事件。我们在之前的 button 实现中,借助dispatchEvent()
和new CustomEvent()
为 web-button
实现了一个自定义事件,并对外监听。
然后我们需要在 Dropdown 组件
中来监听我们注册的这个自定义事件的触发。但是这里需要注意,在Dropdown 组件
中监听的事件名称要和 web-button
自定义的事件名称一一对应。我们这 button 中定义的事件名称是onCustomClick
。所以在 Dropdown 组件
组件要监听的事件名称也是onCustomClick
。
this.$button.addEventListener(
'onCustomClick',
(event) => {
console.log('哎呀,我被点击了...');
console.log(event);
}
);
现在已经完成了触发对象事件的监听,接着就是将我们的下拉列表展示出来。列表的显示和影藏,必须由一个状态来记录。我们定义一个状态this.open
,默认是不展示。
this.open = false;
接着在监听事件的回调中,我们来控制这个状态。我们抽离一个专用于切换显示影藏状态的方法toggleOpen
。
class Dropdown extends HTMLElement {
constructor() {
super();
...
this.open = false;
this.$button.addEventListener(
'onCustomClick',
this.toggleOpen
);
}
toggleOpen(event) {
this.open = !this.open;
}
}
只有状态的切换肯定是不够的,还需要将状态装换为能控制 DOM 显示和影藏的操作。我们这里用一个最简单的版本来控制 DOM 的显示和影藏,那就是display: none
和display: flex
。none
表示默认状态下的下拉列表影藏。flex
表示切换状态下的展示。
当我们切换到展示状态时,我们给 DOM 添加一个 class,class 就包含了列表的样式display: flex
。
template.innerHTML = `
<style>
...
.dropdown.open .dropdown-list {
display: flex;
flex-direction: column;
}
...
</style>
<div class="dropdown">
<span class="label">Label</span>
<my-button></my-button>
<div class="dropdown-list-container">
<ul class="dropdown-list"></ul>
</div>
</div>
`;
class Dropdown extends HTMLElement {
constructor() {
super();
...
this.open = false;
this.$button.addEventListener(
'onCustomClick',
this.toggleOpen
);
}
toggleOpen(event) {
this.open = !this.open;
this.open ? this.$dropdown.classList.add('open') : this.$dropdown.classList.remove('open');
}
}
这样就完成了下拉菜单中下拉列表的显示和影藏。我们在浏览器跑起来看看。
咦,报错了。 Cannot read properties of undefined (reading 'classList')
。
原来是一个典型的问题,this 指向的问题
,dom 元素进行事件绑定,不管是 dom0 级事件还是 dom2 级事件,当事件行为触发,绑定的方法执行,方法中的 this 就是当前 dom 元素本身。
<body>
<script>
document.onclick = function() {
console.log(this); // this -> document 对象
}
document.addEventListener('click', function () {
console.log(this); // this -> document 对象
});
</script>
</body>
这里点击元素时,方法中执行的 this,应该是web-button
这个 dom 元素本身。
所以我们这里修正一下当前点击切换方法中的 this 指向。
this.$button.addEventListener(
'onCustomClick',
this.toggleOpen.bind(this)
);
修正完,我们再来看看效果。
注意下拉列表中的样式是默认已经处理好的样式。我这里就不贴样式出来了。
到这里,我们可以通过单击我们的自定义按钮来打开和关闭自定义下拉列表。这是我们自定义元素的第一个真正的内部行为,它本来可以在 React 或 Angular 等框架中实现。现在,我们自己可以简单地使用这个 Web 组件并期待它的这种行为。
下拉项的默认选中
下拉列表已经能伸缩自如了。接着我们要让我们传入的 option
参数起作用,默认选中。只要选项属性与列表中的选项匹配,我们就可以在我们的渲染方法中设置一个 DOM 的新的样式类,来进行选中标记。有了这个新样式,并在下拉列表中的一个选项上动态设置样式,我们可以看到该功能确实有效:
const template = document.createElement('template');
template.innerHTML = `
<style>
.dropdown-list li.selected {
color: #66b1ff;
font-weight: 700;
z-index: 99;
}
.dropdown-list li:hover {
color: #66b1ff;
background-color: #ecf5ff;
}
.dropdown-list li:active,
.dropdown-list li:hover,
.dropdown-list li.selected {
border-right: none;
border-left: none;
border-width: 1px;
}
</style>
`;
class Dropdown extends HTMLElement {
...
render() {
...
this.options.forEach((item) => {
...
if (this.option && this.option === key) {
$option.classList.add('selected');
}
...
});
}
}
下拉项的选中更新
我们对自定义下拉列表的内部行为有效。我们可以打开和关闭它,接着我们需要通过从下拉列表中选择一个选项来设置一个新选项。
class Dropdown extends HTMLElement {
...
render() {
...
this.options.forEach((item) => {
...
$option.addEventListener('click', () => {
this.option = option.value;
this.toggleOpen();
});
...
});
}
}
下拉项选中更新通知
我们需要再次向外界提供一个 API(例如自定义事件),以通知他们有关更改的选项。因此,为每个列表项单击分派一个自定义事件,用以回调我们那个下拉项被选中了。这里还是借助dispatchEvent()
和new CustomEvent()
为每一个下拉项实现了一个自定义事件。
class Dropdown extends HTMLElement {
...
render() {
...
this.options.forEach((item) => {
...
$option.addEventListener('click', () => {
this.option = option.value;
this.toggleOpen();
this.dispatchEvent(
new CustomEvent('onOptionChange', {
detail: { detail: { ...option, option, options: this.options } }
})
);
});
...
});
}
}
最后,当使用下拉菜单作为 Web 组件时,您可以为自定义事件添加一个事件侦听器以获取有关更改的通知。
<web-dropdown
label="下拉菜单组件"
option="1"
options='[{ "label": "Option 1", "value": 1 }, { "label": "Option 2", "value": 2 }]'
></web-dropdown>
<script>
document
.querySelector('web-dropdown')
.addEventListener('onOptionChange', (event) => {
console.log(event.detail)
});
</script>
看看效果。
到这里,我们已经创建了一个完全封装的下拉组件作为具有自己的结构、样式和行为的 Web Component。这里贴一下完整的代码。
<!DOCTYPE html>
<html>
<head>
<title>Dropdown with Web Components</title>
</head>
<body>
<web-dropdown
label="下拉菜单组件"
option="1"
options='[{ "label": "Option 1", "value": 1 }, { "label": "Option 2", "value": 2 }]'
onOptionChange=
></web-dropdown>
<script>
document
.querySelector('web-dropdown')
.addEventListener('onOptionChange', (event) => {
console.log(event.detail);
});
</script>
</body>
</html>
import './button.js';
const template = document.createElement('template');
template.innerHTML = `
<style>
.dropdown {
box-sizing: border-box;
padding: 3px 8px 8px;
cursor: pointer;
}
.dropdown.open .dropdown-list {
display: flex;
flex-direction: column;
}
.label {
display: block;
margin-bottom: 5px;
font-size: 16px;
font-weight: normal;
line-height: 16px;
}
button {
width: 100%;
position: relative;
padding-right: 45px;
padding-left: 8px;
font-size: 16px;
font-weight: 600;
text-align: left;
white-space: nowrap;
}
.dropdown-list-container {
position: relative;
}
.dropdown-list {
position: absolute;
width: 100%;
display: none;
max-height: 192px;
overflow-y: auto;
margin: 4px 0 0;
padding: 0;
background-color: #ffffff;
border: 1px solid #a1a1a1;
box-shadow: 0 2px 4px 0 rgba('0,0,0', 0.05), 0 2px 8px 0 rgba('161,161,161', 0.4);
list-style: none;
}
.dropdown-list li {
display: flex;
align-items: center;
margin: 4px 0;
padding: 0 7px;
border-right: none;
border-left: none;
border-width: 0;
font-size: 16px;
flex-shrink: 0;
height: 40px;
}
.dropdown-list li:not(.selected) {
box-shadow: none;
}
.dropdown-list li.selected {
color: #66b1ff;
font-weight: 700;
z-index: 99;
}
.dropdown-list li:hover {
color: #66b1ff;
background-color: #ecf5ff;
}
.dropdown-list li:active,
.dropdown-list li:hover,
.dropdown-list li.selected {
border-right: none;
border-left: none;
border-width: 1px;
}
.dropdown-list li:focus {
border-width: 2px;
}
.dropdown-list li:disabled {
color: rgba('0,0,0', 0.6);
font-weight: 300;
}
</style>
<div class="dropdown">
<span class="label">Label</span>
<my-button></my-button>
<div class="dropdown-list-container">
<ul class="dropdown-list"></ul>
</div>
</div>
`;
class Dropdown extends HTMLElement {
constructor() {
super();
this._ropdownRoot = this.attachShadow({ mode: 'open' });
this._ropdownRoot.appendChild(template.content.cloneNode(true));
this.$label = this._ropdownRoot.querySelector('.label');
this.$button = this._ropdownRoot.querySelector('my-button');
this.$dropdown = this._ropdownRoot.querySelector('.dropdown');
this.$dropdownList = this._ropdownRoot.querySelector('.dropdown-list');
this.open = false;
this.$button.addEventListener(
'onCustomClick',
this.toggleOpen.bind(this)
);
}
toggleOpen(event) {
this.open = !this.open;
this.open
? this.$dropdown.classList.add('open') : this.$dropdown.classList.remove('open');
}
static get observedAttributes() {
return ['label', 'option', 'options'];
}
get label() {
return this.getAttribute('label');
}
set label(value) {
this.setAttribute('label', value);
}
get option() {
return this.getAttribute('option');
}
set option(value) {
this.setAttribute('option', value);
}
get options() {
return JSON.parse(this.getAttribute('options'));
}
set options(value) {
this.setAttribute('options', JSON.stringify(value));
}
attributeChangedCallback(name, oldVal, newVal) {
this.render();
}
render() {
if (!Array.isArray(this.options)) {
console.warn('Options must be an array...');
return;
}
this.$label.innerHTML = this.label;
this.$button.setAttribute(
'text',
(
this.options.find(
(item) => item.value === parseInt(this.option)
) ||
(this.options && this.options[0])
).label
);
this.$dropdownList.innerHTML = '';
this.options.forEach((item) => {
let option = item;
let $option = document.createElement('li');
if (this.option && this.option == option.value) {
$option.classList.add('selected');
}
$option.addEventListener('click', () => {
this.option = option.value;
this.toggleOpen();
this.dispatchEvent(
new CustomEvent('onOptionChange', { detail: { ...option, option, options: this.options } })
);
});
$option.innerHTML = option.label;
this.$dropdownList.appendChild($option);
});
}
}
window.customElements.define('web-dropdown', Dropdown);
整个下拉组件功能比较简单,当然你可以按照本文的实战教程为这个组件提交更多的 API ,整体的设计都是一样的。
总结
通过上下两篇实战,我们从 0 ~ 1 实现了一个下拉组件。整个实战回过头来看都是基础知识的实践,没有特别难的地方。之后,我会将实现的下拉的组件提取成一个组件项目发布,在我们的项目中应用起来。
最后,我希望从这个 Web Components 教程中你可以学到了很多东西。
如果文中有什么问题或者错误,请在评论区告诉我。
如果你觉得这篇文章对你有帮助,点个赞吧。
如果你对 Web Components 感兴趣,关注我的 Web Components 专栏吧。
转载自:https://juejin.cn/post/7161816035186720781