4.第四日过程抽象提升系统的可维护性
第四天 过程抽象提升系统的可维护性
第一个故事:只执行一次
上一节我们说过函数是处理数据的最小单元。为了能够让函数具备通用性,我们可以抽象数据或者抽象过程。今天我们就来探讨一下过程是如何抽象的。
在前端开发中,我们经常遇到一些事件处理函数只能执行一次的情况。比如下面的这个例子:
这是一个页面,它处理一个用户事项的清单,大致的代码实现如下:
<ul>
<li><button></button><span>任务一:学习HTML</span></li>
<li><button></button><span>任务二:学习CSS</span></li>
<li><button></button><span>任务三:学习JavaScript</span></li>
</ul>
ul {
padding: 0;
margin: 0;
list-style: none;
}
li button {
border: 0;
background: transparent;
cursor: pointer;
outline: 0 none;
}
li.completed {
transition: opacity 2s;
opacity: 0;
}
li button:before {
content: '☑️';
}
li.completed button:before {
content: '✅';
}
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);
});
});
这是一个非常简单的功能,我们在列表项的按钮上注册click事件,当用户点击到列表项前面的button的时候,显示一个淡出动画,然后将列表项从list中删除。
从下面这个张效果来看,似乎没什么问题:
但是,测试工程师却发现了一个问题 —— 在列表项消失前,如果快速地点击多次列表元素,在控制台上会出现异常信息:
这条信息的出现,是因为当元素还没消失的时候,如果再次被点击,依然会响应事件。所以在事件处理函数中,会启动多个setTimeout定时器。但是当第一次执行list.removeChild(target.parentNode)
时,target.parentNode就已经从list中移除了,所以当后续定时器回调函数再被执行时,就会抛出异常了。
要解决这个问题其实很简单,就必须让click回调函数只执行一次。而让click回调函数只执行一次的方式有很多种:
1. once参数
在新的浏览器里,可以通过addEventListener
的once
参数实现:
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);
}, {once: true});
});
但是在部分老的浏览器中,可能不支持once参数。这时候,也可以有别的方法,比如:
2. removeEventListener方法
const list = document.querySelector('ul');
const buttons = list.querySelectorAll('button');
buttons.forEach((button) => {
button.addEventListener('click', function f(evt) {
const target = evt.target;
target.parentNode.className = 'completed';
setTimeout(() => {
list.removeChild(target.parentNode);
}, 2000);
target.removeEventListener('click', f);
});
});
在click事件处理函数中,通过target.removeEventListener('click', f);
将处理函数本身从事件监听中移除。
3. disabled属性
我们也可以使用元素的disabled
属性来实现目标元素只被点击一次的效果:
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);
target.disabled = true;
});
});
事件处理方法只执行一次的需求还有很多,比如一个购物车提交数据给服务器,按钮点击一次后,我们也要将按钮置为disabled
或者移除监听器:
formEl.addEventListener('submit', function submitData(evt) {
fetch('path/to/url', {
method: 'POST',
body: JSON.stringify(formData),
...
});
formEl.removeEventListener('submit', submitData);
});
上述的这些解决方式在不同的需求中都必须不断的重复。那么,有没有通用的办法覆盖所有只需执行一次的需求呢?
第二个故事:函数装饰器——once函数
为了能够让第一个故事中的“只执行一次“的需求能覆盖不同的事件处理,我们需要将这个需求从事件处理函数中剥离出来。这个过程我们称为过程抽象。下面,我们就来看看过程抽象是如何实现的。
once函数:
function once(fn) {
return function (...args) {
if(fn) {
const ret = fn.apply(this, args);
fn = null;
return ret;
}
};
}
如上代码所示,这个once
函数的参数fn
是一个函数,它就是我们的事件处理函数。once
的返回值也是一个函数。这个返回函数就是“只执行一次”的过程抽象。所以我们把这个返回函数称作是fn
的修饰函数,而把once
称为函数的装饰器。
我们来分析一下这段代码是如何实现”只执行一次”的需求的:当事件被触发,第一次调用fn
的修饰函数的时候,fn
存在,于是执行fn
,然后将fn
设置为null
,并返回fn
的执行结果。当再次执行这个修饰函数的时候,由于fn
已经是null
,就不会再次执行了,这样就实现了只调用一次的过程。
如上图所示。注意,蓝色的部分并不是once
本身,而是once
调用fn
函数后返回的那个函数,也就是前面说的修饰函数,或者也可以说是一个代理函数,它根据情况决定是否将输入的参数传给原函数fn
。
现在,我们就可以用它来实现前面的需求:
const list = document.querySelector('ul');
const buttons = list.querySelectorAll('button');
buttons.forEach((button) => {
button.addEventListener('click', once((evt) => {
const target = evt.target;
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=false
或则target.removeEventListener
等语句了。我们的代码也不会因为疏忽了这些非业务逻辑的语句而报错。
除了上面这些情况,我们还可以对once方法做一些扩展。比如:我们定义了一个对象的初始化方法,这个方法只允许执行一次,如果用户不小心多次执行,我们想让函数抛出异常。我们修改once方法如下代码所示:
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();
这样当我们第二次调用obj.init()
时,就会抛出异常new Error('This method should be called only once.');
。
函数的装饰器是一种新的抽象思路,那么除了once
外,函数装饰器的这种思路还能应用在哪些场景呢?
第三个故事:节流和防抖函数装饰器
除了once
装饰器外,这一讲我们来认识另外两种常见的函数装饰器:节流和防抖。
节流
什么是节流,我们用一个例子来解释一下:
<div id="panel"></div>
html, body {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}
#panel {
display: inline-block;
width: 360px;
height: 360px;
background: hsl(0, 50%, 50%);
}
const panel = document.getElementById('panel');
panel.addEventListener('mousemove', (e) => {
const {x, y} = e;
e.target.style.background = `linear-gradient(${y}deg,
hsl(0, 50%, 50%),
hsl(${0.5 * x}, 50%, 50%))`;
});
上面的代码是一个从真实的业务中抽象出来的一个简单的例子。我们监听panel元素的mousemove
方法,然后根据鼠标移动的(x、y
)位置改变元素的背景色,它的效果如下:
因为在这个例子中,仅仅只是修改元素的背景色,并没有负责的业务逻辑,所以频繁的响应mousemove
事件没什么问题。
可是,假设现在我们需要将更改后的颜色发送给服务器保存,那么频繁触发送过多的mousemove
事件,会导致过多的http请求,给服务器带来比较大的负担。在这种情况下,我们就要设计一个机制,来限制mousemove
被频繁触发。这个限制的过程就是我们说的节流
panel.addEventListener('mousemove', (e) => {
// 省略改变颜色的代码...
//向服务器发送当前颜色
saveToServer(...data); // 应避免请求被频繁发起
});
那么,我们怎样实现节流呢? 我们来看看下面的代码:
const panel = document.getElementById('panel');
let throttleTimer = null;
panel.addEventListener('mousemove', (e) => {
if(!throttleTimer) {
const {x, y} = e;
e.target.style.background = `linear-gradient(${y}deg,
hsl(0, 50%, 50%),
hsl(${0.5 * x}, 50%, 50%))`;
throttleTimer = setTimeout(() => {
throttleTimer = null;
}, 100);
}
});
如上代码所示,我们使用定时器作为限制。当throttleTimer
等于null
时,执行mousemove
事件函数。然后,我们启动定时器。当mousemove
事件在100毫秒内再次触发的时候,因为throttleTimer
还未被设置为null
,所以这次的mousemove
事件被忽略。直到100毫秒之后,throttleTimer
再次被设置为null
时,才能触发mousemove
事件。
上面的代码,虽然我们使用定时器解决了节流的问题,但是并不通用。当下次再遇到需要节流功能的地方时,我们需要复制这个定时器代码。所以,我们需要将这个节流的过程抽象出来,让它成为通用的节流装饰方法。
和once
类似,我们实现一个throttle
函数装饰器,它限制某个函数在ms
间隔中只执行一次:
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;
}
};
}
与once
一样,throttle
的第一个参数是个函数,返回值也是一个函数,它返回的函数修饰了参数fn
。当每次成功调用后,产生一个ms
毫秒后执行回调的定时器,并赋给throttleTimer
。在定时器回调函数未执行时,因为throttleTimer
变量有值,函数fn
就不会被次执行。
有了这个函数,我们就可以使用它来实现节流了:
const panel = document.getElementById('panel');
panel.addEventListener('mousemove', throttle((e) => {
const {x, y} = e;
e.target.style.background = `linear-gradient(${y}deg,
hsl(0, 50%, 50%),
hsl(${0.5 * x}, 50%, 50%))`;
}));
有趣的是,我们还可以使用这个throttle
来实现我们上一个故事里的once
:
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);
}
这时的once
就相当于一个定时器永不过期的throttle
,从这一点上来说,throttle
是比once
更抽象的函数。
防抖
除了throttle
,与之类似的有防抖函数debounce
。那么什么是防抖,我们也通过例子来了解一下:
<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上实现一个不同色彩叠加的圆环,且允许画布的大小随页面宽度弹性改变,它的效果如下:
如上图所示,在我们通过拖拽窗口改变窗口大小时,页面有些卡顿。这是因为(同mousemove
类似)在拖拽窗口时,resize
事件会反复触发,而每次触发的时候,Canvas都要重新绘制,而且绘制是一个耗时的过程,所以出现了图像卡顿。
那么如何解决这种卡顿的现象呢?我们可以让用户在操作过程中,不绘制Canvas,只在用户最后一次改变窗口大小的操作后才重新绘制Canvas。这一过程就是防抖。
同样,我们先用常规方式改写代码,让它具备防抖功能:
// 省略前面的代码...
resize();
draw();
let debounceTimer = null;
window.addEventListener('resize', () => {
if(debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
resize();
draw();
}, 500);
});
与throttle
类似,我们还是使用一个定时器debounceTimer
,但是它的逻辑有所变化。在debounceTimer
定时器存在时,如果resize
操作被触发,那么我们先清除上一次的debounceTimer
,创建一个新的debounceTimer
。这样的话,如果resize
事件反复被触发,那么debounceTimer
定时器就会被一直替换成新的,它的回调就不会被执行,只有当resize
不再被触发超过一定时间(这里是500毫秒)后,它的回调才会被执行。简单来说,就是Canvas的绘制只发生在最后一次操作之后,中间的操作Canvas绘制都不会触发。这样就不会出现抖动现象了。
接下来,我们像之前一样,把debounce
过程抽象出来:
function debounce(fn, ms) {
let debounceTimer = null;
return function (...args) {
if(debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
fn.apply(this, args);
}, ms);
};
}
然后,我们就可以用debounce
来实现防抖了:
window.addEventListener('resize', debounce(() => {
resize();
draw();
}, 500));
因为节流和防抖的函数非常相似,可能你会混淆。这里就再次前调一下它们的区别:
- 节流是让事件处理函数隔一个指定毫秒再触发
- 防抖则忽略中间的操作,只响应用户最后一次操作
回顾第一、二故事,我们了解了三种函数装饰器:once
、debounce
、throttle
。这些函数装饰器有一个共同点:它们的参数是函数,返回值也是函数。我们把这种参数和返回值都是函数的函数,叫做高阶函数(High Ordered Functions)。
高阶函数除了修饰函数外,还有哪些应用和好处呢?
第四个故事:函数拦截器
这个故事,我们将带你了解高阶函数的另外一个应用 —— 函数拦截器。
最近我们遇到一个头疼的问题,我们维护的一个工具库面临一次大的升级。这次版本升级中,一部分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需要废弃,那么在库中,修改该API的代码,在调用时输出信息,比如:
export function foo() {
deprecate('foo', 'bar');
// do sth...
}
这样,当用户调用foo
的时候,控制台上就会输出警告信息:
这么做当然是可以的,但是这么做是有风险的。因为这样做,我们需要找出库中所有要废弃的API,然后一一手动添加deprecate
方法。这样做增加了手误的风险,导致原有的API出现错误。
所以我们需要思考:如何可以不改动原来库中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
这个沙箱,显示提示信息,然后再执行foo
或bar
方法的内容。
那么,这个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
调用的函数。这样,当我们执行这个返回的函数的时候,先执行了提示信息的打印,然后才执行原有的API方法。
这里我们还添加了一个小细节,定义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,不执行后续函数
const ret = fn.apply(this, args);
if(afterCall) return afterCall.call(this, ret);
return ret;
}
};
}
intercept
函数是一个高阶函数,它的第二个参数是一个对象,可以提供beforeCall
、afterCall
两个拦截器函数,分别“拦截”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);
- 我们可以调整参数顺序:
const mySetTimeout = intercept(setTimeout, {
beforeCall(args) {
[args[0], args[1]] = [args[1], args[0]];
}
});
mySetTimeout(1000, () => {
console.log('done');
});
上面的代码,重新定义了一个新的定时器函数mySetTimeout
,它的参数恰好和setTimeout
相反。
- 我们可以校验函数的参数类型:
const foo = intercept(foo, {
beforeCall(args) {
assert(typeof args[1] === 'string');
}
});
除了上述三点用途外,它可以根据你的业务需求,有很多用途。但最关键的是,这些事情是在不修改原函数代码的基础上做到的!
通过这三个例子,我们了解了高阶函数的一个基础应用,下一讲,我们将谈谈高阶函数在应用中的意义。
第五个故事:函数的“纯度”、可测试性和可维护性
在前端开发中,我们都会积累一些方便好用的工具函数,且会将它们添加到工具函数库中,以方便在其它项目中复用。
其中有两个工具函数是这样的:
export function setStyle(el, key, value) {
el.style[key] = value;
}
export function setStyles(els, key, value) {
els.forEach(el => setStyle(el, key, value));
}
这两个函数的功能是给元素设置样式的,其中setStyle
只给一个元素设置样式,而setStyles
则给多个元素设置样式。
这两个函数功能单一,看起来非常简单,但是它们有一个共同的缺点——那就是它们都依赖外部的环境(参数el
元素),同时也改变这个环境。这样定义函数有什么问题呢?
把自己想象成测试人员,如果需要给setStyle
或者setStyles
这样的函数进行黑盒测试,我们必须给它构建测试的环境。比如,针对上面两个函数,我们需要构建不同的DOM元素结构,然后获取元素或元素列表,然后根据操作后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;
});
如上代码所示,batch
是一个高阶函数。在它的返回函数中,第一个参数subject
如果是一个数组,则以这个数组的每个元素为第一个参数,依次迭代调用fn
,将结果作为数组返回。如果subject
不是数组,那么直接调用fn
,并将结果返回。
所以经过batch
之后的setStyle
函数拥有了单个操作或者批量操作元素的能力,相当于原先的setStyle
和setStyles
的结合。
我们来看一个完整的例子 —— 将ul
元素下所有的奇数行的li
元素的字体颜色修改为红色:
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
<li>5</li>
<li>6</li>
<li>7</li>
</ul>
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);
};
}
const setStyle = batch((el, key, value) => {
el.style[key] = value;
});
const items = document.querySelectorAll('li:nth-child(2n+1)');
setStyle([...items], 'color', 'red');
上面的代码中,虽然setStyle
依然不是纯函数,但是batch
是一个纯函数。也就是说,经过重构的代码,减少了工具库的非纯函数,提升了库中纯函数的数量,这样就提升了函数库的可测试性和可维护性。
以batch
函数为例,我们来看看这时的黑盒测试是多么的简单:
const list = [1, 2, 3, 4];
const double = batch(num => num * 2);
double(list); // 2, 4, 6, 8
如上代码所示,我们只需要给batch
传入参数,判断它的返回结果是否和预期一致即可,并不需要为它构建HTML环境。
可能这时,你会问,如果只是合并setStyle
和setStyles
,也可简单的将这两个方法合并为如下形式:
function setStyle(el, key, value) {
if(Array.isArray(el)) {
return el.forEach((e) => {
setStyle(e, key, value);
});
}
el.style[key] = value;
}
这么做,首先它破坏了函数职责单一性的原则。其次,工具库里还有其他类似的函数,比如:
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
只是其中一个高阶函数,就像前面的once
、throttle
、debounce
一样,只是众多函数装饰器中的一个,我们还可以实现其他更多的函数装饰器,用它们来一步一步改造我们的工具函数库。
第六个故事:高阶函数的范式
前面的几个故事中,我们得到了几个高阶函数,包括once
、throttle
、debounce
和batch
,它们的功能各不相同,但是也有共同点,从中我们可以抽取出创建高阶函数的范式:
function HOF0(fn) {
return function(...args) {
return fn.apply(this, args);
}
}
HOF0
是高阶函数的等价范式,或者说,HOF0
修饰的函数功能和原函数fn
的功能完全相同。因为被修饰后的函数就只是采用调用的this
上下文和参数来调用fn
,并将结果返回。也就是说,执行它和直接执行fn
完全没区别。
function foo(...args) {
// do anything.
}
const bar = HOF0(foo);
console.log(foo('something'), bar('something')); // 调用foo和调用bar完全等价
所以HOF0
是基础范式,其他的函数装饰器就是在它的基础上,要么对参数进行修改,如batch
,要么对返回结果进行修改,如once
、throttle
、debounce
和batch
。
那么同样,其他的高阶函数也可以在这基础上设计出来。
比如,我们可以设计出连续执行的函数,用来递归执行,类似于数组的reduce
方法,但更灵活。
function continous(reducer) {
return function (...args) {
return args.reduce((a, b) => reducer(a, b));
};
}
有了continous
,我们可以创建能够递归处理输入的函数,如:
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
也可以用来创建批量操作元素的方法,只不过参数和用法需要调整一下,用起来也没有batch
那么好用。如下代码所示:
const setStyle = continous(([key, value], el) => {
el.style[key] = value;
return [key, value];
});
const list = document.querySelectorAll('li:nth-child(2n+1)');
setStyle(['color', 'red'], ...list);
注意到因为continous是递归迭代执行,我们要把list
展开传入setStyle
。
如果我们想要直接使用list
作为参数而不是传...list
,我们可以再实现一个高阶函数来处理它:
function fold(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);
};
}
fold
函数判断最后一个参数是一个数组或类数组(如NodeList),那么将它展开传给原函数fn
(相对于被修饰的原函数而言是折叠了参数,所以用fold
命名这个高阶函数)。
所以我们再改一下setStyle
:
const setStyle = fold(continous(([key, value], el) => {
el.style[key] = value;
return [key, value];
}));
const list = document.querySelectorAll('li:nth-child(2n+1)');
setStyle(['color', 'red'], list);
我们给setStyle
在continous
基础上再加一个fold
的装饰,就可以达到我们的目的,list不用...
展开。
那么接下来,我们可以调整一下参数顺序,让setStyle更接近batch那一版:
function reverse(fn) {
return function (...args) {
return fn.apply(this, args.reverse());
};
}
reverse
是另一个高阶函数,它将函数的参数调用顺序颠倒:
const setStyle = reverse(fold(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
方法:
const setStyle = spread(reverse(fold(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(fold(continous(...))))
。
所以,就这个例子来说,相当于:
function batch(fn) {
return spread(reverse(fold(continous(fn))));
}
const setStyle = batch(setStyle);
当然这里还有个细微差异,就是这一版原始函数的参数顺序不一样,而且要求有返回值:
// 这是原始函数
([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
,我们可以运用pipe
到前面的几个高阶函数,将batch
用pipe
来表示为:
const batch = pipe(continous, fold, reverse, spread);
更有趣的是,pipe
本身也可以用高阶函数continous
重新定义为:
const pipe = continous((prev, next) => {
return function(input) {
return next.call(this, prev.call(this, input));
}
});
在这里,我们再一次看到高阶函数组合的威力。如果要类比的话,就像数学中,定义少数几条基本公理就能够推导并建立整个系统一样,我们也可以通过定义几个基本的高阶函数,创造出一整套图灵完备的高阶函数系统,并用它来彻底重构我们的基础库,让基础库中只有高阶纯函数和一些基本的原子操作(就像(el, key, value)=> {el.style[key] = value;}
这种简单操作)。如果这样做,那么我们的基础库的可维护性就会非常高。