JavaScript面向对象和模块化

JavaScript面向对象和模块化

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 式用法,这种VuexModule的用法还是显得有些麻烦,让我们来看看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

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 图,不是很完善,但可以正常说明问题,请大家参考学习。

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×