概述
为什么需要使用函数式编程?
在 JavaScript 中,函数是一等公民。 在 JavaScript 中,我们可以使用函数来做:
- 声明
- 参数
- 返回
- 调用
- 构造函数
- 类实例
- 立即执行函数
在 JavaScript 中,函数几乎是无所不能的!
而近年来函数式编程几乎是主流的开发范式(心智模型) 函数式编程也通常会和构造函数为驱动的面相对象编程来做对比
JavaScript 的特点
JavaScript是一个灵活,但相当不可控的语言。
在JavaScript中,通常编程的方式会被看成是函数式编程 + 面相对象编程 混合编程。
而我们编程的时候通常需要函数式编程 & 设计模式 进行约束。
面向对象编程 & 函数式编程的优劣对比
先说说面向对象:
- 优点:
- 复用性好
- 集成性高
- 编写清晰明了
- 缺点:
- 复杂任性的this指向 (this东指西指的,烦死了!)
- 编写复杂, 每一个构造函数/类都需要被实例化后按照一定的方式进行调用
总结:
不一定非要使用面相对象,除非高度复用和集成考虑
再来看看函数式编程:
定义:
将函数作为第一类对象进行开发,而且函数不依赖其他对象而独立存在
-
优点
- 易读易维护
- 功能为主体,简单清晰
-
缺点:
- 继承比较困难
- 复用性没有面向对象编程的高
函数式编程中常见的函数
友好的纯函数
概念
-
纯函数的概念
当一个函数满足以下条件:
- 相同的输入对应相同的输出
- 不依赖且不影响外界环境且不会产生任何的副作用
那么,我们就称这个函数为纯函数
-
副作用的概念
只要和函数外部环境发生的交互的行为就是副作用
-
常见的副作用举例:
- dom 操作
- ajax 请求
- 利用函数直接改变外部数据
- console.log
- 计时器 (setTimeout, setInterval)
- Math.random
- new Date().getTime()
- 存取读写 (cookie localstorage sessionStorage)
- ......
注意
- 副作用是产生问题的可能,不一定是产生问题的原因
- 纯函数是理想情况下需要编写的内容,但也是可遇不可求的。如果封装纯函数比较困难也不能强求 😂
纯函数的优点总结
- 可移植 (不依赖外部环境,在哪里都可以使用)
- 容易测试 (输出只依赖输入)
- 引用透明/合理性 (方法体显而易见)
- 可以并行执行 (无论是客户端还是服务端) 不存在竞争态的问题?
- 可缓存性 (纯函数缓存)
可以操作函数的高阶函数
概念:
如果一个函数满足以下条件:
- 需要传入的参数包含一个函数
- 返回的内容中包含函数
那么我们就称这个函数为高阶函数
优点:
- 功能的抽象 ([].forEach(), [].map())
- 功能的延续 (回调函数)
- 缓存内容 (柯里化的基础)
难懂的柯里化函数 (🔥🔥🔥 面试很爱考 🔥🔥🔥)
概念:
存在一个函数, 它能够将一个函数参数拆分成不同阶段的步骤函数, 当满足原来函数的参数个数时, "适时"地执行原来函数的功能 (注意,不是原来的函数),那么我们称存在的这样一个函数为柯里化函数
柯里化的思想最早是由 Haskell Brooks Curry 提出的,而 Haskell 语言也同样是一门函数式编程的语言
实现思路:
先写一个 add 函数,用于测试柯里化函数:
js
function add(a, b, c, d) {
return a + b + c + d;
}
var curryAdd = curry(add);
console.log(curryAdd(1, 2, 3, 4));
console.log(curryAdd(1)(2, 3) (4));
console.log(curryAdd()()()()()()()()()()(1, 2)(3)(4));
-
ES5 实现:
- 首先我们需要先实现一个方法, 将类数组转化为数组 😁
jsfunction toArr(origin) { var arr = [], item; for (var i = 0; i < origin.length; i++) { item = origin[i]; arr.push(item); } return arr; }
- 然后我们需要实现一个柯里化过程的关键代码,这一步主要是过程化处理原函数,将原函数转化成一个"可拼接"参数的新函数
jsfunction curry(fn, len) { var _len = len || fn.length; return function () { var args = toArr(arguments); if (_len > args.length) { var formatArgs = [].concat([fn], args); return curry(currying.apply(this, formatArgs), _len - args.length); } else { return fn.apply(this, args); } } }
- 最后是柯里化的总思路: 我们需要用一个长度参数来规定是否需要直接执行当前的fn函数 (args.length <= _len), 这一步也是比较难理解的,需要思考😂
jsfunction curry(fn, len) { var _len = len || fn.length; return function () { var args = toArr(arguments); if (_len > args.length) { var formatArgs = [].concat([fn], args); return curry(currying.apply(this, formatArgs), _len - args.length); } else { return fn.apply(this, args); } } }
-
ES6 实现 相比于 ES5 实现,ES6 实现则相对简单:
jsfunction curry(fn, ...args) { const ctx = this; if (args.length >= fn.length) { return fn.apply(ctx, args); } else { return (...args2) => curry.apply(ctx, [fn, ...args, ...args2]); } }
柯里化函数的运用场景:
使用柯里化封装一个 ajax 请求函数:
js
function request(options, callbacks) {
const {
url = '',
params = {},
data = {},
method = 'GET'
} = (options || {});
const {
success,
error,
complete,
} = (callbacks || {});
const totalUrl = getTotalUrl(url, params);
$.ajax({
url: totalUrl,
method,
data,
success,
error,
complete,
// async: false,
});
function getTotalUrl(url, params) {
if (!Object.getOwnPropertyNames(params).length) {
return url;
}
var query = '?';
for (var key in params) {
query += key + '=' + params[key] + '&';
}
return url + query.replace(/&$/, '');
}
}
// 请求命名空间
const ajaxCollection = {
getIndexListData: curry(request),
};
ajaxCollection.getIndexListData
(({
url: 'http://localhost:5923/index-list-data',
method: 'GET',
data: {},
params: {
page: 2,
}
}))
({
success(data) {
console.log(data);
},
error(err) {
console.log(err);
}
});
和柯里化函数长的很像的偏函数
概述:
首先要搞清楚【函数的元】:
函数的元指的就是:函数的参数的个数
结合上述知识,偏函数的概念:
存在这样的一个函数, 满足: 原函数在该函数的封装后, 生成一个函数的元更低的函数
那么我们称这个函数为偏函数
偏函数与柯里化的区别:
偏函数的侧重点在于"降元"
- 偏函数希望的是在args.length > 0 的情形下执行 partial(fn, ...args),生成一个参数更少的新函数(降元函数)
easierFn
柯里化的侧重点在于"分步"和"延迟执行"
- 柯里化希望的是将一个多元的函数fn(a, b, c, d)转化成多个一元的函数
curryFn(a)(b)(c)(d)
偏函数的实现:
-
Function.prototype.bind (后面传入预设的参数进行降元)
-
原型扩展实现
js
function toArr(origin) {
var arr = [],
item;
for (var i = 0; i < origin.length; i++) {
item = origin[i];
arr.push(item);
}
return arr;
}
Function.prototype.partial = function () {
var fn = this,
arg1 = toArr(arguments).slice(1);
if (args.length <= 0) {
throw new Error('partial need at least one argument!');
}
return function () {
var arg2 = toArr(arguments);
var args = [].concat(arg1, arg2);
return fn.apply(this, args);
}
}
惰性函数
思考:
假如,我们在处理 DOM 事件绑定,但是直接使用 addEventListener
方法会有兼容性问题 (IE9 以下不支持)。
这个时候,我们就需要写一个兼容性方法:
js
/**
* @function addEvent
* @description 给元素绑定事件处理函数
* @param {HTMLElement} el 需要绑定事件的元素
* @param {string} evType 需要绑定的事件类型
* @param {Function} fn 事件处理函数
* @return {void} 没有返回值
*/
function addEvent(el, evType, fn) {
if (el.addEventListener) {
el.addEventListener(evType, fn, false);
} else if (el.attachEvent) {
el.attachEvent('on' + evType, function () {
typeof fn === 'function' && fn.apply(el, arguments);
});
} else {
typeof fn === 'function' && (fn['on' + evType] = fn);
}
}
这样就能实现一个兼容性写法的函数来给元素进行事件处理函数的绑定了,但是这样的写法仍然会有问题:
- 每次进来都有做
if ... else if ... else
的判断,必要性不高。 - 每次
if ... else if ... else
会影响整体的性能。
通常这种需要考虑到代码可读性和代码底层性能的提升的场景,使用惰性函数是比较合适的。
概述:
假设有这样的场景,需要满足一下情况:
- 函数执行第1遍时:只执行判断的逻辑 A 和判断后的逻辑 B。
- 函数第2~n次执行时,只执行判断后的逻辑 B。
那么这时候我们就需要函一个能够一开始就自身改变的函数,这个函数也称为惰性函数。
使用惰性函数改写一下上面的方法:
js
/**
* @function addEvent
* @description 给元素绑定事件处理函数
* @param {HTMLElement} el 需要绑定事件的元素
* @param {string} evType 需要绑定的事件类型
* @param {Function} fn 事件处理函数
* @return {void} 没有返回值
*/
var addEvent = function (el, evType, fn) {
if (el.addEventListener) {
addEvent = function (el, evType, fn) {
el.addEventListener(evType, fn, false);
};
} else if (el.attachEvent) {
addEvent = function (el, evType, fn) {
el.attachEvent('on' + evType, function () {
typeof fn === 'function' && fn.apply(el, arguments);
});
}
} else {
addEvent = function (el, evType, fn) {
typeof fn === 'function' && (fn['on' + evType] = fn);
}
}
addEvent(el, evType, fn);
}
记忆函数
概述:
记忆函数就是使用闭包的特性,创建一个具有闭包作用域运算结果的函数。
优点:
- 优化函数本身的运算性能(相同的运算过程对应的结果可以在缓存池中获取)
- 数据运算跟踪(React 里面的 useState)
代码示例:
- 基本实现一个记忆函数
memorize
:
js
function memorize(fn) {
var cache = {};
return function () {
var args = arguments,
ctx = this;
var argStr = [].slice.call(arguments).toString();
return cache[argStr] = cache[argStr] || fn.apply(ctx, args);
}
}
- 使用记忆函数简单实现一个
useState
:
js
const useState = (function (render) {
const states = [];
const stateSetters = [];
let stateIndex = 0;
function createState(initialState, stateIndex) {
return states[stateIndex] !== undefined
? states[stateIndex]
: initialState;
}
function createSetState(stateIndex) {
if (stateSetters[stateIndex] === undefined) {
stateSetters[stateIndex] = function (setter) {
if (typeof setter === 'function') {
states[stateIndex] = setter(states[stateIndex]);
} else {
states[stateIndex] = setter;
}
update();
}
}
return stateSetters[stateIndex];
}
function useState(initialSetter) {
const initialState = typeof initialSetter === 'function'
? initialSetter()
: initialSetter;
if (states[stateIndex] === undefined) {
states[stateIndex] = createState(initialState, stateIndex);
}
if (stateSetters[stateIndex] === undefined) {
stateSetters[stateIndex] = createSetState(stateIndex);
}
const _state = states[stateIndex];
const _setState = stateSetters[stateIndex];
stateIndex++;
return [_state, _setState];
}
function update() {
stateIndex = 0;
render();
}
return useState;
})(render);
防抖函数 & 节流函数
概述:
防抖函数和节流函数都是日常开发中非常常用的优化函数。
防抖函数:
概述:
如果存在一个高阶函数,它接收一个函数 fn 和一个延时时间 delay,并返回一个 newFn,并且 newFn 满足:
- 在连续触发 newFn 时,newFn 会在最后一次触发延迟 delay 毫秒后执行 fn 的逻辑
那么我们就称这个高阶函数为防抖函数(可以理解为:只有在最后一次操作才抖动/触发一次)
代码实现:
js
function debounce(fn, delay) {
var _delay = delay || 300,
timer = null;
return function () {
var args = arguments,
ctx = this;
if (timer) {
clearTimeout(timer);
timer = null;
}
timer = setTimeout(function () {
fn.apply(ctx, args);
clearTimeout(timer);
timer = null;
}, _delay);
}
}
节流函数:
概述:
如果存在一个高阶函数,它接收一个函数 fn 和一个时间间隔 duration,并返回一个 newFn,并且 newFn 满足:
- 在连续触发 newFn 时,fn 限制在 duration 毫秒间隔内只执行一次
那么我们就称这个高阶函数为节流函数(可以理解为:每一个时间段触发次数都只有一次)
代码实现:
js
function throttle(fn, duration) {
var _duration = duration || 500,
timer = null,
startTime = new Date().getTime();
return function () {
var endTime = new Date().getTime(),
args = arguments,
ctx = this;
clearTimeout(timer);
timer = null;
if (endTime - startTime >= _duration) {
fn.apply(ctx, args);
startTime = new Date().getTime();
} else {
timer = setTimeout(function () {
clearTimeout(timer);
timer = null;
}, _duration);
}
}
}
组合函数 & pointfree
场景:
假如存在一些处理字符串的工具纯函数:
javascript
function toUpperCase(str) {
return str.toUpperCase();
}
function toArray(str) {
return str.split('-');
}
function toString(arr) {
return arr.join(' ');
}
// ...
假设存在一个字符串 hello-world
我想让这个字符串都变成 HELLO WORLD
, 那么我就需要把上面的三个方法都实现一遍
ini
var str = 'hello-world';
var newStr = toString(toArray(toUpperCase(str)));
功能虽然是实现了,但是这样会存在以下的问题:
- 大量函数的嵌套导致代码的阅读性降低了。
- 每次获取 newStr 都要作一层这样的嵌套,十分的麻烦。
于是,组合函数 compose
就十分有必要了。
组合函数 compose 概述:
如果存在这样的一个高阶函数,它满足:
- 形参接收多个参数相同的函数
- 让函数这些按照从后往前的顺序形成运行的管道
- 返回一个接收和管道函数中相同形参的函数,并且函数的结果会按照形成的管道依次执行
那么,我们就称这个函数为 组合函数 (compose)
组合函数 compose 代码实现
javascript
function compose() {
var fns = [].slice.call(arguments);
return function (x) {
return fns.reduceRight(function (res, fn) {
var newRes = fn(res);
return newRes;
}, x);
}
}
组合函数 compose 的使用场景:
compose 函数在一些 JavaScript 的库和一些轮子中非常地常见,举个例子:
在 Redux 中, 为了给 store 添加一些中间件函数(e.g 异步 action,devtool,logger ...)
我们需要就需要把这些中间件函数使用 compose
函数组合成一个中间件,然后把组合好的中间件交给 redux 通过 applyMiddleware
去使用。
组合函数的结合律
在组合函数中,对组合函数再进行分组(组合函数参数嵌套同一组合函数执行的结果)并运行,它们得到的最终结果都是一致的
scss
/** 下面到的运行结果是一致的 */
compose(fn1, fn2, fn3);
compose(compose(fn1, fn2), fn3);
compose(fn1, compose(fn2, fn3));
最后,说一下 pointfree
Pointfreee 是一种编程的风格,它指的是:并不关心你的数据,他只是负责函数管道的执行。(不以数据作为粒度,而是以函数作为粒度)