浅谈个人对面向对象编程和函数式编程的一些理解前言 本文阐述的内容是一些虚无缥缈的东西,是一些个人在近10年的软件开发历程
前言
本文阐述的内容是一些虚无缥缈的东西,是一些个人在近10年的软件开发历程中对于如何编写健壮并且可读性良好代码的思考,在阅读本文之前,需要你对JS(TS)的语言特性非常熟悉,比如至少要知道JS的作用域
,闭包
,this
,装饰器(Decorator)
,class
这些关键知识点。对面向对象编程有一定的认知,比如面向对象的三大特征:封装
、继承
、多态
,并且在实际的开发中能够知道如何应用这些技巧。还需要你对设计原则和设计模式有一定的认识。
一些基本概念的介绍
这一小节将向大家阐述两种编程方式的一些基本概念。
什么是面向对象编程?
面向对象编程(OOP)是一种编程范式,它通过“对象”和“类”来组织代码。它强调将数据和行为封装在对象中,通过类来定义对象的结构和行为。这个范式不仅支持代码的模块化和重用,还提供了模型化现实世界问题的方法。
以下是我摘抄的关于面向对象编程的一些重要的概念:
-
对象 :
- 对象是OOP中的基本单位,它代表一个实体,可以是现实世界中的事物(如一辆车、一个人)或抽象概念(如一个订单、一个用户会话)。
- 对象包含两个主要方面:状态 和 行为 。状态由属性(字段或变量)表示,行为由方法(函数)表示。
-
类 :
- 类是对象的蓝图或模板。它定义了对象的属性和方法。通过类可以创建多个对象(实例),每个对象都是该类的实例。
- 例如,
Car
类可以定义所有车共有的属性(如颜色、型号)和行为(如启动、停止),而myCar
是Car
类的一个实例。
-
封装 :
- 封装是将数据(属性)和操作(方法)封装在对象内部,外部不能直接访问对象的内部数据,而是通过公开的方法与对象交互。
- 通过封装,类可以隐藏内部实现细节,只暴露必要的接口给外部。这不仅提高了安全性,还增强了代码的模块化。
关于这个点,有些同学可能在开发的过程中并没有特别留意,就我们团队的同学来说,有很多同学在维护我的代码的时候,总是不喜欢写属性和方法的权限修饰符(private
, protected
,public
),写与不写差异是非常大的,假如你是代码的使用者,当初始化类之后,VsCode给你提示一堆的属性和方法,你作何感想?人都是惰性的,我们总是希望最简单就能用就是最好的。其次,如果这些属性如果被调用者错误的使用了,将会影响基础库正常的运作,这种低级bug是得不偿失的。
属性和方法的权限修饰符怎么用?凡只是在类内部使用的属性或方法一律使用private
修饰,凡是类内部使用,但是有可能子类需要用到的属性或方法一律使用protected
,凡是需要对外暴露的API,一律使用public
(当然也可以不写),我建议大家都写上,养成良好的习惯,从而形成惯性思维,能够让自己的代码写的更加紧凑。另外,对于只读属性还应该增加get
修饰符,以防止调用者非预期的修改。
以下是一个bad case:
class Goto {
// 跳转防抖
timeoutID = 0
/**
* @description: 打开页面
* @param {string} url 注意nohost参数的使用
* @param {OpenPageOption} options
* @return {string} 最终跳转链接
*/
openPage(url: string, options: OpenPageOption = {}) {
if (url.startsWith('xxx://')) {
window.location.href = url
return url
}
const { isCoverFace, noHost, hideTopBar, debounceTime = 800 } = options
const originalUrl = noHost ? url : `${window.location.protocol}//${window.location.host}${url}`
let decodeUrl = encodeURIComponent(originalUrl)
if (hideTopBar) {
decodeUrl += '?hide_topbar=1'
}
let finalLink = `xxx://webView/web/page?url=${decodeUrl}`
if (isCoverFace) {
finalLink += '&half=1'
}
if (!debounceTime) {
window.location.href = finalLink
return finalLink
}
if (this.timeoutID) {
clearTimeout(this.timeoutID)
} else {
window.location.href = finalLink
}
this.timeoutID = setTimeout(() => {
this.timeoutID = 0
}, debounceTime)
return finalLink
}
}
因为timeoutID这个字段仅仅是类内部需要的,外界知道了反而是负担,所以应该声明为private
。
比如这就是一个good case:
Loading组件只对外暴露了两个方法,几乎让你不需要阅读它的API,你就可以上手使用,反正就只有2个API,你想怎么玩儿就怎么玩儿,哈哈。
-
继承 :
- 继承允许一个类(子类)继承另一个类(父类)的属性和方法,从而实现代码的重用和扩展。
- 继承关系下,子类可以增加新的属性和方法,或者重写父类的方法。继承支持代码的层次化设计,形成类的层次结构。
-
多态 :
- 多态允许同一个操作在不同对象上有不同的表现形式。它可以通过方法重写和方法重载来实现。
- 多态使得程序更加灵活,可以用统一的接口处理不同类型的对象。例如,不同类型的
Animal
(如Dog
和Cat
)都可以实现makeSound
方法,但表现不同。
经常会有面试题问求职者,重写和重载有什么区别,这两个东西完全不是八竿子打不着的东西,不就是名字长的像一点儿而已嘛。
重写:子类继承父类,当子类和父类拥有一样的方法名时,子类的方法将会覆盖父类同名的方法(在子类的这个方法中,还可以通过调用【super.父类方法】完成一些行为如果你想要的话)
重载:在一个类中,方法名相同,但是方法的参数不相同。
-
抽象 :
- 抽象是指只保留对象的核心功能,而隐藏其具体实现细节。它帮助简化复杂系统,关注核心概念而非细节。
- 在OOP中,抽象通常通过抽象类和接口来实现。它们定义了子类必须实现的方法,但不提供具体实现。
JS是一门类面向对象的语言,不是完全的面向对象的语言。在JS中,我们无法实现重载(TS虽然可以,但是本质上还是我们自己的处理,而不是编译器的原生实现),JS没有接口和抽象类的概念,但是,我们可以使用一些手段来模拟接口或者抽象类。所以很多设计模式,用JS实现起来,代码会特别的简单,这是由于JS这门语言独特的语法特征所决定的。
比如:
class Person {
public run() {
throw new Error('this method must implemented by sub-class');
}
}
class Baby extends Person {
public run() {
console.log('我还小,我还没有学会走路,我只能爬');
}
}
上述代码,如果Baby
类不重写run
方法的话,我们在开发阶段就会得到一个错误提示,从而提醒我们需要实现这个方法。
使用TS实现重载:
class JsBridge {
/**
* 打开新页面
*/
public openPage(url: string, closePage?: boolean): void
/**
* 打开新页面
* @param url 页面地址
* @param options 打开页面的额外行为控制
*/
public openPage(url: string, options: OpenPageAction): void
public openPage(url: PageOpenParams['url'], openPageAction: any) {
// 自己处理参数的差异,保证参数的一致性,使得外界在调用的时候,有更好的编程体验,后续的逻辑处理不需要再进行额外的处理
let actionCtrl!: OpenPageAction
if (Object.prototype.toString.call(openPageAction) !== '[object Object]') {
actionCtrl = {
closePage: openPageAction ?? true,
} as OpenPageAction
} else {
actionCtrl = openPageAction as OpenPageAction
}
// 省略了一些其它的一些业务逻辑处理
}
}
实际效果:
什么是函数式编程?
函数式编程(FP)也是一种编程范式,它强调使用数学函数的方式来构建程序,并且将函数视为“第一类公民”。
在函数式编程中,函数不仅可以像传统编程语言中那样被调用,还可以作为参数传递,作为返回值,甚至可以存储在数据结构中。
这是一个非常了不起的性质,因为有了它,我们在实现某个功能的时候,可以使得我们程序总是以一个最小的依赖运行的,对于前端来说,将会影响最终构建产物的代码体积,从而实现较好页面优化。
比如,举个例子,叫好的写法如下:
import { debounce } from 'lodash'
const handlerClick = debounce(function handler() {
// do something
}, 300)
不推荐的写法:
import _ from 'lodash'
const handlerClick = _.debounce(function handler() {
// do something
}, 300)
这种不推荐的写法,其实就像我们编写C#或者Java程序一样(解释一下,并不是说Java或C#不支持函数式编程,只是觉得他们实现函数式编程相对于JS来说要麻烦一些),首先得new一个类的实例,然后再调用类的实例的方法,但是这个类中可能有我们并不一定需要的方法,但是同样也会加载在内存中了。
以下是我摘抄的一些关于函数式编程最重要的一些形式:
- 纯函数(Pure Functions) :
- 纯函数是指相同的输入总是产生相同的输出,并且没有任何副作用(side effects)。副作用是指函数在计算结果之外还对外部状态(如全局变量、文件系统或数据库)产生的影响。
- 纯函数的一个重要特性是可以被缓存(memoized),因为相同的输入总是会得到相同的输出。
function add(a, b) {
return a + b;
}
这个add
函数,就是一个纯函数,因为我们提供两个值,无论在什么环境下执行,总是可以得到一个确定的结果。
而,我们稍微对它进行一个改造,它就不是一个纯函数了:
function add(a, b) {
console.log(a+b);
return a + b;
}
它调用了console,向控制台输出了一些内容,这个操作影响了外部的状态,因此它是有副作用的,它不再是纯函数。
-
不可变性(Immutability) :
- 在函数式编程中,数据是不可变的。即一旦创建了数据,就不能修改它。如果需要改变数据,必须创建一个新的数据结构。
- 不可变性有助于避免副作用,简化了程序的理解和调试。
const arr = [{}, {}, {}, {}]
arr.map(v => {
v.idx = Math.floor(10 * Math.random());
return v;
})
上面这个代码就违反了不可变性,因为,我们在map
的过程中,已经改动了源数据。
正确的写法如下:
const arr = [{}, {}, {}, {}]
arr.map(v => {
return {
...v,
idx: Math.floor(10 * Math.random())
};
})
我们在map的过程中,通过浅拷贝得到一份新的数据,避免了修改源数据。
-
高阶函数(Higher-order Functions) :
- 高阶函数是指能够接受其他函数作为参数,或返回一个函数的函数。这种能力使得代码更加抽象和灵活。
- 常见的高阶函数包括
map
、filter
和reduce
,它们允许对集合中的数据进行抽象操作。
这个其实有很多实际场景的应用,给大家举一个例子,当我们点击按钮的时候,需要对服务器发送请求,而服务器的处理不一定很快,而用户又可能没有耐心而频繁的点击,我们需要在服务器真正返回之后,才运行用户进行下一次点击。
function withDebounce(fn) {
let isProcessing = false; // 标记是否正在处理请求
return async function(...args) {
if (isProcessing) {
return; // 如果正在处理,直接返回,防止重复调用
}
isProcessing = true; // 标记正在处理
try {
const result = await fn(...args); // 调用传入的业务函数
return result; // 返回结果
} finally {
isProcessing = false; // 请求完成后,重置标记,允许再次调用
}
};
}
使用这个函数来包装我们的业务代码示例:
async function fetchData() {
// 模拟一个服务器请求
console.log("Request sent to the server...");
await new Promise(resolve => setTimeout(resolve, 2000)); // 模拟2秒的服务器响应时间
console.log("Response received from the server.");
return "Data from server";
}
const fetchDataWithDebounce = withDebounce(fetchData);
// 假设这个函数绑定到按钮的点击事件上
document.getElementById("myButton").addEventListener("click", () => {
fetchDataWithDebounce().then(data => {
console.log(data); // 处理服务器响应的数据
});
});
-
函数的组合(Function Composition) :
- 函数组合是将多个小的函数组合成一个新的函数,其中每个函数接受上一个函数的输出作为输入。这样可以构建出复杂的操作,而保持代码模块化和简洁。
如Redux
中的combineReducers
:
定义子reducer:
const userReducer = (state = {}, action) => {
switch (action.type) {
case 'SET_USER':
return { ...state, ...action.payload };
default:
return state;
}
};
const postsReducer = (state = [], action) => {
switch (action.type) {
case 'ADD_POST':
return [...state, action.payload];
default:
return state;
}
};
组合子reducers:
import { combineReducers } from 'redux';
const rootReducer = combineReducers({
user: userReducer,
posts: postsReducer
});
其实,不光这个例子,像ES6的class装饰器,也是基于这种思想,不要觉得很怪,我们明明在聊函数式编程,为什么又回到了class,因为在不支持class的浏览器中,我们的源代码经过babel编译之后,class最终还是一个函数。
function logger(Class) {
return class extends Class {
log() {
console.log('Logging:', this);
}
};
}
function validator(Class) {
return class extends Class {
validate() {
console.log('Validating:', this);
}
};
}
@logger
@validator
class MyClass {
constructor(name) {
this.name = name;
}
}
const myInstance = new MyClass('Test');
myInstance.log(); // Logging: MyClass { name: 'Test' }
myInstance.validate(); // Validating: MyClass { name: 'Test' }
以下是babel的编译结果:
-
惰性求值(Lazy Evaluation) :
- 惰性求值是指表达式不会立即被计算,只有在需要结果时才会进行计算。惰性求值允许构建无限数据结构和提高性能,避免不必要的计算。
比如柯里化的通用实现:
function curry(fn) {
// 获取原函数的预期参数数量
const arity = fn.length;
// 内部的柯里化函数
function curried(...args) {
// 如果传入的参数数量达到原函数的参数数量,则直接调用原函数
if (args.length >= arity) {
return fn.apply(this, args);
} else {
// 否则,返回一个新的函数,并接收剩余的参数
return function(...nextArgs) {
return curried.apply(this, args.concat(nextArgs));
};
}
}
return curried;
}
使用:
function add(a, b, c) {
return a + b + c;
}
const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 输出 6
console.log(curriedAdd(1, 2)(3)); // 输出 6
console.log(curriedAdd(1)(2, 3)); // 输出 6
由于这个惰性求值的特性,我们可以事先预设一些参数,然后将其作为一个新的函数对外提供,从而可以使得函数能够适应更广阔的业务场景。
为什么纯函数更容易测试和调试?
如果有些同学在这之前没有了解过单元测试的话,对于这小节的内容可能比较懵逼。
纯函数对相同的输入总是产生相同的输出。这种一致性意味着它们完全可预测,无需关心应用程序的其他部分或外部系统的状态。这大大简化了测试,因为可以单独测试函数而无需设置或模拟一个复杂的环境。
纯函数的输出可以成为另一个函数的输入,它们可以自由地组合而不必担心中间状态的问题。这种模块化特性使得开发者可以构建复杂的逻辑,同时保持每个部分的独立和简单。在测试时,可以单独测试每个纯函数组件,然后再测试它们的组合,这有助于识别和定位问题。
纯函数不修改任何外部状态(例如全局变量、数据库、文件等)。这消除了在并发环境中测试和调试时常见的问题,如数据竞争、时间依赖错误等,并且就算被重复执行,也没有什么问题。
我这两年一直都是使用Jest在刷Leetcode,我们对于力扣的关键代码的补充就是一个纯函数,你只需要把力扣给你的测试用例填到你的函数里面去,然后通过Jest运行起来看看和最终答案对不对,如果OK,那就说明你的解答可能是正确的。
像如果是在编写UI组件库的单元测试的时候,相对就没有那么容易了,因为我们需要关注DOM的变化,而这些环境就不太好初始化,所以就需要依赖一些单元测试工具,比如Vue的@vue/test-utils,React的Enzyme。
下面就是一个为力扣两数之和编写的单元测试:
import { twoSum } from './twoSum.ts';
describe('Two Sum Function', () => {
test('should return indices of the two numbers such that they add up to the target', () => {
expect(twoSum([2, 7, 11, 15], 9)).toEqual([0, 1]);
expect(twoSum([3, 2, 4], 6)).toEqual([1, 2]);
expect(twoSum([3, 3], 6)).toEqual([0, 1]);
});
test('should handle cases where no two numbers sum up to the target', () => {
expect(twoSum([1, 2, 3], 7)).toEqual([]);
});
test('should only use element once', () => {
expect(twoSum([5], 10)).toEqual([]);
});
test('should handle cases with negative numbers', () => {
expect(twoSum([-1, -2, -3, -4], -6)).toEqual([1, 2]);
expect(twoSum([-1, -2, 3, 4], 2)).toEqual([1, 3]);
});
test('should handle cases with empty array input', () => {
expect(twoSum([], 0)).toEqual([]);
});
});
OOP PK FP
一个问题的产生
因为我最开始学习编程的时候,最先接触的是C#编程,所以我更喜欢面向对象编程的方式。另外,再加上我的技术栈一直是vue,所以对于React的一些高阶的理念或编程思想掌握的较少。直到有一天,我在编写团队的SDK时候,发现有些事儿不对劲儿了。
我封装了一组统一的请求服务端接口的方法,准备对外暴露这组统一的接口,比如Put
,表示使用Http的put方法请求接口;Get
,表示使用Http的get方法请求接口。为什么要这样设计呢,因为这样每个方法已经具备一定的业务含义,大家在使用的时候更加舒服(封装的粒度非常精细)。
由于之前的惯性思维,我创建了一个叫做Request
的类,然后在对外暴露之前所提到的统一的API的时候,我必须首先得初始化这个类,然后我必须使用bind
方法,为这些方法绑定在Request
类的实例上执行,否则,这些函数在执行的时候,this
指向不会是Request
的实例,就会出现问题。
以下是这个Request
的节选代码:
class Request {
private loading = new Loading()
private toast = new Toast()
/**
* URL前缀
*/
private baseURL = ''
/**
* 业务参数,活动Key
*/
private activityKey = ''
// 创建axios实例
private instance!: AxiosInstance
private createInstance(serviceName: ServiceName) {
// 省略了关键代码
}
/**
* 设置请求的URL前缀
* @param baseURL
*/
public setBaseURL(baseURL: string): void {
this.baseURL = baseURL
}
/**
* 设置请求的活动的ActivityKey
* @param ActivityKey
*/
public setActivityKey(ActivityKey: string): void {
this.activityKey = ActivityKey
}
private buildBaseURL(ignoreBaseURL: boolean) {
return ignoreBaseURL ? '' : this.baseURL
}
public async request<T extends unknown>(
url: string,
manualOptions: AxiosRequestConfig & StandardRequestConfig = {},
maxRetry = 0
): Promise<StandardResponse<T>> {
// 省略实现细节
}
}
对外导出:
export function useRequest(serviceName: ServiceName): UseRequestStruct {
/**
* 统一封装的请求对象
*/
const request = new Request(serviceName)
/**
* 统一封装的get请求方法
* @param options 请求参数
* @returns
*/
function get<T extends unknown | Array<unknown>>(
options: StandardGetConfig | string
): Promise<Omit<StandardResponse<T>, '$response'>> {
// 省略实现
}
/**
* 统一封装的post请求方法
* @param options 请求参数
* @returns
*/
function post<T extends unknown | Array<unknown>>(
options: StandardPostConfig | string
): Promise<Omit<StandardResponse<T>, '$response'>> {
if (typeof options === 'string') {
options = {
url: options,
}
}
// 设置默认的请求方式为json发送
if (typeof options.json === 'undefined') {
options.json = true
}
const { url } = options
const postOptions = omit(options, ['url'])
return request.request(url, { ...postOptions, method: 'post' }) as Promise<StandardResponse<T>>
}
/**
* 统一封装的delete请求方法
* @param options 请求参数
* @returns
*/
function _delete<T extends unknown | Array<unknown>>(
options: StandardGetConfig | string
): Promise<Omit<StandardResponse<T>, '$response'>> {
// 省略实现
}
/**
* 统一封装的put请求参数
* @param options 请求参数
* @returns
*/
function put<T extends unknown | Array<unknown>>(
options: StandardPostConfig | string
): Promise<Omit<StandardResponse<T>, '$response'>> {
// 省略实现
}
const _request = request.request.bind(request)
/**
* 设置请求的URL前缀
*/
const setBaseURL = request.setBaseURL.bind(request)
/**
* 设置请求的URL前缀
*/
const setActivityKey = request.setActivityKey.bind(request)
return {
get,
post,
del: _delete,
put,
request: _request,
setBaseURL,
setActivityKey,
}
}
总觉得使用那个bind有点儿让人不舒服。
我的一些思考。
对于Request
这个类,如果把它改写成函数,好像也是没有什么问题的,为什么可以这样的呢,首先,我们基于这个业务场景下来看,class
初始化得到一个对象,这个对象上有一系列的方法,通过这些方法的相互调用完成业务,每当我们new一个Request
类的实例时,都创建一个各自独立的对象。但是问题也随之而来,因为这些方法都是归属于这个对象的,如果说当调用者的this
上下文不再指向这个对象,那么就不行了,这也就是为什么之前导出的时候一定要使用bind
方法绑定到实例的原因。
我们使用函数式的思维方式来改写上面的Request
类的代码:
function Request() {
const loading = new Loading()
const toast = new Toast()
/**
* URL前缀
*/
let baseURL = ''
/**
* 业务参数,活动Key
*/
let activityKey = ''
// 创建axios实例
let instance!: AxiosInstance
function createInstance(serviceName: ServiceName) {
// 省略了关键代码
}
/**
* 设置请求的URL前缀
* @param baseURL
*/
function setBaseURL(url: string): void {
baseURL = url
}
/**
* 设置请求的活动的ActivityKey
* @param ActivityKey
*/
function setActivityKey(ActivityKey: string): void {
activityKey = ActivityKey
}
function buildBaseURL(ignoreBaseURL: boolean) {
return ignoreBaseURL ? '' : this.baseURL
}
async function request<T extends unknown>(
url: string,
manualOptions: AxiosRequestConfig & StandardRequestConfig = {},
maxRetry = 0
): Promise<StandardResponse<T>> {
// 省略实现细节
}
//对于contructor的内容,可以直接在构造函数中执行,效果是一样的
// 将一些公共的方法暴露给外部
return {
setBaseURL,
setActivityKey,
request
}
}
为什么可以是这样,这就牵涉到JS的闭包的知识点了,当Request
函数执行完成的时候,因为这些内部的方法引用着函数内部的局部变量,而这些变量就作为一个这些内部的方法能够运行起来的环境绑定在一起了,这就是创建了闭包。这也使得JS的垃圾回收机制无法回收这些变量。
构造函数在创建对象的时候,因为我们的Request
类并没有依赖外部的状态,用前文阐述的知识点讲,就是这个函数仍然是纯的,也就是在创建实例的时候,各个实例仍然是互不干扰的。
我个人的总结是:
OOP的最终方式是把它依赖的那些方法绑定到一个对象上,然后在这个对象上大家可以相互调用,从而完成相应的功能;而FP的最终方式是把它依赖的那些方法通过闭包的方式绑定到自己能访问到的作用域上,(但是别人不能访问到),仅自己可以调用,从而完成相应的功能。
在React的类组件中,早期编写代码的时候,由于this指向的问题,我们就需要bind操作,比如:
import { Component } from 'react';
export MyButton extends Component {
constructor(props) {
super(props);
this.handlerClick = this.handlerClick.bind(this)
}
handlerClick() {
console.log('hello world')
}
render() {
return <button onClick={this.handlerClick}>点击</button>
}
}
后来,我们可以直接将成员方法申明为箭头函数,从而避免了bind操作。
import { Component } from 'react';
export MyButton extends Component {
handlerClick = () => {
console.log('hello world')
}
render() {
return <button onClick={this.handlerClick}>点击</button>
}
}
回到我这个Request
类其实也是一样的,我如果把每个成员方法写成箭头函数,就可以在使用的时候不再绑定了,而这还是JS的语言特性所决定的,像别的语言,方法和属性就有非常明显的界限,而作为一等公民的函数使得JS在这种场景下,它虽然是作为属性存在的,但是它确实是可以执行的。
装饰器与高阶函数
两者其实都是装饰模式的实际应用,但是所处的角度是不同的。装饰器是从OOP
的角度出发的,而高阶函数是从FP
的角度出发的。
装饰器有一个很大的限制条件,因为JS的函数存在提升,所以装饰器是无法装饰函数的(坑爹的鸿蒙的ArkTS就不要说了,我个人对其表示非常不认同),而高阶函数没有这个问题,高阶函数在什么场景下都可以用。
由于现在的前端框架都基于函数式思想进行开发,前端代码中很难见到装饰器,目前只有一些Node端的框架在使用装饰器。
以下是我们分别使用装饰器和高阶函数来实现一个共享Promise
的例子(所谓Promise共享,就是我们执行某个函数得到一个Promise,这个函数也可以执行多次得到多个Promise的结果,但是这些Promise的结果都是同一个值,并且如果这个过程中需要发送请求到服务器端,也仅仅只会发送一次,这样会节省一些流量,主要在一些并发的场景下使用)。
首先是高阶函数:
let hasExecuteFn = false;
const queue: Array<{ resolve: (val: any) => void; reject: (err: any) => void }> = [];
function flushQueue(exec: 'resolve' | 'reject', val: any): void {
while (queue.length) {
const node = queue.shift();
if (node) {
const executor = node[exec];
executor(val);
}
}
}
export function singlePromise<T extends (...args: any[]) => Promise<any>>(fn: T, ctx?: any): (...args: Parameters<T>) => Promise<ReturnType<T>> {
return function decorate(this: any, ...args: Parameters<T>): Promise<ReturnType<T>> {
return new Promise<ReturnType<T>>((resolve, reject) => {
if (hasExecuteFn) {
queue.push({ resolve, reject });
} else {
const p = fn.apply(ctx || this, args);
hasExecuteFn = true;
Promise.resolve(p)
.then((res) => {
hasExecuteFn = false;
resolve(res);
flushQueue('resolve', res);
})
.catch((err) => {
hasExecuteFn = false;
reject(err);
flushQueue('reject', err);
});
}
});
};
}
const fn = () => {
return new Promise((resolve) => {
console.log('hello world')
resolve({
hello: 'world'
})
}, 300)
}
const getAppConfig = singlePromise(fn)
getAppConfig().then(res => {
console.log(res)
})
getAppConfig().then(res => {
console.log(res)
});
// 只会打印一次 hello world
然后是装饰器:
function SinglePromise() {
let hasExecuteFn = false;
const queue: Array<{ resolve: (val: any) => void; reject: (err: any) => void }> = [];
function flushQueue(exec: 'resolve' | 'reject', val: any): void {
while (queue.length) {
const node = queue.shift();
if (node) {
const executor = node[exec];
executor(val);
}
}
}
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
return new Promise((resolve, reject) => {
if (hasExecuteFn) {
queue.push({ resolve, reject });
} else {
const p = originalMethod.apply(this, args);
hasExecuteFn = true;
Promise.resolve(p)
.then((res) => {
hasExecuteFn = false;
resolve(res);
flushQueue('resolve', res);
})
.catch((err) => {
hasExecuteFn = false;
reject(err);
flushQueue('reject', err);
});
}
});
};
return descriptor;
};
}
class ApiService {
@SinglePromise()
fetchData(): Promise<string> {
console.log('Sending request...');
return new Promise((resolve) => {
setTimeout(() => {
resolve('Data from server');
}, 2000); // 模拟2秒的请求延迟
});
}
}
const apiService = new ApiService();
// 下面的代码会在第一次请求完成后才处理第二次请求
apiService.fetchData().then(data => console.log(data));
apiService.fetchData().then(data => console.log(data));
我个人觉得,高阶函数要比装饰器稍微好用一些,毕竟不用被局限,哈哈。
组合与继承
在传统的基于OOP的开发中,我们可以把一些通用的内容进行抽象,将通用的代码抽离到基础类,然后业务类根据自身的业务需求,继承这些基础类,子类可以根据自己的业务重写基类的方法,从而完成业务。但是继承也有个不好的点,继承很容易形成很长的继承链,而一旦我们需要改动基类的某些方法时,就可能影响到各个业务实现类,出现牵一发而动全身的尴尬局面。
类有一个相对来说比较劣势的一个点,就是它的粒度不够函数那么灵活,还是回到文章中开头提到的例子,如果某个类我其实只需要1-2个方法,那么,如果我把基类继承下来的话,有些不需要的方法也让我拥有了,而我实际上不需要,因为知道的多了,反而容易出问题,而函数级别的粒度刚好是最佳的,它是一个功能的最小单元。
比如这就是一个典型的例子,我想使我的类具备对外抛出事件的能力,因此我们可以考虑继承node的原生模块events
。
import Events from 'node:events'
class Widgets extends Events {
public show() {
// 显示当前widget
}
public hide() {
// 关闭当前widget
}
public destroy() {
// 销毁当前widget
}
}
但是,当你初始化这个类的时候,发现惊掉了下巴,怎么这么多方法,因为这些方法都来自于events
:
另外,如果使用类的话,我们第一步首先得初始化类得到实例,然后才能调用(静态方法除外,但是既然都静态方法了,为啥还要用类呢?),如果是函数的话,拿来就可以执行,使用函数可以少一个初始化的步骤。
继承:
class Animal {
eat(): void {
console.log("This animal is eating.");
}
sleep(): void {
console.log("This animal is sleeping.");
}
}
class Bird extends Animal {
fly(): void {
console.log("This bird is flying.");
}
}
// 使用示例
const myBird = new Bird();
myBird.eat();
myBird.sleep();
myBird.fly();
组合:
type Action = () => void;
const eat: Action = () => {
console.log("This entity is eating.");
};
const sleep: Action = () => {
console.log("This entity is sleeping.");
};
const fly: Action = () => {
console.log("This entity is flying.");
};
interface BirdActions {
eat: Action;
sleep: Action;
fly: Action;
}
function createBird(): BirdActions {
return { eat, sleep, fly };
}
// 使用示例
const bird = createBird();
bird.eat();
bird.sleep();
bird.fly();
这个好处就是随用随取,粒度非常容易控制,某个基础方法的修改也仅仅只需要影响到引用到它的方法。
健壮性、模块化与可维护性
关于健壮性,函数式在使用的时候,不要考虑类中this
的指向问题,这在一定程度上是比基于类的编程方式更加安全的,因为我们不能保证我们在任何的时刻都能够清楚的知道我该明确函数的this
上下文,这是能避免bug的。但是函数式也不是银弹,因为它大量的借助闭包实现数据共享,错误的使用闭包有可能造成内存泄露的问题,也可能影响JavaScript引擎优化代码的能力,尤其是在涉及大量闭包的复杂函数中。因为闭包的存在,优化器可能难以决定哪些变量应该存储在堆上,哪些应该存储在栈上。再者就是我们调试起来相对来说要难受一些,毕竟这些变量是定义在不同的作用域下的。
类和函数都可以做到很好的模块化,这点没有多大的差异,因为函数有自己的作用域。不论是函数式还是类式代码,都需要考虑到模块膨胀的问题,不要让一个模块做太多事儿。
对于可维护性的话,基于类的代码的可维护性会比函数式好很多,我个人的总结就是,函数式的代码你想要写好不容易,但是一旦想要写乱那将会变得非常容易,这也对开发者的编程能力有非常高的要求,函数式的代码一定要提前规划,进行一些代码的拆分,一旦一个函数几百行,这真的是灾难性的后果,除了重构,别无它法。对于新手来说,我个人觉得在能使用类的场景下,还是就老老实实的使用类的模式进行开发是最好不过的。
我个人的经验是,在开发cli或者基于Node的服务端程序时,使用基于类的代码进行开发是一个比较好的选择,可以和TS相结合,套用很多常用的设计模式,然后代码组织起来也比较舒服,不至于越写越乱。
性能
对于性能这块,我们主要考量的不是代码的执行效率或者内存占用,因为函数式的粒度比基于类的代码更加的可控,如果代码组织不恰当的情况下,函数式肯定是要比基于类的代码少初始化一些内容的,这个点,我们其实可以忽略不计的。
其实最重要的一个问题就是打包代码体积的问题,在前文我们就已经聊过了,函数式编程能够使得我们用到的几乎可以算是能够实现功能的最小单元,现代的Web构建工具都有一个叫做TreeShaking的优化手段,通过将不用的代码删除掉,不打包到生产环境的代码中,从而减少的网站的加载体积
有些同学在实际的开发中经常喜欢封装Utils操作的代码,这种场景下就建议使用函数式而不是基于类的代码,因为很多场景下其实你只是做了封装,但是并没有真正的调用,如果使用函数式的编程方式,最终构建工具将会把你那些定义但是没有用过的代码消掉。
总结
本文是我最近的一些思考,希望能够帮助到大家,本文提炼了我在7年的实际开发中的一些经验总结,包括曾经自己遇到的一些问题,文中涵盖的知识点非常丰富,希望各位读者可以加以体会。
文章对读者的基础要求非常高,包含了JS的this
函数上下文,作用域
,闭包
,class
以及class
的继承,装饰器
,还包括了TS的一些高级用法比如重载实现函数的多签名,也提到了几种常见的设计模式,比如装饰模式,(隐式的提到了模板方法模式,如果你不知道,那就继续不知道就行了,它仅仅只是一个技术名词而已,不重要),然后又聊到了单元测试的相关知识点,最后还向大家聊到了现代Web构建工具的一种优化手段TreeShaking,如果你还不知道的话,可以自行查阅资料进行掌握。
如果你目前看起来还不是特别明白的话,可以不用着急,因为编程思想这个东西它不是一朝一日练成的,我所提到的一些套路或者观点,它可能仅仅只是适合我自己的业务场景而并不一定适合你的业务场景,只有适合自己的才是最好的。
以上内容可能有纰漏,刊误可以在评论区联系我,谢谢大家的支持。
转载自:https://juejin.cn/post/7403553327018475547