Desmond

Desmond

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

The Art of Component Encapsulation

Encapsulation, correctness, scalability, reusability

Online Demo

Determine the HTML structure of the UI component#

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

Set the styles of the elements#

.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%; /* Positioned in the vertical center of the carousel component */
  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); /* Set to semi-transparent */
  cursor: pointer; /* Set to show as a hand when hovering over this element */
  opacity: 0; /* Initial state is transparent */
  transition: opacity .5s; /* Set the opacity change animation time to .5 seconds */
}

.slider__previous {
  left: 0; /* Positioned at the far left of the slider element */
}

.slider__next {
  right: 0; /* Positioned at the far right of the slider element */
}

.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 layout */
  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%;/* Set to circular */
  margin: 0 5px;
  background-color: white;
  cursor: pointer;
}

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

Design the API#

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

  /*
    Get the selected element by the selector `.slider__item--selected`
  */
  getSelectedItem() {
    const selected = this.container.querySelector('.slider__item--selected');
    return selected;
  }

  /*
    Return the position of the selected element in the items array.
  */
  getSelectedItemIndex() {
    return this.items.indexOf(this.getSelectedItem());
  }

  slideTo(idx) {
    const selected = this.getSelectedItem();
    if(selected) { // Mark the previously selected image as normal
      selected.className = 'slider__item';
    }
    const item = this.items[idx];
    if(item) { // Mark the currently selected image as selected
      item.className = 'slider__item--selected';
    }
  }

  /*
    Mark the next image as selected
  */
  slideNext() {
    const currentIdx = this.getSelectedItemIndex();
    const nextIdx = (currentIdx + 1) % this.items.length;
    this.slideTo(nextIdx);
  }

  /*
    Mark the previous image as selected
  */
  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);

Implement User Control#

We modify the constructor to look like this:

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

  /*
    Register slide event, set the selected image and small dot to selected state
  */
  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);
}

Modify the slideTo method to include custom event triggering:

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

Finally, change the calling process to:

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

Component Pluginization#

For this image carousel component, its pluginization can be achieved by separating the user control component from the Slider component and making it a plugin, thus enhancing the scalability of the Slider component.

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

  /*
    Get the selected element by the selector `.slider__item--selected`
  */
  getSelectedItem() {
    const selected = this.container.querySelector('.slider__item--selected');
    return selected;
  }

  /*
    Return the position of the selected element in the items array.
  */
  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);
  }

  /*
    Mark the next image as selected
  */
  slideNext() {
    const currentIdx = this.getSelectedItemIndex();
    const nextIdx = (currentIdx + 1) % this.items.length;
    this.slideTo(nextIdx);
  }

  /*
    Mark the previous image as selected
  */
  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);
  }
}

/* Dot control plugin */
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();

If one day, the product manager needs to add new user control to this component, such as adding a button called "Try Your Luck", which randomly switches to an image when clicked, we only need to do this:

Add the "Try Your Luck" button in the HTML code:

<button class="lucky">Try Your Luck</button>

Then create this plugin:

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

Finally, register it with the slider:

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

Component Templateization#

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

  /*
    Get the selected element by the selector `.slider__item--selected`
  */
  getSelectedItem() {
    const selected = this.container.querySelector('.slider__item--selected');
    return selected;
  }

  /*
    Return the position of the selected element in the items array.
  */
  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);
  }

  /*
    Mark the next image as selected
  */
  slideNext() {
    const currentIdx = this.getSelectedItemIndex();
    const nextIdx = (currentIdx + 1) % this.items.length;
    this.slideTo(nextIdx);
  }

  /*
    Mark the previous image as selected
  */
  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 = { // Dot plugin
  render(images) { // As the number of images increases, the dot elements also need to increase
    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();

After templateization, the code's scalability has been further enhanced. Now, if we increase or decrease the number of carousel images, we only need to modify the number of elements in the images array. If we need or do not need a certain plugin, we only need to modify the parameters passed to the registerPlugins() method.

Design Component Framework#

To improve the reusability of components, we need to design a unified specification for the components. To implement this unified specification, we can design a set of general component mechanisms and build a library based on this mechanism. This general mechanism actually provides a common specification for code design and abstraction, and the underlying library that follows this specification is essentially a complete UI component framework.

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();
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.