介绍

上篇文章通过一个深夜模式切换的示例,我们知道了写好 JS 的一些原则:

  • 让 HTML、CSS 和 JS 职能分离。
  • 应当避免不必要的由 JS 直接操作样式和 HTML。
  • 用 CSS 类(或伪元素)来表示状态。
  • 纯展示类交互可以寻求零 JS 方案。

这样不仅便于后续代码的维护扩展,而且可以做到代码简洁、可读性高。在开发过程中,写好 JS 还有两个重要的原则:组件封装、过程抽象。

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

如何实现一个电商网站的轮播图?

  1. HTML - 轮播图是一个典型的列表结构,我们可以使用无序列表 ul 元素来实现。
  2. CSS - 使用绝对定位将图片重叠在同一个位置,添加 CSS 类表示轮播图切换的状态,轮播图的切换动画使用 CSS transition。
  3. 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,这样更易于扩展。

t0196498fb325ccb123.png

将 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)。、

总结

  1. 实现一个组件的步骤:结构设计、展现效果、行为设计。
  2. 组件设计的原则:正确性、封装性、扩展性、复用性。
  3. 组件重构的原则:插件化(扩展性)、模板化(易于扩展)、抽象化(复用性)。