写好 JS 的原则 组件封装、过程抽象 | 青训营笔记
介绍
上篇文章通过一个深夜模式切换的示例,我们知道了写好 JS 的一些原则:
- 让 HTML、CSS 和 JS 职能分离。
- 应当避免不必要的由 JS 直接操作样式和 HTML。
- 用 CSS 类(或伪元素)来表示状态。
- 纯展示类交互可以寻求零 JS 方案。
这样不仅便于后续代码的维护扩展,而且可以做到代码简洁、可读性高。在开发过程中,写好 JS 还有两个重要的原则:组件封装、过程抽象。
UI 组件是指 Web 页面上抽出来一个个包含模版(HTML)、功能(JS)和样式(CSS)的单元。好的 UI 组件具备正确性、封装性、扩展性、复用性。
如何实现一个电商网站的轮播图?
- HTML - 轮播图是一个典型的列表结构,我们可以使用无序列表
ul
元素来实现。 - CSS - 使用绝对定位将图片重叠在同一个位置,添加 CSS 类表示轮播图切换的状态,轮播图的切换动画使用 CSS transition。
- JS - API 设计应保证原子操作、职责单一、灵活性,并使用自定义事件 CustomEvent 来解耦控制流。
版本一
定义一个轮播图类,将各功能通过在类中定义一系列方法实现:
- getSelectedItem - 获取当前播放图片。
- getSelectedItemIndex - 获取当前播放图片指针。
- slideTo - 鼠标悬浮底部小圆点时,切换到指定位置图片。
- slideNext - 切换到下一张图片。
- slidePrevious - 切换到上一张图片。
- start - 轮播图开始播放。
- stop - 轮播图暂停播放。
- constructor - 构造函数,在里面定义逻辑和绑定事件。
class Slider{
constructor(id, cycle = 3000){
}
getSelectedItem(){
}
getSelectedItemIndex(){
}
slideTo(idx){
}
slideNext(){
}
slidePrevious(){
}
start(){
}
stop(){
}
}
const slider = new Slider('my-slider');
slider.start();
这样就实现了轮播图的功能,但是类中的构造器实在是太臃肿了,做了很多本来不应该它要做的事,如果我们要改需求,比如去除底部小圆点的鼠标悬浮切换图片的功能,那我们要在构造函数里面查找这部分功能并删去相应的代码。
这样看来,我们只做到了封装性和正确性,但是缺乏扩展性和复用性,所以我们需要考虑插件化,将构造器进行简化,将各功能分离出来。
版本二
将控制元素抽取成插件,插件与组件之间通过依赖注入(将依赖对象传入插件初始化函数)的方式建立联系和解耦,这样就提高了组件的可扩展性。
首先将小圆点的控制抽离成一个插件 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();
});
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();
});
}
}
然后,将下一张图片按钮控制抽离成一个插件 pluginPrevious。
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();
});
}
}
最后,在轮播图类中定义一个注册插件的方法。
registerPlugins(...plugins) {
plugins.forEach(plugin => plugin(this));
}
这样,我们就可以通过注册插件 registerPlugins 来使用各种插件,如果我们要去除底部小圆点的鼠标悬浮切换图片的功能,只需要调用 registerPlugins 方法时,不将 pluginController 传入即可。
const slider = new Slider('my-slider');
slider.registerPlugins(/*pluginController, */pluginPrevious, pluginNext);
slider.start();
但是这里有了新的问题,下方小圆点虽然失效了,但是在页面上并没有消失,要将小圆点也去除就要手动去操作 HTML,所以我们需要解耦 HTML。
版本三
将 HTML 模板化,也就是让 JavaScript 来渲染组件的 HTML,这样更易于扩展。
将 HTML 解耦后,我们只需要一个轮播图容器标签即可。
<div id="my-slider" class="slider-list"></div>
首先,在轮播图类中定义 render() 渲染方法,根据图片列表返回一段包含 ul 列表元素的 HTML 代码。
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>`;
}
然后,在轮播图类中定义一个注册插件的方法。
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);
});
}
然后,定义各个插件,插件中也要定义 render() 渲染方法,用来返回该插件对应的 HTML 代码,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 需要由 JavaScript 来渲染,所以需要手动先调用 this.slideTo(0) 渲染第一张图片。
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);
}
这样,我们就可以通过注册插件 registerPlugins 来使用各种插件,如果我们要去除底部小圆点的鼠标悬浮切换图片的功能,只需要调用 registerPlugins 方法时,不将 pluginController 传入即可,同时底部小圆点也会在页面中去除。
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
});
slider.registerPlugins(/*pluginController, */pluginPrevious, pluginNext);
slider.start();
至此,扩展性有了,但是可复用性还不够,我们继续重构,将组件抽象成一个组件框架,提高组件的复用性。
版本四
将通用的组件模型抽象出来,定义成一个通用组件类,这样我们如果有多个组件,就可以在各个组件中继承和复用这个组件模型。
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 = `${this.options.name}__plugin`;
pluginContainer.innerHTML = plugin.render(this.options.data);
this.container.appendChild(pluginContainer);
plugin.action(this);
});
}
render(data) {
/* 抽象方法 */
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);
}
}
其他实现和版本三相同,这样我们就实现了一个小型的组件框架(还可以解耦 CSS)。、
总结
- 实现一个组件的步骤:结构设计、展现效果、行为设计。
- 组件设计的原则:正确性、封装性、扩展性、复用性。
- 组件重构的原则:插件化(扩展性)、模板化(易于扩展)、抽象化(复用性)。