# 什么是编程范式?

编程范式(Programming paradigm)是一类典型的编程风格,是指从事软件工程的一类典型的风格(可以对照方法学)。如:函数式编程、过程式编程、面向对象编程、指令式编程等等为不同的编程范式。

JS 是一种动态的基于原型和多范式的脚本语言,并且支持面向对象(OOP,Object-Oriented Programming)、命令式和声明式(如函数式编程 Functional Programming)的编程风格。

那么面向对象,命令式,声明式编程到底是什么呢?他们有什么区别呢?

  • 面向过程(Process Oriented Programming,POP)
  • 面向对象(Object Oriented Programming,OOP)
  • 面向接口(Interface Oriented Programming, IOP)
  • 面向切面(Aspect Oriented Programming,AOP)
  • 函数式(Funtional Programming,FP)
  • 响应式(Reactive Programming,RP)
  • 函数响应式(Functional Reactive Programming,FRP)

# 命令式编程

命令式编程是一种描述计算机所需作出的行为的编程典范,即一步一步告诉计算机先做什么再做什么。举个简单的 🌰:找出所有人中年龄大于 35 岁的,你就需要这样告诉计算机:

  • 创建一个新的数组 newArry 存储结果;
  • 循环遍历所有人的集合 people;
  • 如果当前人的年龄大于 35,就把这个人的名字存到新的数组中;
const people = [
  { name: "Lily", age: 33 },
  { name: "Abby", age: 36 },
  { name: "Mary", age: 32 },
  { name: "Joyce", age: 35 },
  { name: "Bella", age: 38 },
  { name: "Stella", age: 40 },
];
const newArry = [];
for (let i = 0; i < people.length; i++) {
  if (people[i].age > 35) {
    newArry.push(people[i].name);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

命令式编程的特点是非常易于理解,按照实际的步骤实现,优点就在于性能高,但是会依赖,修改较多外部变量,可读性低。

# 声明式编程

声明式编程与命令式编程是相对立的,只需要告诉计算机要做什么而不必告诉他怎么做。声明式语言包括数据库查询语(SQL),正则表达式,逻辑编程,函数式编程和组态管理系统。上边的例子用声明式编程是这样的:

const peopleAgeFilter = (people) => {
  return people.filter((item) => item.age > 35);
};
1
2
3

# 函数式编程(FP)

函数式编程这里的函数并不是我们所知道的 Function,而是数学中的函数,即变量之间的映射,输入通过函数都会返回有且只有一个输出值。

// js 中的 function
function fun(data, value, type) {
  // 逻辑代码
}
// 函数
y = f(x);
1
2
3
4
5
6

早在 1958 年,随着被创造出来的 LISP (https://baike.baidu.com/item/lisp%E8%AF%AD%E8%A8%80/2840299?fr=aladdin),函数式编程就已经问世。在近几年,在前端领域也逐渐出现了函数式编程的影子:箭头函数、map、reduce、filter,同时 Redux 的 Middleware 也已经应用了函数式编程...

# 函数式编程的特性

  • 简洁、易懂、书写便捷

  • 函数是"第一等公民"

所谓"第一等公民",指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。例如:

let fun = function (i) {
  console.log(i);
}[(1, 2, 3)].forEach((element) => {
  fun(element);
});
1
2
3
4
5
  • 惰性计算

在惰性计算中,表达式不是在绑定到变量时立即计算,而是在求值程序需要产生表达式的值时进行计算。即函数只在需要的时候执行。

  • 没有"副作用"

"副作用"指的是函数内部与外部互动(最典型的情况,就是修改全局变量的值),产生运算以外的其他结果。由于 JS 中对象传递的是引用地址,即使我们使用 const 关键词声明对象时,它依旧是可以变的。这样就会导致我们可能会随意修改对象。例如:

const user = {
  name: "jingjing",
};
const changeName = (obj, name) => (obj.name = name);
const changeUser = changeName(user, "lili");
console.log(user); // {name: "lili"} user 对象已经被改变
1
2
3
4
5
6

改成无副作用的纯函数的写法:

const user = {
  name: "jingjing",
};
// const changeName = (obj, name) => obj.name = name;
const changeName = (obj, name) => ({ ...user, name });
const changeUser = changeName(user, "lili");
console.log(user); // {name: "jingjing"}, 此时user对象并没有改变
1
2
3
4
5
6
7
  • 引用透明性

即如果提供同样的输入,那么函数总是返回同样的结果。就是说,任何时候只要参数相同,引用函数所得到的返回值总是相同的。 在函数式编程中柯里化(Currying)和函数组合(Compose)是必不可少。

  • 函数柯理化

网上关于柯里化的文章很多,这里不再赘述,可以参考:函数柯里化 Currying (https://juejin.cn/post/6844903748137926669)。

柯里化 (https://zh.wikipedia.org/wiki/%E6%9F%AF%E9%87%8C%E5%8C%96)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。简单来说,就是只传递给函数一个参数来调用它,让它返回一个函数去处理剩下的参数。即:

f(x, y, z) -> f(x)(y)(z)

1
2

如下例,求两个数的平方和:

// 原始版本
const squares = function (x, y) {
  return x * x + y * y;
};
// 柯里化版本
const currySquares = function (x) {
  return function (y) {
    return x * x + y * y;
  };
};
console.log(squares(1, 2));
console.log(currySquares(1)(2));
1
2
3
4
5
6
7
8
9
10
11
12

在柯里化版本中,实际的执行如下:

currySquares(1) = function(y){
  return 1 + y * y;
}
currySquares(1)(2) = 1 + 4 = 5;
1
2
3
4
  • 函数组合(Compose)

函数组合就是将两个或多个函数组合起来生成一个新的函数。

在计算机科学中,函数组合是将简单函数组合成更复杂函数的一种行为或机制。就像数学中通常的函数组成一样,每个函数的结果作为下一个函数的参数传递,而最后一个函数的结果是整个函数的结果。所以说柯里化是函数组合的基础。 双函数情况:

const compose = (f, g) => (x) => f(g(x));
const f = (x) => x * x;
const g = (x) => x + 2;
const composefg = compose(f, g);
composefg(1); //9
1
2
3
4
5

对于多函数情况,简单实现如下:

const compose =
  (...fns) =>
  (...args) =>
    fns.reduceRight((val, fn) => fn.apply(null, [].concat(val)), args);
const f = (x) => x * x;
const g = (x) => x + 2;
const h = (x) => x - 3;
const composefgh = compose(f, g, h);
composefgh(5); // 16
1
2
3
4
5
6
7
8
9

声明式编程的特点是不产生“副作用”,不依赖也不会改变当前函数以外的数据,优点在于:

  • 减少了可变变量,程序更加安全;
  • 相比命令式编程,少了非常多的状态变量的声明与维护,天然适合高并发多现成并行计算等任务,这也是函数式编程近年又大热的重要原因;
  • 代码更为简洁,接近自然语言,易于理解,可读性更强。但是函数编程也有天然的缺陷:
  • 函数式编程相对于命令式编程,往往会对方法过度包装,导致性能变差;
  • 由于函数式编程强调不产生“副作用”,所以他不擅长处理可变状态;

# 面向对象编程(OOP)

面向对象的程序设计把计算机程序视为一组对象的集合,而每个对象都可以接收其他对象发过来的消息,并处理这些消息,计算机程序的执行就是一系列消息在各个对象之间传递。

面向对象的两个基本概念:

  • 类:类是对象的类型模板;例如:政采云前端 ZooTeam 是一个类;
  • 实例:实例是根据类创建的对象;例如:ZooTeam 可以创建出刘静这个实例;面向对象的三个基本特征:封装、继承、多态:注 ⚠️:以下例子均采用 ES6 写法。
  • 封装:封装即隐藏对象的属性和实现细节,仅对外公开接口,控制在程序中属性的读和修改的访问级别;将抽象得到的数据和行为(或功能)相结合,形成一个有机的整体。根据我的理解,其实就是把子类的属性以及公共的方法抽离出来作为公共方法放在父类中。
class Zcy {
  constructor(name) {
    this.name = name;
  }
  doSomething() {
    let { name } = this;
    console.log(`${name}9点半在开晨会`);
  }
  static soCute() {
    console.log("Zcy 是一个大家庭!");
  }
}
let member = new Zcy("jingjing", 18);
member.soCute(); // member.soCute is not a function
member.doSomething(); // jingjing9点半在开晨会
Zcy.soCute(); // Zcy 是一个大家庭!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

Zcy 的成员都有名字和年龄,九点半时都在开晨会,所以把名字和年龄当作共有属性, 九点半开晨会当作公共方法抽离出来封装起来。static 表示静态方法,静态方法只属于 Zcy 这个类,所以当 member 调用 soCute 方法时,控制台报错。

  • 继承:继承就是子类继承父类的特征和行为,使得子类对象(实例)具有父类的属性和方法,或子类从父类继承方法,使得子类具有父类相同的行为。子类继承父类后,子类就会拥有父类的属性和方法,但是同时子类还可以声明自己的属性和方法,所以子类的功能会大于等于父类而不会小于父类。
class Zcy {
  constructor(name) {
    this.name = name;
  }
  doSomething() {
    let { name } = this;
    console.log(`${name}9点半在开晨会`);
  }
  static soCute() {
    console.log("Zcy 是一个大家庭!");
  }
}
class ZooTeam extends Zcy {
  constructor(name) {
    super(name);
  }
  eat() {
    console.log("周五一起聚餐!");
  }
}
let zooTeam = new ZooTeam("jingjing");
zooTeam.doSomething(); // jingjing9点半在开晨会
zooTeam.eat(); // 周五一起聚餐!
zooTeam.soCute(); // zooTeam.soCute is not a function
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

ZooTeam 继承了 Zcy 的属性和方法,但是不能继承他的静态方法;而且 ZooTeam 声明了自己的方法 eat。

  • 多态:多态按字面的意思就是“多种状态”,允许将子类类型的指针赋值给父类类型的指针。即同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。多态的表现方式有重写,重载和接口,原生 JS 能够实现的多态只有重写。

  • 重写:重写是子类可继承父类中的方法,而不需要重新编写相同的方法。但有时子类并不想原封不动地继承父类的方法,而是想作一定的修改,这就需要采用方法的重写。方法重写又称方法覆盖。

class Zcy {
  constructor(name) {
    this.name = name;
  }
  getName() {
    console.log(this.name);
  }
  doSomething() {
    let { name } = this;
    console.log(`${name}9点半在开晨会`);
  }
  static soCute() {
    console.log("Zcy 是一个大家庭!");
  }
}
class ZooTeam extends Zcy {
  constructor(name) {
    super(name);
  }
  doSomething() {
    console.log("zooTeam周五要开周会!");
  }
}
const zcy = new Zcy("jingjing");
const zooTeam = new ZooTeam("yuyu");
zcy.doSomething(); // jingjing9点半在开晨会
zcy.getName(); // jingjing
zooTeam.doSomething(); // zooTeam周五要开周会!
zooTeam.getName(); // yuyu
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

ZooTeam 为了满足自己的需求,继承了父类的 doSomething 方法后重写了 doSomething 方法,所以调用 doSomething 方法之后得到了不同的结果,而 getName 方法只是继承并没有重写。

面向对象编程的特点是抽象描述对象的基本特征,优点在于对象易于理解和抽象,代码容易扩充和重用。但是也容易产生无用代码,容易导致数据修改。

# 面向切面编程(AOP)

用过 koa 和 redux 或 Axios 的同学应该都知道它们都有“中间件”这样一个概念(前端意义上的中间件,不是指平台与应用之间的通用服务),在 redux 中我们可以通过中间件的方式使用 redux-thunk 和 loger 的功能,在 koa 中我们可以通过中间件对请求上下文 context 进行处理。

通过中间件,我们可以在一些方法执行前添加统一的处理(如登录校验,打印操作日志等),中间件的设计思想都是面向切面编程的思想,把一些跟业务无关的逻辑进行抽离,在需要使用的场景中再切入,降低耦合度,提高可重用性,而且使代码更简洁。

通过对 redux 和 koa 中间件实现的简单分析,大家应该对面向切面编程有了一个简单的理解,下面一部分内容就是详细介绍面向切面编程,以及如果不用面向切面编程的方式,我们还能用什么方式来满足需求,以及这些方式有什么问题。

面向切面编程(Aspect Oriented Programming,也叫面向方面编程)是一种非侵入式扩充对象、方法和函数行为的技术。 核心思想是通过对方法的拦截,在预编译或运行时进行动态代理,实现在方法被调用时可以以对业务代码无侵入的方式添加功能。

比如像日志、事务等这些功能,和核心业务逻辑没有直接关联,通过切面的方式和核心业务逻辑进行剥离,让业务同学只需关心业务逻辑的开发,当需要用到这些功能的时候就把切面插拔到业务流程的某些节点上,做到了切面和业务的分离。

我们举个例子,来帮助理解面向切面编程的使用场景。

农场的水果包装流水线一开始只有采摘-清洗-贴标签三步。

为了提高销量,想加上两道工序分类和包装但又不能干扰原有的流程,同时如果没增加收益可以随时撤销新增工序。

最后在流水线的中的空隙插上两个工人去处理,形成采摘-分类-清洗-包装-贴标签的新流程,而且工人可以随时撤回。

回到 AOP 的作用这个问题。

AOP 就是在现有代码程序中,在不影响原有功能的基础上,在程序生命周期或者横向流程中加入/减去一个或多个功能。

# 响应式编程

# 什么是响应式

  • 在计算机领域,响应式编程是一个专注于数据流和变化传递的异步编程范式。
  • 这意味着可以使用编程语言很容易地表示静态(例如数组)或动态(例如事件发射器)数据流,
  • 并且在关联的执行模型中,存在着可推断的依赖关系,这个关系的存在有利于自动传播与数据流有关的更改。

是一种面向数据流和变化传播的编程范式。 这意味着可以在编程语言中很方便地表达静态或动态的数据流,而相关的计算模型会自动将变化的值通过数据流进行传播。 例如,对于a=b+c 这个表达式的处理,在命令式编程中,会先计算b+c 的结果,再把此结果赋值给变量a,因此b,c 两值的变化不会对变量a 产生影响。

它是一种基于事件模式的模型。在上面的异步编程模式中,我们描述了两种获得上一个任务执行结果的方式,一个就是主动轮训,我们把它称为 Proactive 方式。另一个就是被动接收反馈,我们称为 Reactive。

简单来说,在 Reactive 方式中,上一个任务执行结果的反馈就是一个事件,这个事件的到来将会触发下一个任务的执行。

这也就是 Reactive 的内涵!我们把处理和发出事件的主体称为 Reactor,它可以接受事件并处理,也可以在处理完事件后,发出下一个事件给其他 Reactor。两个 Reactors 之间没有必然的强耦合,他们之间通过消息管道来传递消息。Reactor 可以定义一些事件处理函数,根据接收到的事件不同类型来进行不同的处理。如果我们的系统复杂,我们还可以专门定义不同功能类别的 Reactors,分别处理不同类型的事件。而在每个 Reactor 中对事件又进行细分处理。

需要强调的是,实现 Reactive 模型最核心的是线程和消息管道。线程用于侦听事件,消息管道用于 Reactor 之间通信不同的消息。与他们相关的是事件管理器用于注册、注销事件,而消息分配器则会根据消息类型分发。

下面是一个 Reactive 模型的示意图:

# 响应式编程的设计与实现

接下来,讲一下实现上的架构设计与实现。

通常 Reactor 的数量可以是预先定义的,因为一个系统,我们通常可以约束它处理哪些预定义的事件,比如有处理网络连接的 Reactor,处理日志收集的 Reactor,处理数据存储的 Reactor 等等,各司其职。而错误(未定义)事件则可以单独放在一个专门处理 Error/Exception 的 Reactor 中。通过事件管理器,每个 Reactor 可以根据要发出或者接收的消息,即时地创建一个线程/协程去执行响应的操作。发出和接收消息可以根据业务的复杂度,分开单独线程,也可以放在一个线程。这样的设计架构简单而清晰。

下面是一个简单的示意图

# RxJS

RxJS 中含有两个基本概念:Observables 与 Observer。Observables 作为被观察者,是一个值或事件的流集合;而 Observer 则作为观察者,根据 Observables 进行处理。 Observables 与 Observer 之间的订阅发布关系(观察者模式) 如下:

  • 订阅:Observer 通过 Observable 提供的 subscribe() 方法订阅 Observable。
  • 发布:Observable 通过回调 next 方法向 Observer 发布事件。

「RxJS」是使用 Observables 的响应式编程的库,它使编写异步或基于回调的代码更容易。

这样一看,我们可能会把「RxJS」来和 Promise 作一些列比较,因为 Promise 也是为了使得编写异步或基于回调的代码更容易而出现的。也确实,很多在谈论「RxJS」的文章都会举例子来比较两者的优缺点。

所以,在这里我就不举例比较,就简单列举几点「RxJS」解决了哪些使用 Promise 存在的痛点,如:

  • 控制异步任务的执行顺序。
  • 控制异步任务的开始和暂停。
  • 可以获取异步任务执行的各个阶段的状态。
  • 监听异步任务执行过程中发生的错误,在并发执行的情况下,不会因为一个异步任务出错而影响到其他异步代码的执行。

# 参考

RxJS 入门及应用 (opens new window)

从业务视角来聊一聊为什么我们需要 RxJS? (opens new window)

深入浅出 RxJS 核心原理(源码实现) (opens new window)

作为前端,你需要知道 RxJS(响应式编程-流) (opens new window)

# 总结

命令式、声明式、面向对象本质上并没有优劣之分,面向对象和命令式、声明式编程也不是完成独立、有严格的界限的,在抽象出各个独立的对象后,每个对象的具体行为实现还是有函数式和过程式完成。在实际应用中,由于需求往往是特殊的,所以还是要根据实际情况选择合适的范式。

  • 参考

面向切面编程—koa、redux 框架中间件原理解析 (opens new window) 编写高质量可维护的代码:编程范式 (opens new window) 聊聊编程范式 (opens new window)