为了账号安全,请及时绑定邮箱和手机立即绑定

前端工程师进阶要点四——过程抽象

标签:
JavaScript

个人总结:

高阶函数的特定是输入是函数,返回还是函数。输入的函数是实际的要执行的操作或要完成的功能的函数,但是该函数是作为过程被保留出来的,把原有的完整的处理代码中的共性的部分剥离出来(或抽象出来),即抽象过程,业务的具体的代码作为函数被保留下来,也就是作为高阶函数的参数函数传递进去;然后,高级函数中完成抽象的过程后,把传递给装饰函数(也就是返回函数)的作用域、参数,原封不动的传递到输入函数(也就是实际完成操作的具体的函数)中并执行,和直接执行输入函数是一样的效果(加上抽象出的过程场景的处理代码),因此书写上输入函数几乎就是原本要实现的代码函数。也就做到了不影响原函数的情况下,完成对原函数的包装(装饰),函数拦截器的实现即来源于此。oncedebouncethrottle的实现来源于纯粹的对过程抽象。纯函数则来源于将所有的影响外部环境的代码、非幂等的代码保留下来,抽象其他过程的实现。

高阶函数的返回函数值是做个包装,承载抽象出来的过程及连接原来函数的参数和作用域。

本部分看一下如何进行过程抽象。过程抽象可以提升系统的可维护性,同时简化代码的额外处理逻辑,减少逻辑陷阱。

太难了,到后面几乎看不懂,只能一步步反推,要是自己来设计实现(自己创建相关的高阶函数),感觉几乎是不可能的。。。

绑定事件只执行一次

事件处理函数只能执行一次的情况是很常见的。

比如下面的事项清单的处理:

<ul>
  <li><button><span>任务一:学习HTML</span></button></li>
  <li><button><span>任务二:学习CSS</span></button></li>
  <li><button><span>任务三:学习JavaScript</span></button></li>
</ul>
复制代码
ul {
  padding: 0;
  margin: 0;
  list-style: none;
}

li button {
  border: 0;
  background: transparent;
  outline: 0 none;
}

li.completed {
  transition: opacity 2s;
  opacity: 0;
}

li button:before {
    content: '';
    cursor: pointer;
    padding-right: 2px;
}

li.completed button:before {
  content: '';
  cursor: pointer;
  padding-right: 2px;
}
复制代码
const list = document.querySelector('ul');
const buttons = list.querySelectorAll('button');
buttons.forEach((button) => {
    button.addEventListener('click', (evt) => {
        const target = evt.currentTarget;
        target.parentNode.className = 'completed';
        setTimeout(() => {
            list.removeChild(target.parentNode);
        }, 2000);
    });
});
复制代码

效果如下:

但是,当点击后,在列表项消失前,如果快速点击多次列表元素,控制台会发生异常:

异常产生的原因是:当元素还没消失时,再次点击,会再次响应事件,因此执行事件处理函数,会启动多个setTimeout定时器。第一个定时器执行完后,该节点已经被移除,其他的定时器执行移除就会报错。

解决办法就是让click事件程序只执行一次

添加事件监听时指定once参数

const list = document.querySelector('ul');
const buttons = list.querySelectorAll('button');
buttons.forEach((button) => {
    button.addEventListener('click', (evt) => {
        const target = evt.currentTarget;
        target.parentNode.className = 'completed';
        setTimeout(() => {
            list.removeChild(target.parentNode);
        }, 2000);
    }, { once: true });
});
复制代码

低版本的浏览器可能不支持once参数。

使用removeEventListener方法

执行完事件后移除

const list = document.querySelector('ul');
const buttons = list.querySelectorAll('button');
buttons.forEach((button) => {
    button.addEventListener('click', function f(evt) {
        const target = evt.currentTarget;
        target.parentNode.className = 'completed';
        setTimeout(() => {
            list.removeChild(target.parentNode);
        }, 2000);
        target.removeEventListener('click', f);
    });
});
复制代码

使用元素的disabled属性

使用元素的disabled属性禁用目标元素,达到只允许点击一次的效果:

const list = document.querySelector('ul');
const buttons = list.querySelectorAll('button');
buttons.forEach((button) => {
    button.addEventListener('click', function f(evt) {
        const target = evt.currentTarget;
        target.parentNode.className = 'completed';
        setTimeout(() => {
            list.removeChild(target.parentNode);
        }, 2000);
        target.disabled = true;
    });
});
复制代码

事件方法只执行一次的其他使用地方。比如,购物车数据、或支付数据提交到服务器时,按钮点击后需要确保不再执行第二次:

但是,这些解决方式在不同的需求场景中,必须不断的重复。有没有通用的办法覆盖所有只需执行一次的需求呢?

函数装饰器——once函数

我们需要将"只执行一次"的处理过程从函数中剥离出来,这个过程称为过程抽象

once函数

function once(fn) {
  return function (...args) {
    if(fn) {
      const ret = fn.apply(this, args);
      fn = null;
      return ret;
    }
  };
}
// this指向函数的调用者,即调用function (...args)的对象
复制代码

这个once函数的参数fn是一个函数,即事件处理函数。once的返回值也是一个函数。这个返回函数就是 “只执行一次”的过程抽象。所以把这个返回函数称作是fn修饰函数,把once称为函数的装饰器

该代码的实现过程:事件触发,第一次调用fn的修饰函数,fn存在,于是执行fn,然后将fn设置为null,并返回fn的执行结果。当再次执行修饰函数时,fnnull,不会再次执行。从而实现只调用一次的过程。

这样,可以使用它实现前面的需求:

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

// 只提交一次的购物车数据、订单或支付数据等需求
formEl.addEventListener('submit', once((evt) => {
  fetch('path/to/url', {
    method: 'POST',
    body: JSON.stringify(formData),
    ...
  });
}));
复制代码

这样,将“只执行一次”的过程抽象出来后,不论是事件处理函数还是表单提交函数都只需要关注业务逻辑,而不需要添加target.disabled=falsetarget.removeEventListener等非业务逻辑的语句。

此外,还可以扩展once方法。比如在多于1次执行时,给出自定义的执行处理方法。如下,添加第二个回调,用于在多于1次执行后执行:

function once(fn, replacer = null) {
  return function (...args) {
    if(fn) {
      const ret = fn.apply(this, args);
      fn = null;
      return ret;
    }
    if(replacer) { // 额外执行
      return replacer.apply(this, args);
    }
  };
}
复制代码

如下一个对象的初始化方法,用户多次执行时,就可以抛出异常。

const obj = {
  init: once(() => {
    console.log('Initializer has been called.');
  }, () => {
    throw new Error('This method should be called only once.');
  }),
}

obj.init();
obj.init();
复制代码

节流和防抖函数装饰器

"节流和防抖"也是很常见的函数装饰器

节流

节流就是用于防止频繁的执行操作,限制执行的频率,(比如向服务器发送数据)。

  • 常规实现

比如,在鼠标移动事件中,我们要防止事件函数跟随移动操作频繁的执行:

const panel = document.getElementById('panel');

let throttleTimer = null;
panel.addEventListener('mousemove', (e) => {
  if(!throttleTimer) {
    // throttleTimer为空时,执行事件操作

    // throttleTimer指定一个定时器,事件函数需要在定时结束后,throttleTimer为空时,才能再次执行
    throttleTimer = setTimeout(() => {
      throttleTimer = null;
    }, 100);
  }
});
复制代码

使用这种定时器的方式实现了节流,但是并不通用。每次遇到节流的问题,都要复制代码并修改。

  • 节流装饰器方法

可以将节流的过程抽象出来,改为通用的节流装饰方法:

function throttle(fn, ms = 100) {
  let throttleTimer = null;
  return function (...args) {
    if(!throttleTimer) {
      const ret = fn.apply(this, args);
      throttleTimer = setTimeout(() => {
        throttleTimer = null;
      }, ms);
      return ret;
    }
  };
}
复制代码

throttle的第一个参数是函数,返回值也是函数,它返回的函数修饰了参数fn

这样,再次实现节流操作:

const panel = document.getElementById('panel');
panel.addEventListener('mousemove', throttle((e) => {
  // 事件响应操作
}));
复制代码

如果,给装饰器函数throttle的第二个参数指定Infinity值,则就是一个once函数。

Infinity是全局对象(global object)的一个属性,即它是一个全局变量。Infinity(正无穷大)大于任何值。

function throttle(fn, ms = 100) {
  let throttleTimer = null;
  return function(...args) {
    if(!throttleTimer) {
      const ret = fn.apply(this, args);
      throttleTimer = setTimeout(() => {
        throttleTimer = null;
      }, ms);
      return ret;
    }
  }
}

function once(fn) {
  return throttle(fn, Infinity); // 定时器永不过期的throttle
}
复制代码

防抖

防抖函数debounce和节流很相似,防抖实现的是取消(防止)多余的执行操作,只保留最后一次的执行。也就是如果事件(极短的时间内)连续触发多次,防抖函数可以防止多次(重复无用的)执行,只保留最后一次的操作。

最常见的应用是,窗体的resize事件,只在用户停止调整窗口大小时运行即可。

如下,是一个随着窗口带下不断绘制canvas的例子:

<div id="panel">
  <canvas></canvas>
</div>
复制代码
html, body {
  width: 100%;
  height: 100%;
  padding: 0;
  margin: 0;
}
#panel {
  width: 100%;
  height: 0;
  padding-bottom: 100%;
}
复制代码
const panel = document.getElementById('panel');
const canvas = document.querySelector('canvas');
function resize() {
  canvas.width = panel.clientWidth;
  canvas.height = panel.clientHeight;
}
function draw() {
  const context = canvas.getContext('2d');
  const radius = canvas.width / 2;
  context.save();
  context.translate(radius, radius);
  for(let i = radius; i >= 0; i -= 5) {
    context.fillStyle = `hsl(${i % 360}, 50%, 50%)`;
    context.beginPath();
    context.arc(0, 0, i, i, 0, Math.PI * 2);
    context.fill();
  }
  context.restore();
}

resize();
draw();

window.addEventListener('resize', () => {
  resize();
  draw();
});
复制代码

在画布Canvas上实现一个不同色彩叠加的圆环,且允许画布的大小随页面宽度重新调整并绘制。可以测试,拖动窗口宽度时,明显看到Canvas圆环的卡顿。

在拖拽窗口时,resize事件会反复触发,而每次触发的时候,Canvas都要重新绘制

如果i -= 5看不出卡顿效果,可以改为i -= 0.1

解决办法是,用户在操作过程中不绘制canvas,只在最后一次改变窗口大小后才重新绘制Canvas。这一过程就是防抖

  • 常规实现
let debounceTimer = null;
window.addEventListener('resize', () => {
  if(debounceTimer) clearTimeout(debounceTimer); //定时结束之前再次触发,则清除之前所有操作,重新定时执行对应的操作。
  debounceTimer = setTimeout(() => {
    resize();
    draw();
  }, 500);
});
复制代码

如上,实现Canvas的绘制只发生在最后一次操作之后,中间的操作Canvas绘制都不会触发。从而防止抖动现象。

  • 防抖装饰器方法
function debounce(fn, ms) {
  let debounceTimer = null;
  return function (...args) {
    if(debounceTimer) clearTimeout(debounceTimer);

    debounceTimer = setTimeout(() => {
      fn.apply(this, args);
    }, ms);
  };
}
复制代码

如下,实现防抖:

window.addEventListener('resize', debounce(() => {
  resize();
  draw();
}, 500));
复制代码
  • 节流和防抖的区别

  • 节流是让事件处理函数隔一个指定毫秒再触发。限制执行的频率

  • 防抖则忽略中间的操作,只响应用户最后一次操作。防止多次执行,只保留最后的一次操作

参数和返回值都是函数的函数,叫做高阶函数(High Ordered Functions)oncedebouncethrottle函数装饰器都是高阶函数。

函数拦截器

函数拦截器是高阶函数的另外一个应用。

函数拦截器的实现

有这样一种情况:一个维护的工具库面临一个重大升级,一部分API将发生变化或被废弃;但是很多业务或工具都使用了该版本,不可能一次升级完或者直接替换废弃旧API。必须平缓过渡——先不取消旧API,而是增加一个提示信息,告诉调用的用户该API将被废弃,必须进行升级。

使用console.warn输出提示信息:

function deprecate(oldApi, newApi) {
  const message = `The ${oldApi} is deprecated.
                  Please use the ${newApi} instead.`;
  console.warn(message);
}
复制代码

如上deprecate函数。如果某API要废弃,可修改代码如下:

export function foo() {
  deprecate('foo', 'bar');
  // do sth...
}
复制代码

调用foo,控制台会输出警告信息:

这样做逻辑上是可以的,但是,需要找到所有要废弃的API,然后每个手动添加deprecate方法,这会增加手误的风险、而且工作量大且繁琐。

最好的办法是,不改动原来的API,还能在调用废弃的API前显示提示信息!!!

// deprecation.js
// 引入要废弃的 API
import {foo, bar} from './foo';
...
// 用高阶函数修饰
const _foo = deprecate(foo, 'foo', 'newFoo');
const _bar = deprecate(bar, 'bar', 'newBar');

// 重新导出修饰过的API
export {
  foo: _foo,
  bar: _bar,
  ...
}
复制代码

将库中要废弃的API导入到deprecation模块中。然后将这些废弃的方法,和提示信息丢到deprecate这个沙箱中处理,返回一个修饰过的函数,并将这些函数以相同的名字导出。这样当其他用户调用这些方法时,就会先经过deprecate这个沙箱,显示提示信息,然后再执行foobar方法的内容。

deprecate函数实现如下:

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

  return function(...args) {
    notice(message);
    return fn.apply(this, args);
  }
}
复制代码

deprecate也是一个高阶函数。它输入一个fn函数,返回一个函数。fn就是要废弃的API。返回的函数是一个包含了打印提示信息,和fn调用的函数。

注意,还定义了notice = once(console.warn),用notice输出,这样的话,调用相同的函数只会在控制台显示一遍警告,避免了输出太多重复的信息

当我们想要修改函数库中的某个API,我们可以选择不修改代码本身,而是对这个API进行修饰,修饰的过程可以抽象为拦截它的输入或输出。

这和web开发中的拦截器的思路不谋而合。基于这个思路,可以设计一个简单的通用函数拦截器

function intercept(fn, {beforeCall = null, afterCall = null}) {
  return function (...args) {
    if(!beforeCall || beforeCall.call(this, args) !== false) { // beforeCall不存在,或执行后返回不为false
      // 如果beforeCall返回false,不执行后续函数
      const ret = fn.apply(this, args);
      if(afterCall) return afterCall.call(this, ret);
      return ret;
    }
  };
}
复制代码

intercept函数是一个高阶函数,它的第二个参数是一个对象,可以提供beforeCallafterCall两个拦截器函数,分别“拦截”fn函数的执行前执行后两个阶段。

在执行前阶段,可以通过返回false阻止fn执行; 在执行后阶段,可以用afterCall返回值替代fn函数返回值。

函数拦截器intercept的用途

随时监控一个函数的执行过程,不修改代码的情况下获取函数的执行信息:

function sum(...list) {
  return list.reduce((a, b) => a + b);
}

sum = intercept(sum, {
  beforeCall(args) {
    console.log(`The argument is ${args}`);
    console.time('sum'); // 监控性能
  },
  afterCall(ret) {
    console.log(`The resulte is ${ret}`);
    console.timeEnd('sum');
  }
});

sum(1, 2, 3, 4, 5);
复制代码

console.time:启动一个计时器来跟踪某一个操作的占用时长。每一个计时器必须拥有唯一的名字,页面中最多能同时运行10,000个计时器。当以此计时器名字为参数调用 console.timeEnd() 时,浏览器将以毫秒为单位,输出对应计时器所经过的时间。

console.time(timerName);

调整参数顺序

如下,使用拦截器重新调整定时器参数位置,生成一个新的定时器。

const mySetTimeout = intercept(setTimeout,  {
  beforeCall(args) {
    [args[0], args[1]] = [args[1], args[0]];
  }
});

mySetTimeout(1000, () => {
  console.log('done');
});
复制代码

校验函数的参数类型

const foo = intercept(foo, {
  beforeCall(args) {
    assert(typeof args[1] === 'string');
  }
});
复制代码

函数的“纯度”、可测试性和可维护性

batch函数及纯函数

如下两个工具函数为元素设置样式:

export function setStyle(el, key, value) {
  el.style[key] = value;
}

export function setStyles(els, key, value) {
  els.forEach(el => setStyle(el, key, value));
}
复制代码

它们有一个共同的缺点——那就是它们都依赖外部的环境(参数el元素),同时也改变这个环境。

如果要对这两个函数进行黑盒测试,必须为其构建测试环境,创建不同的DOM元素结构,这必定提高了工具库测试的成本。需要提高函数的测试性

要提高函数的可测试性,需要提高函数的纯度,即减少函数对外部环境的依赖,以及减少该函数对外部环境的改变。这样的函数成为纯函数

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

下面是对两个工具函数的重构:

function batch(fn) {
  return function(subject, ...args) {
    if(Array.isArray(subject)) {
      return subject.map((s) => {
        return fn.call(this, s, ...args);
      });
    }
    return fn.call(this, subject, ...args);
  }
}

export const setStyle = batch((el, key, value) => {
  el.style[key] = value;
});
复制代码

实现太经典了。

高阶函数的特定是输入是函数,返回还是函数。输入的函数是实际的要执行的操作或要完成的功能的函数,但是该函数是作为过程被保留出来的,把原有的完整的处理代码中的共性的部分剥离出来(或抽象出来),即抽象过程,业务的具体的代码作为函数被保留下来,也就是作为高阶函数的参数函数传递进去;然后,高级函数中完成抽象的过程后,把传递给装饰函数(也就是返回函数)的作用域、参数,原封不动的传递到输入函数(也就是实际完成操作的具体的函数)中并执行,和直接执行输入函数是一样的效果(加上抽象出的过程场景的处理代码),因此书写上输入函数几乎就是原本要实现的代码函数。也就做到了不影响原函数的情况下,完成对原函数的包装(装饰),函数拦截器的实现即来源于此。oncedebouncethrottle的实现来源于纯粹的对过程抽象。纯函数则来源于将所有的影响外部环境的代码、非幂等的代码保留下来,抽象其他过程的实现。

高阶函数的返回函数值是做个包装,承载抽象出来的过程及连接原来函数的参数和作用域。

如上代码所示,batch是一个高阶函数。在它的返回函数中,第一个参数subject如果是一个数组,则以这个数组的每个元素为第一个参数,依次迭代调用fn,将结果作为数组返回。如果subject不是数组,那么直接调用fn,并将结果返回。

经过batch之后的setStyle函数拥有了单个操作或者批量操作元素的能力,相当于原先的setStylesetStyles的结合。

比如,实现将某些元素的字体颜色设置为红色:

const items = document.querySelectorAll('li:nth-child(2n+1)');

setStyle([...items], 'color', 'red');
复制代码

重构后,虽然setStyle依然不是纯函数,但是batch是一个纯函数。减少了工具库的非纯函数,提升了纯函数的数量,也就提升了函数库的可测试性和可维护性。

batch函数进行黑盒测试很简单,只需给batch传入参数,判断它的返回结果是否和预期一致即可,并不需要为它构建HTML环境:

const list = [1, 2, 3, 4];
const double = batch(num => num * 2);

double(list); // 2, 4, 6, 8
复制代码

纯函数的必要性

上面借助batch的实现,可以通过合并setStylesetStyles简单的做到:

function setStyle(el, key, value) {
  if(Array.isArray(el)) {
    return el.forEach((e) => {
      setStyle(e, key, value);
    });
  }
  el.style[key] = value;
}
复制代码

首先它破坏了函数职责单一性的原则(js中修改样式),其次,工具库中可能还有其他类似的函数,比如添加/移除calss状态,则需要重新定义一遍类似setStyle的函数,比如:

function addState(el, state) {
  removeState(el, state);
  el.className = el.className ? `${el.className} ${state}` : state;
}

function removeState(el, state) {
  el.className = el.className.replace(new RegExp(`(^|\\s)${state}(\\s|$)`, 'g'), '');
}

function addStates(els, state) {
  els.forEach(el => addState(el, state));
}
复制代码

如果要修改,需要把这些方法一起修改为:

function addState(el, state) {
  if(Array.isArray(el)) {
    return el.forEach((e) => {
      addState(e, state);
    });
  }
  removeState(el, state);
  el.className = el.className ? `${el.className} ${state}` : state;
}

function removeState(el, state) {
  if(Array.isArray(el)) {
    return el.forEach((e) => {
      removeState(e, state);
    });
  }
  el.className = el.className.replace(new RegExp(`(^|\\s)${state}(\\s|$)`, 'g'), '');
}
复制代码

有了batch方法后,因为const setStyle = batch(...)通过函数装饰器的修饰将函数变换为具有批量处理功能,并不违反定义时的职责单一原则。测试的时候,只要保证纯函数batch的正确性,就完全不用担心被batch变换后的函数的正确性。

并且,修改其他的函数也很简单。把所有需要拥有批量处理功能的函数统统用batch装饰一下就可以了:

// 统一的批量化处理
addState = batch(addState);
removeState = batch(removeState);
复制代码

通过设计batch高阶函数,让纯函数增加,非纯函数减少,最终大大提升了库的可测试性和可维护性。这就是我们为什么需要使用高阶函数过程抽象来设计和重构函数库的原因。

batch高阶函数的作用就是将第一个参数由单操作变为批量操作,后续参数保持不变。

高阶函数的范式

了解高阶函数的范式,从而清楚如何对原函数进行过程抽象,设计更多或更高效的高阶函数。

高阶函数的范式

function HOF0(fn) {
  return function(...args) {
    return fn.apply(this, args);
  }
}
复制代码

HOF0是高阶函数的等价范式,或者说,HOF0修饰的函数功能和原函数fn的功能完全相同。因为被修饰后的函数就只是采用调用的this上下文和参数来调用fn,并将结果返回。也就是说,执行HOF0和直接执行fn完全没区别

function foo(...args) {
  // do anything.
}
const bar = HOF0(foo);

console.log(foo('something'), bar('something')); // 调用foo和调用bar完全等价
复制代码

HOF0是基础范式,其他的函数装饰器就是在它的基础上,要么对参数进行修改,如batch,要么对返回结果进行修改,如oncethrottledebouncebatch

其他的高阶函数也可以在这基础上设计出来

比如,设计连续执行的函数,递归执行,类似于数组的reduce方法,但更灵活:

function continous(reducer) {
  return function (...args) {
    return args.reduce((a, b) => reducer(a, b));
  };
}
复制代码

然后,创建能够递归处理输入的函数:

const add = continous((a, b) => a + b);
const multiply = continous((a, b) => a * b);

console.log(add(1, 2, 3, 4)); // 1 + 2 + 3 + 4 = 10

console.log(multiply(1, 2, 3, 4, 5)); // 1 * 2 * 3 * 4 * 5 = 120
复制代码

batch类似,continous也可以用来创建批量操作元素的方法,只不过参数和用法需要调整一下:

const setStyle = continous(([key, value], el) => {
  el.style[key] = value;
  return [key, value]; // 通过返回,使递归调用时第一个参数始终为[key, value]。实现递归批量修改el
});

const list = document.querySelectorAll('li:nth-child(2n+1)');
setStyle(['color', 'red'], ...list);  // continous递归迭代执行,将 list展开 传入setStyle

复制代码

如果想要直接使用list作为参数而不是传...list,可以再实现一个高阶函数来处理它(即处理list的展开):

function foldLastParameter(fn) {
  return function (...args) {
    const lastArg = args[args.length - 1];
    if(lastArg.length) {
      return fn.call(this, ...args.slice(0, -1), ...lastArg); // 最后一个参数是数组则展开
    }
    return fn.call(this, ...args);
  };
}
复制代码

foldLastParameter函数判断最后一个参数是一个数组或类数组(如NodeList),则将它展开传给原函数fn(相对于被修饰的原函数而言是折叠了参数,所以用fold命名这个高阶函数)。

原本自己想着可以修改continous函数,从而实现不展开list,但是这样做,一是破坏了原来的continous返回的装饰函数的结构,修改了代码处理;而且逻辑处理上也不再是递归执行的功能,破坏了功能和原函数的完整性。

通过增加一层装饰器,在不修改原来函数的前提下,实现对展开功能的处理,正是体现了装饰器的作用,以及抽象过程的体现。

月影大佬牛!

再修改setStyle

// 在continous基础上再加一个foldLastParameter的装饰,实现list不用...展开。
const setStyle = foldLastParameter(continous(([key, value], el) => {
  el.style[key] = value;
  return [key, value];
}));

const list = document.querySelectorAll('li:nth-child(2n+1)');

setStyle(['color', 'red'], list);
复制代码

此处的foldLastParameter适用于传递两个参数的情况,因为foldLastParameter中展开的只是最后一个参数,而最后setStyle修改样式的实现需要确定第一个参数为样式,第二个参数为元素。除非实现展开第一个参数之外的所有参数,

然后,调整一下参数顺序,让setStyle更接近batch那一版:

function reverse(fn) {
  return function (...args) {
    return fn.apply(this, args.reverse());
  };
}
复制代码

高阶函数reverse将函数的参数调用顺序颠倒:

const setStyle = reverse(foldLastParameter(continous(([key, value], el) => {
  el.style[key] = value;
  return [key, value];
})));

const list = document.querySelectorAll('li:nth-child(2n+1)');

setStyle(list, ['color', 'red']);  // setStyle的参数变成了list和['color','red']
复制代码

然后,可以把参数['color', 'red']展开,需要实现一个与fold相反的spread高阶函数:

function spread(fn) {
  return function (first, ...rest) {
    return fn.call(this, first, rest);
  };
}
复制代码

最终setStyle方法可以和之前使用batch时一样:

const setStyle = spread(reverse(foldLastParameter(continous(([key, value], el) => {
  el.style[key] = value;
  return [key, value];
}))));

const list = document.querySelectorAll('li:nth-child(2n+1)');

setStyle(list, 'color', 'red');
复制代码

原始函数一共套了四层装饰器spread(reverse(foldLastParameter(continous(...)))),其效果类似实现了batch高阶函数

此时的例子,相当于:

// 四层装饰器实现batch
function batch(fn) {
  return spread(reverse(fold(continous(fn))));
}

const setStyle = batch(setStyle);
复制代码

与原本batch函数的区别是原始函数参数顺序不一样,且要求返回值:

// 这是原始函数
([key, value], el) => {
  el.style[key] = value;
  return [key, value];
}
复制代码

高阶函数可以任意组合,形成更强大的功能。

类似spread(reverse(fold(continous...)))嵌套的写法,也可以通过高阶函数改编成更加友好的形式:

// 通过列表的形式,组合成嵌套调用
function pipe(...fns) {
  return function(input) {
    return fns.reduce((a, b) => {
      return b.call(this, a);
    }, input);
  }
}
复制代码

高阶函数pipe,参数是一个函数列表,返回一个函数,这个函数以参数input对列表中的函数依次迭代,并将最终结果返回。

例如:

const double = (x) => x * 2;
const half = (x) => x / 2;
const pow2 = (x) => x ** 2;

const cacl = pipe(double, pow2, half);
const result = cacl(10); // (10 * 2) ** 2 / 2 = 200
复制代码

pipe就像一根管道一样,输入的数据顺序经过一系列函子,得到最终输出。这个模型也是函数式编程的基本模型高阶函数是函数式编程的基础

通过pipe到前面的几个高阶函数,batch可以改用pipe来表示:

const batch = pipe(continous, fold, reverse, spread);
复制代码

const pipe = continous((prev, next) => {
  return function(input) {
    return next.call(this, prev.call(this, input));
  }
});

复制代码

pipe为使用continue包装的函数,接受函数参数列表,迭代每个函数

pipe(m1,m2,m3)执行结果为迭代后的结果,迭代的结果为函数pipe_iter_func

pipe_iter_func传入参数执行

重点在于如何理解"迭代的结果"

pipe的作用是嵌套每个函数列表项执行

(prev, next) => 箭头函数返回结果也是一个函数,这个函数可以用来执行传递进来的prev、next。同时该函数作为下一次迭代的prev,并在下一次迭代中嵌套进next的执行参数中。

迭代过程中,返回的return function(input)的输入参数input的作用仅仅是为了传递给上一次的函数,而function(input)的代码实现是用来组成后一个函数嵌套前一个函数执行。除了最后一次迭代返回的function(input),该input会在之后的pipe_iter_func执行的传入。同时该input又层层传递给最初的第一个函数。并嵌套执行每一个函数。

理解"迭代的结果"的重点在于了解input的作用。

太难了,几乎看不懂,只能一步步反推,要是自己来设计实现(自己创建相关的高阶函数),感觉几乎是不可能的。。。

高阶函数组合的威力!!!

作者:代码迷途
链接:https://juejin.cn/post/6954667175097401380
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

点击查看更多内容
1人点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
Web前端工程师
手记
粉丝
14
获赞与收藏
46

关注作者,订阅最新文章

阅读免费教程

感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消