Skip to main content
  1. Posts/

JS之组件封装

·5 分钟

遥想当初第一次用js写的功能组件就轮播图,在掘金看到大佬怎样一步步封装抽象轮播图,实在是太优雅了,终于体会到看人家敲代码是种享受的感觉。

初步实现 #

大佬是以京东商城的轮播图为例子实现的。 我特意去淘宝首页看了一眼,过去我的轮播图效果和淘宝的是一样的,都是滚动切换, 而京东的是设置透明度实现切换效果。

图片切换 #

首先是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样式

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

js代码控制轮播

class Slider {
  constructor(id) {
    // 轮播图的dom容器
    this.container = document.getElementById(id)
    // 轮播图中的所有轮播对象dom
    this.items = this.container.querySelectorAll('.slider-list__item--selected, .slider-list__item')
  }
  // 获取当前选中的轮播对象
  getSelectedItem() {
    const selected = this.container.querySelector('.slider-list__item--selected')
    return selected
  }
  // 获取当前选中的轮播对象的下角标
  getSelectedItemIndex() {
    return Array.from(this.items).indexOf(this.getSelectedItem())
  }
  // 滚动至指定下角标的图片
  slideTo(inx) {
    const selected = this.getSelectedItem()
    if(selected) {
      selected.className = 'slider-list__item'
    }
    const item = this.items[inx]
    if(item) {
      item.className = 'slider-list__item--selected'
    }
  }
  // 滚动到下一张图
  slideNext() {
    const currentInx = this.getSelectedItemIndex()
    // 通过取余的方式获得下长图的下角标
    const nextInx = (currentInx + 1) % this.items.length
    this.slideTo(nextInx)
  }
  // 滚动到上一张图
  slidePrevious() {
    const currentInx = this.getSelectedItemIndex()
    // 为了防止下角标为负的,前面再加一个所有轮播对象的总数再取余
    const previousInx = (this.items.length + currentInx - 1) % this.items.length
    this.slideTo(previousInx)
  }
}

// 调用构造函数,每2s切换一次图片
const mySlider = new Slider('my-slider')
setInterval(() => {
    mySlider.slideNext()
}, 2000)

写了一个构造函数,设计的方法遵守指责单一和可扩展性的原则,定义了几个方法,简单明了。 这样图片就能自动切换了,但功能完整的轮播图上还应该有两个小组件

控制流 #

轮播图
下方的指示标和鼠标移入后的左右切换按钮。

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

js代码主要的变化是加了两个方法控制轮播的开始和结束

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

原方法里只有一个地方发生了改变

slideTo(inx) {
    const selected = this.getSelectedItem()
    if(selected) {
      selected.className = 'slider-list__item'
    }
    const item = this.items[inx]
    if(item) {
      item.className = 'slider-list__item--selected'
    }
    // 设置自定义事件,方便监听图片切换
    const detail = {index: inx}
    const event = new CustomEvent('slide', {bubbles:true, detail})
    this.container.dispatchEvent(event)
  }

之所以用自定义事件是为了解耦。 然后是新添加的组件控制流相关代码

constructor(id, cycle = 3000) {
    this.container = document.getElementById(id)
    this.items = this.container.querySelectorAll('.slider-list__item--selected, .slider-list__item')
    this.cycle = cycle

    const controller = this.container.querySelector('.slide-list__control')
    if (controller) {
      const buttons = controller.querySelectorAll('.slide-list__control-buttons--selected, .slide-list__control-buttons')
      controller.addEventListener('mouseover', evt => {
        const idx = Array.from(buttons).indexOf(evt.target)
        if (idx >= 0) {
          this.slideTo(idx)
          this.stop()
        }
      })

      controller.addEventListener('moseout', () => {
        this.start()
      })

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

都是监听组件上的鼠标移入移出事件和点击事件,实现图片的切换以及组件样式的变化。 以上,一个轮播图就实现了。 但对于这个组件来说,添加和删除控制按钮需要修改的代码太多了,对于后期扩展和维护来说体验都很不好, 接下来就要重构代码,让它更易扩展。

重构 #

将之前添加的控制按钮都插件化。

插件化 #

第一步是把控制按钮相关的方法都从构造函数中抽出来

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

插件和组件之间,通过依赖注入的方式建立联系,也就是在构造函数中写一个注册插件的方法

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(...pulgins) {
        plugins.forEach(plugin => plugin(this))
    }
    // ...其它方法
}

调用时

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

如果后续需要添加新的插件,就无需修改构造函数,直接在外部写函数,然后调用registerPlugins方法注册到组件上就好了。 但仍然有个问题,那就是修改组件不仅需要修改js代码,还需要改动html代码,还可以继续封装,做到扩展时只需要添加删除js部分的代码。

模板化 #

将html那部分模板化,那么实际调用组件时的html代码就只剩一行

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

js代码部分,既然要模板化html,那就是动态生成html,给构造函数传入的参数会多一个images,添加render方法生成dom,注册插件的方法也会有所不同。

class Slider{
  constructor(id, opts = {images:[], cycle: 3000}){
    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;
    // 动态生成dom后需要调用slideTo方法,设置选中的第一张图片,否则直到调用下一个slideTo方法后,会一直是空白的
    this.slideTo(0);
  }
  render(){
    const images = this.options.images;
    // 根据传入的图片自动生成html代码
    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);
    });
  }
//   ...其它方法
}

插件注册的方法变了,现在的设计是,传入的插件必须有两个方法:renderaction

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

如果是以前的我,能做到这里就已经很牛逼了,然后迫不及待地上线。 但对于大佬说,这并不是终点。

抽象通用组件框架 #

通用组件
由上图可以看出,通用组件的设计中包含两个方法:

  • registerPlugins(...plugins):注册插件
  • render():渲染html

抽象出来的通用组件代码如下:

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

Slider继承自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函数
  render(data){
    const content = data.map(image => `
      <li class="slider-list__item">
        <img src="${image}"/>
      </li>    
    `.trim());
    
    return `<ul>${content.join('')}</ul>`;
  }
  // ...其它方法
}

上面的Component类已经是一个组件框架了,它支持定义一个组件和注册插件的功能。

总结 #

根据上面几个步奏,我们已经完成了组件的插件化、模板化和抽象化。

这个抽象出来的框架虽小,但对于日常业务来说完全够用了。

但它并没有考虑到组件嵌套,这里直接将插件分离出来了。

如果想要进阶完善,可以将插件作为子组件整合进框架里。

另外,模板化这里只考虑到了html,如果要修改组件还要手动修改css,可以思考如何将css也进行模板化。