Loading...

# 如何写好 JavaScript (组件封装)

​ 组件是指 web 页面上抽出来的一个个包含模板 (HTML)、功能(JS)和样式(CSS)的单元,好的组件具备封装性、正确性、扩展性和复用性。虽然现在由于有很多优秀的组件存在,往往我们不需要去自己设计一个组件,但我们也要去试着了解他们的实现。

举个栗子:用原生 JS 写一个电商网站的轮播图,应该怎么实现?

  • 结构:HTML 中的无序列表( <ul>

    • 轮播图是典型的列表结构,可以用无序列表 <ul> 元素来实现,每个图放在一个 li 标签中。
    <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 绝对定位

    • 使用 CSS 的绝对定位,将图片重叠在一个位置
    • 切换状态使用修饰符(modifier)
      • selected
    • 轮播图切换动画使用 CSS transition 实现
    #my-slider{
      position: relative;
      width: 790px;
    }
    .slider-list ul{
      list-style-type:none;
      position: relative;
      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,可以线创建一个类,封装其中左滑右滑等具体操作,如下所示。

    // 创建一个 Slider 类,封装一些 API
    class Slider{
      constructor(id){
        this.container = document.getElementById(id);
        this.items = this.container
        .querySelectorAll('.slider-list__item, .slider-list__item--selected');
      }
      
      // 获取选中的图片元素:通过选择器 `.slider__item--selected` 获得被选中的元素
      getSelectedItem(){
        const selected = this.container
          .querySelector('.slider-list__item--selected');
        return selected
      }
      
      // 获取选中图片的索引值:返回选中的元素在 items 数组中的位置。
      getSelectedItemIndex(){
        return Array.from(this.items).indexOf(this.getSelectedItem());
      }
      
      // 跳转到指定索引的图片
      slideTo(idx){
        const selected = this.getSelectedItem();
        if(selected){ 
          // 将之前选择的图片标记为普通状态
          selected.className = 'slider-list__item';
        }
        const item = this.items[idx];
        if(item){
          // 将当前选中的图片标记为选中状态
          item.className = 'slider-list__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 slider = new Slider('my-slider');
    setInterval(() => { 
        slider.slideNext(); 
    }, 1000);

    接下来就是在 API 的代码基础上 加入控制流,让轮播图可以自动轮播,也可以手动控制,实现交互效果

    使用自定义事件来解耦

    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;
        const controller = this.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){
              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('.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();
          });
        }
      }
      getSelectedItem(){
        let selected = this.container.querySelector('.slider-list__item--selected');
        return selected
      }
      getSelectedItemIndex(){
        return Array.from(this.items).indexOf(this.getSelectedItem());
      }
      slideTo(idx){
        let selected = this.getSelectedItem();
        if(selected){ 
          selected.className = 'slider-list__item';
        }
        let item = this.items[idx];
        if(item){
          item.className = 'slider-list__item--selected';
        }
      
        const detail = {index: idx}
        const event = new CustomEvent('slide', {bubbles:true, detail})
        this.container.dispatchEvent(event)
      }
      slideNext(){
        let currentIdx = this.getSelectedItemIndex();
        let nextIdx = (currentIdx + 1) % this.items.length;
        this.slideTo(nextIdx);
      }
      slidePrevious(){
        let currentIdx = this.getSelectedItemIndex();
        let 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 slider = new Slider('my-slider');
    slider.start();

总结:组件封装需要注意其结构设计、展现效果、行为设计(API、Event 等)是否达标。

  • 结构设计 HTML
  • 展现效果 CSS
  • 行为设计 JavaScript
    • API (功能)
    • Event (控制流)

思考:如何来改进这个轮播图?

# 重构 1:插件化,解耦

上面解决方案的类中的构造器实在是太臃肿了,做了很多本来不应该它要做的事,所以我们考虑插件化,将构造器进行简化

先来看看之前的构造函数做了哪些事

constructor(id, cycle = 3000){
  
    this.container = document.getElementById(id);
    this.items = this.container.querySelectorAll('.slider-list__item, .slider-list__item--selected');
    this.cycle = cycle;
    
    // 对小圆点的操作控制流
    const controller = this.container.querySelector('.slide-list__control');
    
    if(controller){
      // 鼠标经过某个小圆点,就将此圆点对应的图片显示出来,并且停止循环轮播
      controller.addEventListener('mouseover', evt=>{
          // ...
      });
      
      // 鼠标移开小圆点,就继续开始循环轮播
      controller.addEventListener('mouseout', evt=>{
        this.start();
      });
      
      // 注册 slide 事件,将选中的图片和小圆点设置为 selected 状态
      this.container.addEventListener('slide', evt => {
          // ...
    }
    
    // 点击左边小箭头,翻到前一页
    const previous = this.container.querySelector('.slide-list__previous');
        // ...
    }
    
    // 点击右边小箭头,翻到后一页
    const next = this.container.querySelector('.slide-list__next');
        // ...
  }
  • 将控制元素抽取成一个个插件(左右小箭头、底下的四个小圆点)等等image.png

    解耦: 将控制元素抽取成插件; 插件与组件之间通过依赖注⼊方式建立联系

    我们要将用户控制的操作从组件中抽离出来,做成插件,这样就提高了组件的可扩展性!!!

    用户的控制组件分为三个部分可以抽离成三个插件。

    首先将小圆点的控制抽离成一个插件 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();
        });
        
        // 注册 slide 事件,将选中的图片和小圆点设置为 selected 状态
        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();
    });
  }  
}

将右翻页的控制抽离成插件 pluginNext

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(...plugins){
    // 这里的 this 就是组件的实例对象
    plugins.forEach(plugin => plugin(this));
  }
  getSelectedItem(){
    const selected = this.container.querySelector('.slider-list__item--selected');
    return selected
  }
  getSelectedItemIndex(){
    return Array.from(this.items).indexOf(this.getSelectedItem());
  }
  slideTo(idx){
    const selected = this.getSelectedItem();
    if(selected){ 
      selected.className = 'slider-list__item';
    }
    const item = this.items[idx];
    if(item){
      item.className = 'slider-list__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);  
  }
  addEventListener(type, handler){
    this.container.addEventListener(type, handler)
  }
  start(){
    this.stop();
    this._timer = setInterval(()=>this.slideNext(), this.cycle);
  }
  stop(){
    clearInterval(this._timer);
  }
}
// 下面是插件的定义
  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();
      });
      // 注册 slide 事件,将选中的图片和小圆点设置为 selected 状态
      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();
      });
    }
  }

最后使用的时候也很简单

const slider = new Slider('my-slider');// 内部通过 id 查找
  // 注册三个插件
  slider.registerPlugins(pluginController, pluginPrevious, pluginNext);
  slider.start();

此时的构造函数已经精简了,我们将 JS 进行了解耦,通过注册插件 registerPlugins 来使用各种插件(控件)~

这种将依赖对象传入插件初始化函数的方式,叫做依赖注入,这是一种组件与插件解耦的基本思路

进行插件化之后,我们可以任意组合我们想要的插件,比如我们将底部小圆点插件去除

  slider.registerPlugins(pluginPrevious, pluginNext);

可以看到下方的小圆点已经不生效了 (注意看上面动图小圆点已经不动了),但是这里有了新的问题,下方小圆点虽然失效了,但是没有消失,我们要是将小圆点也去除就要手动去操作 HTML 了~

所以我们要继续对组件进行重构!我们解耦 HTML,让 JavaScript 来渲染组件的结构 —— 模板化

# 重构 2:模板化

模板化

在组件中加入了 render() 渲染函数,用来渲染 HTML

class Slider {
  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);
  }
  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>`;
  }
}

这里将图片放入一个 images 数组中,这样就可以让组件拓展成 指定任意多的图片的 轮播图

接下来定义下三个插件(插件从一个函数变成一个对象,对象中有两个函数,一个渲染 HTML,一个注册自定义事件 JS)

下部小圆点的插件是这样定义的,插件中也要定义 render() 渲染函数, 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 结构,绑定 JS 事件行为

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

将 HTML 解耦后,我们的 HTML 就只需要一个盒子就可以了

<div id="my-slider" class="slider-list"></div>
复制代码

最后,我们是这样来使用这个组件的

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: 1000
});
slider.registerPlugins(pluginController, pluginPrevious, pluginNext);
slider.start();

完美其实还可以解耦 CSS,以后有时间再探索探索吧

# 重构 3:抽象化

将通用的组件模型,抽象出来一个组件类(Component),其他组件类通过继承该类并实现其 render 方法。

image.png

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

让轮播图组件 继承自 我们定义的通用组件

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(data) {
    const content = data.map(image => `
      <li class="slider-list__item">
        <img src="${image}"/>
      </li>    
    `.trim());
    return `<ul>${content.join('')}</ul>`;
  }
  getSelectedItem() {
    const selected = this.container.querySelector('.slider-list__item--selected');
    return selected
  }
  getSelectedItemIndex() {
    return Array.from(this.items).indexOf(this.getSelectedItem());
  }
  slideTo(idx) {
    const selected = this.getSelectedItem();
    if (selected) {
      selected.className = 'slider-list__item';
    }
    const item = this.items[idx];
    if (item) {
      item.className = 'slider-list__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);
  }
  addEventListener(type, handler) {
    this.container.addEventListener(type, handler);
  }
  start() {
    this.stop();
    this._timer = setInterval(() => this.slideNext(), this.cycle);
  }
  stop() {
    clearInterval(this._timer);
  }
}

三个插件

小圆点插件

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) {
    let controller = slider.container.querySelector('.slide-list__control');
    if (controller) {
      let buttons = controller.querySelectorAll(
        '.slide-list__control-buttons, .slide-list__control-buttons--selected');
      controller.addEventListener('mouseover', evt => {
        var 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;
        let 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) {
    let 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) {
    let 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', {
  name: 'slide-list',
  data: ['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: 1000
});
slider.registerPlugins(pluginController, pluginPrevious, pluginNext);
slider.start();

总结:

  • 组件设计的原则 —— 封装性、正确性、拓展性和复用性
  • 实现步骤:结构设计、展现效果、行为设计
  • 三次重构
    • 插件化
    • 模板化
    • 抽象化
  • 改进:CSS 模板化、父子组件的状态同步和消息通信等等

虽然现在有很多组件库比如 Vue 还有 React 中的组件模式,但是我们自己研究一下这里面的机制与原理对我们理解组件库以及 JavaScript 还是会很有帮助的!!

这样的不断解耦 JS 实现插件化,解耦 HTML 实现模板化,甚至还可以解耦 CSS,这中思路提供了代码设计和抽象的一套通用规范,而遵循这套规范的基础库,实际上就是完整的 UI 组件框架!!!。

# 最后附上这次的所有源码

初级

解耦

模板化

终极