深入理解JavaScript——函数式编程

简介: 深入理解JavaScript——函数式编程

前言


在讲函数式组件之前,笔者有必要声明一下,笔者对函数式编程知之甚少,此篇文章全是因为函数式编程在当今前端开发较为流行,所以才要去学习Orz。像 ES6 中的箭头函数,Redux 中的 compose,React 16.6之后的 React.memo(),16.8 之后的 Hooks,使用函数式组件成为主流(自然,Vue3 中也有 composition API等)


因为我们使用的各种框架,越来越偏向于函数式开发,所以为了看懂、使用这些代码,我们有必要学习函数式编程


什么是函数式编程


写代码时,不同的写法造成不同的门派,主流的编程范式有两种,即命令式编程(Imperative programming)和声明式编程(Declarative programming)


当然还有像事件驱动编程等,此处不讲


在命令式编程语言中一般支持四种基本语句:

  • 运算语句
  • 循环语句(for、while)
  • 条件分支语句(if else、switch)
  • 无条件分支语句(return、break、continue)


它关注的过程,所以也被称为过程化编程,与之对应的是声明式编程

  • 命令式编程告诉计算机如何计算、关心解决问题的步骤
  • 声明式编程告诉计算机需要计算什么,关心解决问题的目标


两种编程方式在 JavaScript 均有开花,在以往的开发中(即React、Vue等未出现前),我们习惯使用命令式编程,其中的代表就是面向对象编程,即通过对象、类、继承、封装等特性实现面向对象编程,流行的库是 jquery

即使当初不那么流行,但 JavaScript 是一直支持声明式编程的,即通过函数式编程来实现,其原因是函数是一等公民,它能作为参数和返回值。流行的库是 underscorejs、lodash


函数式编程有哪些特点

柯里化(Currying)、缓存函数、纯函数、函数组合(compose)、不可变数据、高阶函数

我们依次讲讲这几个特点


柯里化


什么是柯里化


柯里化是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术

——wiki[1]


也就是说,柯里化是一种函数的转换,它是将一个函数从f(a, b, c) 的写法转换为f(a)(b)(c)

通俗来讲:用闭包把参数保存起来,当参数的数量足够执行函数了,就开始执行函数。它不会调用函数,只是对函数进行转换


// 柯里化之前
function add(x, y) {
    return x + y;
}
add(1, 2); // 3
// 柯里化之后
function add(y) {
    return function (x) {
        return x + y;
    };
}
// ES6 箭头函数表示
const add = x => y => x + y;
add(2)(1);


如上案例,我们就是将add(1, 2) 转换为了 add(2)(1),当然,此案例是模拟柯里化,一般来说,我们会写个「转换函数」将函数适配成柯里化型函数

const curryAdd = curry(add)
console.log(curryAdd(1)(2)) // 3


那么如何实现 curry 呢?需要满足两点

  • 判断当前函数传入的参数是否大于或等于fn 需要参数的数量,如果是,直接执行 fn
  • 如果传入参数数量不够,返回一个闭包,暂存传入的参数,并重新返回 curry 函数


const curry = (fn, ...args) => {
    if (args.length >= fn.length) {
        return fn(...args)
    } else {
        return (...args2) => curry(fn, ...args, ...args2)
    }
}


我们来一个简单的实例验证一下:

function fun(a, b, c) {
    return a + b + c;
}
const curryFun = curry(fun);
curryingFun(1)(2)(3); // 6
curryingFun(1, 2)(4); // 7
curryingFun(1, 2, 5); // 8


翻看函数式编程概念的两个库:undersore.js 及 lodash 中各自实现的 curry

  • undersore.js[2]
  • lodash[3]


箭头函数的使用

在上面的例子中,我们频繁使用箭头函数,箭头函数的魅力之一是它符合函数式编程的思维,例如:

function add(y) {
    return function (x) {
        return x + y;
    };
}
const add = x => y => x + y;


// redux 中的 compose 函数
const compose = (...func) => {
    if (func.length === 0) {
        return args => args
    }
    if (func.length === 1) {
        return func[0]
    }
    return func.reduce((a,b) => (...args) => a(b(...args)))
}


function enhancer(originF) {
    return function (...args) {
        console.log('before');
        const result = originF(...args);
        console.log('after');
        return result;
    };
}
// 等于
const enhancer =
    (originF) =>
    (...args) => {
        console.log('before');
        const result = originF(...args);
        console.log('after');
        return result;
    };


// 三数之乘
const multi = a => b => c => a * b * c


笔者认为,柯里化就是利用了闭包的特性,将参数存着,到了要使用的时候再释放,而这正式柯里化的应用之一


柯里化的应用

在开发中,我们会遇到这种情况,开发了一个工具函数,在调用时,会使用一个频繁的参数,而如果将这个参数提取出来,就能优化代码,如


function add(a, b) {
    return a + b
}
add(10, 1) // 11
add(10, 11) // 21
add(10, 111) // 121


如果我们柯里化,将第一个参数 10 内置进函数中,代码就能清晰明了

// 最简单的优化
functon add10(b) {
    return add(10, b)
} 
add10(1) // 11
add10(11) // 21
add10(111) // 121


函数 add10 就是经过了柯里化的函数,就像我们上文所讲,一般用个包装函数将其适配成柯里化型函数,这里我们调用上文的 curry 函数


// 完整版
function add(a, b) {
    return a + b;
}
// 柯里化包装函数
const curry = (fn, ...args) => {
    if (args.length >= fn.length) {
        return fn(...args)
    } else {
        return (...args2) => curry(fn, ...args, ...args2)
    }
}
const add10 = curry(add, 10)
add10(1) // 11
add10(11) // 21
add10(111) // 121


缓存函数


缓存函数就是将函数返回值缓存起来的方法。与柯里化相比,它似乎更简单。例如

function add(a, b) {
    return a + b;
}
let memoAdd = memoize(add)
memoAdd(1, 2) // 3
memoAdd(1, 3) // 相同的参数,第二次调用时,从缓存中取出数据


如何实现呢?

其实很简单,就是用闭包存在内存中。如果一致,就返回,不一致就存在对象中,再返回

const memoize = (func) => {
    let cache = {}
    return function(key) {
        if (!cache[key]) {
            cache[key] = func.apply(this, arguments)
        }
        return cache[key]
    }
}


纯函数


笔者认为纯函数就类似数学中的函数,例如 y = f(x),更具体点就是:y=2x,相同的输入,就有相同的输出,在执行过程中不会产生副作用


所有它有两个特点:

  1. 不改变原数据(没有副作用)
  2. 返回一个新数据
const arr = [1, 2, 3]
// 纯函数
arr.slice(1)
console.log(arr) // [1, 2, 3]
// 产生副作用,非纯函数
arr.splice(1)
console.log(arr) // [1]


JavaScript 中的哪些 API 是纯函数

数组:slice、concat、map、reduce、filter


slice、concat 我们在 拷贝的秘密 曾经讲过,是浅拷贝的方法

map、filter 是ES6 新增的 API


字符串:String 类型及其他的基本类型的 API 都是不可变的,它们的每个 API 都是纯函数,不会改变原数据

var str = "hello"
console.log(str.slice(0, 3)) // hel , slice 在 String 类型中同样适用,
console.log(str) // hello
console.log(str.substr(0, 3)) // hel
console.log(str) // hello
console.log(str.substring(0,3)); // hel
console.log(str) // hello
// 其他的 API 也是如此


函数组合(compose)


就是将多个函数依次执行,将这些函数组合起来,自动依次执行,这一过程就是函数组合

这么做的目的是为了让代码更加简单且具有可读性


function double(num) {
    return num * 2
}
function square(num) {
    return num ** 2
}
// 普通做法
const res = square(double(10))
console.log(res) // 400
// 函数组合
function composeFn(fn1, fn2) {
    return function (count) {
        return fn2(fn1(count))
    }
}
const newFn = composeFn(double, square)
const res2 = newFn(20) // 1600


看看 underscore 的 compose[4] 函数

function compose() {
    var args = arguments;
    var start = args.length - 1;
    return function() {
        var i = start;
        var result = args[start].apply(this, arguments);
        while(i--) result = args[i].call(this, result);
        return result;
    }
}


看到 compose 是否很熟悉,redux 中的源码[5]是这样写的

function compose(...funcs) {
    if (funcs.length === 0) {
        return arg => arg
    }
    if(funcs.length === 1) {
        return funcs[0]
    }
    return funcs.reduce((a,b) => (...args) => a(b(...args)))
}


如果在实际开发中,就可以用这一概念解决洋葱代码

fn3(fn2(fn1(fn0(x)))) // 洋葱代码
var result = compose(f3, f2, f1, f0) // 函数组合代码
result(x)


高阶函数


简单来说,高阶函数就是一个返回另一个函数的函数

function sayHello() {
    return function() {
        console.log("hello")
    }
}


其本质是因为函数是一等公民,满足作为参数或者返回值

其 React 中也有高阶组件的概念,其原理都一样,即将组件当作参数传入,返回另一个组件


不可变数据


不可变数据结构式函数式编程中的基本准则

JavaScript 的对象是可变的,原因是为了节约内存,但也同样带来了副作用,当应用程序复杂后,维护代码难度会加深


我们在 拷贝的秘密 中曾经讲过,基本类型的数据是不可变的,因为它们存在栈内存,而引用类型(即对象)的数据是可变的,它存在堆内存,当赋值时,拷贝的时它的引用地址

所以我们在赋值时会根据情况使用浅拷贝、深拷贝来实现


在 React 开发时,我们会用 immutable.js 、immer 对数据进行不可变


总结


我们不管其他的,单单就函数式编程在前端方面有哪些用途?如在 Koa 中的洋葱模型、redux 中的 compose 函数就是函数组合,React 开发时的 HOC 高阶组件就采用了高阶函数,以及它(React)为了让数据保持唯一性,而可以引用 不可变数据库( immutable.js、immer )是函数式编程的特性——不可变数据


当然,这些只是笔者对函数式编程浅薄的认知,其内容当然不知以上说的几点,还有惰性函数、函子等知识点,以及其他语言中的函数式编程,待笔者等级提升后再来战


参考资料


  • 简明 JavaScript 函数式编程-入门篇[8]
  • 函数式编程思维[9]
  • 浅析 JavaScript 函数式编程[10]
  • 函数式编程(FP)[11]
  • 前端开发js函数式编程真实用途体现在哪里?[12]
  • JavaScript专题之函数组合[13]


[1] wiki: https://zh.wikipedia.org/wiki/%E6%9F%AF%E9%87%8C%E5%8C%96

[2] undersore.js: https://github.com/lodash/lodash/blob/npm/curry.js

[3] lodash: https://github.com/lodash/lodash/blob/es/curry.js

[4] compose: https://github.com/jashkenas/underscore/blob/master/modules/compose.js

[5] 源码: https://github.com/reduxjs/redux/blob/master/src/compose.ts

[6] immutable.js: https://immutable-js.com/

[7] immer: https://immerjs.github.io/immer/

[8] 简明 JavaScript 函数式编程-入门篇: https://zhuanlan.zhihu.com/p/81302150

[9] 函数式编程思维: https://github.com/sunyongjian/FP-Code

[10] 浅析 JavaScript 函数式编程: https://juejin.cn/post/6940442700889980965

[11] 函数式编程(FP): https://juejin.cn/post/7065093131233919006

[12] 前端开发js函数式编程真实用途体现在哪里?: https://www.zhihu.com/question/59871249

[13] JavaScript专题之函数组合: https://github.com/mqyqingfeng/Blog/issues/45

相关文章
|
11月前
|
前端开发 JavaScript 数据处理
深入学习JavaScript ES8函数式编程:特性与实践指南
深入学习JavaScript ES8函数式编程:特性与实践指南
77 0
|
3月前
|
存储 JavaScript 前端开发
JavaScript——函数式编程Functor(函子)
JavaScript——函数式编程Functor(函子)
24 0
|
5月前
|
前端开发 JavaScript 开发者
函数式编程在JavaScript中的应用
【6月更文挑战第10天】本文探讨了函数式编程在JavaScript中的应用,介绍了函数式编程的基本概念,如纯函数和不可变数据。文中通过示例展示了高阶函数、不可变数据的使用,以及如何编写纯函数。此外,还讨论了函数组合和柯里化技术,它们能提升代码的灵活性和可重用性。掌握这些函数式编程技术能帮助开发者编写更简洁、可预测的JavaScript代码。
|
6月前
|
JavaScript 前端开发
JavaScript 的数组方法 map()、filter() 和 reduce() 提供了函数式编程处理元素的方式
【5月更文挑战第11天】JavaScript 的数组方法 map()、filter() 和 reduce() 提供了函数式编程处理元素的方式。map() 用于创建新数组,其中元素是原数组元素经过指定函数转换后的结果;filter() 则筛选出通过特定条件的元素生成新数组;reduce() 将数组元素累计为单一值。这三个方法使代码更简洁易读,例如:map() 可用于数组元素乘以 2,filter() 用于选取偶数,reduce() 计算数组元素之和。
46 2
|
6月前
|
JavaScript 前端开发 测试技术
JavaScript中的函数式编程:纯函数与高阶函数的概念解析
【4月更文挑战第22天】了解JavaScript中的函数式编程,关键在于纯函数和高阶函数。纯函数有确定输出和无副作用,利于预测、测试和维护。例如,`add(a, b)`函数即为纯函数。高阶函数接受或返回函数,用于抽象、复用和组合,如`map`、`filter`。函数式编程能提升代码可读性、可维护性和测试性,帮助构建高效应用。
|
6月前
|
JavaScript 前端开发 索引
JavaScript函数式编程【进阶】
JavaScript函数式编程【进阶】
55 1
|
6月前
|
存储 JavaScript 前端开发
JavaScript函数式编程[入门]
JavaScript函数式编程[入门]
54 1
|
6月前
|
前端开发 JavaScript 数据处理
深入学习JavaScript ES8函数式编程:特性与实践指南
深入学习JavaScript ES8函数式编程:特性与实践指南
115 0
|
缓存 JavaScript 前端开发
带你读《现代Javascript高级教程》十四、JavaScript函数式编程(1)
带你读《现代Javascript高级教程》十四、JavaScript函数式编程(1)
|
JavaScript 前端开发 测试技术
带你读《现代Javascript高级教程》十四、JavaScript函数式编程(2)
带你读《现代Javascript高级教程》十四、JavaScript函数式编程(2)
下一篇
无影云桌面