如何在前端代码中,应用面向对象的编程范式?

为什么要面向对象?

你需要知道的面向对象

面向对象并不是针对一种特定的语言,而是一种编程范式。但是每种语言在设计之初,都会强烈地支持某种编程范式,比如面向对象的Java,而Javascript并不是强烈地支持面向对象。

什么时候需要面向对象?

任何一名开发人员,在编写具体的代码的时候,不应该为了套用某种编程范式,而去编写代码和改造代码。任何编写方式的目的是:

  • 让代码逻辑清晰
  • 可读性良好
  • 没有冗余代码

前端编写过程中什么时候需要面向对象?

在我的日常工作中,最不想做的的就是两点:

  • 复制粘贴代码
  • 不同的代码中具备相同的逻辑或者变量

因为这两种方式,会让代码冗余,而且不易维护。为什么?

因为相同的代码,具备相同的逻辑,也就是具备相同的业务逻辑场景,如果场景一旦改变,你将会改变两处代码。

ok,到这里,我们来讲一个具体的业务场景。

场景1: 前端需要显示工人的工作完成状态,如果已经完成了,前端提供一个查看详情的入口,如果没有完成,提供工人去完成任务的入口。后端传递过来显示工人完成状态的字段:user_done_status:0,代表未完成,1代表已完成。前端需要实现这样一个表格:
工人名字完成状态操作
小王已完成查看详情
老王未完成去完成

阶段一:实现最基本的功能

// status.js
// 1:需要一个状态映射表,来实现第二列的功能
export const statusMap = new Map([
  [0, '未完成'],
  [1, '已完成']
]);
// 2: 需要一个动作映射表,来实现第三列的功能
export const actionMap = new Map([
  [0, '查看详情'],
  [1, '去完成']  
]);
// 3: 需要一个状态判读函数,来实现第三列的功能
function isUserDone(status) {
  return +status === 1;
}

const actionMap = new Map([
  [status => isUserDone(status), userCanCheckResult],
  [status => !isUserDone(status), needUserToCompoleteWork]
]);

function handleClick() {
  for (let [done, action] of actionMap) {
    if (done()) {
      actionMap();
      return;
    }
  }
}

至于第三个为什么这么写,可以看一下这篇文章

阶段二:坏代码的味道

上面的三段代码单独写出来没啥问题,看看下面的可能问题就出来,这相当于实现了三个函数,那么需要在显示在表格中就需要这样写:

import {
  statusMap,
  actionMap,
  getUserAction
} from './status.js'

.... ....
// 第二列
return (
  <span>
    {
      statusMap.get(status)
    }
  </span>
);
// 第三列
return (
  <span onClick={() => getUserAction(status)}>
    actionMap.get(status)
  </span>
);

这样的写法,看起来没啥问题,但是可读性是很差的,主要体现在两点:

  • 三个函数都和status相关,但是展现形式上是割裂的
  • 每个函数都需要传递一个status

可能有的人会说,这样把上面的代码单独抽离出一个文件,也没什么问题,状态也是比较集中的,嗯,这种说法也没什么问题,单独提取一个文件,用作处理用的状态,是一种常见的抽象方法。但是可能会遇到下面集中情况,就会让你很难受:

  • 后端改了下字段,那么你就需要在阶段二中的第二列和第三列中传入参数的地方修改对应的字段名字(估计想宰了rd吧)
  • 业务场景变化,工人的任务状态,添加了其他限制,比如任务的时间限制,任务有未开始、进行中、已过期三种状态,只有当在任务进行中的时候,才可以展示用户的状态,否则就展示未开始或者已过期,总结起来,需要下面的几种状态:

    • 未开始
    • 已完成/未完成
    • 已过期

那么显然,你就需要修改代码的逻辑,仅仅依靠一个statusMap就不能行了。当然这里有人说了,那我把map编程一个函数:

const getUserStatus = (status, startTime, endTime) => {
  // ...do something
}

这样是不是就可以了,嗯,说的也没什么问题,那你需要去修改之前写的所有代码,传入不同的参数,就算一开始你用的不是map而是函数,那么你的代码也需要再传入两个多余的参数,start_time和end_time。

需要解决的痛点:

  • 展现形式的分离,需要一种集中的状态处理
  • 需要传入多个参数进行判断,业务场景的变化或者字段的变化,都需要多处修改代码

最开始遇到这来那个问题的时候,我想的是怎么样能够把所有的处理集中到一起,自然而然就想到了面向对象,将用户的状态作为一个对象,对象具备特定的属性和对应的操作行为。

Javascript中如何编写面向对象的代码?

先睹为快,我们看一下,上面的代码在面向对象的写法,直接使用es6的class

上面业务场景的面向对象的写法

import moment from 'moment';

class UserStatus {
  constructor(props) {
    const keys = [
      user_done_status,
      start_time,
      end_time
    ] ;
    for (let key of keys) {
      this.[`_${key}] = (props || {})[key];
    }
  }

  static StatusMap = new Map([
    [0, '未完成'],
    [1, '已完成']
  ]);

  static TimeMap = newMap([
    [0, '未开始'],
    [1, '已过期']
  ]);

  get userDoneStatus () {
    return this._user_done_status;
  }

  get isInWorkingTime() {
    const now = new Date();
    return moment(now).isBetween(moment(this._start_time), moment(this._end_time));
  }

  get isWorkStart() {
    const now = new Date();
    return moment(now).isAfter(moment(now));
  }

  get userStatus () {
    if (this.isInWorkingTime) {
      return UserStatus.StatusMap.get(this.userDoneStatus);
    } else {
      return UserStatus.TimeMap.get(+this.isWorkStart);
    }
  }
  ... ...
  // 省略其他的了
}

那么写好了上面的类,我们应该在其他地方怎么引用呢?

// 第一步:直接讲后端传过来的信息,构造一个新的对象
const userInfo = new UserStatus(info);

// 第二步:直接调用对应的方法或者参数

return (
  <span>
    {
      userInfo.userStatus
    }
  </span>
);

以后无论业务场景如何改变这部分代码都不需要重新改写,只需要改写对应的类的操作就可以了。
这样看了比较干净的是具体的view层代码,就是简单的html和对应的数据,没有其他操作。其实这就是如何消除代码副作用的问题:将副作用隔离。当你把所有的副作用隔离之后,代码看起来干净许多,你像redux-saga就是将对应的异步操作隔离出来。

ok,看了上面的类的写法,我们来看一下面向对象的写法应该要怎么写:

面向对象

面向对象的三大特性

  • 封装
  • 继承
  • 多态
特性特点举例
封装封装就是对具体的属性和实现细节进行隐藏,形成统一的的整体对外部提供对应的接口上面的例子就是很好的解释
继承继承就是子类可以继承父类的属性和行为,也可以重写父类的行为比如工人有用户状态,老板也有用户状态,他们都可以继承UserStatus这一个基类
多态同一个行为在在不同的调用方式下,具备不同的行为,依赖于抽象和重写比如工人和老板都具备一个行为那就是吃饭,工人吃的是馒头,老板吃的是海鲜,同样是吃这个行为,产生了不同的表现形式

封装对象的几个原则

在基本的面向对象中有几个原则SOLID原则,但是这里我不想详细写了,想说一下,我在封装对象的时候会注重的几个方面

  • 基类与具体数据无关,只封装了特定的行为和属性,基类只注重抽象公共的部分
  • 类的行为对扩展是开放的,但是对于修改是不开放的(开放封闭原则),像上面的写法是存在风险的,因为生成的对象实例中的属性可以被随意的修改,我加了_,就是防止这种行为,但是最好的方式应该是使用get/set方法来对属性限制操作;对于对象的属性,一定要明确,因为js中一个是没有类型的限制不要出现下面的写法:
class Base {
  constructor(props) {
    for (let key of props) {
      this[key] = props[key];
    }
  }
}
  • 一个类只应该依赖于他继承的类,不能依赖于其他类,这样能最大限度地减少耦合

注意的问题

注意⚠️在js中一定小小心this的使用,假设有一个初始类:
初始类:

class Base {
    constructor(props) {
        this._a = props.a;
    }

    status() {
        return this._a;
    }
}

避免下面的行为:

// 方式1:
let { status } = new Base({a: 678});
status() // 会报错

而应该使用下面的写法:

//方式2:
let info = new Base({a: 678});
info.status(); //输出正确

根本原因就是this在作怪,第一种this指向了全局作用域。

最后也是最重要的

上面的面向对象主要解决了前文提到的两个痛点,但是也不是所有的业务场景都适合面向对象,当你的代码出现了一些坏味道(代码容易、代码分散不易处理),可以考虑下面向对象,毕竟适合的才是最好的

参考资料

面向对象封装的五个原则)
五个原则比较形象的解释

相关推荐