Desmond

Desmond

An introvert who loves web programming, graphic design and guitar
github
bilibili
twitter

组件封装的艺术

封装性、正确性、扩展性、复用性

轮播图#

在线演示

确定 UI 组件的 HTML 结构#

<div class="slider">
  <ul>
    <li class="slider__item--selected">
      <img src="https://p5.ssl.qhimg.com/t0119c74624763dd070.png"/>
    </li>
    <li class="slider__item">
      <img src="https://p4.ssl.qhimg.com/t01adbe3351db853eb3.jpg"/>
    </li>
    <li class="slider__item">
      <img src="https://p2.ssl.qhimg.com/t01645cd5ba0c3b60cb.jpg"/>
    </li>
    <li class="slider__item">
      <img src="https://p4.ssl.qhimg.com/t01331ac159b58f5478.jpg"/>
    </li>
  </ul>
  <a class="slider__next"></a>
  <a class="slider__previous"></a>
  <div class="slider__control">
    <span class="slider__control-buttons--selected"></span>
    <span class="slider__control-buttons"></span>
    <span class="slider__control-buttons"></span>
    <span class="slider__control-buttons"></span>
  </div>
</div>

设置元素的样式#

.slider {
  position: relative;
  width: 790px;
  height: 340px;
}

.slider ul {
  list-style-type:none;
  position: relative;
  width: 100%;
  height: 100%;
  padding: 0;
  margin: 0;
}

.slider__item,
.slider__item--selected {
  position: absolute;
  transition: opacity 1s;
  opacity: 0;
  text-align: center;
}

.slider__item--selected {
  transition: opacity 1s;
  opacity: 1;
}

.slider__next,
.slider__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; /*设置透明度变化的动画,时间为.5秒*/
}

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

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

.slider:hover .slider__previous {
  opacity: 1;
}

.slider:hover .slider__next {
  opacity: 1;
}

.slider__previous:after {
  content: '<';
}

.slider__next:after {
  content: '>';
}

.slider__control{
  position: relative;
  display: table; /* table 布局*/
  background-color: rgba(255, 255, 255, 0.5);
  padding: 5px;
  border-radius: 12px;
  bottom: 30px;
  margin: auto;
}

.slider__control-buttons,
.slider__control-buttons--selected{
  display: inline-block;
  width: 15px;
  height: 15px;
  border-radius: 50%;/*设置为圆形*/
  margin: 0 5px;
  background-color: white;
  cursor: pointer;
}

.slider__control-buttons--selected {
  background-color: red;
}

设计 API#

class Slider {
  constructor({container}) {
    this.container = container;
    this.items = Array.from(container.querySelectorAll('.slider__item, .slider__item--selected'));
  }

  /*
    通过选择器`.slider__item--selected`获得被选中的元素
  */
  getSelectedItem() {
    const selected = this.container.querySelector('.slider__item--selected');
    return selected;
  }

  /*
    返回选中的元素在items数组中的位置。
  */
  getSelectedItemIndex() {
    return this.items.indexOf(this.getSelectedItem());
  }

  slideTo(idx) {
    const selected = this.getSelectedItem();
    if(selected) { // 将之前选择的图片标记为普通状态
      selected.className = 'slider__item';
    }
    const item = this.items[idx];
    if(item) { // 将当前选中的图片标记为选中状态
      item.className = 'slider__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 container = document.querySelector('.slider');
const slider = new Slider({container});
setInterval(() => {
  slider.slideNext();
}, 3000);

实现用户控制#

我们将构造器修改为下面这样:

constructor({container, cycle = 3000} = {}) {
  this.container = container;
  this.items = Array.from(container.querySelectorAll('.slider__item, .slider__item--selected'));
  this.cycle = cycle;

  const controller = this.container.querySelector('.slider__control');
  const buttons = controller.querySelectorAll('.slider__control-buttons, .slider__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__control-buttons--selected');
    if(selected) selected.className = 'slider__control-buttons';
    buttons[idx].className = 'slider__control-buttons--selected';
  });

  const previous = this.container.querySelector('.slider__previous');
  previous.addEventListener('click', (evt) => {
    this.stop();
    this.slidePrevious();
    this.start();
    evt.preventDefault();
  });

  const next = this.container.querySelector('.slider__next');
  next.addEventListener('click', (evt) => {
    this.stop();
    this.slideNext();
    this.start();
    evt.preventDefault();
  });
}
start() {
  this.stop();
  this._timer = setInterval(() => this.slideNext(), this.cycle);
}

stop() {
  clearInterval(this._timer);
}

修改 slideTo 方法,加入自定义事件触发:

slideTo(idx) {
  const selected = this.getSelectedItem();
  if (selected) {
    selected.className = 'slider__item';
  }
  const item = this.items[idx];
  if (item) {
    item.className = 'slider__item--selected';
  }

  const detail = {index: idx};
  const event = new CustomEvent('slide', {bubbles: true, detail});
  this.container.dispatchEvent(event);
}

最后将调用过程改成:

const slider = new Slider({container});
slider.start();

组件的插件化#

对于这个图片轮播组件来说,它的插件化可以是将用户控制组件从 Slider 组件中剥离出来,做成插件,这样才能提高 Slider 组件的可扩展性。

class Slider {
  constructor({container, cycle = 3000} = {}) {
    this.container = container;
    this.items = Array.from(container.querySelectorAll('.slider__item, .slider__item--selected'));
    this.cycle = cycle;
  }

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

  /*
    通过选择器`.slider__item--selected`获得被选中的元素
  */
  getSelectedItem() {
    const selected = this.container.querySelector('.slider__item--selected');
    return selected;
  }

  /*
    返回选中的元素在items数组中的位置。
  */
  getSelectedItemIndex() {
    return this.items.indexOf(this.getSelectedItem());
  }

  slideTo(idx) {
    const selected = this.getSelectedItem();
    if(selected) {
      selected.className = 'slider__item';
    }
    const item = this.items[idx];
    if(item) {
      item.className = 'slider__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);
  }
}

/* 小圆点控件 */
function pluginController(slider) {
  const controller = slider.container.querySelector('.slider__control');
  if(controller) {
    const buttons = controller.querySelectorAll('.slider__control-buttons, .slider__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__control-buttons--selected');
      if(selected) selected.className = 'slider__control-buttons';
      buttons[idx].className = 'slider__control-buttons--selected';
    });
  }
}

function pluginPrevious(slider) {
  const previous = slider.container.querySelector('.slider__previous');
  if(previous) {
    previous.addEventListener('click', (evt) => {
      slider.stop();
      slider.slidePrevious();
      slider.start();
      evt.preventDefault();
    });
  }
}

function pluginNext(slider) {
  const next = slider.container.querySelector('.slider__next');
  if(next) {
    next.addEventListener('click', (evt) => {
      slider.stop();
      slider.slideNext();
      slider.start();
      evt.preventDefault();
    });
  }
}

const container = document.querySelector('.slider');
const slider = new Slider({container});
slider.registerPlugins(pluginController, pluginPrevious, pluginNext);
slider.start();

如果,有一天,产品经理又需要对这个组件添加新的用户控制,比如,添加一个按钮叫 “试试手气”,点击该按钮,让轮播图随机切换到一张图片上,那么们我们只需要这样做:

在 HTML 代码中增加 “试试手气 “这个按钮:

<button class="lucky">试试手气</button>

然后创建这个插件:

function  pluginLucky(slider) {
  const luckyBtn = document.querySelector('.lucky');
  if (luckyBtn) {
    luckyBtn.addEventListener('click', evt => {
      slider.stop();
      slider.slideTo(Math.floor(Math.random() * slider.items.length));
      slider.start();
      evt.preventDefault();
    });
  }
}

最后将它注册到 slider 中去即可:

slider.registerPlugins(pluginController, pluginPrevious, pluginNext, pluginLucky);

组件的模板化#

image

class Slider {
  constructor({container, images = [], cycle = 3000} = {}) {
    this.container = container;
    this.data = images;
    this.container.innerHTML = this.render(this.data);
    this.items = Array.from(this.container.querySelectorAll('.slider__item, .slider__item--selected'));
    this.cycle = cycle;
    this.slideTo(0);
  }

  render(images) {
    const content = images.map(image => `
      <li class="slider__item">
        <img src="${image}"/>
      </li>    
    `.trim());

    return `<ul>${content.join('')}</ul>`;
  }

  registerPlugins(...plugins) {
    plugins.forEach((plugin) => {
      const pluginContainer = document.createElement('div');
      pluginContainer.className = 'slider__plugin';
      pluginContainer.innerHTML = plugin.render(this.data);
      this.container.appendChild(pluginContainer);
      plugin.initialize(this);
    });
  }

  /*
    通过选择器`.slider__item--selected`获得被选中的元素
  */
  getSelectedItem() {
    const selected = this.container.querySelector('.slider__item--selected');
    return selected;
  }

  /*
    返回选中的元素在items数组中的位置。
  */
  getSelectedItemIndex() {
    return this.items.indexOf(this.getSelectedItem());
  }

  slideTo(idx) {
    const selected = this.getSelectedItem();
    if(selected) {
      selected.className = 'slider__item';
    }
    const item = this.items[idx];
    if(item) {
      item.className = 'slider__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);
  }
}

const pluginController = { // 小圆点插件
  render(images) { // 随着图片数量的增加,小圆点元素也需要增加
    return `
      <div class="slider__control">
        ${images.map((image, i) => `
            <span class="slider__control-buttons${i === 0 ? '--selected' : ''}"></span>
        `).join('')}
      </div>    
    `.trim();
  },

  initialize(slider) {
    const controller = slider.container.querySelector('.slider__control');

    if(controller) {
      const buttons = controller.querySelectorAll('.slider__control-buttons, .slider__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__control-buttons--selected');
        if(selected) selected.className = 'slider__control-buttons';
        buttons[idx].className = 'slider__control-buttons--selected';
      });
    }
  },
};

const pluginPrevious = {
  render() {
    return '<a class="slider__previous"></a>';
  },

  initialize(slider) {
    const previous = slider.container.querySelector('.slider__previous');
    if(previous) {
      previous.addEventListener('click', (evt) => {
        slider.stop();
        slider.slidePrevious();
        slider.start();
        evt.preventDefault();
      });
    }
  },
};

const pluginNext = {
  render() {
    return '<a class="slider__next"></a>';
  },

  initialize(slider) {
    const previous = slider.container.querySelector('.slider__next');
    if(previous) {
      previous.addEventListener('click', (evt) => {
        slider.stop();
        slider.slideNext();
        slider.start();
        evt.preventDefault();
      });
    }
  },
};

const 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'];

const container = document.querySelector('.slider');
const slider = new Slider({container, images});
slider.registerPlugins(pluginController, pluginPrevious, pluginNext);
slider.start();

模板化之后,代码的可扩展性得到了进一步的提升。现在,如果我们增加或者减少轮播图片数量,只需要修改数据中 images 数组的元素个数。如果我们需要或者不需要某个插件,我们只需要修改传给 registerPlugins () 方法的参数即可。

设计组件框架#

为了提高组件的复用性,我们需要为组件设计一个统一的规范。实现这个统一的规范,我们可以通过设计一套通用的组件机制,并以这套机制为原则构建一个库。这个通用机制实际上提供了代码设计和抽象的一套通用规范,而遵循这套规范的基础库,实际上就是完整的 UI 组件框架。

image

class Component {
  static name = 'component';

  constructor({container, data, parent = null} = {}) {
    this.data = data;
    this.container = container;
    this.container.innerHTML = this.render(this.data);
  }

  registerSubComponents(...Comps) {
    const data = this.data;
    const container = this.container;
    this.children = this.children || [];
    Comps.forEach((Comp) => {
      const subContainer = document.createElement('div');
      const sub = new Comp({container: subContainer, data, parent: this});
      container.appendChild(subContainer);
      this.children.push(sub);
    });
  }

  render(data) {
    /* abstract */
    return '';
  }
}

class Slider extends Component {
  static name = 'slider';

  constructor({container, images = [], cycle = 3000} = {}) {
    super({container, data: images});
    this.items = Array.from(this.container.querySelectorAll('.slider__item, .slider__item--selected'));
    this.cycle = cycle;
    this.slideTo(0);
  }

  render(images) {
    const content = images.map(image => `
      <li class="slider__item">
        <img src="${image}"/>
      </li>    
    `.trim());

    return `<ul>${content.join('')}</ul>`;
  }

  getSelectedItem() {
    const selected = this.container.querySelector('.slider__item--selected');
    return selected;
  }

  getSelectedItemIndex() {
    return this.items.indexOf(this.getSelectedItem());
  }

  slideTo(idx) {
    const selected = this.getSelectedItem();
    if(selected) {
      selected.className = 'slider__item';
    }
    const item = this.items[idx];
    if(item) {
      item.className = 'slider__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);
  }
}

class SliderController extends Component {
  static name = 'slider__control';

  constructor({container, data, parent: slider}) {
    super({container, data});

    const buttons = container.querySelectorAll('.slider__control-buttons, .slider__control-buttons--selected');
    container.addEventListener('mouseover', (evt) => {
      const idx = Array.from(buttons).indexOf(evt.target);
      if(idx >= 0) {
        slider.slideTo(idx);
        slider.stop();
      }
    });

    container.addEventListener('mouseout', (evt) => {
      slider.start();
    });

    slider.container.addEventListener('slide', (evt) => {
      const idx = evt.detail.index;
      const selected = container.querySelector('.slider__control-buttons--selected');
      if(selected) selected.className = 'slider__control-buttons';
      buttons[idx].className = 'slider__control-buttons--selected';
    });
  }

  render(images) {
    return `
      <div class="slider__control">
        ${images.map((image, i) => `
            <span class="slider__control-buttons${i === 0 ? '--selected' : ''}"></span>
        `).join('')}
      </div>    
    `.trim();
  }
}

class SliderPrevious extends Component {
  constructor({container, parent: slider}) {
    super({container});
    const previous = container.querySelector('.slider__previous');
    previous.addEventListener('click', (evt) => {
      slider.stop();
      slider.slidePrevious();
      slider.start();
      evt.preventDefault();
    });
  }

  render() {
    return '<a class="slider__previous"></a>';
  }
}

class SliderNext extends Component {
  constructor({container, parent: slider}) {
    super({container});
    const previous = container.querySelector('.slider__next');
    previous.addEventListener('click', (evt) => {
      slider.stop();
      slider.slideNext();
      slider.start();
      evt.preventDefault();
    });
  }

  render() {
    return '<a class="slider__next"></a>';
  }
}

const 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'];

const container = document.querySelector('.slider');
const slider = new Slider({container, images});
slider.registerSubComponents(SliderController, SliderPrevious, SliderNext);
slider.start();

References:
https://juejin.cn/book/6891929939616989188/section/6891940071075741700

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.