写点什么

JavaScript 的实例化与继承:请停止使用 new 关键字

  • 2013-11-22
  • 本文字数:7190 字

    阅读完需:约 24 分钟

JavaScript 中的 new 关键字可以实现实例化和继承的工作,但个人认为使用 new 关键字并非是最佳的实践,还可以有更友好一些的实现。本文将介绍使用 new 关键字有什么问题,然后介绍如何对与 new 相关联的一系列面向对象操作进行封装,以便提供更快捷的、更易让人理解的实现方式。

传统的实例化与继承

假设我们有两个类,Class:function Class() {}SubClass:function SubClass(){},SubClass 需要继承自 Class。传统方法一般是按如下步骤来组织和实现的:

  • Class 中被继承的属性和方法必须放在 Class 的 prototype 属性中
  • SubClass 中自己的方法和属性也必须放在自己 prototype 属性中
  • SubClass 的 prototype 对象的 prototype(__proto__) 属性必须指向的 Class 的 prototype

这样一来,由于 prototype 链的特性,SubClass 的实例便能追溯到 Class 的方法,从而实现继承:

复制代码
new SubClass() Object.create(Class.prototype)
| |
V V
SubClass.prototype ---> { }
{ }.__proto__ ---> Class.prototype

举一个具体的例子:下面的代码中,我们做了以下几件事:

  • 定义一个父类叫做 Human
  • 定义一个名为 Man 的子类继承自 Human
  • 子类继承父类的一切属性,并调用父类的构造函数,实例化这个子类
复制代码
// 构造函数 / 基类
function Human(name) {
this.name = name;
}
/*
基类的方法保存在构造函数的 prototype 属性中
便于子类的继承
*/
Human.prototype.say = function () {
console.log("say");
}
/*
道格拉斯的 object 方法(等同于 object.create 方法)
*/
function object(o) {
var F = function () {};
F.prototype = o;
return new F();
}
// 子类构造函数
function Man(name, age) {
// 调用父类的构造函数
Human.call(this, name);
// 自己的属性 age
this.age = age;
}
// 继承父类的方法
Man.prototype = object(Human.prototype);
Man.prototype.constructor = Man;
// 实例化子类
var man = new Man("Lee", 22);
console.log(man);
// 调用父类的 say 方法:
man.say();

DEMO

通过上面的代码可以总结出传统的实例化与继承的几个特点:

  • 传统方法中的“类”一定是一个构造函数。
  • 属性和方法绑定在 prototype 属性上,并借助 prototype 的特性实现继承。
  • 通过 new 关键字来实例化一个对象。

为什么我会十分的肯定 Object.create 方法与道格拉斯的 object 方法是一致呢?因为在 MDN 上,object 方法就是作为 Object.create 的一个 Polyfill 方案:

new 关键字的不足之处

在《Javascript 语言精粹》(Javascript: The Good Parts)中,道格拉斯认为应该避免使用 new 关键字:

If you forget to include the new prefix when calling a constructor function, then this will not be bound to the new object. Sadly, this will be bound to the global object, so instead of augmenting your new object, you will be clobbering global variables. That is really bad. There is no compile warning, and there is no runtime warning. (page 49)

大意是说在应该使用 new 的时候如果忘了 new 关键字,会引发一些问题。

当然了,你遗忘使用任何关键字都会引起一系列的问题。再退一步说,这个问题是完全可以避免的

复制代码
function foo()
{
// 如果忘了使用关键字,这一步骤会悄悄帮你修复这个问题
if ( !(this instanceof foo) )
return new foo();
// 构造函数的逻辑继续……
}

或者更通用的抛出异常即可

复制代码
function foo()
{
if ( !(this instanceof arguments.callee) )
throw new Error("Constructor called as a function");
}

又或者按照 John Resig 的方案,准备一个 makeClass 工厂函数,把大部分的初始化功能放在一个 init 方法中,而非构造函数自己中:

复制代码
// makeClass - By John Resig (MIT Licensed)
function makeClass(){
return function(args){
if ( this instanceof arguments.callee ) {
if ( typeof this.init == "function" )
this.init.apply( this, args.callee ? args : arguments );
} else
return new arguments.callee( arguments );
};
}

在我看来,new 关键字不是一个好的实践的关键原因是:

…new is a remnant of the days where JavaScript accepted a Java like syntax for gaining “popularity”. And we were pushing it as a little brother to Java, as a complementary language like Visual Basic was to C++ in Microsoft’s language families at the time.

道格拉斯将这个问题描述为:

This indirection was intended to make the language seem more familiar to classically trained programmers, but failed to do that, as we can see from the very low opinion Java programmers have of JavaScript. JavaScript’s constructor pattern did not appeal to the classical crowd. It also obscured JavaScript’s true prototypal nature. As a result, there are very few programmers who know how to use the language effectively.

简单来说,JavaScript 是一种 prototypical 类型语言,在创建之初,是为了迎合市场的需要,让人们觉得它和 Java 是类似的,才引入了 new 关键字。Javascript 本应通过它的 Prototypical 特性来实现实例化和继承,但 new 关键字让它变得不伦不类。

把传统方法加以改造

既然 new 关键字不够友好,那么我们有两个办法可以解决这个问题:一是完全抛弃 new 关键字,二是把含有 new 关键字的操作封装起来,只向外提供友好的接口。下面将介绍第二种方法的实现思路,把传统方法加以改造。

我们开始构造一个最原始的基类Class(类似于 JavaScript 中的 Object 类),并且只向外提供两个接口:

  • Class.extend 用于拓展子类
  • Class.create 用于创建实例
复制代码
// 基类
function Class() {}
// 将 extend 和 create 置于 prototype 对象中,以便子类继承
Class.prototype.extend = function () {};
Class.prototype.create = function () {};
// 为了能在基类上直接以.extend 的方式进行调用
Class.extend = function (props) {
return this.prototype.extend.call(this, props);
}

extend 和 create 的具体实现:

复制代码
Class.prototype.create = function (props) {
/*
create 实际上是对 new 的封装;
create 返回的实例实际上就是 new 构造出的实例;
this 即指向调用当前 create 的构造函数;
*/
var instance = new this();
/*
绑定该实例的属性
*/
for (var name in props) {
instance[name] = props[name];
}
return instance;
}
Class.prototype.extend = function (props) {
/*
派生出来的新的子类
*/
var SubClass = function () {};
/*
继承父类的属性和方法,
当然前提是父类的属性都放在 prototype 中
而非上面 create 方法的“实例属性”中
*/
SubClass.prototype = Object.create(this.prototype);
// 并且添加自己的方法和属性
for (var name in props) {
SubClass.prototype[name] = props[name];
}
SubClass.prototype.constructor = SubClass;
/*
介于需要以.extend 的方式和.create 的方式调用:
*/
SubClass.extend = SubClass.prototype.extend;
SubClass.create = SubClass.prototype.create;
return SubClass;
}

仍然以 Human 和 Man 类举例使用说明:

复制代码
var Human = Class.extend({
say: function () {
console.log("Hello");
}
});
var human = Human.create();
console.log(human)
human.say();
var Man = Human.extend({
walk: function () {
console.log("walk");
}
});
var man = Man.create({
name: "Lee",
age: 22
});
console.log(man);
// 调用父类方法
man.say();
man.walk();

DEMO

至此,基本框架已经搭建起来,接下来继续补充功能。

  1. 我们希望把构造函数独立出来,并且统一命名为 init。就好像Backbone.js中每一个 view 都有一个initialize方法一样。这样能让初始化更灵活和标准化,甚至可以把 init 构造函数借出去
  2. 我还想新增一个子类方法调用父类同名方法的机制,比如说在父类和子类的中都定义了一个 say 方法,那么只要在子类的 say 中调用this.callSuper()就能调用父类的 say 方法了。例如:
复制代码
// 基类
var Human = Class.extend({
/*
你需要在定义类时定义构造方法 init
*/
init: function () {
this.nature = "Human";
},
say: function () {
console.log("I am a human");
}
})
var Man = Human.extend({
init: function () {
this.sex = "man";
},
say: function () {
// 调用同名的父类方法
this.callSuper();
console.log("I am a man");
}
});

那么 Class.create 就不仅仅是 new 一个构造函数了:

复制代码
Class.create = Class.prototype.create = function () {
/*
注意在这里我们只是实例化一个构造函数
而非最后返回的“实例”,
可以理解这个实例目前只是一个“壳”
需要 init 函数对这个“壳”填充属性和方法
*/
var instance = new this();
/*
如果对 init 有定义的话
*/
if (instance.init) {
instance.init.apply(instance, arguments);
}
return instance;
}

实现在子类方法调用父类同名方法的机制,我们可以借用 John Resig 的方案

复制代码
Class.extend = Class.prototype.extend = function (props) {
var SubClass = function () {};
var _super = this.prototype;
SubClass.prototype = Object.create(this.prototype);
for (var name in props) {
// 如果父类同名属性也是一个函数
if (typeof props[name] == "function"
&& typeof _super[name] == "function") {
// 重新定义用户的同名函数,把用户的函数包装起来
SubClass.prototype[name]
= (function (super_fn, fn) {
return function () {
// 如果用户有自定义 callSuper 的话,暂存起来
var tmp = this.callSuper;
// callSuper 即指向同名父类函数
this.callSuper = super_fn;
/*
callSuper 即存在子类同名函数的上下文中
以 this.callSuper() 形式调用
*/
var ret = fn.apply(this, arguments);
this.callSuper = tmp;
/*
如果用户没有自定义的 callsuper 方法,则 delete
*/
if (!this.callSuper) {
delete this.callSuper;
}
return ret;
}
})(_super[name], props[name])
} else {
// 如果是非同名属性或者方法
SubClass.prototype[name] = props[name];
}
..
}
SubClass.prototype.constructor = SubClass;
}

最后给出一个完整版,并且做了一些优化:

复制代码
function Class() {}
Class.extend = function extend(props) {
var prototype = new this();
var _super = this.prototype;
for (var name in props) {
if (typeof props[name] == "function"
&& typeof _super[name] == "function") {
prototype[name] = (function (super_fn, fn) {
return function () {
var tmp = this.callSuper;
this.callSuper = super_fn;
var ret = fn.apply(this, arguments);
this.callSuper = tmp;
if (!this.callSuper) {
delete this.callSuper;
}
return ret;
}
})(_super[name], props[name])
} else {
prototype[name] = props[name];
}
}
function Class() {}
Class.prototype = prototype;
Class.prototype.constructor = Class;
Class.extend = extend;
Class.create = Class.prototype.create = function () {
var instance = new this();
if (instance.init) {
instance.init.apply(instance, arguments);
}
return instance;
}
return Class;
}

下面是测试的代码。为了验证上面代码的健壮性,故意实现了三层继承:

复制代码
var Human = Class.extend({
init: function () {
this.nature = "Human";
},
say: function () {
console.log("I am a human");
}
})
var human = Human.create();
console.log(human);
human.say();
var Man = Human.extend({
init: function () {
this.callSuper();
this.sex = "man";
},
say: function () {
this.callSuper();
console.log("I am a man");
}
});
var man = Man.create();
console.log(man);
man.say();
var Person = Man.extend({
init: function () {
this.callSuper();
this.name = "lee";
},
say: function () {
this.callSuper();
console.log("I am Lee");
}
})
var person = Person.create();
console.log(person);
person.say();

DEMO

是时候彻底抛弃 new 关键字了

如果不使用 new 关键字,那么我们需要转投上两节中反复使用的Object.create来生产新的对象

假设我们有一个矩形对象:

复制代码
var Rectangle = {
area: function () {
console.log(this.width * this.height);
}
};

借助 Object.create,我们可以生成一个拥有它所有方法的对象:

复制代码
var rectangle = Object.create(Rectangle);

生成之后,我们还可以给这个实例赋值长宽,并且取得面积值

复制代码
var rect = Object.create(Rectangle);
rect.width = 5;
rect.height = 9;
rect.area();

注意这个过程我们没有使用 new 关键字,但是我们相当于实例化了一个对象 (rectangle),给这个对象加上了自己的属性,并且成功调用了类 (Rectangle) 的方法。

但是我们希望能自动化赋值长宽,没问题,那就定义一个 create 方法:

复制代码
var Rectangle = {
create: function (width, height) {
var self = Object.create(this);
self.width = width;
self.height = height;
return self;
},
area: function () {
console.log(this.width * this.height);
}
};

使用方式如下:

复制代码
var rect = Rectangle.create(5, 9);
rect.area();

在纯粹使用 Object.create 的机制下,我们已经完全抛弃了构造函数这个概念。一切都是对象,一个类也可以是对象,这个类的实例不过是一个它自己的复制品。

下面看看如何实现继承。我们现在需要一个正方形,继承自这个长方形

复制代码
var Square = Object.create(Rectangle);
Square.create = function (side) {
return Rectangle.create.call(this, side, side);
}

实例化它:

复制代码
var sq = Square.create(5);
sq.area();

这种做法其实和我们第一种最基本的类似

复制代码
function Man(name, age) {
Human.call(this, name);
this.age = age;
}

上面的方法还是太复杂了,我们希望进一步自动化,于是我们可以写这么一个 extend 函数

复制代码
function extend(extension) {
var hasOwnProperty = Object.hasOwnProperty;
var object = Object.create(this);
for (var property in extension) {
if (hasOwnProperty.call(extension, property) || typeof object[property] === "undefined") {
object[property] = extension[property];
}
}
return object;
}
/*
其实上面这个方法可以直接绑定在原生的 Object 对象上:Object.prototype.extend
但个人不推荐这种做法
*/
var Rectangle = {
extend: extend,
create: function (width, height) {
var self = Object.create(this);
self.width = width;
self.height = height;
return self;
},
area: function () {
console.log(this.width * this.height);
}
};

这样当我们需要继承时,就可以像前几个方法一样用了

复制代码
var Square = Rectangle.extend({
// 重写实例化方法
create: function (side) {
return Rectangle.create.call(this, side, side);
}
})
var s = Square.create(5);
s.area();

结束语

本文对去 new 关键字的方法做了一些罗列,但工作还远远没有结束,有非常多的地方值得拓展,比如:如何重新定义instance of方法,用于判断一个对象是否是一个类的实例?如何在去 new 关键字的基础上继续实现多继承?希望本文的内容在这里只是抛砖引玉,能够开拓大家的思路。

引用资料

作者简介

李光毅,新晋前端工程师,现就职于爱奇艺,热于前端技术分享。


感谢崔康对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。

2013-11-22 06:1117837

评论

发布
暂无评论

重新开始,被自己搞砸的生活

小天同学

个人感想 日常思考

这个名字,你不能再读错了

小天同学

历史 科普

五个“为什么” —— 读《精益创业》

YoungZY

读书笔记

Kubectl exec 的工作原理解读

米开朗基杨

Kubernetes kubelet

ARTS week 1

丽子

技术工作中的颜值

N维空间的尘埃

【转载】如何在团队中做好Code Review?

北纬32°

要和竞争对手做比较吗?

邓瑞恒Ryan

创业 战略管理

有点干货 | Jdk1.8新特性实战篇(41个案例)

小傅哥

函数式接口 Lambda 小傅哥 jdk8 编码

实用贴丨正确的「递归」打开方式:让计算机像计算机一样去计算

博文视点Broadview

Python 递归

工厂模式 (一)简单的工厂模式概念以及示例代码

LSJ

实战 Java8-CompletableFuture

lee

Java 多线程 java8 CompletableFuture

内容比形式更重要

Winann

内容 生活 工作 形式主义

Elasticsearch 实战

代码诗人

[GitHub] 跟我一起白嫖 GitHub Pages 做个人站点 ?

猴哥一一 cium

git GitHub GitHub Pages

不要抱怨,也别憋屈

孙苏勇

职场 随笔杂谈

写给产品经理的信(2):产品设计能力怎样进阶

punkboy

产品 个人成长 产品经理 产品设计 进阶

Zabbix实战指南

橙子冰

技术 运维 监控 运维自动化 zabbix

[Git] Git 可以这么学

猴哥一一 cium

git

ARTS打卡 第1周

引花眠

ARTS 打卡计划

阿里的OceanBase上天了,但你还不会用Explain看SQL的查询计划吗?

Super~琪琪

MySQL 数据库 后台开发 后端

一文带你彻底厘清 Kubernetes 中的证书工作机制

首富手记

Kubernetes

面试官问你MyBatis SQL是如何执行的?把这篇文章甩给他

cxuan

mybatis

学计算机你后悔了吗?

陈辰

学习 技术 大前端

Lean UX 教你设计如何驱动产品

Yanel 说敏捷产品

产品 敏捷 设计

Java开发工具与HelloWorld

编号94530

Java eclipse Hello World ! IDEA 开发工具

怎么控制老板不断加需求?

kimmking

已发表的技术文章-大数据方面

绝影-大数据

孩子,我们在睡前一起来阅读 15 分钟的好书,让彼此都带着好的故事入眠。

叶小鍵

正确阅读 托马斯·奥本 Doug Antin 蒂·泰德罗克

python实现·十大排序算法之希尔排序(Shell Sort)

南风以南

Python 排序算法 希尔排序

Flutter的staggered GridView详细使用

潘珉

flutter

JavaScript的实例化与继承:请停止使用new关键字_JavaScript_李光毅_InfoQ精选文章