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();
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。