组件封装的艺术

封装性、正确性、扩展性、复用性

轮播图

在线演示

确定 UI 组件的 HTML 结构

Copy

























设置元素的样式

Copy
.slider {
position: relative;
width: 790px;
height: 340px;
}

.slider ul {
list-style-type:none;
position: relative;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}

.slider__item,
.slider__item--selected {
position: absolute;
transition: opacity 1s;
opacity: 0;
text-align: center;
}

.slider__item--selected {
transition: opacity 1s;
opacity: 1;
}

.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__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;
}

设计 API

Copy
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);
}
}

Copy
const container = document.querySelector('.slider');
const slider = new Slider({container});
setInterval(() => {
slider.slideNext();
}, 3000);

实现用户控制

我们将构造器修改为下面这样：

Copy
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();
});
}

Copy
start() {
this.stop();
this._timer = setInterval(() => this.slideNext(), this.cycle);
}

stop() {
clearInterval(this._timer);
}

修改 slideTo 方法，加入自定义事件触发:

Copy
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);
}

最后将调用过程改成：

Copy
const slider = new Slider({container});
slider.start();

组件的插件化

对于这个图片轮播组件来说，它的插件化可以是将用户控制组件从 Slider 组件中剥离出来，做成插件，这样才能提高 Slider 组件的可扩展性。

Copy
class Slider {
constructor({container, cycle = 3000} = {}) {
this.container = container;
this.items = Array.from(container.querySelectorAll('.slider__item, .slider__item--selected'));
this.cycle = cycle;
}

registerPlugins(...plugins) {
plugins.forEach(plugin => plugin(this));
}

/*
通过选择器`.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';
}

const detail = {index: idx};
const event = new CustomEvent('slide', {bubbles: true, detail});
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);
}
}

/* 小圆点控件 */
function pluginController(slider) {
const controller = slider.container.querySelector('.slider__control');
if(controller) {
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) {
slider.slideTo(idx);
slider.stop();
}
});

controller.addEventListener('mouseout', (evt) => {
slider.start();
});

slider.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';
});
}
}

function pluginPrevious(slider) {
const previous = slider.container.querySelector('.slider__previous');
if(previous) {
previous.addEventListener('click', (evt) => {
slider.stop();
slider.slidePrevious();
slider.start();
evt.preventDefault();
});
}
}

function pluginNext(slider) {
const next = slider.container.querySelector('.slider__next');
if(next) {
next.addEventListener('click', (evt) => {
slider.stop();
slider.slideNext();
slider.start();
evt.preventDefault();
});
}
}

const container = document.querySelector('.slider');
const slider = new Slider({container});
slider.registerPlugins(pluginController, pluginPrevious, pluginNext);
slider.start();

如果，有一天，产品经理又需要对这个组件添加新的用户控制，比如，添加一个按钮叫 “试试手气”，点击该按钮，让轮播图随机切换到一张图片上，那么们我们只需要这样做：

在 HTML 代码中增加 “试试手气 “这个按钮：

Copy
试试手气

然后创建这个插件：

Copy
function pluginLucky(slider) {
const luckyBtn = document.querySelector('.lucky');
if (luckyBtn) {
luckyBtn.addEventListener('click', evt => {
slider.stop();
slider.slideTo(Math.floor(Math.random() * slider.items.length));
slider.start();
evt.preventDefault();
});
}
}

最后将它注册到 slider 中去即可：

Copy
slider.registerPlugins(pluginController, pluginPrevious, pluginNext, pluginLucky);

组件的模板化

Copy
class Slider {
constructor({container, images = [], cycle = 3000} = {}) {
this.container = container;
this.data = images;
this.container.innerHTML = this.render(this.data);
this.items = Array.from(this.container.querySelectorAll('.slider__item, .slider__item--selected'));
this.cycle = cycle;
this.slideTo(0);
}

render(images) {
const content = images.map(image => `



`.trim());

return ` ${content.join('')} `;
}

registerPlugins(...plugins) {
plugins.forEach((plugin) => {
const pluginContainer = document.createElement('div');
pluginContainer.className = 'slider__plugin';
pluginContainer.innerHTML = plugin.render(this.data);
this.container.appendChild(pluginContainer);
plugin.initialize(this);
});
}

/*
通过选择器`.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';
}

const detail = {index: idx};
const event = new CustomEvent('slide', {bubbles: true, detail});
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 pluginController = { // 小圆点插件
render(images) { // 随着图片数量的增加，小圆点元素也需要增加
return `

${images.map((image, i) => `

`).join('')}

`.trim();
},

initialize(slider) {
const controller = slider.container.querySelector('.slider__control');

if(controller) {
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) {
slider.slideTo(idx);
slider.stop();
}
});

controller.addEventListener('mouseout', (evt) => {
slider.start();
});

slider.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 pluginPrevious = {
render() {
return '';
},

initialize(slider) {
const previous = slider.container.querySelector('.slider__previous');
if(previous) {
previous.addEventListener('click', (evt) => {
slider.stop();
slider.slidePrevious();
slider.start();
evt.preventDefault();
});
}
},
};

const pluginNext = {
render() {
return '';
},

initialize(slider) {
const previous = slider.container.querySelector('.slider__next');
if(previous) {
previous.addEventListener('click', (evt) => {
slider.stop();
slider.slideNext();
slider.start();
evt.preventDefault();
});
}
},
};

const 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'];

const container = document.querySelector('.slider');
const slider = new Slider({container, images});
slider.registerPlugins(pluginController, pluginPrevious, pluginNext);
slider.start();

模板化之后，代码的可扩展性得到了进一步的提升。现在，如果我们增加或者减少轮播图片数量，只需要修改数据中 images 数组的元素个数。如果我们需要或者不需要某个插件，我们只需要修改传给 registerPlugins () 方法的参数即可。

设计组件框架

为了提高组件的复用性，我们需要为组件设计一个统一的规范。实现这个统一的规范，我们可以通过设计一套通用的组件机制，并以这套机制为原则构建一个库。这个通用机制实际上提供了代码设计和抽象的一套通用规范，而遵循这套规范的基础库，实际上就是完整的 UI 组件框架。

Copy
class Component {
static name = 'component';

constructor({container, data, parent = null} = {}) {
this.data = data;
this.container = container;
this.container.innerHTML = this.render(this.data);
}

registerSubComponents(...Comps) {
const data = this.data;
const container = this.container;
this.children = this.children || [];
Comps.forEach((Comp) => {
const subContainer = document.createElement('div');
const sub = new Comp({container: subContainer, data, parent: this});
container.appendChild(subContainer);
this.children.push(sub);
});
}

render(data) {
/* abstract */
return '';
}
}

class Slider extends Component {
static name = 'slider';

constructor({container, images = [], cycle = 3000} = {}) {
super({container, data: images});
this.items = Array.from(this.container.querySelectorAll('.slider__item, .slider__item--selected'));
this.cycle = cycle;
this.slideTo(0);
}

render(images) {
const content = images.map(image => `



`.trim());

return ` ${content.join('')} `;
}

getSelectedItem() {
const selected = this.container.querySelector('.slider__item--selected');
return selected;
}

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';
}

const detail = {index: idx};
const event = new CustomEvent('slide', {bubbles: true, detail});
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);
}
}

class SliderController extends Component {
static name = 'slider__control';

constructor({container, data, parent: slider}) {
super({container, data});

const buttons = container.querySelectorAll('.slider__control-buttons, .slider__control-buttons--selected');
container.addEventListener('mouseover', (evt) => {
const idx = Array.from(buttons).indexOf(evt.target);
if(idx >= 0) {
slider.slideTo(idx);
slider.stop();
}
});

container.addEventListener('mouseout', (evt) => {
slider.start();
});

slider.container.addEventListener('slide', (evt) => {
const idx = evt.detail.index;
const selected = container.querySelector('.slider__control-buttons--selected');
if(selected) selected.className = 'slider__control-buttons';
buttons[idx].className = 'slider__control-buttons--selected';
});
}

render(images) {
return `

${images.map((image, i) => `

`).join('')}

`.trim();
}
}

class SliderPrevious extends Component {
constructor({container, parent: slider}) {
super({container});
const previous = container.querySelector('.slider__previous');
previous.addEventListener('click', (evt) => {
slider.stop();
slider.slidePrevious();
slider.start();
evt.preventDefault();
});
}

render() {
return '';
}
}

class SliderNext extends Component {
constructor({container, parent: slider}) {
super({container});
const previous = container.querySelector('.slider__next');
previous.addEventListener('click', (evt) => {
slider.stop();
slider.slideNext();
slider.start();
evt.preventDefault();
});
}

render() {
return '';
}
}

const 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'];

const container = document.querySelector('.slider');
const slider = new Slider({container, images});
slider.registerSubComponents(SliderController, SliderPrevious, SliderNext);
slider.start();

References:
https://juejin.cn/book/6891929939616989188/section/6891940071075741700