JS成功学之「组件封装」 | 青训营笔记
这是我参与「第四届青训营 」笔记创作活动的的第3天。
写好JS的三大原则:
- 各司其责: 组件封装过程抽象让HTML、CSS和JavaScript职能分离。
- 组件封装: 好的UI组件具备正确性、扩展性、复用性。
- 过程抽象: 应用函数式编程思想。
接下来将详细探讨一下 「组件封装」 这一原则。
组件封装:好的UI组件具备正确性、扩展性、复用性。
组件是指Web页面上抽出来一个个包含模版(HTML)、功能(JS)和样式(CSS)的单元。好的组件具备封装性、正确性、扩展性、复用性。
案例:电商轮播图
-
HTML结构
轮播图是一个典型的列表结构,这里使用无序列表
ul元素来实现。
<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>
-
CSS样式
- 使用 CSS 绝对定位将图片重叠在同一个位置
- 轮播图切换的状态使用修饰符(modifier)
- 轮播图的切换动画使用 CSS transition
#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;
}
css部分详细讲解见这篇博客,作者:金鹧鸪。
-
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);
接下来,需要将行为添加至组件中,这一步叫做控制流,具体操作:使用自定义事件来实现 控制Slider的组件的状态 和 Slider类 之间的解耦。
控制流
在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();
js部分详细讲解见这篇博客,作者:金鹧鸪。
对目前的工作流程进行总结:
-
首先,我们需要确定组件的HTML结构,完成HTML结构的设计;
-
之后,根据想要的样式,在CSS中对效果进行展示;
-
最后,在JS中进行行为设计,行为设计分为两步:
- 第一步:确定功能(API)
- 第二步:添加控制流(Event)
然而到这一步,我们得到的组件并不是一个好的组件。一个好的组件需要做到兼顾四个方面,分别为封装性、正确性、扩展性、复用性。在上面的代码中,我们只完成了封装性与正确性,因此,接下来,我们需要做的就是提高组件的扩展性与复用性。
为此,我们需要在原来的基础上,对代码进行重构。
改进1:插件化
首先可以观察到,我们目前的构造函数太复杂了,而且占据了较大的篇幅。
在未来如果我们在新的项目里面并不需要这左右按钮或者这五个圆点,那么我们想要的效果是直接在HTML中直接删除或者换掉这部分HTML代码,而不需要去动JS中的核心逻辑代码。
因此我们需要把图片轮播和这几个按钮的主体部分分离出来,也就是将左右两个按钮和五个圆点解耦出来。
解耦
- 将控制元素抽取成插件
- 插件与组件之间通过依赖注入方式建立联系
class Slider{
constructor(id, cycle = 3000){
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));
}
//class里有regisrerPlugins方法
getSelectedItem(){
const selected = this.container.querySelector('.slider-list__item--selected');
return selected
}
getSelectedItemIndex(){
return Array.from(this.items).indexOf(this.getSelectedItem());
}
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}
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);
}
addEventListener(type, handler){
this.container.addEventListener(type, handler)
}
start(){
this.stop();
this._timer = setInterval(()=>this.slideNext(), this.cycle);
}
stop(){
clearInterval(this._timer);
}
}
// 圆点控制件
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';
}
});
}
}
// 向左翻页插件
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();
});
}
}
// 向右翻页插件
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();
})
}
}
/*
调用的时候,就只需要去注册需要的插件即可
不需要的则注释掉相应的插件,然后从HTML中删除掉相应的结构代码即可
如果需要扩展新的插件,则只需要重新写好插件然后再注册到这个组件里面去
*/
const slider = new Slider('my-slider');
/*slider.registerPlugins(pluginController, pluginPrevious, pluginNext);*/
slider.registerPlugins(/*pluginController, */pluginPrevious, pluginNext);//注册圆点, 上一页,下一页事件
slider.start();
在进行插件化之后,我们就可以在组件中任意定义我们想要的功能了,但是此时仍然存在问题,由于我们的HTML与插件并不是绑定的,因此,在某个插件不用的时候,我们会发现其对应的HTML还存在于页面中。
这显然是不行的,因此,我们需要再次对组件进行重构,使我们的JS代码能够对组件的结构进行渲染,也就是我们接下来要进行的模板化工作。
改进2:模板化
数据驱动,通过JS来生成HTML模板,而不要把HTML写死。
采用的方法是使用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>
在slider构造函数中,我们只要把图片作为参数传进去就可以了
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:3000});
这么做的好处是:如果我们不需要那个小圆点,我们直接注册掉插件,那么小圆点的HTML模板和它的行为功能会一起消息,这样就会非常灵活
根据月影老师的介绍,在这里还可以对CSS进行解耦,感兴趣的同学可以找一些相关资料进行学习。
到目前为止,我们已经完成了组件的拓展性,下一步,我们需要完成组件的复用性。这一步被称为抽象,即将组件通用模型抽象出来。
改进3:抽象化
- 将组件通用模型抽象出来
在这里我们可以看到,他定义了一个通用组件类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();
总结
-
组件设计的原则:封装性、正确性、扩展性、复用性
-
实现组件的步骤:结构设计、展现效果、行为设计
-
三次重构
- 插件化
- 模板化
- 抽象化(组件框架)
参考博客:
- 链接:juejin.cn/post/712565…,作者:Victor252
- 链接:juejin.cn/post/712547…,作者:金鹧鸪