vlambda博客
学习文章列表

【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象

月影老师告诉我们写好JavaScript(包括其他语言)的三大原则 ① 各司其责 ② 组件封装 ③ 过程抽象

今天我们来学习写好JavaScript的最后一个原则——过程抽象

0. 起步

我们之前学习的两个原则,可以说都是对数据进行抽象,也就是将我们的数据抽象成对象or数组,将对象or数组传给插件或组件构造方法中使用。

接下来我们要谈论的是对过程进行抽象。

过程抽象⽤来处理局部细节控制的⼀些⽅法,是函数式编程思想的基础应⽤

来看这张有意思的图

image.png

开门这个过程(动作),我们是可以抽象的,因为有很多地方都可以用到这个过程(动作)。

和之前一样,我们通过例子来学习

1. 案例引入

限制操作

我们经常需要对操作次数进行限制,比如一些异步交互、一次性的HTTP请求

来看一个具体的需求:让用户勾选任务之后,任务会慢慢消失

【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
GIF 2021-8-27 12-53-15.gif
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); 
    }); 
});

效果虽然可以呈现,但是会有一个问题,如果我们多次快速点击同一个button,就会出现下面的错误

【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
image.png

元素没有消失,我们点击,它所绑定的事件就还会被触发,导致多次removeChild,所以会报错

那要怎么解决这个问题呢?我们可以让绑定的事件只在第一次点击的时候执行,之后点击都不执行。也就是说我们要让绑定的事件“只执行一次”。

我们可以设置addEventListeneronce参数(IE不行)

【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
image.png

我们也可以在回调函数中添加removeEventListener来在第一次执行之后去除绑定的事件

上面的方法虽然可以做到“一次执行”,但是在原有的代码中做了修改,有的兼容性也有问题,并且也不易于拓展。

为了能够让“只执⾏⼀次”的需求覆盖不同的事件处理,我们可以将这个需求剥离出来。这个过程我们称为过程抽象

Once一次性执行函数

【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
image.png

一次性执行函数Once

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

once函数接收的参数是一个函数fn,返回的是一个新的函数,在返回的函数中,做了一件事,就是让fn只执行一次,第二次执行的时候给fn已经被赋值为null,就无法被再次执行了。【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象

我们来使用这个once函数

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

这样,我们就将一次执行这个过程抽象出来了,任何需要一次执行的函数,只要在外面包一层once就可以实现,我们可以把once称为函数装饰器,什么是函数装饰器呢,我们下一小节探讨!

2. 高阶函数

定义

我们来看看高阶函数的定义

【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
image.png

以函数作为参数 或者 以函数作为返回值 的函数成为 高阶函数

上面说的一次性执行函数once就是一个高阶函数

同时满足两个条件的函数常⽤于作为函数装饰器

拓展

JS中数组的哪些API是高阶函数?

【答】everymapfilterforEachreducesort

HOF0 等价范式

HOF0是高阶函数的等价范式

function HOF0(fn
    return function(...args
        return fn.apply(this, args); 
    } 
}
【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
image.png

fnHOF0(fn) 是完全等价的,无论参数、调用上下文怎么变化,他们都是等价的!也就是说,执行fn与执行HOF0(fn)没有任何区别

可以看出我们的Once函数就是在HOF0上进行拓展出来的

常见高阶函数

除了Once函数,还有很多其他常用高阶函数

throttle函数

① 定义节流函数

将多次事件按照时间做平均分配触发

function throttle(fn, time = 500){
  let timer;
  return function(...args){
    if(timer == null){
      fn.apply(this,  args);
      timer = setTimeout(() => {
        timer = null;
      }, time)
    }
  }
}
【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
image.png

② 使用节流函数,只需要将函数进行包裹即可使用

btn.onclick = throttle(function(e){
  circle.innerHTML = parseInt(circle.innerHTML) + 1;
  circle.className = 'fade';
  setTimeout(() => circle.className = ''250);
});

③ 效果,连续多次点击只会每500ms记录一次

【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
GIF 2021-8-27 14-42-31.gif

debounce函数

① 定义防抖函数

适合多次事件一次响应的情况

function debounce(fn, time = 100){
  var timer;
  return function(){
    clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(thisarguments);
    }, time);
  }
}
【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
image.png

② 使用防抖函数

我们要让小鸟跟着鼠标走,但是却不是一直跟随,而是鼠标定在那里的时候小鸟才开始直线移动

var i = 0;
// 让鸟煽动翅膀
setInterval(function(){
  bird.className = "sprite " + 'bird' + ((i++) % 3);
}, 1000/10);

// 使用防抖函数
document.addEventListener('mousemove', debounce(function(evt){
  var x = evt.clientX,
      y = evt.clientY,
      x0 = bird.offsetLeft,
      y0 = bird.offsetTop;
  
  console.log(x, y);
  
  var a1 = new Animator(1000function(ep){
    bird.style.top = y0 + ep * (y - y0) + 'px';
    bird.style.left = x0 + ep * (x - x0) + 'px';
  }, p => p * p);
  
  a1.animate();
}, 100));

③ 效果

【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
GIF 2021-8-27 14-52-29.gif

更多关于节流与防抖的介绍,可以阅读我之前的博文【JS】函数节流与函数防抖[1]

consumer函数

① 定义consumer函数

这里的consumer函数,相当于将同步操作变成一个异步的操作

function consumer(fn, time){
  let tasks = [],
      timer;
  
  return function(...args){
    tasks.push(fn.bind(this, ...args));
    if(timer == null){
      timer = setInterval(() => {
        tasks.shift().call(this)
        if(tasks.length <= 0){
          clearInterval(timer);
          timer = null;
        }
      }, time)
    }
  }
}

② 用法1 逐步累加

这里的consumerAdd相当于是consumer化的add,这样就可以实现类似于异步的效果,相隔一秒执行一次add

function add(ref, x){
  const v = ref.value + x;
  console.log(`${ref.value} + ${x} = ${v}`);
  ref.value = v;
  return ref;
}

let consumerAdd = consumer(add, 1000);

const ref = {value0};
for(let i = 0; i < 10; i++){
  consumerAdd(ref, i);
}

③ 效果

【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
GIF 2021-8-27 14-54-33.gif

② 用法2 连击异步增加(快速点击慢慢执行)

btn.onclick = consumer((evt)=>{
  let t = parseInt(count.innerHTML.slice(1)) + 1;
  count.innerHTML = `+${t}`;
  count.className = 'hit';
  let r = t * 7 % 256,
      g = t * 17 % 128,
      b = t * 31 % 128;
  
  count.style.color = `rgb(${r},${g},${b})`.trim();
  setTimeout(()=>{
    count.className = 'hide';
  }, 500);
}, 800)

③ 效果

快速点击慢慢执行

【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
GIF 2021-8-27 14-58-36.gif

iterative函数

① 定义迭代器函数

这样我们就可以将一个操作函数包装成可以迭代使用的操作

function iterative(fn{
  return function(subject, ...rest{
    // 如果对象是可迭代对象,就迭代次对象
    if(isIterable(subject)) {
      const ret = [];
      for(let obj of subject) {
        ret.push(fn.apply(this, [obj, ...rest]));
      }
      return ret;
    }
    return fn.apply(this, [subject, ...rest]);
  }
}
【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
image.png

② 使用

我们将改变颜色这个一次操作,经过iterative装饰之后,传入的是可迭代对象是,就会迭代对象中的所有元素进行操作

const isIterable = obj => obj != null && typeof obj[Symbol.iterator] === 'function';
  
const setColor = iterative((el, color) => {
  el.style.color = color;
});

const els = document.querySelectorAll('li:nth-child(2n+1)');
setColor(els, 'red');

③ 效果

【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
image.png

拦截函数

除了函数修饰器,还可以定义函数拦截器

比如我们有一个库,里面的一个函数不推荐使用了,我们最好不要直接在代码库中修改我们原始的代码 我们最好对库中的函数进行一个拦截,也就是定义一个拦截器函数deprecate

定义

function deprecate(fn, oldApi, newApi
    const message = `The ${oldApi} is deprecated. Please use the ${newApi} instead.`
    return function(...args
        console.warn(message); 
        return fn.apply(this, args); 
    } 
}

使用

不修改代码本身,而是对这个API进行修饰,修饰的过程可以抽象为拦截它的输入或输出。

// 引入要废弃的 API 
import {foo, bar} from './foo'

// 用拦截器函数deprecate修饰
const _foo = deprecate(foo, 'foo''newFoo'); 
const _bar = deprecate(bar, 'bar''newBar'); 

// 重新导出修饰过的API 
export { foo: _foo, bar: _bar}

3. 纯函数

学习了那么多高阶函数,那我们为什么要用高阶函数呢?

在解释这个问题之前,我们先引出纯函数这个概念

定义

【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
image.png

一个严格的纯函数,是具有确定性、无副作用,幂等的特点。也就是说,纯函数不依赖外部环境,也不改变外部环境,不管调用几次,不管什么时候调用,只要参数确定,返回值就确定。这样的函数,就是纯函数。

我们来看一个例子

纯函数

function add(a, b{
    return a + b;
}
add(1,2// 3
add(1,2// 3

每次执行,结果都一样,改变顺序结果也都一样

有纯函数就有非纯函数,我们看一个非纯函数的例子

let x = 10
function foo({
    // 会改变函数上下文数据x
    return x++
}
function bar({
    return x * 10


foo() // 11
bar() // 110
bar() // 1100
foo() // 1101

每次执行,结果都不一样,改变次序结果也不一样

可以看出,我们通过HOF0等价范式拓展出来的高阶函数都是纯函数

纯函数的优势

可测试性

我们在进行单元测试的时候,如果是一个纯函数的话,我们可以不需要上下文环境直接进行测试,

而如果是非纯函数,我们还要构建好它的上下文环境

所以最佳实践就是 多写纯函数!!!

拓展

JS中数组的哪些API是纯函数?

【纯】concat、map、filter、slice

【非纯】push、pop、shift、unshift、forEach、some、every、reduce

4. 编程范式

分类

【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
image.png

命令式 关注怎么做 How (FPRTRAN、C、C++、Java)

声明式 关注做什么 What (Prolog、Haskell、Erlang)[这几个没听过-。-]

我们的JavaScript即可以写命令式的代码,也可以写声明式的代码~就是这么任性

处理复杂逻辑时,推荐使用声明式,抽象程度更高,拓展性更强

【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
image.png

我们来看一个案例

Toggle案例

【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
GIF 2021-8-27 15-02-29.gif

命令式

switcher.onclick = function(evt){
  if(evt.target.className === 'on'){
    evt.target.className = 'off';
  }else{
    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'
);

看上去声明式的更加繁琐,但是如果要给这个切换再加一个状态,使用声明式的方式就非常方便了

声明式编码方式扩展性更强

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 = 'warn',
  evt => evt.target.className = 'off',
  evt => evt.target.className = 'on'
);
【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象
GIF 2021-8-27 15-04-03.gif

5. 总结

image.png

过程抽象 / HOF / 装饰器

  • 不仅可以对数据进行抽象,也可以对过程进行抽象
  • 对函数的操作可以用 高阶函数抽象,利于复用且不修改原有函数代码(非侵入式)
  • 代码库中要多用 纯函数

命令式 / 声明式

  • JavaScript既可以编写 命令式的代码,也可以编写 声明式的代码。
  • 多写 声明式代码,抽象程度更高,拓展性更强
image.png

参考资料

[1]

【JS】函数节流与函数防抖: https://juejin.cn/post/7000170997412266021