写好JavaScript三大原则 — 组件封装 | 青训营笔记

1,056 阅读11分钟

这是我参与「第四届青训营 」笔记创作活动的第4天

写好JavaScript的一些原则:

  • 各司其职(让HTML、CSS和JavaScript职能分离)
  • 组件封装(好的UI组件具备正确性、扩展性、复用性)
  • 过程抽象(应用函数式编程思想)

组件是指Web页面上抽出来的一个个包含模版(HTML)、功能(JS)和样式(CSS)的单元。好的组件具备封装性、正确性、扩展性和复用性。

一、案例:封装一个轮播图组件

image.png 上图中的组件实现了3个功能:

  1. 四张图片循环播放,每张图片停留若干时间;
  2. 当用户点击左右两边的小箭头时,图片分别切换到上一张/下一张;
  3. 当用户点击底部的小圆点的时候,则立即跳到小圆点顺序所对应的那张图片。

第一步:确定组件结构(HTML)

轮播图是一个典型的列表结构,我们可以使用无序列表<ul>来实现,把图片放在各个小li中,使用两个<a>元素分别表示左右切换的小箭头,用<span>元素来表示底部的四个小圆点。

<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>

注意: 这里我们使用的 CSS 命名采用了BEM(Block Element Modifier)规范,这一规范采用以下三个部分来描述规则,让你的代码更利于维护。

  • Block(块):逻辑和功能独立的单元,类似于组件。
  • Element(元素):block的组成部分。
  • Modifier(修饰符):用于修饰块或元素,体现出外观、行为、状态等特征。

我们对应来看,首先是Block因为这个组件是实现轮播图的功能逻辑所以我们给这个组件起名字叫slider-list来代表组件名。然后是Element,比如对应的列表项li元素,表示item,所以它的class就是slider-list__item,这里BlockElement之间使用双下划线__连接。最后是Modifier表示状态,其中一个列表的状态是selected,所以最终的 class 是slider-list__item--selected,这里ElementModifier之间使用双横杠--连接。

第二步:设计元素的样式(CSS)

主要思路:

  • 使用CSS的绝对定位将图片重叠在同一个位置
  • 轮播图切换的状态使用修饰符(modifier)——selected
  • 轮播图的切换动画使用CSS transition
#my-slider{
  position: relative;
  width: 790px;
  height: 340px;
}

.slider-list ul{
  list-style-type:none;/*取消ul元素默认的列表样式*/
  position: relative;
  width: 100%;
  height: 100%;
  padding: 0;
  margin: 0;
}

.slider-list__item,/*将图片设为绝对定位使之重叠*/
.slider-list__item--selected{/*设置图片透明度变化的动画效果*/
  position: absolute;
  transition: opacity 1s;
  opacity: 0;/*状态为slider__item时,显示为透明。*/
  text-align: center;
}

.slider-list__item--selected{
  transition: opacity 1s;
  opacity: 1;/*状态为slider__item--selected时显示为不透明。*/
}
/*小箭头的样式*/
.slider-list__next,
.slider-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;
  color: white;
  background: rgba(0,0,0,0.2); /*设置为半透明*/
  cursor: pointer; /*设置鼠标移动到这个元素时显示为手指状*/
  opacity: 0; /*初始状态为透明*/
  transition: opacity .5s; /*设置透明度变化的过渡效果*/
}

.slider-list__previous {
  left: 0; /*定位在slider元素的最左边*/
}

.slider-list__next {
  right: 0; /*定位在slider元素的最右边*/
}

.slider-list:hover .slider-list__previous {
  opacity: 1;
}

.slider-list:hover .slider-list__next {
  opacity: 1;
}

.slider-list__previous:after {
  content: '<';
}

.slider-list__next:after {
  content: '>';
}
/*四个小点的样式*/
.slider-list__control{/*使其子元素固定显示在图片中部下方*/
  position: relative;
  display: table; /* 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 设计应保证 原子操作 ,职责单一,满足灵活性。

说明: 原子操作就是在执行某一操作时不被打断的操作。

根据组件要实现的需求,我们设计了如下几个API:

  • getSelectedItem() - 获取选中的图片
  • getSelectedItemIndex() - 获取选中的图片的位置
  • slideTo() - 切换到某张图片
  • slideNext() - 切换到下一张图
  • slidePrevious() - 切换到上一张图

将组件封装为一个类——Slider:

class Slider {
  constructor({container}) {
    this.container = container;
    this.items =  this.container
                .querySelectorAll('.slider-list__item, .slider-list__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);
  }
}

上面的代码中:Slider 的构造器中的参数{container}表示放置这4张图片的父容器。在构造器中,我们获取了这个父容器下所有的<li>元素。然后,通过setInterval方法实现循环播放,间隔为3秒:

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

第四步:实现用户控制

实现了组件的API后,我们还需要实现用户控制功能:

  • 当用户点击左右两边的小箭头时,图片分别切换到上一张/下一张,并点亮与该图片相对应的小圆点;
  • 当用户鼠标移进到底部小圆点时,则立即跳到小圆点顺序所对应的那张图片,停止轮播;
  • 当用户鼠标移出底部小圆点后,图片再次恢复轮播。

为实现上述功能,我们需要对原来的代码进行如下修改:

1.在Slider类中增加startstop两个方法,用来控制自动轮播效果

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

2.将构造器稍作修改,为控件增添行为:

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('.slider-list__control');
  const buttons = controller.querySelectorAll('.slider-list__control-buttons, .slider-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('.slider-list__control-buttons--selected');
    if(selected) selected.className = 'slider-list__control-buttons';
    buttons[idx].className = 'slider-list__control-buttons--selected';
  });
  //当用户点击上一张时,停止定时器,然后执行slidePrevious()方法,让图片向前翻一张,然后重启定时器
  const previous = this.container.querySelector('.slider-list__previous');
  previous.addEventListener('click', (evt) => {
    this.stop();
    this.slidePrevious();
    this.start();
    evt.preventDefault();
  });
  //当用户点击下一张时,先停止定时器,然后向后翻一张,再重启定时器
  const next = this.container.querySelector('.slider-list__next');
  next.addEventListener('click', (evt) => {
    this.stop();
    this.slideNext();
    this.start();
    evt.preventDefault();
  });
}

3.修改类中的slideTo方法,加入自定义事件触发。

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

这个自定义事件(CustomEvent)的作用是让底部小圆点控件监听slideTo方法。当slideTo方法执行后,这个方法就会分发一次slide事件,然后在这个事件中,更新底部小圆点的状态,让小圆点的状态和各自的图片状态对应起来。

最后将调用过程改成:

const slider = new Slider('my-slider');
slider.start();

以上已经实现了轮播图组件的全部功能。

总结:基本方法

  • 结构设计 HTML
  • 展现效果 CSS
  • 行为设计 JavaScript
    • API (功能)
    • Event (控制流)

回到开头,说一个好的组件具备封装性、正确性、扩展性和复用性

我们上面的代码基本实现了封装性正确性,但是缺少了可扩展性复用性。这个组件只能满足自身的使用,它的实现代码很难扩展到其他的组件,当有功能变化时,也需要修改其自身内部的代码。所以为了实现后面两点,我们需要稍微重构一下轮播图组件。

二、重构

解耦JS —— 插件化

如果我们在以后的项目里不再需要左右箭头或者下方小圆点,那么我们想要的效果是直接在HTML中直接删除或者替换掉这部分HTML代码,而不是去修改JS中的核心逻辑代码,所以我们需要把图片轮播和这些控件的主体部分分离出来,也就是将左右箭头和小圆点解耦出来

1.将控制元素抽取成插件

如图,我们可以将其抽离出三个插件:

  • pluginController() - 对小圆点的控制
  • pluginPrevious() - 控制上一张
  • pluginNext() - 控制下一张

代码如下:

// 小圆点按钮插件
function pluginController(slider) {
    const controller = slider.container.querySelector('.slider-list__control');
    const buttons = controller.querySelectorAll('.slider-list__control-buttons, .slider-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.container.addEventListener('slide', (evt) => {
        const idx = evt.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');
    previous.addEventListener('click', (evt) => {
        slider.stop();
        slider.slidePrevious();
        slider.start();
        evt.preventDefault();
    });
}
// 下一张插件
function pluginNext(slider) {
    const next = slider.container.querySelector('.slider-list__next');
    next.addEventListener('click', (evt) => {
        slider.stop();
        slider.slideNext();
        slider.start();
        evt.preventDefault();
    });
}

2.插件与组件之间通过依赖注入方式建立联系

依赖注入:把有依赖关系的类放入容器中,解析出这些类的实例,就是依赖注入,目的是实现类的解耦。

为Slider类添加注册函数APIregisterPlugins:这里会将我们传入的插件注册并使用

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

现在来看我们的构造器已经精简了很多

    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))
        }
        getSelectedItem() {
            const selected = this.container.querySelector('.slider-list__item--selected');
            return selected;
        }
        ...
        ...
    }

最后将调用过程改成:

    const slider = new Slider('my-slider');
    slider.registerPlugins(/*pluginController*/, pluginPrevious, pluginNext); 
    slider.start();

现在我们可以自由组合控件的使用,比如我不想要小圆点的效果直接将其注释或删除即可。但是会发现虽然小圆点的效果消失了,但是它的结构依然在HTML中(所以页面上我们还能看到小圆点存在),我们需要手动删除,这样还是比较麻烦的。为了更好的扩展性,我们将进一步优化:将HTML模板化。

HTML模板化

1.我们只留下作为容器的根标签

<div id="my-slider" class="slider-list"></div>

2.利用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>`;
    }
    registerPlugins(...plugins) {
        plugins.forEach(plugin => {//将HTML结构和行为渲染出来
            const pluginContainer = document.createElement('div');
            pluginContainer.className = '.slider-list__plugin';
            pluginContainer.innerHTML = plugin.render(this.options.images);
            this.container.appendChild(pluginContainer);
            plugin.action(this);
        })
    }
    ...
    ...
}

3.修改三个控件插件:

改动:将函数变为对象,对象中包含两个函数:

  • render():渲染HTML
  • action():注册自定义事件
// 小圆点按钮插件
const pluginController = {
    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', (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.container.addEventListener('slide', (evt) => {
                    const idx = evt.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){
        // //当用户点击上一张时,停止定时器,然后执行slidePrevious()方法,让图片向前翻一张,然后重启定时器
        const previous = slider.container.querySelector('.slider-list__previous');
        if(previous){
            previous.addEventListener('click', (evt) => {
                slider.stop();
                slider.slidePrevious();
                slider.start();
                evt.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', (evt) => {
                slider.stop();
                slider.slideNext();
                slider.start();
                evt.preventDefault();
            });   
        }
    }
}

4.将调用过程改成:

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

以上就完成了对HTML的模块化,此时如果我们不想要小圆点控件,只需将其注释掉,项目中将不会再出现小圆点控件相关的UI与功能,当我们需要新的插件时只要重新写好再注册即可。现在组件的扩展性大大提高,但是要做到复用性还需要进一步优化。

抽象 —— 组件框架

  • 将组件通用模型抽象出来

HTML模板化中的registerPluginsrender方法是通用的,我们可以将它们抽离出来放在Component类中,然后让轮播组件Slider类通过继承该类实现其render方法。

1.父类:我们抽象出一个Component类,这个类体系相对完整,可以相当于是一个很小的组件框架。

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

2.子类:继承Component

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;
        }
        //返回选中的元素在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';
            }
            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);
    }
}

3.三个控件插件:这里代码没有做任何改动,详细可参考HTML模板化中的插件写法

4.将调用过程稍作修改:

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:2000
});
slider.registerPlugins(pluginController, pluginPrevious, pluginNext); 
slider.start();

总结:

经过了三次重构(插件化、模板化、抽象化),最后我们终于得到了一个同时具备封装性、正确性、扩展性、复用性的UI组件,但是该组件还存在可以改进的地方,比如CSS模板化、父子组件的状态同步和消息通信等等。