如何写好JavaScript :三大原则 -- 组件封装 | 青训营

71 阅读6分钟

概念


组件是指Web页面上抽出来一个个包含模版(HTML)、功能(JS)和样式(CSS)的单元

好的组件具备封装性、正确性、扩展性、复用性。

轮播图案例

版本1 -- 无交互版

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

CSS

  • 通过绝对定位将图片重叠到一个位置
  • 通过切换不同图片的样式类名,以改变当前显示图片的样式 --selected表示的是选中状态【即展示图片】
#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

api:

  • getSelectedItem() -- 获取当前选中的图片
  • getSelectedItemIndex() -- 获取当前选中图片的索引
  • slideTo() -- 跳转到指定索引的图片
  • slideNext() -- 跳转到下一索引的图片:将下一张图片标记为选中状态
  • slidePrevious() -- 跳转到上一索引的图片:将上一张图片标记为选中状态
 // 创建一个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);
        }
    }
    
    //自动播放
    const slider = new Slider('my-slider');
    setInterval(() => {
        slider.slideNext();
    }, 2000);

版本2 -- 交互版

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

<style>
    #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;
    }
</style>

JavaScript

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

总结

  • 结构设计 HTML

  • 展现效果 CSS

  • 行为设计 JavaScript

    • API (功能)
    • Event (控制流)

对于该轮播图的组件基本功能算是实现了,但是对于组件封装的概念,似乎还没有体现出来。

接下来,让我们一起思考,对于组件的封装性正确性扩展性复用性 我们如何实现呢?

改进

解耦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));
    }
    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('.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';
        });
    }
}

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

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

function pluginRandomGet(slider) {
    randomGet.addEventListener('click', () => {
        const idx = Math.floor(slider.items.length * Math.random());
        slider.stop();
        slider.slideTo(idx);
        slider.start();
    })
}


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

这样就可以自行配置需要哪些小组件或者不需要(比如左右切换按钮、底部的小圆点等)

但是目前依然还有一个缺点: 当不需要对应插件的时候,还得手动移出对应的dom结构,因此,还需要将HTML模板化,进行解耦。

解耦HTML -- 模板化

将HTML模板化,也就是让JavaScript来渲染组件的HTML,这样更易于扩展。

HTML

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

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

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

.slide-list__previous {
  left: 0;
}

.slide-list__next {
  right: 0;
}

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

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

JavaScript

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 => {
      const pluginContainer = document.createElement('div');
      pluginContainer.className = '.slider-list__plugin';
      pluginContainer.innerHTML = plugin.render(this.options.images);
      this.container.appendChild(pluginContainer);
      
      plugin.action(this);
    });
  }
  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';
    }
    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(){
    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){
    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();
      });
    }  
  }
};

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

此时,多了一个render()函数,现在你不需要手动删除(添加)对应的dom结构进行自定义轮播图组件了,并且你还可以自己定义使用几张图片以及哪几张图片进行轮播,满足了扩展性。

那么还有一点就是复用性没有完全的体现,接下来继续进行重构,将其抽象为一盒组件框架,增加其复用性

抽象 -- 组件框架

定义一个通用组件类

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

总结

对于上述的通用组件类,还有一定的优化空间,比如CSS的抽象,比如CSS in JS 的方式,或是其他方式。

  • 组件设计的原则:封装性、正确性、扩展性、复用性
  • 实现组件的步骤:结构设计、展现效果、行为设计 (封装性、正确性)
  • 三次重构:插件化(扩展性)、模板化(扩展性)、抽象化(复用性)