【JS】707- JavaScript 中使用 Class 的正确姿势

共 8931字,需浏览 18分钟

 ·

2020-09-06 04:10


看似无处不在的OOP

OOP 即 面向对象编程 (Object Oriented Programming)毫无疑问是软件设计和发展中的一大进步。事实上,一些编程语言如 Java 、C++ 就是基于 OOP 的核心概念 class 开发出来。

在高校的 CS 相关专业中,无论教授什么编程语言,OOP的学习是绝对不会被落下的。

同时,OOP在业界中也的确被大量使用,尤其是的后端服务领域、桌面软件、移动APP开发等。

因此,OOP看起来在软件行业无处不在,在这种有点教条主义的氛围下,很多程序员甚至以为 class 是编程固有的概念 —— 然而并不是。

OOP 只是一套帮助开发者设计和编写软件的方法论,但并不代表它能解决所有领域的问题,也不是能在所有编程语言的任何场景下都适用。我们应避免陷入这种教条主义。

JavaScript中使用Class的坑

ES6 之后,JavaScript 也引入了 class 关键字用于声明一个类。但需要注意的是,这样声明出来的类其实在底层还是使用了 JavaScript 的函数 和 原型链 (来模拟类的行为)

看个例子:

class Person {
  constructor (name) {
    this.name = name
  }
  
  talk () {
    console.log(`${this.name} says hello`)
  }
}

上面的代码在底层实现时,非常接近于

function Person (name{
  this.name = name
}
Person.prototype.talk = function ({
  console.log(`${this.name} says hello`)
}

这边可以注意到 talk 其实并不是一个Person类内部封装的方法,而只是一个常规的JavaScript函数,赋值到了Person的原型上而已。因此,「talk 函数里的 this 对应的是调用时的上下文而不是定义时的上下文」,这点跟 Java 和 C++ 的差别很大。

这种差异最明显的影响是在别的对象试图调用这个对象的talk时

const Grey = new Person('Grey')
const mockDomButton = {} // 模拟一个DOM上的按钮对象
mockDomButton.onClick = Grey.talk; // 绑定点击事件
mockDomButton.onClick() // 输出的结果是 undefined says hello

上面这段模拟代码输出的结果并不是我们想要的。原因是 onClick 被调用时,其实是 talk 函数在执行,且talk 函数的this 指向的是 mockDomButton 而不是 GreymockDomButton 并没有 name 属性于是 输出了 undefined says hello

这种“特殊”的表现让很多 JavaScript 新手感到头疼,尤其是那些从 Java 或者 C++ 背景过来的新手前端程序员。

解决这个问题的办法当然是有的,先介绍两个仍然使用 class 的方案

「方案一」

使用函数的 bind 方法

**bind()**方法创建一个新的函数,在bind()被调用时,这个新函数的this被指定为bind()的第一个参数

修改 Person.js 文件如下

class Person {
  constructor (name) {
    this.name = name
    this.talk = this.talk.bind(this); // 在构造器里显式调用 bind 函数绑定 this
  }

  talk () {
    console.log(`${this.name} says hello`)
  }
}

再次运行上面的测试代码,这次的输出就是正确的了 —— Grey says hello

这种方案的缺点就是需要繁琐地写这种 bind 方法调用语句,当这个类的方法很多时,会显得构造器非常臃肿,降低可读性和编码效率如

img

「方案二」

使用类属性+箭头函数的方式来定义方法

class Person {
  constructor(name) {
    this.name = name
  }

  talk = () => {
    console.log(`${this.name} says hello`)
  }
}

这种语法是 ES2017 才引入的,它等效于

class Person {
  constructor(name) {
    this.name = name
    this.talk = () => {
      console.log(`${this.name} says hello`)
    }
  }
}

运行测试代码,依然能成功输出 Grey says hello

但是,这种方案也有缺点 —— 由于它等效于函数定义放在了构造器内,所以

一、这个方法不在原型链上,即 Person.prototype.talk 的值是undefined ,所以这个类的子类并不能使用 super.talk() 调用到父类这个方法,所以下面这段代码会报错

class Student extends Person {
  talk = () => {
    super.talk(); // 报错
    console.log("student talk hi");
  }
}

const student = new Student('Tom');
student.talk();

二、每次创建一个 Person 实例都会创建一个 talk 函数,造成性能浪费 (仅仅是用来与方案一对比)

const Grey = new Person('Grey')
const Tom = new Person('Tom')
console.log(Grey.talk === Tom.talk); //  输出 false

在 JavaScript 中使用类居然有上面这么多坑,那何不试试其他方案?

首先,我们回到源头想想什么是类,我们想利用类达到什么目的:

大多数时候,我们定义的类 其实是 创建对象的蓝图(模板) —— 我们先规划好一个类的模样,之后通过 new 的方式创建出许许多多的对象,每个对象都符合我们想要的格式(即属性,方法)

在 JavaScript 中,我们还有其他方案可以达到这个目的

工厂函数(factory functions)

const PersonFactory = (name) => {
  return {
    talk() => {
      console.log(`${name} says Hello`)
    }
  }
}

PersonFactory 是个简单的工厂函数,它返回一个对象,这个对象拥有一个 talk 方法

(p.s. 我更新了一下代码,看起来可读性更高一点,想看原版代码的可以查看历史记录

const Grey = PersonFactory('Grey'); // 使用工厂函数生成对象
const mockDomButton = {} // 模拟一个DOM上的按钮对象
mockDomButton.onClick = Grey.talk; // 绑定点击事件
mockDomButton.onClick() // 输出的结果是 Grey says Hello

由于JavaScript的「闭包」特性,name已经被封装在了函数里,所以上面的测试代码可以正常运作。而且更赞的是,这个方案中,name甚至自动成为了「私有的变量」,不怕被更改(上面的那些 class 方案里 name 都可以被公共访问的)

而且相比之下,工厂函数的代码更简洁易读,也不需要考虑 this 的繁琐问题。

因此,「如果只是为了给对象创建绘制蓝图(模板),工厂函数是比类更合适的方案」

继承

类的另一个特征是继承机制,子类可以继承(分享)来自父类的属性和方法。

如果仅仅是共享属性和方法,使用组合(composition)也可以很容易实现

const Workable = {
  inOfficetrue
}
const WorkablePersonFactory = (name) => (
  Object.assign(
    {},
    Workable,
    PersonFactory(name)
  )
)
// 或者
const WorkablePersonFactory = (name) => (
    {
     ... Workable,
     ...PersonFactory(name),
    }
)

上面的代码意图十分明显,可读性很高,这也是组合模式的一个优点。

当然,对于某些更复杂的类使用场景,工厂函数并不能替代类。

关注代码表达性而不是死守教条主义

在 JavaScript 的现实场景中,尤其是前端代码,我们很少真正用到类继承,大多数时候,工厂函数就能完成我们的目标。

以React为例,官方这几年推崇 Hooks 的意图也很明显 —— 摆脱JavaScript class 带来的复杂性,拥抱函数式风格。

由于 JavaScript 实现的特殊性,在 JavaScript 应用中使用 class 对于一些程序员来说有许多坑,于此同时,大多数场景下其他替代方案如 工厂函数 可能更契合JavaScript的特性,反而带来更好的效果。

当然,「并不是一杆子打死 JavaScript 的 class,在一些特别适合 OOP 的场景中,依然鼓励使用 class」

总之,不要被教条主义所束缚,牢记编写程序最重要的两点是:

  1. 真正将需求转化成了代码
  2. 写出可读的,容易维护的,方便理解的代码

没想到这篇文章有这么高的阅读量,以及部分争议。统一回复一下吧。

「本文的讨论的场景主要是基于业务开发的上下文,不包括底层库、工具库开发等场景。」

1. bind 以外的其他方案

感谢

@贺师俊

大佬的提醒

class fields或者autobind decorator都有很多问题,而且这两者还不是最终标准,建议不要用

读者们可以参考

关于 工厂函数 的举例

首先这个例子主要是针对这种场景 ——在 JavaScript 给创建某类对象定制一个标准,以便可以用这个 「模板」 创建许多对象

这个例子的确还不够亮眼,那我再举个更实际的例子吧

function httpClientFactory(baseUrl{
  return {
    baseUrl: baseUrl,
    listUsers() => {
      return axios.get(`${baseUrl}/users`)
    },
    getUser(id) => {
      return axios.get(`${baseUrl}/users/${id}`)
    },
    createUser(user) => {
      return axios.post(`${baseUrl}/users`, user);
    },
    listBooks() => {
      return axios.get(`${baseUrl}/books`)
    },
    getBook(bookName) => {
      return axios.get(`${baseUrl}/books/${bookName}`)
    },
    createBook(book) => {
      return axios.post(`${baseUrl}/books`, book)
    }
  }
}

const httpClient = httpClientFactory("https://your-endpoints/api");
httpClient.getUser("123");
httpClient.getBook("JavaScript Is Interesting");
console.log("The httpClient's baseUrl is " + httpClient.baseUrl);

对比

class HttpClient {
  constructor(baseUrl) {
    this.baseUrl = baseUrl;
    this.listUsers = this.listUsers.bind(this);
    this.getUser = this.getUser.bind(this);
    this.createUser = this.createUser.bind(this);
    this.listBooks = this.listBooks.bind(this);
    this.getBook = this.listUsers.bind(this);
    this.createBook = this.createBook.bind(this);
  }

  listUsers() {
    return axios.get(`${this.baseUrl}/users`)
  }

  getUser(id) {
    return axios.get(`${this.baseUrl}/users/${id}`)
  }

  createUser(user) {
    return axios.post(`${this.baseUrl}/users`, user);
  }

  listBooks() {
    return axios.get(`${this.baseUrl}/books`)
  }

  getBook(bookName) {
    return axios.get(`${this.baseUrl}/books/${bookName}`)
  }

  createBook(book) {
    return axios.post(`${this.baseUrl}/books`, book)
  }
}

const httpClient = new HttpClient("https://your-endpoints/api");
httpClient.getUser("123");
httpClient.getBook("JavaScript Is Interesting");
console.log("The httpClient's baseUrl is " + httpClient.baseUrl);

感受一下代码的整洁程度

(彩蛋:bind 语句复制粘贴导致的bug你们发现了吗?)

注意使用 class 的初衷

太多开发者一上来就写个class的原因通常是因为 他/她 是从OOP背景过来的 —— 在Java,你不能光秃秃地定义一个常量,一个函数或者一个表达式,你得先有个类,然后在类里定义一个静态不可变的属性 (public static final 三连) 才能产生一个常量,类似的,也只能在类里定义一个(静态或者非静态)的方法才能让函数有容身之地 (为了防杠,我谨慎加一条 —— Java 8 的 functional interface 开始可以让函数单独出来走两步了,但前提还是要有interface)

如果你想好好写 native JavaScript,那么你通常不需要一个类

// xxx.js
import _ from 'lodash';

export const BOOK_NAME_PREFIX = "JS_"// 定义常量
export const DEFAULT_USER_AGE = 18;

export const convertVarToObject = function (v// 定义一个工具方法,将传入的值包装返回一个对象
  // ...
}

const privateSecret = "zhimakaimen"// 不export的常量自然变成模块私有的

function privateFunc(){  // 同样可以定义模块私有的函数
   // ... 
}

export default {  // 可以export出自定义的对象(包含自定义的属性)
   render: xxx,  
   property: yyy,
}

直接在 js module 里定义常量、函数,然后 export 出来给其他模块用,这么简单直接不香吗?(js module 里也可以定义私有的变量、常量、函数等)

再次推荐阅读 这篇文章,好好理解 js 模块,别再像 Java 那样只用 class 来组织所有代码了。

JavaScript 模块化:CommonJS vs AMD vs ES6:https://zhuanlan.zhihu.com/p/158683510

使用 class 的心智负担

业务代码中,现在大家写 JavaScript class 相信已经不会再直接访问 prototype 了,而是使用 class 关键字 —— 而 class 关键字的底层实现仍然是 prototype,仍然要考虑 this 的复杂性,在复杂的继承场景中甚至仍然得理解 prototype chaining

也就是说,一个新手接触/维护一个由大量类构成的项目时,他要么赶紧精通理解JavaScript class,要么就很可能掉进坑里。

我在个人体验里谈到的那个Nodejs项目,实习生新增一个方法后忘记加bind语句,然后程序一直报错 ReferenceError: XXX is not defined, 他一头雾水 —— ”明明方法定义就在那儿啊!“

当然这是因为实习生的基础问题,他需要更多学习历练,但话说回来**这样的心智负担真的有必要吗?为什么不让程序更简单明了一点?**仅仅是为了让代码看起来更 OOP 吗?

这个油管视频 https://www.youtube.com/watch?v=Tllw4EPhLiQ (有条件的读者可以看看) 里说 「在 JavaScript添加 class 关键字」 就好像

giving clean needles to meth addicts

给(xi du的)瘾君子送来一些干净的针头 (太犀利了!)(有夸张成分 狗头护体)

简单来说,JavaScript 并不擅长玩 OOP class 这一套,它有自己非常擅长且自然而然的风格(函数式),如果你想好好学 JavaScript 且正宗地用好 JavaScript ,我个人十分建议,把你花在 JavaScript OOP上的时间用来先搞清楚 JavaScript function 和 闭包 (React 开发者学好 Hooks)—— 然后再去学 class、prototype 等知识

「牢记JavaScript的一个特性 —— Functions are first-class in JavaScript 函数是一等公民」

工厂函数会每次都重复生成函数(影响性能)吗?

可以参考这个回答

https://www.zhihu.com/answer/943385371

另外,可以简单回想一下,在我们日常业务开发中,真的有需要创建那么多类对象吗?

你写的类里被 new 过几次?真的每次 new 都有必要吗?如果没有,往上看第 3 点。

@贺师俊

贺大提到另一个点

class具有更高的声明性和静态可分析性,也跟platform api更为一致,同时在现代引擎里也有更好的优化

感谢贺大的指出,底层库的开发我本人经历不多,目前接触更多是还是业务代码为主。

至于引擎的代码优化,我持保留意见,之前在研究React Hooks的时候,不记得在哪看到过React的官方开发者认为在未来 Functional Component 的优化有比 Class Component 更好的趋势(原句和原文我暂时找不到了,找到了再补充回来,有读者看到过也可以评论给我,谢谢) —— 更新:找到了 https://zh-hans.reactjs.org/docs/hooks-intro.html#classes-confuse-both-people-and-machines

img

后记

挺意外这篇文章有这么大的关注度,多谢大家的支持和讨论。

其实我个人还是有点耿耿于怀的,虽然文章整体表达了我的观点,但感觉并没有完全把 JavaScript class 的所有坑介绍清楚(仅提了比较常见的 bind 问题),其实还有 prototype 的机制差异、prototype chain 等问题,但是限于篇幅就没写出来。

接下来我会继续写一篇后续的相关的文章,接着讨论 JavaScript 和 OOP 碰撞的另一簇火花 —— 原来不使用 class ,JavaScript 依然能借鉴前人OOP的最佳实践和经验!



1. JavaScript 重温系列(22篇全)
2. ECMAScript 重温系列(10篇全)
3. JavaScript设计模式 重温系列(9篇全)
4. 正则 / 框架 / 算法等 重温系列(16篇全)
5. Webpack4 入门(上)|| Webpack4 入门(下)
6. MobX 入门(上) ||  MobX 入门(下)
7. 70+篇原创系列汇总

回复“加群”与大佬们一起交流学习~

点击“阅读原文”查看70+篇原创文章

浏览 39
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报