写好JS的三大原则之「过程抽象」|青训营笔记

108 阅读6分钟

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

写好JS的三大原则之「过程抽象」

大家好,这里是即将写完三大原则的Vic,今天给大家带来三大原则中的最后一条过程抽象

0BBEC016.gif

什么是过程抽象

过程抽象的定义

首先,我们需要了解什么是抽象,抽象就是从众多的事物中抽取出共同的、本质性的特征,而舍弃其非本质的特征的过程

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

这种说法似乎太过于抽象,因此我们将用生活中的例子来作为解释。比如说小明的一天就是吃饭、睡觉、打豆豆,在这里,我们就可以通过过程抽象将吃饭、睡觉、打豆豆这三个过程抽象出来,因为每天小明都会用到这三个过程。

在这里,我们是用一个上课时候的例子进行讲解。

todoList案例.gif

如图所示,是一个todoList的案例。其JS部分的代码如下:

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事件,但由于第一次已经移除了,所以之后就找不到要移除的元素了。

针对这个问题,我们需要对操作次数进行限制,即让每个选项只会执行一次removeChild事件。

如何实现这个效果呢?我们可以使用addEventListener中的once参数,其作用是使得addEventListener中的事件只触发一次。

但是,这样修改存在着两个问题。第一,我们需要在原函数上进行修改,如果这个函数使我们写的,那可能很方便就能够进行修改,但实际工作中,我们往往需要针对别人写的代码进行维护,因此很难对原有代码进行修改。第二,这个方法也存在着很多的兼容性问题,并不是一个很好的方案。

因此,我们需要将“只执行一次”这个过程抽象出来,这个过程,我们就称之为过程抽象

只执行一次函数once的实现

我们对once函数的逻辑进行分析,在这个函数中,我们需要传入一个函数fn,之后返回一个函数,在这个函数中,我们控制fn只执行一次,执行完毕后就将fn设为空值。因此再调用第二次fn的时候,就无法再执行了。其实现代码如下:

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

通过这样操作,我们就将只执行一次这个过程抽象出来了,这个抽象函数once可以运用在一切只需要执行一次的函数上。在使用中,只需要使用once函数对需要的函数进行包裹即可。

这个once函数就是一个典型的高阶函数。那么什么是高阶函数呢?在下一小节,我们将介绍高阶函数

高阶函数

高阶函数简写为HOF,其特征是以函数作为参数,且其返回值也是一个函数,一般在使用中,高阶函数常常用作为函数装饰器使用。像我们之前写的once函数就是一个函数装饰器。

image.png

在这里,我们先介绍高阶函数的等价范式

高阶函数等价范式

其代码如下所示:

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

通过这段代码,我们返回了一个作用一样的函数,实现了函数的拷贝工作。可以发现,这段代码和之前写的once代码很相似。这是由于,我们的高阶函数都是在这个等价范式的框架上变更而来的。

下面,我们介绍几个常见的高阶函数。

常见的高阶函数

throttle节流函数

节流函数的关键点在于让一段时间只发生一次事件,因此,我们编写代码如下:

function throttle(fn, time = 500) {
  let timer; // 定义一个计时器
  return function(...args){
    if(timer === null) {
      fn.apply(this, args);
      timer = setTimeout(() => {
        timer = null;
      }, time)
    }
  }
}

这段代码的逻辑很简单,就是创建一个计时器,当计时器存在的时候,函数为空,若计时器不存在或计时器已经被销毁了,那么就返回一个目标函数的拷贝函数并设置一个计时器。

debounce防抖函数

防抖函数的关键点在于让一个事件在停止一段时间后再触发,代码如下:

function debounce(fn, time = 100) {
  var timer;
  return function(){
    // 清除计时器
    clearTimeout(timer);
    // 重新设置计时器
    timer = setTimeout(() => {
      fn.apply(this, arguments);
    }, time);
  }
}

这段代码的逻辑就是设置一个计时器,当事件触发的时候就将计时器清空,若计时器一直没被清除的话,规定时间后,事件触发。事件触发后,计时器自动销毁。

consumer消费函数

消费函数的关键点是让事件一个一个按顺序的触发,代码如下:

function consemer(fn, time) {
  // 定义一个队列与计时器
  let taskQueue = [],
      timer;
  
  return function(...args){
    // 将事件放入队列中
    taskQueue.push(fn.bind(this, ...args));
    if(timer === null) {
      // 设置循环处理事件的计时器
      timer = setInterval(() => {
        // 从队列中取出事件并执行
        taskQueue.shift().call(this);
        // 队列为空时,清除计时器,并将计时器置为空,为下次消费做准备
        if (taskQueue.length <= 0) {
          clearInterval(timer);
          timer = null;
        }
      }, time)
    }
  }
}

这段代码的逻辑通过注释已经比较清晰了,在这里就不做分析了。

iterative迭代器函数

迭代器函数的关键点在于判断对象是否可以迭代,伪代码如下:

function iterative(fn) {
  return function(subject, ...rest) {
    // 如果对象是可迭代对象,就对对象进行一次迭代
    if(isIterative(subject)) {
      const ret = [];
      // 对对象进行迭代
      for(let obj of subject) {
        ret.push(fn.apply(this, [obj, ...rest]));
      }
      return ret;
    }
    return fn.apply(this, [subject, ...rest]);
  }
}

注意:上面是一段伪代码,实际使用中还需要添加判断是否为可迭代对象的函数。

高阶函数的优势

在解释高阶函数的优势之前,我们需要先解释一下,什么是纯函数。纯函数指对外部环境不存在依赖,也不会对外部环境造成改变,不管任何时候,只要参数确定的情况下,其返回的结果都是确定的,这种函数就是纯函数。

因此在进行单元测试的时候,如果是一个纯函数,我们就可以不使用上下文环境直接对函数进行测试。

而正如上面的例子,我们可以看到,我们的高阶函数都是纯函数。

编程范式

编程范式一般分为两类:命令式声明式。命令式更加关注如何做(what),而声明式式则侧重于做什么(how)。

在JS中既可以使用声明式编程,也可以使用命令式编程。

当处理的逻辑较为复杂时,此时推荐使用声明式编程,因为这样抽象程度更高,扩展性更强。

下面就是一个命令式的例子:

switcher.onclick = function(evt){
  if(evt.target.className === 'on'){
    evt.target.className = 'off';
  }else{
    evt.target.className = 'on';
  }
}

其效果如下所示:

命令式.gif

这里是一段声明式的例子:

function toggle(...actions){
  return function(...args){
    let action = actions.shift();
    actions.push(action);
    return action.apply(this, args);
  }
}

switcher.onclick = toggle(
  evt => evt.target.className = 'off',
  evt => evt.target.className = 'on'
);

实现的效果是一样的,这样看来似乎声明式更加复杂,但并不是这样。当我们需要切换的状态增加时,这时声明式的优越性就体现出来了。其代码如下所示:

function toggle(...actions){
  return function(...args){
    let action = actions.shift();
    actions.push(action);
    return action.apply(this, args);
  }
}

switcher.onclick = toggle(
  evt => evt.target.className = 'off',
  evt => evt.target.className = 'on',
  evt -> evt.target.className = 'wait'
);

总结

在这篇文章中,我们对三大原则中的最后一个原则过程抽象进行了学习,主要学习了如何使用高阶函数,其有利于我们实现对原有代码的复用。

在实际编程中,我们应多使用纯函数与声明式代码