组件封装是一个前端工程师进阶的必经之路。组件封装是指Web页面上抽出来一个个包含模版(HTML)、功能(Javascript)和样式(CSS)的单元。所以,今天的内容,我们将带你了解组件封装的开发思路,让你的组件具备封装性、正确性、扩展性和复用性。
我们以实现一个首页轮播图的UI组件为例,这个组件的效果如下图所示:
上图中的组件实现了3个功能:
四张图片循环播放,每张图片停留若干时间;
当用户点击左右两边的小箭头时,图片分别切换到上一张/下一张;
当用户点击底部的小圆点的时候,则立即跳到小圆点顺序所对应的那张图片。
下面,我们就带你一步步实现这个组件:
第一步:确定UI组件的HTML结构。
根据效果图,这个组件包含了4张图片,4张图片就需要有4个HTML元素来封装。你可以采用4个块级元素来安排,比如div元素等。我们也可以将这4张图看作是一个图片列表,使用列表元素作为图片的容器:
<div class="slider">
<ul>
<li class="slider__item--selected">
<img src="https://p5.ssl.qhimg.com/t0119c74624763dd070.png"/>
li>
<li class="slider__item">
<img src="https://p4.ssl.qhimg.com/t01adbe3351db853eb3.jpg"/>
li>
<li class="slider__item">
<img src="https://p2.ssl.qhimg.com/t01645cd5ba0c3b60cb.jpg"/>
li>
<li class="slider__item">
<img src="https://p4.ssl.qhimg.com/t01331ac159b58f5478.jpg"/>
li>
ul>
<a class="slider__next">a>
<a class="slider__previous">a>
<div class="slider__control">
<span class="slider__control-buttons--selected">span>
<span class="slider__control-buttons">span>
<span class="slider__control-buttons">span>
<span class="slider__control-buttons">span>
div>
div>
上面的HTML结构中,首先是一个大的容器,div.slider,其中包含一个ul列表,列表中是包含四张图片的四个li元素。
这里,我们使用两个元素分别表示“下一张“和”上一张“的控制
<a class="slider__next">a>
<a class="slider__previous">a>
用四个元素表示底部的四个小圆点的控制:
<div class="slider__control">
<span class="slider__control-buttons--selected">span>
<span class="slider__control-buttons">span>
<span class="slider__control-buttons">span>
<span class="slider__control-buttons">span>
div>
当然这些控制你也可以使用其他的HTML元素来表示。
?注意:这里我们使用的CSS规则名有点特别,如果你是第一次见到可能会觉得有些奇怪。实际上这里的命名是一种CSS书写规范,叫做BEM,是英文Block-Element-Modifier的简写。
这一规范采用三个部分来描述规则,首先是Block表示组件名,这个任务是写轮播图,我们给这个组件起名字叫slider。然后是Element,比如对应的列表项li元素,表示item,所以它的class就是slider__item,这里Block和Element之间使用双下划线__连接。最后是Modifier表示状态,其中一个列表的状态是selected,所以最终的class是slider__item--selected,这里Element和Modifier之间使用双横杠--连接。
在比较复杂的UI组件中,使用BEM有几个好处:
让CSS规则保持相对简单,只用一个class就能定位对应的元素,这样优先级也相对扁平,管理起来不容易冲突。
阅读代码的人一眼可以知道一个元素是哪个组件的哪个部分。由于组件复杂的时候,HTML代码比较长,可能元素离组件容器比较远,如果使用普通层级关系,你看到一个.item元素,除非找到外层的.slider,你才能知道它属于.slider组件而不是其他组件的.item,那样在HTML代码很复杂的时候找起来就比较费劲。
第二步:设置元素的样式
然后根据效果图,我们给这段HTML代码添加CSS样式。
首先,我们给class=slider的div元素设置了宽度和高度,以及取消ul元素默认的列表样式:
.slider {
position: relative;
width: 790px;
height: 340px;
}
.slider ul {
list-style-type:none;
position: relative;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}
然后,将class=slider__item和class=slider__item--selected的li元素的position属性设置为绝对定位(absolute),这样就能够将这4张图片重叠显示在同一个位置。如下代码所示:
.slider__item,
.slider__item--selected {
position: absolute;
transition: opacity 1s;
opacity: 0;
text-align: center;
}
.slider__item--selected {
transition: opacity 1s;
opacity: 1;
}
其中,transition: opacity 1s表示设置图片透明度变化的动画,时间为1秒。状态为slider__item时,显示为透明。状态为slider__item--selected时显示为不透明。
接着是控制元素的样式:
.slider__next,
.slider__previous{
display: inline-block;
position: absolute;
top: 50%; /*定位在录播图组件的纵向中间的位置*/
margin-top: -25px;
width: 30px;
height:50px;
text-align: center;
font-size: 24px;
line-height: 50px;
overflow: hidden;
border: none;
color: white;
background: rgba(0,0,0,0.2); /*设置为半透明*/
cursor: pointer; /*设置鼠标移动到这个元素时显示为手指状*/
opacity: 0; /*初始状态为透明*/
transition: opacity .5s; /*设置透明度变化的动画,时间为.5秒*/
}
.slider__previous {
left: 0; /*定位在slider元素的最左边*/
}
.slider__next {
right: 0; /*定位在slider元素的最右边*/
}
.slider:hover .slider__previous {
opacity: 1;
}
.slider:hover .slider__next {
opacity: 1;
}
.slider__previous:after {
content: ';
}
.slider__next:after {
content: '>';
}
上面的规则中,.slider__next, .slider__previous分别表示“向下一张”和“向上一张”的控制。初始状态下,这两个控制元素的背景色为半透明,字体颜色是白色。
.slider:hover .slider__previous 和 .slider:hover .slider__next这两条规则表示当鼠标悬停在class=slider的元素上时,显示左右两侧的控制元素。
最后,定义底部四个小点的样式:
.slider__control{
position: relative;
display: table; /* table 布局*/
background-color: rgba(255, 255, 255, 0.5);
padding: 5px;
border-radius: 12px;
bottom: 30px;
margin: auto;
}
.slider__control-buttons,
.slider__control-buttons--selected{
display: inline-block;
width: 15px;
height: 15px;
border-radius: 50%;/*设置为圆形*/
margin: 0 5px;
background-color: white;
cursor: pointer;
}
.slider__control-buttons--selected {
background-color: red;
}
上面的规则中,
第一条规则表示给四个小圆点设置一个灰色的背景。其中的position: relative; display: table;声明表示将它的子元素(也就是4个小圆点)采用相对定位和table布局,让它们固定显示在图片中部下方。
第二条规则设置了小圆点的大小,形状,默认情况下,小圆点的颜色(白色),以及鼠标滑入后的状态(pointer)
第三条规则表示,当选择后,小圆点的颜色变成红色。
第三步:设计API
页面的主体结构和样式完成之后,我们需要根据组件的功能,为该组件设计API。
我们回顾一下这个组件的需求:
四张图片循环播放,每张图片停留若干时间;
当用户点击左右两边的小箭头时,图片分别切换到上一张/下一张;
当用户点击中下部的小圆点的时候,则立即跳到小圆点顺序所对应的那张图片。
根据上述的需求呢,我们设计了4个组件API:
slideTo(idx) - 切换显示idx指示位置的图片
slideNext() - 切换到下一张图
slidePrevious() - 切换到上一张图
getSelectedItem() - 获取选中的图片
getSelectedItemIndex() - 获取选中的图片的位置
然后,将这个组件封装为一个类——slider:
class Slider {
constructor({container}) {
this.container = container;
this.items = Array.from(container.querySelectorAll('.slider__item, .slider__item--selected'));
}
/*
通过选择器`.slider__item--selected`获得被选中的元素
*/
getSelectedItem() {
const selected = this.container.querySelector('.slider__item--selected');
return selected;
}
/*
返回选中的元素在items数组中的位置。
*/
getSelectedItemIndex() {
return this.items.indexOf(this.getSelectedItem());
}
slideTo(idx) {
const selected = this.getSelectedItem();
if(selected) { // 将之前选择的图片标记为普通状态
selected.className = 'slider__item';
}
const item = this.items[idx];
if(item) { // 将当前选中的图片标记为选中状态
item.className = 'slider__item--selected';
}
}
/*
将下一张图片标记为选中状态
*/
slideNext() {
const currentIdx = this.getSelectedItemIndex();
const nextIdx = (currentIdx + 1) % this.items.length;
this.slideTo(nextIdx);
}
/*
将上一张图片标记为选中状态
*/
slidePrevious() {
const currentIdx = this.getSelectedItemIndex();
const previousIdx = (this.items.length + currentIdx - 1) % this.items.length;
this.slideTo(previousIdx);
}
}
上面的代码中:Slider的构造器中的参数{container}表示放置这4张图片的父容器。在构造器中,我们获取了这个父容器下所有的元素。
然后,通过setInterval方法实现循环播放,间隔为3秒:
const container = document.querySelector('.slider');
const slider = new Slider({container});
setInterval(() => {
slider.slideNext();
}, 3000);
这样,我们的轮播图就以每3秒的切换一次频率动起来了。
第四步:实现用户控制
实现了组件的API后,我们还需要实现用户控制功能:
当用户点击左右两边的小箭头时,图片分别切换到上一张/下一张,并点亮与该图片相对应的小圆点;
当用户鼠标移进到底部小圆点时,则立即跳到小圆点顺序所对应的那张图片,停止轮播;
当用户鼠标移出底部小圆点后,图片再次恢复轮播。
我们将构造器修改为下面这样:
constructor({container, cycle = 3000} = {}) {
this.container = container;
this.items = Array.from(container.querySelectorAll('.slider__item, .slider__item--selected'));
this.cycle = cycle;
const controller = this.container.querySelector('.slider__control');
const buttons = controller.querySelectorAll('.slider__control-buttons, .slider__control-buttons--selected');
controller.addEventListener('mouseover', (evt) => {
const idx = Array.from(buttons).indexOf(evt.target);
if(idx >= 0) {
this.slideTo(idx);
this.stop();
}
});
controller.addEventListener('mouseout', (evt) => {
this.start();
});
/*
注册slide事件,将选中的图片和小圆点设置为selected状态
*/
this.container.addEventListener('slide', (evt) => {
const idx = evt.detail.index;
const selected = controller.querySelector('.slider__control-buttons--selected');
if(selected) selected.className = 'slider__control-buttons';
buttons[idx].className = 'slider__control-buttons--selected';
});
const previous = this.container.querySelector('.slider__previous');
previous.addEventListener('click', (evt) => {
this.stop();
this.slidePrevious();
this.start();
evt.preventDefault();
});
const next = this.container.querySelector('.slider__next');
next.addEventListener('click', (evt) => {
this.stop();
this.slideNext();
this.start();
evt.preventDefault();
});
}
如上代码所示,参数cycle表示循环播放的时间间隔,默认为3秒。然后是每个控制元素相应的事件处理。下面,我们来依次分析一下它们。
第一个用户事件:当鼠标移入或移出小圆点事件
const controller = this.container.querySelector('.slider__control');
const buttons = controller.querySelectorAll('.slider__control-buttons, .slider__control-buttons--selected');
controller.addEventListener('mouseover', (evt) =>{
const idx = Array.from(buttons).indexOf(evt.target);
if(idx >= 0){
this.slideTo(idx);
this.stop(); // 停止自动循环播放
}
});
controller.addEventListener('mouseout', (evt) => {
this.start(); // 开始自动循环播放
});
上面代码表示:当鼠标移动到小圆点上方的时候(mouseover),判断当前选中的是第几个小圆点,停止自动循环播放功能,然后切换到对应的图片。当鼠标移出controller元素后(mouseout),重启自动循环播放功能。
这里呢,我们先来看看stop()和start()的实现:
start() {
this.stop();
this._timer = setInterval(() => this.slideNext(), this.cycle);
}
stop() {
clearInterval(this._timer);
}
如上代码所示:start()方法启动了一个定时器,每隔cycle秒执行一次slideNext()。stop()则是停止这个定时器。
第二个用户事件:点击上一张或下一张事件
const previous = this.container.querySelector('.slider__previous');
previous.addEventListener('click', evt => {
this.stop();
this.slidePrevious();
this.start();
evt.preventDefault();
});
const next = this.container.querySelector('.slider__next');
next.addEventListener('click', evt => {
this.stop();
this.slideNext();
this.start();
evt.preventDefault();
});
如上代码所示:当用户点击上一张时,程序停止定时器,然后执行slidePrevious()方法,让图片向前翻一张,然后重启定时器。类似的,当用户点击下一张时,先停止定时器,然后向后翻一张,再重启定时器。
第三个是处理一个自定义事件 —— slide事件:
this.container.addEventListener('slide', evt => {
const idx = evt.detail.index
const selected = controller.querySelector('.slider__control-buttons--selected');
if(selected) selected.className = 'slider__control-buttons';
buttons[idx].className = 'slider__control-buttons--selected';
});
对应地,修改slideTo方法,加入自定义事件触发。
slideTo(idx) {
const selected = this.getSelectedItem();
if(selected) {
selected.className = 'slider__item';
}
const item = this.items[idx];
if(item) {
item.className = 'slider__item--selected';
}
const detail = {index: idx};
const event = new CustomEvent('slide', {bubbles: true, detail});
this.container.dispatchEvent(event);
}
这个自定义事件(CustomEvent),它的作用是让底部小圆点控件监听slideTo方法。当slideTo方法执行后,这个方法就会分发一次slide事件,然后在这个事件中,更新底部小圆点的状态,让小圆点的状态和各自的图片状态对应起来。
最后将调用过程改成:
const slider = new Slider({container});
slider.start();
到此,这个组件的全部功能就完成了。
通过轮播组件编写过程,我们可以总结一下组件设计的一般性步骤:
设计HTML结构
设计组件的API
设计用户控制流程
从上面的代码中,我们可以看到这个轮播组件实现了封装性和正确性,但是缺少了可扩展性。这个组件只能满足自身的使用,它的实现代码很难扩展到其他的组件,当有功能变化时,也需要修改其自身内部的代码。
比如产品经理因为某种原因,希望将图片下方的小圆点暂时去掉,只保留左右箭头。那么在这个版本中,就需要这么做:
注释掉HTML中.slider__control相关的代码
修改Slider组件,注释掉与小圆点控制相关的代码
又或者,将来需要为这个组件添加新的用户控制,都需要对这个组件进行再修改。
那么,如何可以避免这样的修改,让组件具备可扩展性呢?
关于组件封装,你是否心中有其他想法,欢迎留言讨论。也可以关注小程序【君喻学堂】参与课程《前端进阶十日谈》,来一步步完善我们的组件,直到完成一个小而美的UI组件框架。