【第二届字节青训营】写好 JS 原则 - 组件封装

154 阅读6分钟

写好 JS 的一些原则

  • 各司其责
  • 组件封装
  • 过程抽象

组件封装

组件是指 Web 页面上抽出来一个个包含模板(HTML)、功能(JS)和样式(CSS)的单元。

好的组件具备封装性,正确性、扩展性、复用性

现在又来看一个例子:

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

版本一

结构:HTML

轮播图是一个典型的列表结构,可以使用无序列表 <ul> 元素来实现

<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),轮播图的切换动画使用 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;
}

行为设计:API

API 设计应保证原子操作,职责单一,满足灵活性

Slider 类的大致职责如下:

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

现在再在 HTML 中加一段 JS 代码就可以看到效果了

const slider = new Slider('my-slider');

setInterval(() => {
  slider.slideNext();
}, 1000);

效果图如下:

版本二

结构:HTML

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

  <a class="slide-list__next"></a>
  <a class="slide-list__previous"></a>

  <div class="slide-list__control">
    <span class="slide-list__control-buttons--selected"></span>
    <span class="slide-list__control-buttons"></span>
    <span class="slide-list__control-buttons"></span>
    <span class="slide-list__control-buttons"></span>
  </div>
</div>

表现:CSS

#my-slider {
  position: relative;
  width: 790px;
  height: 340px;
}

.slider-list ul {
  list-style-type: none;
  position: relative;
  width: 100%;
  height: 100%;
  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;
}

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

.slide-list__next,
.slide-list__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;
  background: transparent;
  color: white;
  background: rgba(0, 0, 0, 0.2);
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.5s;
}

.slide-list__previous {
  left: 0;
}

.slide-list__next {
  right: 0;
}

#my-slider:hover .slide-list__previous {
  opacity: 1;
}

#my-slider:hover .slide-list__next {
  opacity: 1;
}

.slide-list__previous:after {
  content: "<";
}

.slide-list__next:after {
  content: ">";
}

.slide-list__control-buttons,
.slide-list__control-buttons--selected {
  display: inline-block;
  width: 15px;
  height: 15px;
  border-radius: 50%;
  margin: 0 5px;
  background-color: white;
  cursor: pointer;
}

.slide-list__control-buttons--selected {
  background-color: red;
}

行为设计:控制流

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

现在就可以在 HTML 加上一点代码开始实现

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

向前翻页功能的插件

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

最后对 Slider 类进行一点修改

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

  /*
   more code
   */
}

现在就可实现了,再在 HTML 中添加一点代码

const slider = new Slider('my-slider');

 // 注册三个插件
slider.registerPlugins(pluginController, pluginPrevious, pluginNext);
slider.start();

重构二:模板化

第一次重构可以实现效果,JS 的扩展性是较好的,但是 HTML 中代码没有扩展性

现在就不是在 HTMl 代码中定义有几张图片,而是将图片传给 JS,让 JS 来动态渲染

重写 Slider

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

  // 注册插件
  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);
    });
  }

  /*
    more code
   */
}

重构 pluginController 函数(控制圆点的函数)

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.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 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 中代码就不需要写太多标签

 <div id="my-slider" class="slider-list"></div>

然后再在 HTML 中执行 JS 代码

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

重构三:组件框架

这里就用到的抽象(将通用的组件模型抽象出来)

这个类图简单的描述了函数之间的关系

SliderPlugin 与 Component 是关联关系;Slider 继承自 Component

Component 类组件

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

现在写 Slider 类,它是继承自 Component 类

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

接下来就只用直接使用了

<body>
  <div id="my-slider" class="slider-list"></div>

  <script src="./index.js"></script>
  <script>
    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();
  </script>
</body>

现在这段代码可扩展性是比较高的(月影 nb)

以上就是三个原则中的组件封装

最后

JS 的写法还真是不少,想要写好还需要长时间的磨练🤣。

若有不当之处,欢迎评论指出