背景
函数式编程可以说是非常古老的编程方式,但是近几年变成了一个非常热门的话题。不管是Google力推的Go、学术派的Scala与Haskell,还是Lisp的新语言Clojure,这些新的函数式编程语言越来越受到人们的关注。函数式编程思想对前端的影响很大,Angular、React、Vue等热门框架一直在不断通过该思想来解决问题。
函数式编程作为一种高阶编程范式,更接近于数学和代数的一种编程范式,与面向对象的开发理念和思维模式截然不同,深入理解这种差异性,是程序员进阶的必经之路。
编程范式
编程范式(Programming Paradigm)是编程语言领域的模式风格,体现了开发者设计编程语言时的考量,也影响着程序员使用相应语言进行编程设计的风格。大体分为两大类,具体内容如下图所示:
函数式概念与思维
函数式编程(Functional Programming)是基于λ演算(Lambda Calculus)的一种语言模式,它的实现基于λ演算和更具体的α-等价、β-归约等设定 。这是一个较官方的解释,大家不要被这种概念吓到,很有可能你已经在日常开发中使用了大量的函数式编程概念和工具。如越来越函数式的ES6,新的规范有非常多的新特性,其中不少借鉴其他函数式语言的特性,给JavaScript语言添加了不少函数式的新特性。箭头函数就是ES6发布的一个新特性,箭头函数也被叫做肥箭头(Fat Arrow),大致是借鉴自CoffeeScript或者Scala语言。箭头函数是提供词法作用域的匿名函数。
函数式编程思维的目标:程序执行时,应该把程序对结果以外的数据的影响控制到最小。
函数式编程的特点
- 声明式(Declarative)
- 纯函数(Pure Function)
- 函数的执行过程完全由输入参数决定,不会受除参数之外的任何数据影响。
- 函数不会修改任何外部状态,比如修改全局变量或传入的参数对象。
数据不可变性(Immutability)
当我们需要数据状态发生改变时,保持原有数据不变,产生一个新的数据来体现这种变化。不可改变的数据就是Immutable数据,一旦产生,可以肯定它的值永远不会变,这非常有利于代码的理解。
下面用一段对比代码解释命令式编程与函数式编程:
// 计算传入数据乘以2
// 命令式编程
function double(arr) {
const results = []
for (let i = 0; i < arr.length; i++){
results.push(arr[i] * 2)
}
return results
}
console.log(double([1, 2, 3]));// [2, 4, 6]
// 函数式编程
function double(arr) {
return arr.map(item => item * 2);
}
const oneArray = [1, 2, 3];
const anotherArray = double(oneArray);
console.log(oneArray); // [1, 2, 3]
console.log(anotherArray);// [2, 4, 6]
函数是一等公民
数字在JavaScript里就是一等公民,同样作为一等公民的函数就会拥有类似数字的性质。
- 函数与数字一样可以存储为变量
let one = function() { return 1 };
- 函数与数字一样可以存储为数组的一个元素
let ones = [1, function() { return 1 }];
- 函数与数字一样可以被传递给另一个函数
function numAdd(n, f) { return n + f()};
numAdd(1, function() { return 1}); // 2
- 函数与数字一样可以被另一个函数返回
return 1;
return function() { return 1 };
最后两点其实就是“高阶”函数的定义;一个高阶函数应该可以至少执行一项,以一个函数作为参数或者返回一个函数作为结果。
高阶函数(High Order Function)
高阶函数,通俗来说,就是以其他函数为参数的函数,返回其他函数的函数。我们称函数的嵌套高阶调用为高阶函数,高阶函数可以说是编程语言便捷践行函数式的基础。比如在React中我们会遇到的高阶组件HOC。
以数字添加千分位符号为demo的代码如下:
const addThousandSeprator = (strOrNum) => {
return parseFloat(strOrNum).toString().split('.').map((x,idx) => {
if(!idx) {
return x.split('')
.reverse()
.map((xx,idxx) => (idxx && !(idxx % 3)) ? (xx + ',') : xx )
.reverse()
.join('')
} else {
return x;
}
}).join('.')
}
高阶函数应用之柯里化(Currying)
柯里化函数为每一个逻辑参数返回一个新的函数,会逐渐返回已配置的函数,直到所有的参数用完。
function curry(fun) {
return function(arg) {
return fun(arg)
}
}
const arr = ['1', '2', '3', '4'].map(curry(parseInt));
console.log(arr) // [ 1, 2, 3, 4 ]
使用柯里化比较容易产生流利的函数式API。在Haskell编程语言中,函数式默认柯里化。但在JavaScript中,函数式API的设计必须利用柯里化,而且必须文档化。
递归
程序调用自身的编程技巧称为递归( recursion)。递归作为一种算法在程序设计语言中广泛应用。 递归是一种解决过程堆叠的方法,在运行时承担了更多的工作。递归的能力在于用有限的语句来定义对象的无限集合。一般来说,递归需要有边界条件、递归前进段和递归返回段。当边界条件不满足时,递归前进;当边界条件满足时,递归返回。
说起递归,不得不谈起尾递归。早期的浏览器引擎是不支持尾递归,所以当我们计算经典的斐波那契数列或进行其他递归操作时,可能会触发堆栈调用超限的提醒。如果每次递归尾部返回的内容都是一个待计算的表达式,那么运行时的内存栈中会一直压入等待计算的变量和环境,这就是产生超限的根本原因。而如果我们使用新的递归方法,若运行环境支持优化,则立即释放被替换的函数负载。
// 递归:将外层调用保存在内存堆栈中
const factorialFn = (n) => {
if (n <= 1) {
return 1;
} else {
return n + factorialFn(n - 1);
}
}
console.log('factorialFn: ', factorialFn(30))
// 返回函数调用;尾递归优化
const factorialFun = (n, acc) => {
if(n <= 1) {
return acc;
} else {
return factorialFun(n - 1, n + acc)
}
}
console.log('factorialFun: ', factorialFun(30, 1))
运行结果如下:
基于流的编程
在前端领域中,「流」的经典代表之一「RxJS」。
在Rx官网https://reactivex.io/ 上,有一段介绍文字:
An API for asynchronous programming with observable streams.
翻译过来就是:Rx是一套通过可监听流来做异步编程的API。老实说,这句描述并没有把概念解释清楚,所以在下面我们就用普通的语言来解释Rx。
RxJS初认识
RxJS是Reactive Extension模式的JavaScript语言实现。
RxJS是一个使用可观察序列组成异步和基于事件的程序库。它提供了一种核心类型,Observable,广播类型(Observer,Schedulers,Subjects)和操作符(map,filter,reduce等),允许将异步事件作为集合处理。
RxJS的运行就是Observable和Observer之间的互动游戏。
RxJS中的数据流就是Observable对象,Observable实现了两种设计模式:观察者模式(Observer Pattern)、迭代器模式(Iterator Pattern) 。
Observable和Observer的关系是观察者模式和迭代器模式的结合,通过Observable对象的subscribe函数,可以让一个Observer对象订阅某个Observable对象的推送内容,可以通过unsubscribe函数退订内容。
RxJS核心概念
Observable:可观察者对象,表示可以调用的未来值或事件集合的方法。
Observer: 观察者,是一组回调函数,处理Observable提供的值。
/**
* Observable对象(source$)就是一个发布者,通过Observable对象的subscribe函数,把发布者和观察者连接起来
* 扮演观察者的是console.log,不管传入什么“事件”,它只管把“事件”输出到console上
*/
const source$ = of(1, 2, 3); // 发布者
source$.subscribe(console.log); // 观察者
这段代码输出结果如下:
Subscription:订阅关系,表示Observable执行,主要用于取消执行。
import {Observable} from 'rxjs/Observable';
const onSubscribe = observer => {
let number = 1;
const handle = setInterval(() => {
console.log(`onSubscirbe: ${number}`)
observer.next(number++);
}, 1000);
return {
unsubscribe: () => {
clearInterval(handle);
}
};
};
const source$ = new Observable(onSubscribe);
const subscription = source$.subscribe(item => console.log(`第${item}次调用`));
setTimeout(() => {
subscription.unsubscribe();
}, 5500);
这段代码输出结果如下:
该行代码被注释后 clearInterval(handle),代码输入结果如下:
当unsubscribe函数中的clearInterval被注释掉后,也就是setInterval不被打断,setInterval的函数参数中输出当前number,修改之后的程序会不断的输出 onSubscirbe: n。
由此可见,Observable产生的事件,只有Observer通过subscribe订阅之后才会收到,在unsubscribe之后就不会再收到。
Operators:操作符,纯粹的函数,一个操作符是返回一个Observable对象的函数。
说起操作符,不得不说的就是弹珠图,弹珠图可以通过动画很直白的向我们展示操作过程,动态: https://reactive.how/rxjs/ , 静态:https://rxmarbles.com/#interval。
在所有操作符中最容易理解的可能就是map和filter,因为JavaScript的数组对象有两个同名的函数map和filter。
JavaScript写法:
const source = [1,2,3,4,5,6];
const result = source.filer(x => x % 2 === 0).map(x => x * 2);
console.log(result);
RxJS写法:
const result$ = of(1,2,3,4,5,6).filter(x => x % 2 === 0).map(x => x * 2);
result$.subscribe(console.log);
按功能分类,大致可以分为9大类:
- 创建类(creation)
- 转化类(transformation)
- 过滤类(filtering)
- 合并类(conbination)
- 多播类(multicasting)
- 错误处理类(error Handling)
- 辅助工作类(untility)
- 条件分支类(conditional & boolean)
- 数据和合计类(mathmatical & aggregate)
Subject:主题,相当于EventEmitter,将值或事件广播到多个Observer的唯一方法。
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';
import 'rxjs/add/observable/interval';
import 'rxjs/add/operator/take';
const tick$ = Observable.interval(1000).take(3);
const subject = new Subject();
tick$.subscribe(subject);
subject.subscribe(value => console.log('observer 1: ' + value));
setTimeout(() => {
subject.subscribe(value => console.log('observer 2: ' + value));
}, 1500);
这段代码的执行结果如下:
以上代码可以看出,Subject兼具Observable和Observer的性质,就像有两副面孔,可以左右逢源。
日常常用场景如浏览器中鼠标的移动事件、点击事件,浏览器的滚动事件,来自WebSocket的推送消息,还有Node.js支持的EventEmitter对象消息,及微服务系统中主应用与各个子应用之间的通信等。
Scheduler:控制并发的集中调度器,使我们能够协调发生在setTimeout或其他的事件。
Scheduler实例:
- undefined/null:也就是不指定Scheduler,代表同步执行的Scheduler。
- asap:尽快执行的Scheduler。
- async:利用setInterval实现的Scheduler,用于基于时间吐出数据的场景。
- queue:利用队列实现的Scheduler,用于迭代一个大的集合的场景。
- animationFrame:用于动画场景的S cheduler。
RxJS默认选择Scheduler的原则是:尽量减少并发运行。所以,对于range,就选择undefined,指的是同步执行的Scheduler;对于很大的数据,就选择queue;对于时间相关的操作符比如interval,就选择async。
import {Observable} from 'rxjs/Observable';
import 'rxjs/add/observable/range';
import {asap} from 'rxjs/scheduler/asap';
const source$ = Observable.range(1, 3, asap);
console.log('before subscribe');
source$.subscribe(
value => console.log('data: ', value),
error => console.log('error: ', error),
() => console.log('complete')
);
console.log('after subscribe');
这段代码的执行结果如下:
函数式在前端的积极作用
web开发时,我们会在服务端管理大量的系统状态和系统数据,可以看到随着前端工作流逐渐增多,事件和远程状态响应都会变得错综复杂。对于查看一个多于10个页面或组件复杂的项目代码时,我们会发现相比于后端,很难通过前端代码读懂整个业务链路。如果我们将核心代码更换成较为合理的函数式逻辑,或者使用函数式工具和规范对已有逻辑进行归纳,就可以明显提高代码的可读性和代码运行时的可调试性,这也是对历史代码进行升级、改造的方法之一。
前端函数式的初衷是我们希望能更好、更快、更强地解决开发过程中遇到的问题。与其等待后续的治理,不如在日常开发中进行合理的规划,养成良好的开发习惯。