JavaScript编码原则 | 青训营

128 阅读9分钟

各司其职

html/css/js​​分离,各司其职。以一个更换主题样式的按钮为例:

最开始会考虑采用DOM按钮绑定js事件的方法:

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

但是这种使用js修改样式的方法会使得代码内容混乱,复用性不高。对新的需求,如要新增一个样式主题,或者新增某个主题的样式,就需要新增js代码。

所以考虑将在css中编写两种主题样式,通过js切换类名的方法:

const btn = doucument.getElmentById('modeBtn');
btn.addEventListener('click', (e)=>{
  const body = document.body;
  if(body.className === 'daymode'){
    body.className = 'nightmode';
  } else {
    body.classNamw = 'daymode'
  }
})

这样做比第一种方法节省了许多代码量,但是没有完全做到css和js分离,各司其职。

有一种可以只使用css更换主题的方法:在主题更换按钮前添加勾选框,使用伪类checked​​作为主题的标识。

<body>
  <input id="modeCheckBox" type="checkBox" />
  <div class="container">
     <label for="modeCheckBox">{{ mode }}</label>
     ...  <!--主体内容-->
  </div>
</body>
#modeCheckBox: checked + .container {
  backgrond-color: black;
  color: white;
  transition: all 1s;
}

组件封装

以轮播图组件为例:

  • html代码:

    <!--轮播图主体-->
    <div id="my-slider" class="slider-list">
      <ul>
        <li class="slider-list__item--selected">
           <img src="img1.png">
        </li>
        <li class="slider-list__item">
           <img src="img2.png">
        </li>
        <li class="slider-list__item">
           <img src="img3.png">
        </li>
        <li class="slider-list__item">
           <img src="img4.png">
        </li>
      </ul>
    </div>
    <!--轮播图底部用于控制的圆点-->
    <div class="slide-list__control">
       <span class="slide-list__control-buttons--slected"></span>
       <span class="slide-list__control-buttons"></span>
       <span class="slide-list__control-buttons"></span>
       <span class="slide-list__control-buttons"></span>
    </div>
    
  • css代码:

    #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;
    }
    .slider-list__control-buttons--selected,
    .slider-list__control-buttons{
      background-color: grey;
      transition: background-color 1s; 
    }
    .slider-list__control-buttons--selected{
      background-color: red;
      transition: background-color 1s;
    }
    
  • js代码:

    • 定义一个轮播图类Slider​,给类定义修改状态的API
    • 创建轮播图类实例,将实例和dom​元素联系起来
    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 controler = this.container.querySelector('.slider-list__control');
            // 四个圆点个体
            if(controler){
                const buttons = this.controler.querySelectorAll('.slider-list__control-buttons, slider-list__control-buttons__selected');
                //鼠标悬停时,轮播暂停,显示悬停索引指向的图片
                controler.addEventListener('mouseover', e=>{
                    const ind = Array.form(buttons).indexOf(e.target);
                    if(ind >= 0){
                        this.slideTo(ind);
                        this.stop();
                    }
                })
                //鼠标离开时,轮播继续
                controler.addEventListener('mouseout', e=>{
                    this.start();
                })
                //自定义事件slide表示图片轮播,下面将轮播图播放和红点的显示联系起来
                this.container.addEventListener('slide', e=>{
                    const ind = e.detail.index;
                    const selected = controler.querySelector('.slide-list__conctrol-buttons-selected');
                    if(selected) selected.className = '.slide-list__control-buttons';
                    buttons[ind].className = '.slide-list__conctrol-buttons-selected';
                })
            }
        }
        getSelectedItem(){
            const selected = this.container.querySelector('.slider-list__item--selected');
            return selected;
        }
        getSelectedItemIndex(){
            return Array.from(this.items).indexOf(this.getSelectedItem());
        }
        slideTo(ind){
            const selected = this.getSelectedItem();
            if(selected){
                selected.className = '.slider-list__item'
            } 
            const item = this.items[idx];
            if(item){
                item.className = '.slider-list__item--selected';
            }
    
            // 自定义事件slide:表示轮播图在进行图片轮播的状态
            const event = new CustomEvent('slide', { 
                bubbles: true, 
                detail: {index: ind}
            });
            this.container.dispatchEvent(event);
        }
        slideNext(){
            const currentInd = this.getSelectedItemIndex();
            const nextInd = (currentInd + 1) % this.items.length;
            this.slideTo(nextInd);
        }
        sliderPrevious(){
            const currentInd = this.getSelectedItemIndex();
            const preInd = (this.items.length + currentInd - 1) % this.items.length;
            this.slideTo(preInd);
        }
        stop(){
            clearInterval(this._timer);
        }
        start(){
            this.stop();
            this._timer = setInterval(()=>this.slideNext(), this.cycle)
        }
    }
    
    //将html元素和轮播图类联系起来
    const slider = new Slider('my-slider');
    slider.start();
    

对于上面的代码,可以从三个方面进行优化:

  1. 插件化

    组件拆卸,保留主体,其余部分抽取成可添加到主体上的插件

    比如,将轮播图中的图片列表作为主体,而底部圆点抽取成插件,通过依赖注入的方式添加到整个组件中

    // 将轮播图类的构造函数中创建底部圆点的部分提取出来
    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){
          plugins.forEach(plugin => plugin(this));
        }
        ...
    }
    
    // 对应底部圆点插件的创建方法
    function pluginController(slide) {
        const controler = this.container.querySelector('.slider-list__control');
        if (controler) {
            const buttons = this.controler.querySelectorAll('.slider-list__control-buttons, slider-list__control-buttons__selected');
            //鼠标悬停时,轮播暂停,显示悬停索引指向的图片
            controler.addEventListener('mouseover', e => {
                const ind = Array.form(buttons).indexOf(e.target);
                if (ind >= 0) {
                    this.slideTo(ind);
                    this.stop();
                }
            })
            //鼠标离开时,轮播继续
            controler.addEventListener('mouseout', e => {
                this.start();
            })
            //自定义事件slide表示图片轮播,下面将轮播图播放和红点的显示联系起来
            this.container.addEventListener('slide', e => {
                const ind = e.detail.index;
                const selected = controler.querySelector('.slide-list__conctrol-buttons-selected');
                if (selected) selected.className = '.slide-list__control-buttons';
                buttons[ind].className = '.slide-list__conctrol-buttons-selected';
            })
        }
    }
    
    const slider = new Slider('my-slider');
    // 注册底部圆点的插件
    slider.registerPlugins(pluginController);
    slider.start();
    

    这样写如果要自己增加其他的小插件比如前一页和后一页的按钮,就可以直接在类外新增pluginPrevious/pluginNext​方法,然后通过registerPlugins​注入插件。使用组件的时候可以更自由的增删组件主体上的其他功能,而不会将组件写死,或者频繁修改类的构造函数导致类的代码冗杂。

  2. 模板化

    在插件化的基础上,将可以动态添加(如图片列表、插件)的html部分抽象出来,在创建组件的时候再动态添加html模板,避免手动修改html代码:

    <!--html中只剩下外部容器->
    <!--轮播图主体-->
    <div id="my-slider" class="slider-list">
      <!--<ul>
        <li class="slider-list__item--selected">
           <img src="img1.png">
        </li>
        <li class="slider-list__item">
           <img src="img2.png">
        </li>
        <li class="slider-list__item">
           <img src="img3.png">
        </li>
        <li class="slider-list__item">
           <img src="img4.png">
        </li>
      </ul>-->
    </div>
    <!--轮播图底部用于控制的圆点-->
    <!-- <div class="slide-list__control">
       <span class="slide-list__control-buttons--slected"></span>
       <span class="slide-list__control-buttons"></span>
       <span class="slide-list__control-buttons"></span>
       <span class="slide-list__control-buttons"></span>
    </div> -->
    

    在轮播图类中新建render()​函数用于动态生成html​代码,将轮播图中的图片列表作为参数传入:

    class Slider{
        constructor(id, opts = {images: [], cycle: 3000}){
            // 轮播图容器
            this.container = document.getElementById(id);
            // 动态生成html
            this.container.innerHTML = this.render();
            // 获取传入的参数对象
            this.options = opts;
            // 轮播图播放的图片
            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>`;
        }
        ...
    }
    
    const slider = new Slider('my-slider', {images: [img1, img2, img3], cycle: 3000});
    // 注册底部圆点的插件
    slider.registerPlugins(pluginController);
    slider.start();
    

    插件动态生成html代码:

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

    插件作为模块:

    // puginController不再是一个函数
    const pluginController = {
        // 动态生成插件html
        render(images){
            return `
               <div class="slide-list__control">
                  ${images.map((img, i)=> `
                  <span class="slide-list__control-buttons${i==0?'--selected':''}"></span>
               `).join('')}
               </div>
            `.trim();
        },
        // 实现html交互
        action(slider){
            const controler = this.container.querySelector('.slider-list__control');
            if (controler) {
                const buttons = this.controler.querySelectorAll('.slider-list__control-buttons, slider-list__control-buttons__selected');
                controler.addEventListener('mouseover', e => {
                    const ind = Array.form(buttons).indexOf(e.target);
                    if (ind >= 0) {
                        this.slideTo(ind);
                        this.stop();
                    }
                })
                controler.addEventListener('mouseout', e => {
                    this.start();
                })
                this.container.addEventListener('slide', e => {
                    const ind = e.detail.index;
                    const selected = controler.querySelector('.slide-list__conctrol-buttons-selected');
                    if (selected) selected.className = '.slide-list__control-buttons';
                    buttons[ind].className = '.slide-list__conctrol-buttons-selected';
                })
            }
        }
    }
    

    这样大大简化了html中的代码,也实现了对组件的封装,避免使用者直接修改组件的内容

  3. 通用组件抽象

    在前面两者的基础上,可以总结出组件通用的部分并抽象出来,形成组件的模板/框架,比如:

    • 组件的构成包含两部分:Component + ComponentPlugins
    • 组件所需的接口包括:constructor() + render() + registerPlugins()
    • 组件插件所需接口包括:render() + action()

过程抽象

  1. 高阶函数&函数装饰器

    高阶函数:

    • 以函数为返回值
    • 以函数为返回值
    • 常用于函数装饰器

    形成一个独立的作用域,可以单独给内部函数(即返回的函数)使用

    function HOF0(fn) {
      return function(...args){
        return fn.appy(this, args);
      }
    }
    

    用高阶函数进行过程抽象,常见几种应用:

    • Once​​

      限制函数只执行一次:将需要执行的函数fn​作为参数传入once​函数中,在once​函数内部,判断fn​若不为null​,则执行fn​并将其设为null​,否则不做操作;当第一次执行完once​函数后,fn​被设为null​,此后就不再执行:

      // once函数定义
      function once(fn){
        return function(...args){
          if(fn){
            const ret = fn.apply(this, args);
            // 将函数设置为null
            fn = null;
            return ret;
          }
        }
      }
      
      // 使用once函数
      const func = once(()=>{
        console.log('inner');
      })
      func();
      func();
      func();
      // 三次调用只打印一次'inner'
      
    • Throttle

      节流函数:函数fn​规定每500毫秒可以记录一次,即在一段连续点击中,只有与上次点击间隔了500毫秒才会触发点击事件。将fn​作为throttle​函数的参数传入,在throttle​函数中,设置timer​变量(该变量在外部函数作用域中),每次触发fn​时检查timer​是否为null​,为null​则执行并间隔时间销毁timer​,若不为null​则不执行操作,从而实现节流:

      function throttle(fn, time = 500){
        let timer;
        return function(...args){
          if(timer==null){
            fn.apply(this, args);
            timer = setTimeout(()=>{
              timer = null;
            }, time)
          }
        }
      }
      
    • Debounce​​

      防抖函数:和节流类似,但是在连续不断的点击中,节流是不断间隔时间触发点击事件,防抖是仅在最后一次触发。将触发的事件函数fn​作为参数传入debounce​函数,在debounce​函数内部,规定每间隔一个dur​时间触发fn​;但是每次执行fn​函数前,都会将原有的timer​清除,因此前一次触发fn​的timer​还没有到达时间可能被清除了,只有在最后一次触发fn​时顺利执行完:

      function debounce(fn, dur){
        dur = dur || 100;
        var timer;
        return function(){
          clearTimeout(timer);
          timer = setTimeout(()=>{
            fn.apply(this, arguments);
          }, dur)
        }
      }
      
    • Consumer​​

      函数消费器(同步执行任务变为异步执行):要实现add​函数多次但是按一定时间间隔执行,将add​函数作为consumer​函数的参数传入,返回的是consumerAdd​函数,每次执行consumerAdd​函数时,都会先将“执行add​”这一任务放入tasks​数组中,在设置定时器(setInterval​)间隔一定时间从tasks​中取出一个任务并执行。设置timer==null​的判断保证只有在存入第一个任务(即开始存任务那一刻)才设置一个定时器,直到所有任务执行完定时器才销毁。

      这里传入this​的作用是保存每个任务的上下文。因为每执行依次add​都会使ref​中的value​改变,而每个任务执行时都是在当前最新的状态,即前一个任务执行完成了的状态下,才能执行的。所以传入this​保证每次都是用最新的value​值。

      function consumer(fn, time){
        // tasks用于存储需要执行的函数,timer用于存储定时器的引用,控制函数的执行间隔
        let tasks = [], 
            timer;
        return function(...args){
          // bind:生成一个新函数,该函数会以fn的内容执行,但this指向传入的this
          tasks.push(fn.bind(this, ...args));
          if(timer == null){
            timer = setInterval(()=>{
              // shift+call:弹出第一个任务,并执行
              task.shift().call(this);
              if(task.length <= 0){
                 clearInterval(timer);
                 timer = null;
              }
            }, time)
          }
        }
      };
      
      // 使用consumer函数
      // 加法函数
      function add(){
        const v = ref.value + x;
        console.log(`${ref.value} + ${x} = ${v}`);
        return ref;
      };
      // 每个1s执行一次加法并打印,而不是一次性将所有结果打印出来
      let consumerAdd = consumer(add, 1000);
      const ref = {value: 0};
      for(let i = 0; i <10; i++){
        consumerAdd(ref, i);
      };
      
    • Iterative

      可迭代函数:要实现将索引为奇数的元素修改为红色,若含多个元素(为可迭代的元素数组),则将返回修改后的可迭代数组;若只有一个元素,则只返回修改后的元素。

      //检查是否为可迭代对象
      const isIterable = obj => obj != null 
        && typeof obj[Symbol.iterator] === 'function';
      
      // iterative函数
      function iterative(){
        return function(subject, ...rest){
          // 可迭代对象:迭代执行
          if(isIterableSubject){
            const ret = [];
            for(let obj of subject){
              ret.push(fn.apply(this, [obj, ...rest]));
            }
            return ret;
          }
          // 不可迭代对象:只执行一次
          return fn.apply(this, [subject, ...rest]);
        }
      }
      
      // 使用iterative
      const setColor = iterative((el, color) => {
        el.style.color = color;
      })
      // 选中索引为奇数的元素并修改为红色
      const els = document.querySelectorAll('li:nth-child(2n+1)');
      setColor(els, 'red');
      
  2. 编程范式

    • 命令式编程

      更偏向于如何做,包括面向对象和面向过程两种

      如下面实现一个按钮点击切换状态的事件:

      button.onclick = function(e){
        if(e.target.className == 'on'){
          e.target.className = 'off';
        } else {
          e.target.className = 'on';
        }
      }
      
    • 声明式编程

      更偏向于做什么,可扩展性更强,增删改更便捷

      同样实现一个按钮点击切换状态的事件:

      function toggle(...actions){
        return function(...args){
          // 将所有的状态放入actions队列中
          // 每次点击触发点击事件,实现队列头的状态,再将队列头的状态取出放在队列未
          // 实现每次点击都轮换状态的效果
          let action = actions.shift();
          actions.push(action);
          return action.apply(this, args);
        }
      }
      
      button.onclick = toggle(
        // 表示两种状态
        evt => evt.target.className = 'off',
        evt => evt.target.className = 'on'
        // ...新增其他状态
        // evt => evt.target.className = 'other'
      );
      

      当有新的状态需要添加时,命令式编程中需要新增其他的if-else​语句,而在声明式编程中,只需要在toggle​中新增状态