JS 是否有必要使用面向对象、设计模式
在一次面试过程中,一位已经有 5 年工作经验的前端,在回答面试问题时这样说到。
问:你能说说 JS 的面向对象和设计模式吗?
回答说:这些内容主要是后端的 Java,C#这种高级语言才会用到的,前端一般我们没有用到。
对于这样的回答,不禁让我有点无话可说,JS 中是否有必要使用面向对象以及设计模式呢?我列举了以下几个场景:
数据接口请求
一般的,在请求接口方面,我们一般会使用一些第三方库,比如axios
。然后在逻辑代码部分,比如在组件中直接使用axios
进行请求,例如:
let methods = {
/**
* 获得分类信息
*/
async getBarData() {
try {
let res = await axios.get(url, params);
} catch (e) {
console.error("something error", e);
}
}
};
这样的做法在功能上讲没什么问题,但在新增一些其他动作后,这样的做法就变得非常难以管理。比如,需要在请求中加入一些关联请求,需要获取一个商品页的列表,查询参数包含,分页参数(当前页,查询数),分类 Id,搜索内容,排序方式,筛选项。执行该去请求时,发现分类 Id 也需要另外一个接口去获取。于是代码成了:
let params2 = {
sort: -1,
search: "",
filter: "",
page: {
start: 1,
number: 10
}
};
let methods = {
/**
* 获得商品列表
*/
async getGoodsData() {
try {
let { id: typeId } = await axios.get(url.goodsType, params1); // 获取所有分类Id
let res = await axios.get(url.goods, { ...params2, typeId }); // 获取商品
} catch (e) {
console.error("something error", e);
}
}
};
上面的代码中,我们简单的实现了获取一个接口的值然后请求另外一个接口。那么如果当前的搜索内容、或者分页数据修改了,还需要重新获取新的商品数据,此时getGoodsData
还需要执行一遍,而获取分类的请求又需要请求一遍,所以需要改动代码为:
let params = {
sort: -1,
search: "",
filter: "",
page: {
start: 1,
number: 10
}
};
let methods = {
/**
* 获得分类信息
*/
async getTypeData() {
try {
let { ids } = await axios.get(url.goodsType, params1); // 获取所有分类Id
this.typeIdNow = ids[0];
} catch (e) {
console.error("something error", e);
throw e;
}
},
/**
* 获得商品列表
*/
async getGoodsData() {
try {
this.typeIdNow === undefined && (await getTypeData());
let typeId = this.typeIdNow;
let res = await axios.get(url.goods, { ...params, typeId }); // 获取商品
} catch (e) {
console.error("something error", e);
}
}
};
当params
的任意数据改变后会请求getGoodsData
,这样暂时我们已经实现了一个商品请求的逻辑,并且支持数据暂存。
紧接着问题又来了,切换类别时会要求获取新的筛选列表(不同的分类下筛选列表是不同的)。
切换类别后,会要求重置params
,因为之前的搜索值,分页值在切换类别后不能继续使用。
字段组装,比如筛选字段的filter
,一般的后台可能会用一些特殊的分隔符比如(|)来做多个筛选项的分割,此时我们又需要处理以下的代码:
return this.types.map(val => val.id).join("|");
节流优化,用户输入 Value 时,需要做防抖函数的优化,防止一直请求接口。
恩,终于,当一大堆问题都解决后,需求来了,能不能在其他组件使用这些数据?? 哇特?
回顾一下,我们要做的就是一个内容,getGoodsData
为什么会同时出现这么多代码呢?一个商品列表的组件会需要这么多组装数据的代码吗?
面向对象优化
面对这种让人抓耳挠腮,看着头晕的代码难道就没有更优雅的实现方式吗?面向对象了解一下,数据模型了解一下!
我们可以将Goods
这一中数据类型抽象成为一种资源对象,在 Model 中专门处理Goods
获取时所需要的数据组装等工作。
import { API, axios } from "../api";
/**
* 商品列表数据
*/
class Goods {
private params: Object = {};
private initParamsData: Object = {
sort: -1,
search: "",
filter: "",
page: {
start: 1,
number: 10
}
};
constructor() {
this.initParams();
}
/**
* 初始化所有请求参数
*/
public initParams() {
this.params = JSON.parse(JSON.stringify(this.initParamsData)); // 深拷贝
}
/**
* 设置请求参数
* @param key
* @param val
*/
public setParams(key, val) {
this.params[key] = val;
}
/**
* 获取商品请求
*/
public async get(params = {}) {
let { id: typeId } = await Type.get(); // 在另外一个Type类中获取并做缓存处理
params = { ...this.params, ...params, typeId };
let res = await axios.get(API.GOODS_LIST, { params });
return res;
}
public async save() {}
}
export default new Goods();
然后就可以在组件中优雅的进行使用,Goods
的数据模型中已经可以自行处理依赖请求,缓存数据,参数组装等功能,在另外组件使用中也同样可以使用相同的数据和缓存,代码如下:
let methods = {
/**
* 获得商品列表
*/
async getGoodsData() {
try {
let res = await Goods.get(); // 获取商品
} catch (e) {
console.error("something error", e);
}
},
/**
* 设置请求参数
* @param key
* @param val
*/
setParams(key, val) {
// 设置请求参数,对于部分需要特殊组装的字段可以在类中单独分装方法处理
Goods.setParams(key, val);
}
};
状态管理
一般的,在处理多组件数据通信时,会使用Redux/Mobx/Vuex
这类Flux
模式的状态管理库来处理。对于Vuex
来讲,一般会在实例化一个Vuex
,设置其state
对象以及对应的mutations
,然后将其挂载到 Vue 的原型链中,就可以方便的完成响应式的状态管理。
实际上,当项目中的不同路由层级,不同组件,不同生命周期的状态都混在一个state
中管理是很混乱,很不明智的做法。当然 Vuex 中也提供了Modoles
的用法,让我们可以通过不同的模块化来管理不同状态,从某种程度上来讲这也是一种面向对象的做法。
const moduleA = {
state: { ... },
mutations: { ... },
actions: { ... },
getters: { ... }
}
const moduleB = {
state: { ... },
mutations: { ... },
actions: { ... }
}
const store = new Vuex.Store({
modules: {
a: moduleA,
b: moduleB
}
})
store.state.a // -> `moduleA`'s state
store.state.b // -> `moduleB`'s state
但相对于Mobx
的 Class 式用法,这种Vuex
中Module
的用法还是显得有些麻烦,让我们来看看Mobx
的 Class 定义状态管理是如何处理的:
import { computed, observable } from "mobx";
class StoreData {
@observable isSideBarShow = true; // 是否显示侧边栏
@observable routeNow: any = {};
public setSideBarShow(val) {
this.isSideBarShow = val;
}
public setRoute(val) {
this.routeNow = val;
}
}
export default new StoreData();
相比与Mobx
的这种状态管理,引入了 Es7 中的装饰器,在编写代码中如果有使用其中所需要的状态值,可以直接方便的引入StoreData
,然后直接使用即可。
从定义、使用、修改各种步骤都更容易理解和使用。
很遗憾的是,Mobx
目前还没有完善的方案在 Vue 中使用。
Vuex
社区中,也有很多开发者有着同样的感受,所以出现了类似Vuex-class
这样的库,来使用Class
模式编写状态管理。也希望Vuex
官方能对面向对象编程有更好的支持!
组件继承
组件可以继承吗?比如我写了一个投票页面,用户是可以操作进行投票的,但是当他投票结束后,这个页面就不能再进行交互操作了,用户只能查看已经投票的结果。
正常的,我们可能会使用一个状态值来判断是否开放编辑功能。但是,当投票活动再加入:开始前,正在投票,投票结束等状态后。需要处理的就是:开始前已经投票的状态,开始前未投票的状态,正在投票已经投票的状态….这样 6 种状态。虽然从其他维度可以解决这样的问题,但状态再多复杂度就会再增加一层。如果还这样写,那么,恭喜你,传说中的面条代码就产生了!
所以我们最好还是用两个组件来做这件事情,一个组件用于提交,一个组件用于查看。在完成提交组件后,再实现查看组件时,会发现非常多的代码都是重复的 ,就比如投票数据获取,样式,模板,状态管理。
此时,组件继承的需求就变得愈发强烈,实际上,组件继承是可以实现的。拿 Vue 来说,在 Vue2.5 后 Vue 推出了vue-class-component。看看以下组件 Test1。
继承组件 Test2,在继承 Test1 后就可以使用 Test1 中定义的变量、样式,对于模板,实际上 Vue 在 template 中的内容最终也会被转移为render
函数中返回的模板值,如果你的 JSX 了解的话,你可以把它理解为 JSX,只不过 Vue 把它换了一个位置,如果你想在 Vue 中使用 JSX 同样是可以实现的,参考文档。那么 Test1 就可以写成:
JavaScript 中是如何实现面向对象的
说了这么多,那么在 JavaScript 中是如何实现Class
的呢?在 ES5 的标准中,是并没有Class
关键字的。
在 JavaScript 中的所有数据跟对象都是 Object.prototype 对象。我们在 JavaScript 遇到的每个对象,实际上都是从 Object.prototype 对象克隆而来的,Object.prototype 就是他们的原型。
在 JavaScript 中执行的new Object()
,在内部引擎中实际上是从 Object.prototype 上面克隆一个对象出来,我们最终得到的才是这个对象。
function Person(gender) {
this.gender = gender;
}
Person.prototype.getGender = function() {
return this.gender;
};
var me = new Person(1);
console.log(me.getGender());
在 JavaScript 中没有类的概念,但上面这段代码中不是明明调用了new Person()
吗?
在这段代码中,Person
并不是一个类,而是函数构造器。当使用new
来调用函数时,实际上是在克隆Object.prototype
对象,然后再才开始运行函数。
所以当我们得到me
时,内部已经完成了对Person.protorype
的克隆,当请求me.getGender
时,JavaScript 完成了以下几步操作:
- 尝试查找
me
对象中是否有getGender
属性 - 没找到
genGender
,把该请求委托给Person
的构造器原型,原型会被记录在__proto__
中。 - 在原型链中找到了
getGender
属性,并返回它的值
多态与继承
多态的实际含义是:同一操作作用于不同对象上面,可以产生不同的解释和不同的执行结果。多态分为编译期多态、运行期多态。
举个例子:两个人打招呼,不同的人见面打招呼的方式不一样。比如好基友见面,会说:Hey,老哥~。陌生人见面会说:你好,幸会。Github 上提问会说:Hello,nice to …。韩国人见面打招呼会用韩语,日本人见面打招呼用日语等等。
用一段 JavaScript 代码来实现英国人和中国人打招呼:
var Chinese = function() {};
var British = function() {};
var sayHello = function(man) {
man instanceof Chinese && console.log("你好");
man instanceof British && console.log("Hello");
};
sayHello(new Chinese()); // 编译期多态
sayHello(new British());
这段代码确实实现了”多态性”,当在发出sayHello
的命令后,不同的人会执行不同的打招呼方式,但却不是理想化的。试想如果后来新增了一个俄罗斯人,就必须要修改sayHello
函数,才能实现俄罗斯人打招呼。那么后面再加入对不同人打招呼,再增加其他国家的人,sayHello
函数将会变得非常庞大和难以维护。
从源头来看sayHello
这个动作中要输出什么的逻辑是由不同类型的人定义的,所以应该将sayHello
封装起来,作为不同类型的人sayHello
的一种方法。这就属于一种面向对象,代码变成了一种可扩展,可生长的代码。修改,并加入俄罗斯人的代码:
var Chinese = function() {};
Chinese.prototype.sayHello = function() {
console.log("你好");
};
var British = function() {};
British.prototype.sayHello = function() {
console.log("Hello");
};
var Russian = function() {};
Russian.prototype.sayHello = function() {
console.log("#&(*$(K");
};
var sayHello = function(man) {
man.sayHello(); // 运行期多态
};
sayHello(new Chinese()); // 编译期多态
sayHello(new British());
sayHello(new Russian());
在实现多态的同时,JavaScript 中同样可以使用继承来实现类的多样性。比如我和 MilkGao 都是中国人,我们一般遇到其他人会说”你好”来打招呼,但是我们俩见面后因为一些其他的原因,打招呼的方式会不一样。我见到他会说:Hey,老哥。他见到我会说:哇,帅哥!
分析这段逻辑,两个人都是在跟特定的人打招呼(做同样的动作),两个人都是中国人,遇到陌生人都会说”你好”,来打招呼。两个人不同的地方是,相互见面后打招呼的内容不同。所以可以都继承中国人来处理相同的打招呼逻辑,又有各自不同的遇到朋友的打招呼方法。
var Chinese = function() {};
Chinese.prototype.sayHello = function() {
console.log("你好");
};
var YeeWang = function() {
Chinese.call(this);
};
YeeWang.prototype = Object.create(Chinese.prototype);
YeeWang.prototype.constructor = YeeWang;
YeeWang.prototype.sayHelloTo = function(man) {
if (man instanceof MilkGao) console.log("Hey,老哥!");
else this.sayHello();
};
var MilkGao = function() {
Chinese.call(this);
};
MilkGao.prototype = Object.create(Chinese.prototype);
MilkGao.prototype.constructor = MilkGao;
MilkGao.prototype.sayHelloTo = function(man) {
if (man instanceof YeeWang) console.log("哇,帅哥!");
else this.sayHello();
};
var twoPersonSayHello = function(man1, man2) {
man1.sayHelloTo(man2); // 运行期多态
};
twoPersonSayHello(new YeeWang(), new MilkGao()); // 编译期多态
twoPersonSayHello(new MilkGao(), new YeeWang());
TypeScript
既然说 JavaScript 的面向对象,就不能不提TypeScript。
关于 TypeScript 的文档我就不具体介绍了,如果官网有详细的 TypeScript 的使用、规范说明。我列出了几点关于 TypeScript 相对于 JavaScript 的优势点和注意事项。
首先 TypeScript 编码过程中要求对变量进行类型定义,比如在项目中一旦定义一个变量的类型后,如果赋值类型不同,在编译器中就会直接报错,这或许在你看来比起 JavaScript 这显得非常麻烦,但对于长期受益来讲这会显得非常有用。
自动提示,使用 TypeScript 定义好
Class
后,在使用过程中,都会有对这个类的自动提示,在编码过程中一路回车,体验真的不要太好!相比于之前使用 JS 时借助 IDE 一些插件实现的关键字自动检索,TypeScript 的提示速度更快更准确!参数提示,在使用 TypeScript 编码时,如果遇到陌生的方法,可以直接快速追溯到该方法的定义,迅速查找参数类型。比如在使用
lodash
中的方法函数,就可以快速查到 findIndex 中所需要到参数类型,以及返回类型。定义文件(.d.ts),使用 TypeScript 一定要注意的一点是,如果引入非 TypeScript 写的库。发现 import 报错,那么很有可能该库没有更新配置 TypeScript,目前大多数用到的库都已经有对 TypeScript 的支持包括
Vue
,React
,Lodash
等等,但还是有一些库官方并没有更新.d.ts
的类型定义文件,对于这类文件TypeScript
另外做了一个开源项目,专门整理各大库的定义文件。比如three
这个库,如果要使用 TypeScript,只需要运行npm i @types/three -D
就可以匹配找到该库的类型定义文件啦。
模块化
一套优秀的系统源码,是文件多、还是文件大?
对于上面这个问题答案是肯定的,一套优秀的系统源码应该是尽可能将逻辑颗粒度细化,尽可能的抽象和模块化可以使业务代码变得相对较少。
究竟什么是模块化?其实在 Vue/React 中的组件,就属于模块化,每个组件都被抽象成为一个 module 暴露出来,在其他组件中被使用,并被框架按照自己的组件处理方式制作成最终业务效果。
以下代码都是在对外暴露一个模块。
export default {}; // ES6
module.exports = {};
静态加载与动态加载
- 静态加载:在编译阶段进行,把所有需要的依赖打包到一个文件中
- 动态加载:在运行时加载依赖
AMD 标准是动态加载的代表,而 CommonJS 是静态加载的代表。
AMD 的目的是用在浏览器上,所以是异步加载的。
而 NodeJS 是运行在服务器上的,同步加载的方式显然更容易被人接收,所以使用了 CommonJS。
import Gallery from "@/views/Gallery"; // 静态加载
const Gallery = () => import("@/views/Gallery"); // 动态加载
为什么要使用模块化?
为什么要使用组件呢?
在很早以前(我还在做 PHP 的时候)和朋友在谈起 Laravel 框架时说到:恩,我觉得这个框架很强大,很多代码都是一个方法里面嵌套了很多其他方法,代码阅读起来非常舒服。朋友:我最讨厌这样的写法,一层嵌一层都不知道他在干什么。我:…
为什么要使用模块化?为了尽可能的少写代码。
使用模块化可以让我们在编写代码时,会”少写”很多代码。
我们在实现业务逻辑时可以尽可能的对代码复用,从而减少很多可能会出错的几率,增加开发效率和可维护性。
// 常量
export const HOST = "127.0.0.1";
export const HELLO_MSG = "你好";
// 方法
export function wait(time) {
return new Promise(resolve => {
setTimeout(resolve, time);
});
}
// 类
export default class Vector2 {
x = null;
y = null;
add() {}
sub() {}
distence() {}
}
UML
什么是 UML
Unified Modeling Language (UML)又称统一建模语言或标准建模语言,是始于 1997 年一个 OMG 标准,它是一个支持模型化和软件系统开发的图形化语言,为软件开发的所有阶段提供模型化和可视化支持,包括由需求分析到规格,到构造和配置。 面向对象的分析与设计(OOA&D,OOAD)方法的发展在 80 年代末至 90 年代中出现了一个高潮,UML 是这个高潮的产物。它不仅统一了 Booch、Rumbaugh 和 Jacobson 的表示方法,而且对其作了进一步的发展,并最终统一为大众所接受的标准建模语言。
UML 实际上在前期设计项目数据模型时是非常有用的一套工具,个人认为在构造一个关联级超过 3 层以上的功能时,都应该针对这个功能抽象制作 UML 图,这样非常有利于后面的代码编写。正所谓,磨刀不费砍柴工。
UML 类图关系
继承(Generalization)
指的是一个类继承另外一个类的功能,并可以增加自己的新功能的能力,继承是类与类或接口与接口之间最常见的关系。
实现(Realization)
指的是一个 class 类实现 interface 接口(可以是多个)的功能;实现是类与接口之间最常见的关系。
依赖(Dependency)
可以简单的理解,就是一个类 A 使用到了另一个类 B,而这种使用关系是具有偶然性的、临时性的、非常弱的,但 B 类的变化会影响到 A;比如某人要过河,需要借用一条船,此时人与船的关系就是依赖。
关联(Association)
他体现的是两个类,或者类与接口之间语义级别的一种强依赖关系,比如我和我的朋友,双方关系是平等的。
聚合(Aggregation)
聚合是关联关系的一种特例,他体现的是整体与部分、拥有的关系,即 has-a 的关系,此时整体与部分之间是可分离的,他们可以具体各自的什么周期,部分可以属于多个整体对象,也可以为多个整体对象共享;
组合(Composition)
组合也是关联关系的一种特征,他体现的是一种 contains-a 的关系,这种关系比聚合更强,也称强聚合;他同样体现整体与部分间的关系,但此时整体与部分是不可分的,整体的生命周期结束也就意味着部分的生命周期结束;
UML 的简单应用
说了这么多,主要是简单介绍一下 UML 最简单的一些类图关系定义。这个在画 UML 图、看 UML 图时都非常有用!如果不了解上面的这些箭头的含义,那么是很难理解 UML 类图的。
我举个例子,比如现在需要构建一个房子的全部数据。
一个房子需要些什么抽象模型?楼层,房间,墙,家具,吊顶,地板,踢脚线,窗口,门,墙角等等。
只看户型信息的话有哪些内容?楼层,房间,墙,墙中门、窗所需要的洞,墙面,吊顶,地板,每层的高度,地面、吊顶、墙面所需要的铺贴材质,材质铺贴的方向,墙的长、厚,墙洞的长宽。
家具这些需要什么内容?普通家具的长宽高、位置坐标和旋转方向,组合家具的长宽高、位置坐标和旋转方向。
面对这么多复杂的数据内容,我们必须要细化到每个类中才可以实现整体的House
数据。我简单的做了一个 UML 图,不是很完善,但可以正常说明问题,请大家参考学习。