本文深入解析 JavaScript 中面向对象编程(OOP)与函数式编程(FP)的核心差异。OOP 部分涵盖继承、多态、封装三大特性及五大设计原则,结合 ES6 Class 与 ES5 原型链实现;FP 部分探讨纯函数、闭包、高阶函数、柯里化、偏函数及函子概念,旨在通过对比帮助开发者理解两种范式在代码组织与问题解决上的不同思维模式。
前言
阅读本文建议具备一定 JavaScript 基础。文中涉及的知识点较为丰富,每个知识点单独展开均可撰写多篇文章。本文主要提供一种实现思路与框架,具体细节需读者自行深入钻研。内容篇幅较长,阅读辛苦,若有其他见解或建议,欢迎交流指正。正如古语所云:纸上得来终觉浅,绝知此事要躬行。
JavaScript 中函数与对象的关系
在 JavaScript 中,一切皆对象。除了基本数据类型外,函数本身也是对象,且大多数对象由函数创建。基于这一特性,我们可以对面向对象编程(OOP)与函数式编程(FP)两种范式进行探讨。此处对类型判断和原型链仅作简要概述,非本文重点,感兴趣者可自行查阅相关资料。
// 类型判断
// 基本类型
console.log(typeof 1) // | ==> number
console.log(typeof '2') // | ==> string
console.log(typeof undefined) // | ==> undfined
// null 类型判断【特殊】
console.log(typeof null) // | ==> object
console.log(Object.prototype.toString.call(null)) // | ==> [object Null]
// 报错【null 不是一个对象】TypeError: Right-hand side of 'instanceof' is not an object
console.log(null instanceof null)
console.log(typeof Symbol()) // | ==> symbol 【ES6 新类型】
console.log(typeof false) // | ==> boolean
console.log(typeof BigInt(9007199254740991n)) // | ==> bigint 【新类型】
// 引用类型 - 对象
console.log(typeof (() => {})) // | ==> function
console.log((() => {}) instanceof Object) // true
console.log(typeof []) // | ==> object
console.log(typeof {}) // | ==> object
console.log(typeof (/\./)) // | ==> object
console.log(typeof new Date()) // | ==> object
console.log(typeof new String()) // | ==> object
console.log(typeof new Number()) // | ==> object
// 原型链
// fn ====> function fn () {}
// Object ====> function Object () {}
// Function ====> function Funtion()
new fn() - __proto__ --|
↑ ↓
---→ fn ----------- fn.prototype -------- __proto__ -----→ Object.prototype -- __proto__--→ null
| ↑
|--------- __proto__ ------→ Function.prototype --- __proto__ -----|
↑
Function -------→ __proto__
|
Object
面向对象编程(OOP)
面向对象编程最常见的表现形式是类,它提供了面向对象的三大特征和五大原则。网络上相关资源众多,本文仅做简要罗列,随后将对这些特征进行具体实现。我的理解是:原则是 OOP 的规范,而特征是 OOP 的具体实现方式,前提是读者需先理解以下核心概念。
三大特征
- 继承
- 多态
- 封装
五大原则
- 单一职责原则:一个类应当且仅有一个引起它变化的理由,即一个类只负责一项工作。
- 开闭原则:对象或实体应当对扩展开放,对修改封闭。
- 里氏替换原则:子类应当能够替换其父类,即对父类的调用同样适用于子类。
- 依赖倒置原则:高层次的模块不应依赖于低层次的模块,二者都应依赖于抽象接口。
- 接口隔离原则:不应强迫客户端实现它们不需要的接口,即客户端不应被迫依赖它们不使用的方法。
继承
继承是面向对象的一个显著特征,它允许子类调用父类的属性和方法,从而实现代码复用。
/** ES6 **/
class Parent {}
class Child extends Parent { constructor () { super() } }
/** ES5 **/
function parent () { this.run () {} }
parent.prototype.eat = function () {}
function child () {}
// 原型式继承
child.prototype = parent.prototype
child.prototype.constructor = child
// 原型链继承
child.prototype = new parent()
child.prototype.constructor = child
// 构造器继承
function boyChild (..arg) { parent.apply(this, arg) }
// 组合继承
function boyChild (..arg) { parent.apply(this, arg) }
boyChild.prototype = new parent()
child.prototype.constructor = child
// 寄生组合继承
function child (..arg) { parent.apply(this, arg) }
// ${1}
(
function () {
function transmit () {};
transmit.prototype = parent.prototype
child.prototype = new prototype()
child.prototype.constructor = child
}
)()
// ${2}
child.prototype = Object.create(parent.prototype)
// ......
// 总结
// 继承的方式方法多种多样,不外乎,就是通过,某一种方式将不属于自己的属性方法可以调用,沿着原型的方式和拷贝赋值就可以总结出很多种不同的继承方式,每种方式的优缺点,多是考虑,继承的属性方法的完整性和对实例化对象的影响,如实例上方法和原型链上方法是否都可以调用有或者引用传递改变同一原型链问题。
/** 上面为对实例对继承,下面说一说对于接口对继承 **/
// ES6 中并没有提供接口这个概念,但是 Typescript 中对于接口又很好对支持,typescript 是 javascript 对超集,对面向对象提供了非常好对支持
// Typescript 【一时用一时爽,一直用一直爽】
// 很推荐用这个,他能避免很多低级错误,提供类型检查,特别是写过 java 转前端的。
interface parent { run: () => void }
class child implements parent { run () {} }
// 转码后
var child = /** @class */ (function () {
function child() {
}
child.prototype.run = function () { };
return child;
}());
多态
多态是面向对象的另一个特征,它允许子类表现出不同的形态。通过多态,同一种操作或表现形式可以对应不同的状态或行为。
/** ES6 **/
// ${1} 重写
class Animal {
eat () { console.log('animal eat') }
}
class Pig extends Animal {
constructor () { super() }
eat () { console.log('pig eat grass') }
}
class Tiger extends Animal {
constructor () { super() }
eat () { console.log('tiger eat pig') }
}
// ${2} 重载,模拟实现
class Animal {
eat () {
if (typeof arg === '') {
console.log('操作 one')
} else if (typeof arg === '') {
console.log('操作 two')
} else {
console.log('操作 three')
}
}
}
/** ES5 【提供实现一种】**/
// 原理就是沿着原型链往上找,只要在父类前定义重写这个方法即可
// ${1} 重写
function animal () { this.eat = function () { console.log('Animal eat') } }
function pig () {
animal.call(this)
this.eat = function () { console.log('pig eat grass') }
}
function tiger () {
animal.call(this)
this.eat = function () { console.log('tiger eat pig') }
}
// ${2} 重载
function animal () {
eat () {
if (typeof arg === '') {
console.log('操作 one')
} else if (typeof arg === '') {
console.log('操作 two')
} else {
console.log('操作 three')
}
}
}
封装
封装是面向对象的第三个特征,它将属性和方法封装在对象内部。通过提供公有或私有的访问权限,对外暴露必要的接口或属性,隐藏内部实现细节。
/** ES6 **/
// ES6 没有提供真正的私有方法和属性,有一个还在提案阶段
// 在属性和方法前面加 #
class Animal {
#height = ''
#eat () {}
}
// 模拟实现 【提供一种实现】
class Animal {
constructor () { this.height = '50' }
get height() { return undefined }
set height (value) { return undefined }
}
/** ES5 **/
const animal = (function (arg) {
let height = 50
function eat () {console.log(height)}
return { eat }
})([])
/** Typescript **/
class Animal {
public height: number
private name: string
protected color: string
constructor (height: number, name: string, color: string) {
this.height = height
this.name = name
this.color = color
}
private eat ():void { console.log(this.name) }
}
函数式编程(FP)
函数式编程强调函数是“第一公民”,即函数与其他数据类型地位平等,可以赋值给变量、作为参数传递给其他函数,或作为函数的返回值。纯粹的函数式编程依赖于纯函数,即只要传入参数相同,返回结果必然相同,且不依赖外部数据状态。以下将介绍函数式编程的主要特点。
// 纯函数
const add = (one, two) => { return one + two }
// 非纯函数
let two = 1
const add = (one) => { return one + two }
- 闭包和高阶函数
- 柯里化
- 偏函数
- 组合和管道
- 函子
闭包和高阶函数
理解闭包:函数内部包含其他函数,使得父函数的数据状态得以保存。理解高阶函数:函数可以作为变量传递给其他函数,或作为返回值。
// 利用封包实现一个只能调用一次的 map 高阶函数
const map = (fn) => {
let once = false
return (arr) => { return once? null: (once = true, arr.map(fn)) }
}
const fn = (item) => item + 10
const arrMap = map(fn)
arrMap([1, 2, 3]) // [11, 12, 13]
arrMap([4, 5, 6]) // null
柯里化
理解柯里化:柯里化是将一个多元函数转换为嵌套的一元函数的过程,即每次调用只接收一个参数。
function curry (fn) {
return curryN (...arg) {
if (arguments.length < fn.length) {
return function () {
return curryN.call(null, ...arg.concat(...arguments))
}
}
return fn.call(null, ...arguments)
}
}
const add = curry ((x, y, z) => x + y + z)
console.log(add(2)(3)(4)) // 9
偏函数
理解偏函数:在初始化时指定原函数的部分参数,并创建一个新的函数,该新函数用于接收剩余的参数。
function proto(fn, ...pagram) {
return (...args) => {
args.forEach((item, index) => { if (item && !pagram[index]) pagram[index] = item })
return fn.apply(null, pagram)
}
}
let add = proto((x, y) => { console.log(x + y) }, undefined, 10)
add(2) // 12
组合和管道
理解组合和管道:将一个函数的输出作为另一个函数的输入,数据像流水一样从函数队列中从左到右或从右到左流动,实现函数的链式调用。
// 单个参数,简单组合
const compose = (fn, fnc) => (arg) => fn(fnc(arg))
// 多个参数,借助偏函数实现
function mapArr(arr, fn) { return arr.map(fn) }
function filte (arr, fn) { return arr.filter(fn) }
let map = proto(mapArr, undefined, (item) => { return item + 10 })
let filter = proto(filte, undefined, (item) => { return item })
let mapFilter = compose(map, filter)
console.log(mapFilter([1, false, 9, 4])) // [11, 19, 14]
// 多个函数组合
const reduce = (arr, fn, value) => {
let initValue = value? value: arr[0]
arr.forEach((item) => { initValue += fn(initValue, item) })
return initValue
}
const compose = (...arg) => (value) => reduce(arg.reverse(), (acc, fn) => fn(acc), value)
let add = compose(() => { return 1 }, () => { return 2 }, () => { return 3 })
add(6) // 12
函子
函子的定义:函子是一个普通对象(在其他语言中可能是一个类),它实现了 map 函数。在遍历每个对象值时生成一个新对象。这听起来很抽象,简单来说,函子是一个持有值的容器。为了更直观理解,请参考以下代码示例。
- 如图(引用自网络)

// 实现一个基本定义的函子,满足定义
// 实现 map 方法,在遍历对象的时候生成一个新对象
function container (value) { this.value = value }
container.prototype.of = function (value) { return new container(value) }
container.prototype.map = function(fn) { return new container().of(fn(this.value)) }
new container().of([1, 5, 7, 3]).map((arr) => { return arr.filter((item) => item === 5)})
console.log(
new container().of([1, 5]).map((arr) => { return arr.filter((item) => item === 5)}).value
) // 5
结语
至此,面向对象和函数式编程的基本思想已得到简单实现。更多深入的内容需要读者自行学习探索。
上述两种编程方式在实践过程中为我提供了许多解决问题的思路和代码组织框架,在许多开源库中也能看到它们的影子。当然,真正理解这两种编程范式并非易事,需要不断的实践、思考和总结,慢慢积累。
https://github.com/loo41/Blog
以上就是 JavaScript 面向对象编程(OOP)与函数式编程(FP)区别的详细解析,更多关于 JS 中 OOP 与 FP 区别的资料,请持续关注本站相关文章!