这是我参与「第五届青训营 」伴学笔记创作活动的第 4 天
月影老师告诉我们写好JavaScript(包括其他语言)的三大原则 ① 各司其责 ② 组件封装 ③ 过程抽象
1. 组件的概念
组件是指Web页面上抽出来一个个包含模版(HTML)、功能(JS)和样式(CSS)的单元。
好的组件具备封装性、正确性、扩展性、复用性。
2. 轮播图案例
版本一:API无交互版
结构:HTML
轮播图是⼀个典型的列表结构,我们可以使⽤⽆序列表<ul>
元素来实现。
这里类的命名有点讲究,是一种CSS规则名书命名规范,其中 slider
表示组件名,-list
表示元素,__item
表示具体元素项,--selected
表示的是状态(看完CSS的代码你就知道为什么这样命名更好了~)
<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)这里是
--checked
- 轮播图的切换动画使用 CSS transition
再回顾一下这种讲究的CSS规则名命名规范,其中 slider
表示组件名,-list
表示元素,__item
表示具体元素项,--selected
表示的是状态
这样命名,当组件多了,CSS多起来的时候,很容易分辨清楚这段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;
}
最后我们需要通过JavaScript来控制页面的行为
行为:JS —— API
API 设计应保证原子操作,职责单一,满足灵活性。
// 创建一个Slider类,封装一些API
class Slider{
constructor(id){
this.container = document.getElementById(id);
this.items = this.container
.querySelectorAll('.slider-list__item, .slider-list__item--selected');
}
// 获取选中的图片元素:通过选择器`.slider__item--selected`获得被选中的元素
getSelectedItem(){
const selected = this.container
.querySelector('.slider-list__item--selected');
return selected
}
// 获取选中图片的索引值:返回选中的元素在items数组中的位置。
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';
}
}
// 跳转到下一索引的图片:将下一张图片标记为选中状态
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);
}
}
通过手动调用API来使用轮播图了
const slider = new Slider('my-slider');
slider.slideTo(1);
slider.slideTo(2);
slider.slideNext();
slider.slidePrevious();
复制代码
或者可以直接定义一个定时器,让他自动播放
const slider = new Slider('my-slider');
setInterval(() => {
slider.slideNext();
}, 1000);
版本二 控制流交互版
我们要让用户可以控制我们轮播图的状态,所以需要设计一套控制流
结构 HTML
这里加入了一些控制轮播图的元素,比如两边控制前后翻图的箭头,下面控制选图的小圆点
<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="slide-list__next"></a>
<a class="slide-list__previous"></a>
<div class="slide-list__control">
<span class="slide-list__control-buttons--selected"></span>
<span class="slide-list__control-buttons"></span>
<span class="slide-list__control-buttons"></span>
<span class="slide-list__control-buttons"></span>
</div>
</div>
表现:CSS
这种命名规范有一个名字,叫做Block-Element-Modifier
简称为BEM
#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;
}
.slide-list__control{
position: relative;
display: table;
background-color: rgba(255, 255, 255, 0.5);
padding: 5px;
border-radius: 12px;
bottom: 30px;
margin: auto;
}
.slide-list__next,
.slide-list__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;
background: transparent;
color: white;
background: rgba(0,0,0,0.2); /*设置为半透明*/
cursor: pointer; /*设置鼠标移动到这个元素时显示为手指状*/
opacity: 0; /*初始状态为透明*/
transition: opacity .5s; /*设置透明度变化的动画,时间为.5秒*/
}
.slide-list__previous {
left: 0; /*定位在slider元素的最左边*/
}
.slide-list__next {
right: 0; /*定位在slider元素的最右边*/
}
#my-slider:hover .slide-list__previous {
opacity: 1;
}
#my-slider:hover .slide-list__next {
opacity: 1;
}
.slide-list__previous:after {
content: '<';
}
.slide-list__next:after {
content: '>';
}
/*下面是四个小圆点的样式,其实通过这种BEM命名规则你也能看出来*/
.slide-list__control-buttons,
.slide-list__control-buttons--selected{
display: inline-block;
width: 15px;
height: 15px;
border-radius: 50%;
margin: 0 5px;
background-color: white;
cursor: pointer; /*设置鼠标移动到这个元素时显示为手指状*/
}
/*当选择后,小圆点的颜色变成红色*/
.slide-list__control-buttons--selected {
background-color: red;
}
行为:JS —— 控制流
接下来就是在API的代码基础上 加入控制流,让轮播图可以自动轮播,也可以手动控制,实现交互效果
使用自定义事件来解耦
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;
const controller = this.container.querySelector('.slide-list__control');
if(controller){
const buttons = controller.querySelectorAll('.slide-list__control-buttons, .slide-list__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('.slide-list__control-buttons--selected');
if(selected) selected.className = 'slide-list__control-buttons';
buttons[idx].className = 'slide-list__control-buttons--selected';
})
}
// 点击左边小箭头,翻到前一页
const previous = this.container.querySelector('.slide-list__previous');
if(previous){
previous.addEventListener('click', evt => {
this.stop();
this.slidePrevious();
this.start();
evt.preventDefault();
});
}
// 点击右边小箭头,翻到后一页
const next = this.container.querySelector('.slide-list__next');
if(next){
next.addEventListener('click', evt => {
this.stop();
this.slideNext();
this.start();
evt.preventDefault();
});
}
}
getSelectedItem(){
let selected = this.container.querySelector('.slider-list__item--selected');
return selected
}
getSelectedItemIndex(){
return Array.from(this.items).indexOf(this.getSelectedItem());
}
slideTo(idx){
let selected = this.getSelectedItem();
if(selected){
selected.className = 'slider-list__item';
}
let 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(){
let currentIdx = this.getSelectedItemIndex();
let nextIdx = (currentIdx + 1) % this.items.length;
this.slideTo(nextIdx);
}
slidePrevious(){
let currentIdx = this.getSelectedItemIndex();
let 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();
3. 总结:基本方法
-
结构设计 HTML
-
展现效果 CSS
-
行为设计 JavaScript
- API (功能)
- Event (控制流)
对于我这样的新手来说,我觉得我已经完工了,但是我们再回想一下组件的定义
具备封装性、正确性、扩展性、复用性
这样看来我们只做到了封装性和正确性,但是扩展性和复用性还差点意思
也就是说上面的基本代码具有很大的改进空间,接下来我们准备来 重构 这个轮播图组件
4. 重构
重构一:解耦JS——插件化
上面解决方案的类中的构造器实在是太臃肿了,做了很多本来不应该它要做的事,所以我们考虑插件化,将构造器进行简化
先来看看之前的构造函数做了哪些事
constructor(id, cycle = 3000){
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('.slide-list__control');
if(controller){
// 鼠标经过某个小圆点,就将此圆点对应的图片显示出来,并且停止循环轮播
controller.addEventListener('mouseover', evt=>{
// ...
});
// 鼠标移开小圆点,就继续开始循环轮播
controller.addEventListener('mouseout', evt=>{
this.start();
});
// 注册slide事件,将选中的图片和小圆点设置为selected状态
this.container.addEventListener('slide', evt => {
// ...
}
// 点击左边小箭头,翻到前一页
const previous = this.container.querySelector('.slide-list__previous');
// ...
}
// 点击右边小箭头,翻到后一页
const next = this.container.querySelector('.slide-list__next');
// ...
}
解耦: 将控制元素抽取成插件; 插件与组件之间通过依赖注⼊方式建立联系
我们要将用户控制的操作从组件中抽离出来,做成插件,这样就提高了组件的可扩展性!!!
用户的控制组件分为三个部分可以抽离成三个插件。
首先将小圆点的控制抽离成一个插件pluginController
插件接收的参数就是组件的实例,将控制流中的事件写在这里,插件中的逻辑就是之前构造函数中的逻辑。
function pluginController(slider){
// 对小圆点的操作控制流
const controller = slider.container.querySelector('.slide-list__control');
if(controller){
const buttons = controller.querySelectorAll('.slide-list__control-buttons, .slide-list__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();
});
// 注册slide事件,将选中的图片和小圆点设置为selected状态
slider.addEventListener('slide', evt => {
const idx = evt.detail.index
const selected = controller.querySelector('.slide-list__control-buttons--selected');
if(selected) selected.className = 'slide-list__control-buttons';
buttons[idx].className = 'slide-list__control-buttons--selected';
});
}
}
将左翻页的控制抽离成插件pluginPrevious
function pluginPrevious(slider){
const previous = slider.container.querySelector('.slide-list__previous');
if(previous){
previous.addEventListener('click', evt => {
slider.stop();
slider.slidePrevious();
slider.start();
evt.preventDefault();
});
}
}
将右翻页的控制抽离成插件pluginNext
function pluginNext(slider){
const next = slider.container.querySelector('.slide-list__next');
if(next){
next.addEventListener('click', evt => {
slider.stop();
slider.slideNext();
slider.start();
evt.preventDefault();
});
}
}
最后我们的组件就是这样定义的(省略重复API代码)
此时的构造函数已经精简了,我们将JS进行了解耦,通过注册插件registerPlugins
来使用各种插件(控件)~
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){
// 这里的this就是组件的实例对象
plugins.forEach(plugin => plugin(this));
}
}
这种将依赖对象传入插件初始化函数的方式,叫做依赖注入,这是一种组件与插件解耦的基本思路
const container = document.querySelector('.slider');
const slider = new Slider({container});
// 注册三个插件
slider.registerPlugins(pluginController, pluginPrevious, pluginNext);
slider.start();
进行插件化之后,我们可以任意组合我们想要的插件,比如我们将底部小圆点插件去除
slider.registerPlugins(pluginPrevious, pluginNext);
复制代码
可以看到下方的小圆点已经不生效了(注意看上面动图小圆点已经不动了),但是这里有了新的问题,下方小圆点虽然失效了,但是没有消失,我们要是将小圆点也去除就要手动去操作HTML了~
所以我们要继续对组件进行重构!我们解耦HTML,让JavaScript来渲染组件的结构 —— 模板化
重构二: 解耦HTML——模板化
将HTML模板化,也就是让JavaScript来渲染组件的HTML,这样更易于扩展
在组件中加入了render()
渲染函数,用来渲染HTML
class Slider {
constructor(id, opts = {
images: [],
cycle: 3000
}) {
this.container = document.getElementById(id);
this.options = opts;
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);
}
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>`;
}
}
这里将图片放入一个images数组中,这样就可以让组件拓展成 指定任意多的图片的 轮播图
接下来定义下三个插件(插件从一个函数变成一个对象,对象中有两个函数,一个渲染HTML,一个注册自定义事件JS)
下部小圆点的插件是这样定义的,插件中也要定义render()
渲染函数,action()
用来注册自定义事件
const pluginController = {
render(images) {
return `
<div class="slide-list__control">
${images.map((image, i) => `
<span class="slide-list__control-buttons${i===0?'--selected':''}"></span>
`).join('')}
</div>
`.trim();
},
action(slider) {
const controller = slider.container.querySelector('.slide-list__control');
if (controller) {
const buttons = controller.querySelectorAll(
'.slide-list__control-buttons, .slide-list__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.addEventListener('slide', evt => {
const idx = evt.detail.index
const selected = controller.querySelector('.slide-list__control-buttons--selected');
if (selected) selected.className = 'slide-list__control-buttons';
buttons[idx].className = 'slide-list__control-buttons--selected';
});
}
}
};
向前翻页的插件是这样定义的
const pluginPrevious = {
render() {
return `<a class="slide-list__previous"></a>`;
},
action(slider) {
const previous = slider.container.querySelector('.slide-list__previous');
if (previous) {
previous.addEventListener('click', evt => {
slider.stop();
slider.slidePrevious();
slider.start();
evt.preventDefault();
});
}
}
};
向后翻页的插件是这样定义的
const pluginNext = {
render() {
return `<a class="slide-list__next"></a>`;
},
action(slider) {
const previous = slider.container.querySelector('.slide-list__next');
if (previous) {
previous.addEventListener('click', evt => {
slider.stop();
slider.slideNext();
slider.start();
evt.preventDefault();
});
}
}
};
注册插件是这样定义的,渲染HTML结构,绑定JS事件行为
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解耦后,我们的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(pluginController, pluginPrevious, pluginNext);
slider.start();
这时,如果不想要下面小圆点的插件了,可以这样
slider.registerPlugins(pluginPrevious, pluginNext);
复制代码
完美~其实还可以解耦CSS,但是这里课上就没有说了,以后有时间再探索探索吧~~
至此,拓展性有了,但是可复用性还不够,我们继续重构,将组件抽象成一个组件框架,提高组件的复用性
重构三:抽象——组件框架
将通⽤的组件模型抽象出来
定义一个通用组件类
class Component {
constructor(id, opts = {
name,
data: []
}) {
this.container = document.getElementById(id);
this.options = opts;
this.container.innerHTML = this.render(opts.data);
}
registerPlugins(...plugins) {
plugins.forEach(plugin => {
const pluginContainer = document.createElement('div');
pluginContainer.className = `.${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);
}
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());
}
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);
}
}
三个插件
小圆点插件
const pluginController = {
render(images) {
return `
<div class="slide-list__control">
${images.map((image, i) => `
<span class="slide-list__control-buttons${i===0?'--selected':''}"></span>
`).join('')}
</div>
`.trim();
},
action(slider) {
let controller = slider.container.querySelector('.slide-list__control');
if (controller) {
let buttons = controller.querySelectorAll(
'.slide-list__control-buttons, .slide-list__control-buttons--selected');
controller.addEventListener('mouseover', evt => {
var idx = Array.from(buttons).indexOf(evt.target);
if (idx >= 0) {
slider.slideTo(idx);
slider.stop();
}
});
controller.addEventListener('mouseout', evt => {
slider.start();
});
slider.addEventListener('slide', evt => {
const idx = evt.detail.index;
let selected = controller.querySelector('.slide-list__control-buttons--selected');
if (selected) selected.className = 'slide-list__control-buttons';
buttons[idx].className = 'slide-list__control-buttons--selected';
});
}
}
};
向前翻页插件
const pluginPrevious = {
render() {
return `<a class="slide-list__previous"></a>`;
},
action(slider) {
let previous = slider.container.querySelector('.slide-list__previous');
if (previous) {
previous.addEventListener('click', evt => {
slider.stop();
slider.slidePrevious();
slider.start();
evt.preventDefault();
});
}
}
};
向后翻页插件
const pluginNext = {
render() {
return `<a class="slide-list__next"></a>`;
},
action(slider) {
let previous = slider.container.querySelector('.slide-list__next');
if (previous) {
previous.addEventListener('click', evt => {
slider.stop();
slider.slideNext();
slider.start();
evt.preventDefault();
});
}
}
};
使用我们的组件,使用方式不变
const slider = new Slider('my-slider', {
name: 'slide-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();
这,这就是一个小型的组件框架啊~
虽然现在有很多组件库比如Vue还有React中的组件模式,但是我们自己研究一下这里面的机制与原理对我们理解组件库以及JavaScript还是会很有帮助的!!
这样的不断解耦JS实现插件化,解耦HTML实现模板化,甚至还可以解耦CSS,这中思路提供了代码设计和抽象的一套通用规范,而遵循这套规范的基础库,实际上就是完整的UI组件框架!!!
5. 最佳实践:组件封装
- 组件设计的原则:封装性、正确性、扩展性、复用性
- 实现组件的步骤:结构设计、展现效果、行为设计 (封装性、正确性)
- 三次重构:插件化(扩展性)、模板化(扩展性)、抽象化(复用性)