我们经常听到一句话,“写代码要有良好的封装,要高内聚,低耦合”。究竟怎样的代码才算得上是良好的代码。
什么是高内聚,低耦合?
即五大基本原则(SOLID)的简写
我们为什么要封装代码?
其实封装代码有这些好处:
我们可以观察React、Vue、EventEmitter、Axios等等这些优秀的源码,会发现其实他们封装的模块都是有迹可循的。这些规律总结起来就是设计模式。
借用鲁迅先生说过的一句话,世上本没有路,走的人多了也便成了路。所谓设计模式,是前辈们总结下来的,在软件设计、开发过程中,针对特定场景、特定问题的较优解决方案。
实际上,不使用设计模式,照样可以进行需求开发。但是这造成的后果是:因设计缺陷、代码实现缺陷,给后期维护、开发、迭代带来了麻烦。
设计模式一共分为3大类23种,主要介绍常用的几种
模式类型 | 设计模式 |
---|---|
创建型模式 | 单例模式、工厂模式、建造者模式 |
结构型模式 | 适配器模式、装饰器模式、代理模式 |
行为型模式 | 策略模式、观察者模式、发布订阅模式、职责链模式、中介者模式 |
单例模式:一个类只有一个实例,并提供一个访问他的全局访问点。
Singleton
:特定类,这是我们需要访问的类,访问者要拿到的是它的实例;instance
:单例,是特定类的实例,特定类一般会提供 getInstance
方法来获取该单例;getInstance
:获取单例的方法;class Singleton {
let _instance = null;
static getInstance() {
if (!Singleton._instance) {
Singleton.instance = new Singleton()
}
// 如果这个唯一的实例已经存在,则直接返回
return Singleton._instance
}
}
const s1 = Singleton.getInstance()
const s2 = Singleton.getInstance()
console.log(s1 === s2) // true
Vuex:实现了一个全局的store用来存储应用的所有状态。这个store的实现就是单例模式的典型应用。
// 安装vuex插件
Vue.use(Vuex)
// store注入Vue实例
new Vue({
el:"$app",
store
})
通过调用Vue.use
方法,安装Vuex插件。Vuex插件本质上是一个对象,内部实现了一个install
方法,这个方法在插件安装时被调用,从而把Store
注入到Vue
实例中。
let Vue // instance 实例
export function install (_Vue) {
// 判断传入的Vue实例对象是否已经被install过(是否有了唯一的state)
if (Vue && _Vue === Vue) {
if (process.env.NODE_ENV !== 'production') {
console.error(
'[vuex] already installed. Vue.use(Vuex) should be called only once.'
)
}
return
}
// 若没有,则为这个Vue实例对象install一个唯一的Vuex
Vue = _Vue
// 将Vuex的初始化逻辑写进Vue的钩子函数里
applyMixin(Vue)
}
通过这种方式,可以保证一个 Vue 实例只会被 install 一次 Vuex 插件,所以每个 Vue 实例只会拥有一个全局的 Store。
优点: 节约资源,保证访问的一致性。
缺点: 扩展性不友好,因为单例模式一般自行实例化,没有接口。
工厂模式:根据不同的参数,返回不同类的实例。
核心思想:将对象的创建与对象的实现分离。实现复杂,但使用简单。工厂会给我们提供一个工厂方法,我们直接去调用即可。
我们去环球影城的餐厅吃饭,点了一份“牛肉拉面”、“馄饨云吞面”,面煮好了,就直接端到桌子上,我们只管吃,不用在乎煮面的过程。
这个过程中,我们扮演访问者的角色,餐厅扮演的就是工厂的角色,“xxx”面就是产品。
class Restaurant{
constructor(){
this.menuData = {};
}
// 获取菜品
getDish(dish){
if(!this.menuData[menu]){
console.log("菜品不存在,获取失败");
return;
}
return this.menuData[menu];
},
// 添加菜品
addMenu(menu,description){
if(this.menuData[menu]){
console.log("菜品已存在,请勿重复添加");
return;
}
this.menuData[menu] = menu;
}
// 移除菜品
removeMenu(menu){
if(!this.menuData[menu]){
console.log("菜品不存在,移除失败");
return;
}
delete this.menuData[menu];
},
}
class Dish{
constructor(name,description){
this.name = name;
this.description = description;
}
eat(){
console.log(`I'm eating ${this.name},it's ${`this.description);
}
}
这些场景都有一些特点:使用者只需要知道产品名字就可以拿到实例,不关心创建过程。所以我们可以把复杂的过程封装在一块,更便于使用。
document.createElement
创建DOM
元素。这个方法采用的就是工厂模式,方法内部很复杂,但外部使用很简单。只需要传递标签名,这个方法就会返回对应的DOM
元素。
和原生的 document.createElement
类似,Vue
和 React
这种具有虚拟 DOM
树机制的框架在生成虚拟 DOM
的时候,都提供了 createElement
方法用来生成 VNode
,用来作为真实 DOM 节点的映射。上面实现一致,调用createEle``ment
后,返回VNode
元素。
// 使用
ccreateElement('h3', { class: 'main-title' }, [
createElement('p', { class: 'main-content' }, '真有意思')
])
// 函数大致结构如下
export function createElement(tag,data,children){
//....各种判断 判断生成什么样的Vnode
if(!tag){
return createEmptyVNode()
}
return new VNode(tag,data,children);
}
vue-router
中使用了工厂模式的思想来获得响应路由控制类的实例,this.history
用来保存路由实例。
export default class VueRouter{
constructotr(options){
const mode = options.mode || "hash";
switch (mode) {
case 'history':
this.history = new HTML5History(this, options.base)
break
case 'hash':
this.history = new HashHistory(this, options.base, this.fallback)
break
case 'abstract':
this.history = new AbstractHistory(this, options.base)
break
default:
if (process.env.NODE_ENV !== 'production') {
assert(false, `invalid mode: ${mode}`)
}
}
}
}
优点:
缺点:
给系统增加了抽象性,带来了额外的系统复杂度,不能滥用。(合理抽象能提高系统维护性,但可能会提高阅读难度,还是需要合理看待)
滥用只是增加了不必要的系统复杂度,过犹不及。
适配器模式:用于解决兼容问题,接口/方法/数据不兼容,将其转换成访问者期望的格式进行使用。
生活案例:
场景特点:
适配器模式必须包含目标(Target)、源(Adaptee)和适配器(Adapter)三个角色。
场景:我们要获取通过多个接口获取列表数据,拼接在一起,在一个组件内进行展示。因历史遗留原因,这些列表数据的格式不太一样。
// 格式 1
{
book_id: 1001
status: 0,
create: '2021-12-12 08:10:20',
update: '2022-01-15 09:00:00',
},
// 格式 2
{
id: 1002
status: 0,
createTime: 16782738393022,
updateAt: '2022-01-15 09:00:00',
},
// 格式 3
{
book_id: 1003
status: 0,
createTime: 16782738393022,
updateAt: 16782738393022,
}
三个数据来源,三种时候数据结构,这时候我们有几种实现方式。
对比之下,使用适配器模式 ,将不同的数据结构适配成展示组件所能接受的数据结构。保持了组件的单一职责,更优。
interface bookData {
book_id: number;
status: number;
createAt: string; // 时间戳
updateAt: string; // 时间戳
}
interface bookDataType1 {
book_id: number;
status: number;
create: string;
update: string;
}
interface bookDataType2 {
id: number;
status: number;
createTime: number;
updateAt: string;
}
interface bookDataType3 {
book_id: number;
status: number;
createTime: number;
updateAt: number;
}
const getTimeStamp = function (str: string): number {
//.....转化成时间戳
return timeStamp;
};
//适配器
export const bookDataAdapter = {
adapterType1(list: bookDataType1[]) {
const bookDataList: bookData[] = list.map((item) => {
return {
book_id: item.book_id,
status: item.status,
createAt: getTimeStamp(item.create),
updateAt: getTimeStamp(item.update),
};
});
return bookDataList;
},
adapterType2(list: bookDataType2[]) {
const bookDataList: bookData[] = list.map((item) => {
return {
book_id: item.id,
status: item.status,
createAt: item.createTime,
updateAt: getTimeStamp(item.updateAt),
};
});
return bookDataList;
},
adapterType3(list: bookDataType3[]) {
const bookDataList: bookData[] = list.map((item) => {
return {
book_id: item.book_id,
status: item.status,
createAt: item.createTime,
updateAt: item.updateAt,
};
});
return bookDataList;
},
};
//整合数据
const bookDataList = [
...bookDataAdapter.adapterType1(type1MatailList),
...bookDataAdapter.adapterType2(type2MatailList),
...bookDataAdapter.adapterType3(type3MatailList),
];
优点: 可以使原有逻辑得到更好的复用,有助于避免大规模改写现有代码;
缺点: 会让系统变得零乱,明明调用 A,却被适配到了 B,如果滥用,那么对可阅读性不太友好。
装饰器模式:在不改变原对象的基础上,增加新属性/方法/功能。
一个对象被另一个对象包装,形成一条包装链,在原对象上增加功能。
暂时想不到什么好的例子和实际应用场景。
优点:
策略模式:定义一系列算法,根据输入的参数决定使用哪个算法。
重点:算法的实现和算法的使用分开。
场景:双十一满减活动。满200-20、满300-50、满500-100。这个需求,怎么写?
if-else暴力法直接梭哈。
通过判断输入的折扣类型来计算商品总价的方式
function priceCalculate(discountType,price){
if(discountType === 'discount200-20'){
return price - Math.floor(price/200) * 20;
}else if(discountType === 'discount300-50'){
return price - Math.floor(price/300) * 50;
}else if(userType === 'discount500-100'){
return price - Math.floor(price/500) * 100;
}
}
缺点:
if-else
会变得越来越臃肿。priceCalculate
方法,违反开闭原则。使用策略模式对代码改写
// 算法的实现
const discountMap = {
'discount200-20': function(price) {
return price - Math.floor(price / 200) * 20;
},
'discount300-50': function(price) {
return price - Math.floor(price/300) * 50;
},
'discount500-100': function(price) {
return price - Math.floor(price/500) * 100;
},
}
// 算法的使用
function priceCalculate(discountType,price){
return discountMap[discountType] && discountMap[discountType](price);
}
以上代码就将算法的实现和算法的使用分开,以后不管增加或修改了算法,都无需对priceCalculate
方法进行改动。
当然以上代码的抽象程度并不高,如果我们想隐藏计算算法,可以借助 IIFE 使用闭包的方式,提供一个添加策略的方法。
const priceCalculate = (function(){
const discountMap = {
'discount200-20': function(price) {
return price - Math.floor(price / 200) * 20;
},
'discount300-50': function(price) {
return price - Math.floor(price/300) * 50;
},
'discount500-100': function(price) {
return price - Math.floor(price/500) * 100;
},
};
return {
addStategy(stategyName,fn){
if(discountMap[stategyName]) return;
discountMap[stategyName] = fn;
},
priceCal(discountType,price){
return discountMap[discountType] && discountMap[discountType](price);
}
}
})()
// 使用
priceCalculate.priceCal('discount200-20',250); // 230
priceCalculate.addStategy('discount800-200',function(price){
return price - Math.floor(price/800) * 200;
})
这样算法就被隐藏起来,并且预留了增加策略的入口,便于扩展。
场景:表单验证。
表单验证项一般会比较复杂,所以需要给每个表单项增加 validator
自定义校验方法。以ElementUI
的 Form
表单为例。
我们可以像官网示例一样把表单验证都写在组件的状态 data 函数中。
缺点:不好复用使用频率比较高的表单验证方法,造成代码冗余。
使用策略模式和函数 柯里化 对代码进行改写。
utils/validates.js
为通用验证规则,即为StrategyMap
。
utils/index.js
中的formValidateGene
通过柯里化动态选择表单验证方法,即为Context
。
// src/utils/validates.js
// 姓名校验 由2-10位汉字组成
export function validateUsername(str) {
const reg = /^[\u4e00-\u9fa5]{2,10}$/
return reg.test(str)
}
// 手机号校验 由以1开头的11位数字组成
export function validateMobile(str) {
const reg = /^1\d{10}$/
return reg.test(str)
}
// 邮箱校验
export function validateEmail(str) {
const reg = /^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(.[a-zA-Z0-9_-]+)+$/
return reg.test(str)
}
// src/utils/index.js
import * as Validates from './validates.js';
export function formValidateGene = (key,msg) => {
return function(rule,value,cb){
if(Validates[key] && Validates[key](value)){
cb()
}else{
cb(new Error(msg))
}
}
}
// 使用
优点:
if-else
进行策略选择,提高了维护性。缺点:
观察者模式:一个对象(称为subject)维持一系列依赖于它的对象(称为observer),将有关状态的任何变更自动通知给它们(观察者)。
观察者模式中的角色有两类:观察者和被观察者。
生活中的例子:领导(被观察者)在台上介绍防疫政策,底下的工作人员(观察者)“观察”领导说的防疫政策的变化,当政策变化时,通知(update)到街道。
工作人员(观察者)必须订阅内容改变的事件,即用耳朵去听政策的变化。
简单的代码基本实现
// 观察者模式 被观察者Subject 观察者Observer Subject变化 notify观察者
let observerIds = 0;
// 被观察者Subject
class Subject {
constructor() {
this.observers = [];
}
// 添加观察者
addObserver(observer) {
this.observers.push(observer);
}
// 移除观察者
removeObserver(observer) {
this.observers = this.observers.filter((obs) => {
return obs.id !== observer.id;
});
}
// 通知notify观察者
notify() {
this.observers.forEach((observer) => observer.update(this));
}
}
// 观察者Observer
class Observer {
constructor() {
this.id = observerIds++;
}
update(subject) {
// 更新
}
}
优点:目标变化就会通知观察者,这是观察者模式最大的优点。
缺点: 不灵活。目标和观察者是耦合在一起的,要实现观察者模式,必须同时引入被观察者和观察者才能达到响应式的效果。
发布/订阅模式:基于一个主题/事件通道,希望接收通知的对象(称为subscriber)通过自定义事件订阅主题,被激活事件的对象(称为publisher)通过发布主题事件的方式被通知。
发布-订阅模式的角色有两类:发布者和订阅者
我们微信会关注很多公众号,公众号有新文章发布时,就会有消息及时通知我们文章更新了。
这个时候公众号为发布者,用户为订阅者,用户将订阅公众号的事件注册到事件调度中心,当发布者发布新文章时,会发布事件至事件调度中心,调度中心会发消息告诉订阅者。
class Event {
constructor() {
this.eventEmitter = {};
}
// 订阅
on(type, fn) {
if (!this.eventEmitter[type]) {
this.eventEmitter[type] = [];
}
this.eventEmitter[type].push(fn);
}
// 取消订阅
off(type, fn) {
if (!this.eventEmitter[type]) {
return;
}
this.eventEmitter[type] = this.eventEmitter[type].filter((event) => {
return event !== fn;
});
}
// 发布
emit(type) {
if (!this.eventEmitter[type]) {
return;
}
this.eventEmitter[type].forEach((event) => {
event();
});
}
}
Vue
双向绑定通过数据劫持和发布-订阅模式实现。
DefineProperty
劫持各个数据的setter
和getter
,并为每个数据添加一个订阅者列表,这个列表将会记录所有依赖这个数据的组件。响应式后的数据相当于消息的发布者。Watcher
订阅者,当组件渲染函数执行时,会将本组件的Watcher
加入到所依赖的响应式数据的订阅者列表中。相当于完成了一次订阅,这个过程叫做“依赖收集”。setter
,setter
负责通知数据的订阅者列表中的Watcher
,Watcher
触发组件重新渲染来更新视图。视图层相当于消息的订阅者。跟 差不多,略。
优点:
缺点: