这是我参与「第四届青训营 」笔记创作活动的的第5天
写好JS的三大原则之「组件封装」
大家好,这里是改完昨天的垃圾文档之后睡了一觉刚醒的Vic,今天给大家带来写好JS的三大原则之一的组件封装。由于月影老师在这一部分的讲解较快,因此本文中的一些看法是在后续消化吸收的时候一边看PPT一边总结的,不足之处还望多多指正。
在这里也同样和课程里面一样以封装一个轮播图组件为例来一步步讲解组件封装的步骤。
轮播图实现
首先,我们先按照结构、样式、行为的顺序讲解一个基础的轮播图实现步骤。
结构--HTML
关于轮播图的结构,有基础的同学都知道是一个典型的列表结构,因此,我们在HTML中采用无序列表元素<ul>来进行实现。代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>轮播图实现</title>
</head>
<body>
<div id="my-slider" class="slider-list">
<ul>
<li class="slider-list__item--selected">
<img src="https://p5.ssl.qhimg.com/t0119c74624763dd070.png">
</li>
<li class="slider-list__item">
<img src="https://p4.ssl.qhimg.com/t01adbe3351db853eb3.jpg">
</li>
<li class="slider-list__item">
<img src="https://p2.ssl.qhimg.com/t01645cd5ba0c3b60cb.jpg">
</li>
<li class="slider-list__item">
<img src="https://p4.ssl.qhimg.com/t01331ac159b58f5478.jpg">
</li>
</ul>
</div>
</body>
</html>
这段代码的效果如下:
可以看到,这个样式和我们的预期是不符合的,因此,我们需要在CSS中进行样式的调整。我们需要调整的有:
- 设置
list-style-type属性去除列表中每项前面的小圆点; - 将所有的图片重叠在一起,并将选择的图片设在最上方显示。
样式--CSS
在上文中我们已经讲过了我们想要的样式,其实现代码如下:
#my-slider{
position: relative;
width: 790px;
}
.slider-list ul{
list-style-type:none;
position: relative;
padding: 0;
margin: 0;
}
.slider-list__item,
.slider-list__item--selected{
position: absolute;
transition: opacity 1s;
opacity: 0;
text-align: center;
}
.slider-list__item--selected{
transition: opacity 1s;
opacity: 1;
}
在这段代码中,我们采用绝对定位的方式,将<ul>中的所有<li>重叠在一起,这是由于,绝对定位会导致盒子脱离普通流。由于不是本文重点,在此不做更多介绍。
为了使选中的图片能够在前面显示,我们使用了修饰符(modifier)来表示状态,在代码中就是--selected,在其中设置opacity属性为1,使之在前面进行性展示。
之前在各司其责原则部分有讲过,状态切换使用class来进行,因此,在本文中我们同样采用更改class的方法来实现轮播图的切换。
轮播图切换动画的实现利用transition属性来实现。
行为--JS
接下来,我们开始进行JS部分的代码编写工作。由于这部分是本文基础部分的重中之重,后续的所有流程都建立在此基础上,因此进行着重分析。
在写代码前,我们首先需要明确要做的行为。我们需要实现一个轮播图类Slider,其能够实现以下行为:
- 获取当前选择的列表项--
getSelectedItem(); - 获取当前选择的列表项索引值--
getSelectedItemIndex(); - 切换至目标索引值列表项--
slideTo(); - 切换至下一项--
slideNext(); - 切换至上一项--
slidePrevious();
具体代码如下:
class Slider{
constructor(id){
// 通过id获取容器
this.container = document.getElementById(id);
// 获取所有的列表项
this.items = this.container
.querySelectorAll('.slider-list__item, .slider-list__item--selected');
}
// 获取当前选择的列表项
getSelectedItem(){
const selected = this.container
.querySelector('.slider-list__item--selected');
return selected
}
// 获取当前选择项的索引
getSelectedItemIndex(){
return Array.from(this.items).indexOf(this.getSelectedItem());
}
// 切换至,思路就是先将当前选择的取消,再通过索引,将目标项的class修改为选择
slideTo(idx){
const selected = this.getSelectedItem();
if(selected){
selected.className = 'slider-list__item';
}
const item = this.items[idx];
if(item){
item.className = 'slider-list__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);
}
}
const slider = new Slider('my-slider');
// 切换至第四张
slider.slideTo(3);
由于代码比较简单,就不做讲解了。
接下来,我们需要行为添加至组件中,这一步叫做控制流。
控制流
首先,我们需要在HTML中添加控制用的元素,如实现前后切换的小箭头以及下方控制图片显示的小圆点等。代码如下:
HTML:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>轮播图实现</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="my-slider" class="slider-list">
<ul>
<li class="slider-list__item--selected">
<img src="https://p5.ssl.qhimg.com/t0119c74624763dd070.png">
</li>
<li class="slider-list__item">
<img src="https://p4.ssl.qhimg.com/t01adbe3351db853eb3.jpg">
</li>
<li class="slider-list__item">
<img src="https://p2.ssl.qhimg.com/t01645cd5ba0c3b60cb.jpg">
</li>
<li class="slider-list__item">
<img src="https://p4.ssl.qhimg.com/t01331ac159b58f5478.jpg">
</li>
</ul>
<a class="slider-list__next"></a>
<a class="slider-list__previous"></a>
<div class="slider-list__control">
<span class="slider-list__control-buttons--selected"></span>
<span class="slider-list__control-buttons"></span>
<span class="slider-list__control-buttons"></span>
<span class="slider-list__control-buttons"></span>
</div>
</div>
<script src="./index.js"></script>
</body>
</html>
CSS:
#my-slider{
position: relative;
width: 790px;
height: 340px;
}
.slider-list ul{
list-style-type:none;
position: relative;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}
.slider-list__item,
.slider-list__item--selected{
position: absolute;
transition: opacity 1s;
opacity: 0;
text-align: center;
}
.slider-list__item--selected{
transition: opacity 1s;
opacity: 1;
}
/* 前后控制元素样式 */
.slider-list__next,
.slider-list__previous{
display: inline-block;
position: absolute;
/* 此处为了设置居中,注意需要父元素有height */
top: 50%;
width: 30px;
height: 50px;
margin-top: -25px;
/* -------------- */
text-align: center;
font-size: 24px;
line-height: 50px;
overflow: hidden;
border: none;
background: transparent;
color: white;
background: rgba(0, 0, 0, 0.2); /*设置为半透明*/
cursor: pointer;
opacity: 0; /*初始状态透明(不显示)*/
transition: opacity 0.5s; /*透明度变化动画*/
}
.slider-list__next {
right: 0; /*定位到slider元素的右侧*/
}
.slider-list__previous {
left: 0; /*定位到slider元素的左侧*/
}
#my-slider:hover .slider-list__next {
opacity: 1;
}
#my-slider:hover .slider-list__previous {
opacity: 1;
}
.slider-list__next::after {
content: '>';
}
.slider-list__previous::after {
content: '<';
}
/* 下方控制的圆点 */
.slider-list__control {
position: relative;
display: table;
background-color: rgba(255, 255, 255, 0.5);
padding: 5px;
border-radius: 12px;
bottom: 30px;
margin: auto;
}
.slider-list__control-buttons,
.slider-list__control-buttons--selected{
display: inline-block;
width: 15px;
height: 15px;
border-radius: 50%;
margin: 0 5px;
background-color: white;
cursor: pointer; /*设置鼠标移动到这个元素时显示为手指状*/
}
/*当选择后,小圆点的颜色变成红色*/
.slider-list__control-buttons--selected {
background-color: red;
}
我们可以看看上述代码的效果:
接下来,我们在JS中原本的API代码的基础上加上控制流,从而实现交互效果。
代码如下:
class Slider{
constructor(id, cycle = 3000){
// 通过id获取容器
this.container = document.getElementById(id);
// 获取所有的列表项
this.items = this.container
.querySelectorAll('.slider-list__item, .slider-list__item--selected');
// 获取定时器时间
this.cycle = cycle;
// 获取圆点控制器
const controller = this.container.querySelector('.slider-list__control');
if (controller){
const buttons = controller.querySelectorAll('.slider-list__control-buttons, .slider-list__control-buttons--selected');
// 给圆点绑定事件--当鼠标放上去,将对应圆点图片显示出来,且此时循环停止
controller.addEventListener('mouseover', e => {
const idx = Array.from(buttons).indexOf(e.target);
if(idx >= 0) {
this.slideTo(idx);
this.stop();
};
});
// 给圆点绑定离开恢复循环
controller.addEventListener('mouseout', e => {
this.start();
});
// 注册一个slide事件,将选中的图片与小圆点设置为selected状态
this.container.addEventListener('slide', e => {
const idx = e.detail.index;
const selected = controller.querySelector('.slider-list__control-buttons--selected');
if(selected) {
selected.className = 'slider-list__control-buttons';
buttons[idx].className = 'slider-list__control-buttons--selected';
}
})
}
// 给左侧箭头绑定翻到前一页
const previous = this.container.querySelector('.slider-list__previous');
if(previous) {
previous.addEventListener('click', e => {
this.stop();
this.slidePrevious();
this.start();
e.preventDefault();
})
}
// 给右侧箭头绑定翻到后一页
const next = this.container.querySelector('.slider-list__next');
if (next) {
next.addEventListener('click', e => {
this.stop();
this.slideNext();
this.start();
e.preventDefault();
})
}
}
// 获取当前选择的列表项
getSelectedItem(){
const selected = this.container
.querySelector('.slider-list__item--selected');
return selected
}
// 获取当前选择项的索引
getSelectedItemIndex(){
return Array.from(this.items).indexOf(this.getSelectedItem());
}
// 切换至,思路就是先将当前选择的取消,再通过索引,将目标项的class修改为选择
slideTo(idx){
const selected = this.getSelectedItem();
if(selected){
selected.className = 'slider-list__item';
}
const item = this.items[idx];
if(item){
item.className = 'slider-list__item--selected';
}
const detail = {index: idx};
// 创建'slide'事件
const event = new CustomEvent('slide', {bubbles: true, detail});
// 向container中派发事件
this.container.dispatchEvent(event);
}
// 下一个
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);
}
// 循环开始
start(){
this.stop();
this._timer = setInterval(() => this.slideNext(), this.cycle);
}
// 循环停止
stop(){
clearInterval(this._timer);
}
}
const slider = new Slider('my-slider');
slider.start();
到这一步,我们就完成了一个轮播图组件的初步开发工作。对目前的工作流程进行总结:
-
首先,我们需要确定组件的HTML结构,完成HTML结构的设计;
-
之后,根据想要的样式,在CSS中对效果进行展示;
-
最后,在JS中进行行为设计,行为设计分为两步:
- 第一步:确定功能(API)
- 第二步:添加控制流(Event)
然而到这一步,我们得到的组件并不是一个好的组件。一个好的组件需要做到兼顾四个方面,分别为封装性、正确性、扩展性、复用性。在上面的代码中,我们只完成了封装性与正确性,因此,接下来,我们需要做的就是提高组件的扩展性与复用性。
为此,我们需要在原来的基础上,对代码进行重构。
代码重构
代码重构的步骤与之前的步骤相反,在这里我们先对JS进行解耦,这一步也被称为插件化。我们将控制元素抽取出来作为插件,在插件与组件之间通过依赖注入的方式建立联系。
插件化
在之前的代码中,我们已经知道,在轮播图组件中一共有三个控制元素,分别为圆点控制件、左侧翻页键和右侧翻页键。
因此,我们首先将圆点控制件的逻辑抽取出来,作为一个组件pluginController,对应的代码如下:
// 圆点控制件
function pluginController(slider) {
// 获取圆点控制器
const controller = slider.container.querySelector('.slider-list__control');
if (controller){
const buttons = controller.querySelectorAll('.slider-list__control-buttons, .slider-list__control-buttons--selected');
// 给圆点绑定事件--当鼠标放上去,将对应圆点图片显示出来,且此时循环停止
controller.addEventListener('mouseover', e => {
const idx = Array.from(buttons).indexOf(e.target);
if(idx >= 0) {
slider.slideTo(idx);
slider.stop();
};
});
// 给圆点绑定离开恢复循环
controller.addEventListener('mouseout', e => {
slider.start();
});
// 注册一个slide事件,将选中的图片与小圆点设置为selected状态
slider.container.addEventListener('slide', e => {
const idx = e.detail.index;
const selected = controller.querySelector('.slider-list__control-buttons--selected');
if(selected) {
selected.className = 'slider-list__control-buttons';
buttons[idx].className = 'slider-list__control-buttons--selected';
}
});
}
}
不难看出,这就是从我们之前的代码中提取出来的。因此,同理另外两个插件pluginPrevious和pluginNext。
其代码分别如下:
pluginPrevious:
// 向左翻页插件
function pluginPrevious(slider) {
// 给左侧箭头绑定翻到前一页
const previous = slider.container.querySelector('.slider-list__previous');
if(previous) {
previous.addEventListener('click', e => {
slider.stop();
slider.slidePrevious();
slider.start();
e.preventDefault();
});
}
}
pluginNext:
// 向右翻页插件
function pluginNext(slider) {
// 给右侧箭头绑定翻到后一页
const next = slider.container.querySelector('.slider-list__next');
if (next) {
next.addEventListener('click', e => {
slider.stop();
slider.slideNext();
slider.start();
e.preventDefault();
})
}
}
至此,我们将所需要的控制元素抽离出来,构成了三个插件,为了使用这三个插件,我们需要在Slider类中使用一个registerPlugins来使用插件。代码如下:
class Slider{
constructor(id, cycle = 3000){
// 通过id获取容器
this.container = document.getElementById(id);
// 获取所有的列表项
this.items = this.container
.querySelectorAll('.slider-list__item, .slider-list__item--selected');
// 获取定时器时间
this.cycle = cycle;
}
// 注册组件
registerPlugins(...plugins) {
plugins.forEach(plugin => plugin(this));
}
// ...下方省略原本写的API代码
}
这种将依赖对象传入插件初始化函数的方式,叫做依赖注入,是一种组件与插件解耦的基本思路。
接下来,我们在下方进行组件的使用:
const slider = new Slider('my-slider');
// 注册插件
slider.registerPlugins(pluginController, pluginPrevious, pluginNext);
slider.start();
在进行插件化之后,我们就可以在组件中任意定义我们想要的功能了,但是此时仍然存在问题,由于我们的HTML与插件并不是绑定的,因此,在某个插件不用的时候,我们会发现其对应的HTML还存在于页面中。
这显然是不行的,因此,我们需要再次对组件进行重构,使我们的JS代码能够对组件的结构进行渲染,也就是我们接下来要进行的模板化工作。
模板化
采用的方法是使用render函数来进行HTML的渲染工作。
我们首先完成对组件的渲染工作。由于在实际使用中,我们并不知道要在轮播图中插入几张图片,因此,我们在模板化的过程中,需要将组件拓展成可以指定任意数量图片。其代码如下:
class Slider{
constructor(id, opts = {
images: [],
cycle: 3000
}){
// 通过id获取容器
this.container = document.getElementById(id);
// 获取配置
this.options = opts;
// 渲染HTML
this.container.innerHTML = this.render();
// 获取所有的列表项
this.items = this.container
.querySelectorAll('.slider-list__item, .slider-list__item--selected');
// 获取定时器时间
this.cycle = opts.cycle || 3000;
this.slideTo(0);
}
// 渲染HTML用的render函数
// trim()的作用就是删除两端的空白符的
render() {
const images = this.options.images;
const content = images.map(image => `
<li class="slider-list__item">
<img src="${image}" />
</li>
`.trim());
return `<ul>${content.join('')}</ul>`;
}
// ...之后的代码还是相同的
}
接下来对插件进行重构,在这里,我们将插件从函数变为一个对象,对象中有两个函数,分别为渲染HTML用的render和用来注册自定义事件的action。重构后的代码如下所示:
// 圆点控制件
const pluginController = {
// render()
render(images) {
return `
<div class="slider-list__control">
${images.map((image, i) => `
<span class="slider-list__control-buttons${i === 0 ? '--selected' : ''}"></span>
`).join('')}
</div>
`.trim();
},
// 行为
action(slider) {
// 获取圆点控制器
const controller = slider.container.querySelector('.slider-list__control');
if (controller){
const buttons = controller.querySelectorAll('.slider-list__control-buttons, .slider-list__control-buttons--selected');
// 给圆点绑定事件--当鼠标放上去,将对应圆点图片显示出来,且此时循环停止
controller.addEventListener('mouseover', e => {
const idx = Array.from(buttons).indexOf(e.target);
if(idx >= 0) {
slider.slideTo(idx);
slider.stop();
};
});
// 给圆点绑定离开恢复循环
controller.addEventListener('mouseout', e => {
slider.start();
});
// 注册一个slide事件,将选中的图片与小圆点设置为selected状态
slider.container.addEventListener('slide', e => {
const idx = e.detail.index;
const selected = controller.querySelector('.slider-list__control-buttons--selected');
if(selected) {
selected.className = 'slider-list__control-buttons';
buttons[idx].className = 'slider-list__control-buttons--selected';
}
});
}
}
}
// 向左翻页插件
const pluginPrevious = {
render() {
return `<a class="slider-list__previous"></a>`;
},
action(slider) {
// 给左侧箭头绑定翻到前一页
const previous = slider.container.querySelector('.slider-list__previous');
if(previous) {
previous.addEventListener('click', e => {
slider.stop();
slider.slidePrevious();
slider.start();
e.preventDefault();
});
}
}
}
// 向右翻页插件
const pluginNext = {
render() {
return `<a class="slider-list__next"></a>`
},
action(slider) {
// 给右侧箭头绑定翻到后一页
const next = slider.container.querySelector('.slider-list__next');
if (next) {
next.addEventListener('click', e => {
slider.stop();
slider.slideNext();
slider.start();
e.preventDefault();
})
}
}
}
由于现在的插件是对象的形式,因此,我们在注册插件中也需要进行相应的修改:
// 注册组件
registerPlugins(...plugins) {
plugins.forEach(plugin => {
const pluginContainer = document.createElement('div');
pluginContainer.className = '.slider-list__plugin';
pluginContainer.innerHTML = plugin.render(this.options.images);
this.container.appendChild(pluginContainer);
plugin.action(this);
});
}
在经过了模板化之后,我们在HTML中只需要保留容器盒子即可。
<div id="my-slider" class="slider-list"></div>
使用组件的方法如下:
const slider = new Slider('my-slider', {
images: ['https://p5.ssl.qhimg.com/t0119c74624763dd070.png',
'https://p4.ssl.qhimg.com/t01adbe3351db853eb3.jpg',
'https://p2.ssl.qhimg.com/t01645cd5ba0c3b60cb.jpg',
'https://p4.ssl.qhimg.com/t01331ac159b58f5478.jpg'
],
cycle: 1000
});
slider.registerPlugins(pluginPrevious, pluginNext);
slider.start();
此时我们已经可以实现插件在HTML上显示控制了。
根据月影老师的介绍,在这里还可以对CSS进行解耦,感兴趣的同学可以找一些相关资料进行学习。
到目前为止,我们已经完成了组件的拓展性,下一步,我们需要完成组件的复用性。这一步被称为抽象,即将组件通用模型抽象出来。
抽象
在这里我们可以看到,他定义了一个通用组件类Component,其将通用的注册组件功能抽象了出来。代码如下:
class Component {
constructor(id, opts = {name, data:[]}) {
// 通过id获取容器
this.container = document.getElementById(id);
// 获取配置
this.options = opts;
// 渲染HTML
this.container.innerHTML = this.render(opts.data);
}
// 注册组件
registerPlugins(...plugins) {
plugins.forEach(plugin => {
const pluginContainer = document.createElement('div');
pluginContainer.className = `.${this.options.name}__plugin`;
pluginContainer.innerHTML = plugin.render(this.options.data);
this.container.appendChild(pluginContainer);
plugin.action(this);
});
}
render(data) {
/* abstract */
return ''
}
}
让轮播图组件继承通用组件:
class Slider extends Component{
constructor(id, opts = {
name: 'slider-list',
data: [],
cycle: 3000
}){
super(id, opts);
// 获取所有的列表项
this.items = this.container
.querySelectorAll('.slider-list__item, .slider-list__item--selected');
// 获取定时器时间
this.cycle = opts.cycle || 3000;
this.slideTo(0);
}
// 渲染HTML用的render函数
// trim()的作用就是删除两端的空白符的
render(data) {
const content = data.map(image => `
<li class="slider-list__item">
<img src="${image}" />
</li>
`.trim());
return `<ul>${content.join('')}</ul>`;
}
// 获取当前选择的列表项
getSelectedItem(){
const selected = this.container
.querySelector('.slider-list__item--selected');
return selected
}
// 获取当前选择项的索引
getSelectedItemIndex(){
return Array.from(this.items).indexOf(this.getSelectedItem());
}
// 切换至,思路就是先将当前选择的取消,再通过索引,将目标项的class修改为选择
slideTo(idx){
const selected = this.getSelectedItem();
if(selected){
selected.className = 'slider-list__item';
}
const item = this.items[idx];
if(item){
item.className = 'slider-list__item--selected';
}
const detail = {index: idx};
// 创建'slide'事件
const event = new CustomEvent('slide', {bubbles: true, detail});
// 向container中派发事件
this.container.dispatchEvent(event);
}
// 下一个
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);
}
// 循环开始
start(){
this.stop();
this._timer = setInterval(() => this.slideNext(), this.cycle);
}
// 循环停止
stop(){
clearInterval(this._timer);
}
}
接下来使用我们的组件,使用方式和之前是一样的:
const slider = new Slider('my-slider', {
name: 'slider-list',
data: ['https://p5.ssl.qhimg.com/t0119c74624763dd070.png',
'https://p4.ssl.qhimg.com/t01adbe3351db853eb3.jpg',
'https://p2.ssl.qhimg.com/t01645cd5ba0c3b60cb.jpg',
'https://p4.ssl.qhimg.com/t01331ac159b58f5478.jpg'
],
cycle: 1000
});
slider.registerPlugins(pluginController, pluginPrevious, pluginNext);
slider.start();
这样,我们就完成了一个小型的组件框架的实现。
总结
在这篇笔记中,我们从头实现了一个轮播图组件的开发功能,这个开发过程也可以扩展至其他的UI组件的开发中。
对内容进行总结:
- 组件的设计需要遵循四个原则:封装性、正确性、扩展性、复用性;
- 一个组件的开发需要经过如下步骤:结构设计、展现效果、行为设计;
- 在这个基础上,为了实现扩展性与复用性,我们需要将组件进行三次重构:插件化(提取控制元素)、模板化(提取HTML)、抽象化(提取组件通用模型)。