写好 JS 的三大原则 | 青训营笔记

87 阅读8分钟

这是我参与「第四届青训营」笔记创作活动的第11天

三大原则

各司其职

前端 JS 的主要作用就是为了跟用户完成交互过程,所以首先写好 JS 的一步就是 “各司其职”,什么是 “各司其职” 呢?比如 HTML 是页面结构,CSS 是页面样式,而 JS 就是页面交互逻辑,我们就不能用 JS 去操作 CSS,而 JS 只做属于它自己的部分这就是 “各司其职”。

组件封装

当下前端各种框架下,拿 Vue 来举例,在它当中就会有一个重要的概念,那就是组件化思想,什么是组件化思想呢?就是会将一整个页面,可以把它们单独封装成一个个独立的组件,这些独立的组件会向外暴露一些 props等属性,这样在使用这些组件的时候就可以传递不同的参数,从而展示出不一样的结果。合理的组件封装可以大大提高我们的开发效率。组件封装最重要的就是为了让 UI 组件具有更强的扩展性,复用性等特点。

过程抽象

过程抽象简单理解就是,将众多事物当中抽取出共同的、本质性的特征,而舍弃其非本质的特征的过程。

过程抽象就是将用来处理局部细节控制的一些方法,它是函数式编程思想的基础应用

image.png

各司其职

下面我们就使用栗子的形式来讲解 “各司其职” 这个概念

image.png

上面图片就是当我们点击标题右侧的按钮时,实现页面背景色的一个切换,可能我们第一个想到的就是使用 js 来改变 背景色,如下:

  const btn = document.getElementById('modeBtn');
  btn.addEventListener('click', (e) => {
    const body = document.body;
    if(e.target.innerHTML === '🌞') {
      body.style.backgroundColor = 'black';
      body.style.color = 'white';
      e.target.innerHTML = '🌜';
    } else {
      body.style.backgroundColor = 'white';
      body.style.color = 'black';
      e.target.innerHTML = '🌞';
    }
  });

很显然,虽然可以实现,但是会给我们一种感觉就是代码量很多,下面是经过优化的一个版本,改用控制class类名的方式来完成切换效果

 const btn = document.getElementById('modeBtn');
  btn.addEventListener('click', (e) => {
    const body = document.body;
    if(body.className !== 'night') {
      body.className = 'night';
    } else {
      body.className = '';
    }
  });

现在的代码比上面的显然简洁了需要,但是这真的是最优的方案麻?

很显然不是,因为我们已经打破了一个原则,那就是 各司其职,这种操作样式的事情不应该交给 JS 来完成,而是由 CSS 来完成,下面是 CSS 的实现

首先需要在页面当中增加一个 checkbox

  <input id="modeCheckBox" type="checkbox">

并且使用一个 label 来引用这个 checkbox

    <header>
      <label id="modeBtn" for="modeCheckBox"></label>
      <h1>深夜食堂</h1>
    </header>

下面就是 css 实现主题切换效果

  #modeCheckBox {
    display: none;
  }

  #modeCheckBox:checked + .content {
    background-color: black;
    color: white;
    transition: all 1s;
  }

我们以后在写 JS 的时候也一定遵循以下过程,一些简单的样式操作的情况下,能避免使用 JS 操作就避免

  • HTML/CSS/JS各司其责
  • 应当避免不必要的由JS直接操作样式
  • 可以用class来表示状态
  • 纯展示类交互寻求零JS方案

组件封装

栗子:我们该怎么样去封装一个轮播图组件呢?

首先我们得考虑,我们该怎样去实现这个轮播图组件,实现完成之后,我们又该考虑,该如何去将它进行封装,使得它更加的独立于我们开发的项目之外,在任何项目中都能使用

轮播图结构

    <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>
      </ul>
    </div>

轮播图样式

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

轮播图逻辑代码 首先我们肯定会想到如下的实现逻辑,能实现,但是缺点也很明显,就是所有的逻辑都被集中在一起,很难进行维护于改进,如果需要进行一些功能的拓展,那就需要修改源代码的方式才能进行完成

class Slider{
    constructor(id){
      this.container = document.getElementById(id);
      this.items = this.container
      .querySelectorAll('.slider-list__item, .slider-list__item--selected');
    }
    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';
      }
    }
    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');
  slider.slideTo(3);

代码重构 -- 解耦

什么是解耦呢?

简单点理解就是为了降低功能于功能之间的耦合度,使得它们之间不具有那么高的耦合度,以上的代码它们就具有很高的耦合度

插件化:将轮播图切换的功能使用插件的形式注入进去,比如轮播图切换上一页下一页等功能,将它们抽离成插件的形式,然后统一进行一个注入进去

  // 获取容器
  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.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();
      });
    }  
  }

模块化:将 html 进行模块化,改为 js 动态生成,原因是因为,当我们传递轮播图 url 时并不知道会传递多少个,所以使用 js 动态生成的方式可以使得更加灵活可扩展

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

抽象化:将组件的通用模型进行抽离

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

组件设计的原则:封装性、正确性、扩展性、复用性实现组件的步骤:结构设计、展现效果、行为设计

  • 三次重构
    • 插件化
    • 模板化
    • 抽象化(组件框架)

过程抽象

过程抽象简单理解就是,将众多事物当中抽取出共同的、本质性的特征,而舍弃其非本质的特征的过程。

在了解了抽象的定义,我们再来看“过程抽象”这个词。过程抽象就是将用来处理局部细节控制的一些方法,它是函数式编程思想的基础应用。

下面我们来简单举例一下: image.png

上面案例是一个类似于 todolist 当我们点击左边的按钮时,会进行网络请求,然后来删除我们所要完成的任务列表,但是网络请求的时间是不固定的,下面我们就来通过定时器来模拟一下网络请求。

const list = document.querySelector('ul');
  const buttons = list.querySelectorAll('button');
  buttons.forEach((button) => {
    button.addEventListener('click', (evt) => {
      const target = evt.target;
      target.parentNode.className = 'completed';
      setTimeout(() => {
        list.removeChild(target.parentNode);
      }, 2000);
    });
  });

当点击右侧按钮时,会进行网络请求,这里是定时器操作,当我们定时器到达时间之后,就会将该节点进行删除,模拟后端删除该条任务,但是这样会存在问题,如果我们多次点击,就会出现下面报错

image.png

这个报错很显然出现的原因是因为我们多次点击了,但是在上一次的时候该节点可能已经被删除了,并没有了,由于延迟执行的原因导致,我们多次点击的时候,那个时间差里,该节点并没有被删除,但是当时间到达之后就会删除该节点,下一次在执行这次 removeChild 的时候,这个节点已经不存在了,所以才报了如上的错误信息。

那么我们如何处理这种错误呢?也很简单,我们只需要让绑定到该按钮上的函数只触发一次即可,但是我们可以想一想,这种函数如果直接写在监听事件的回调中真的麻?

答案肯定是不好的,因为这个函数不只是可以将这个事件监听内的回调使它执行一次,它也可以是其他的任何函数都只执行一次,因此我们需要将此函数进行抽离,因此我们会将这个过程称之为 过程抽象

下面我们就来实现一下这个函数

该函数会返回另一个函数,这个也被称之为 高阶函数 ,首先也很简单,首先先判断一下传递的函数是否存在,如果存在就使用 apply 将其进行执行,使用 apply 执行函数,只是为了 this 指向不会丢失,执行之后将 传递的函数 赋值为 null,并且返回其执行结果,赋值为 null 之后,如果下一次我们再次点击了,该函数已经为 null 了,所以不在执行了

  function once(fn) {
    return function(...args) {
      if(fn) {
        const ret = fn.apply(this, args);
        fn = null;
        return ret;
      }
    }
  }

现在我们使用 once 高阶函数来改进一下我们的代码

    button.addEventListener('click', once((e) => {
      const target = e.target;
      target.parentNode.className = 'completed';
      setTimeout(() => {
          list.removeChild(target.parentNode);
      }, 2000);
    }));

总结

首先我们想写好 JS 的前提下一定要考虑到 “各司其职” 这个原则,能使用 css 来完成的功能,一定不要使用 JS 来完成。组件封装思想,这个思想在软件开发当中应用广泛,通过封装,将一些公用的逻辑以及样式等等进行抽离,暴露出这些公共的一些属性等,方便日后维护和拓展。过程抽象化,将一些公共的逻辑进行抽离,封装成一个个独立于项目之外的功能,这个过程称之为过程抽象。