你不懂JS:this与对象原型 附录A:ES6 `class`

官方中文版原文链接

感谢社区中各位的大力支持,译者再次奉上一点点福利:阿里云产品券,享受所有官网优惠,并抽取幸运大奖:点击这里领取

如果说本书后半部分(第四到六章)有什么关键信息,那就是类是一种代码的可选设计模式(不是必要的),而且用像JavaScript这样的[[Prototype]]语言来实现它总是很尴尬。

虽然这种尴尬很大一部分关于语法,但 不仅 限于此。第四和第五章审视了相当多的难看语法,从使代码杂乱的.prototype引用的繁冗,到 显式假想多态:当你在链条的不同层级上给方法相同的命名以试图实现从低层方法到高层方法的多态引用。.constructor被错误地解释为“被XX构建”,这成为了一个不可靠的定义,也成为了另一个难看的语法。

但关于类的设计的问题要深刻多了。第四章指出在传统的面向类语言中,类实际上发生了从父类向子类,由子类向实例的 拷贝 动作,而在[[Prototype]]中,动作 不是 一个拷贝,而是相反——一个委托链接。

OLOO风格和行为委托接受了[[Prototype]],而不是将它隐藏起来,当比较它们的简单性时,类在JS中的问题就凸显出来。

class

我们 不必 再次争论这些问题。我在这里简单地重提这些问题仅仅是为了使它们在你的头脑里保持新鲜,以使我们将注意力转向ES6的class机制。我们将在这里展示它如何工作,并且看看class是否实质上解决了任何这些“类”的问题。

让我们重温第六章的Widget/Button例子:

class Widget {
    constructor(width,height) {
        this.width = width || 50;
        this.height = height || 50;
        this.$elem = null;
    }
    render($where){
        if (this.$elem) {
            this.$elem.css( {
                width: this.width + "px",
                height: this.height + "px"
            } ).appendTo( $where );
        }
    }
}

class Button extends Widget {
    constructor(width,height,label) {
        super( width, height );
        this.label = label || "Default";
        this.$elem = $( "<button>" ).text( this.label );
    }
    render($where) {
        super.render( $where );
        this.$elem.click( this.onClick.bind( this ) );
    }
    onClick(evt) {
        console.log( "Button '" + this.label + "' clicked!" );
    }
}

除了语法上 看起来 更好,ES6还解决了什么?

  1. 不再有(某种意义上的,继续往下看!)指向.prototype的引用来弄乱代码。
  2. Button被声明为直接“继承自”(也就是extendsWidget,而不是需要用Object.create(..)来替换.prototype链接的对象,或者用__proto__Object.setPrototypeOf(..)来设置它。
  3. super(..)现在给了我们非常有用的 相对多态 的能力,所以在链条上某一个层级上的任何方法,可以引用链条上相对上一层的同名方法。第四章中有一个关于构造器的奇怪现象:构造器不属于它们的类,而且因此与类没有联系。super(..)含有一个对此问题的解决方法 —— super()会在构造器内部想正如你期望的那样工作。
  4. class字面语法对指定属性没有什么启发(仅对方法有)。这看起来限制了某些东西,但是绝大多数情况下期望一个属性(状态)存在于链条末端的“实例”以外的地方,这通常是一个错误和令人诧异(因为这个状态被隐含地在所有“实例”中“分享”)的。所以,也可以说class语法防止你出现错误。
  5. extends甚至允许你用非常自然的方式扩展内建的对象(子)类型,比如Array或者RegExp。在没有class .. extends的情况下这样做一直以来是一个极端复杂而令人沮丧的任务,只有最熟练的框架作者曾经正确地解决过这个问题。现在,它是小菜一碟!

凭心而论,对大多数明显的(语法上的)问题,和经典的原型风格代码使人诧异的地方,这些确实是实质上的解决方案。

class的坑

然而,它不全是优点。在JS中将“类”作为一种设计模式,仍然有一些深刻和非常令人烦恼的问题。

首先,class语法可能会说服你JS在ES6中存在一个新的“类”机制。但不是这样。class很大程度上仅仅是一个既存的[[Prototype]](委托)机制的语法糖!

这意味着class实际上不是像传统面向类语言那样,在声明时静态地拷贝定义。如果你在“父类”上更改/替换了一个方法(有意或无意地),子“类”和/或实例将会受到“影响”,因为它们在声明时没有得到一份拷贝,它们依然都使用那个基于[[Prototype]]的实时委托模型。

class C {
    constructor() {
        this.num = Math.random();
    }
    rand() {
        console.log( "Random: " + this.num );
    }
}

var c1 = new C();
c1.rand(); // "Random: 0.4324299..."

C.prototype.rand = function() {
    console.log( "Random: " + Math.round( this.num * 1000 ));
};

var c2 = new C();
c2.rand(); // "Random: 867"

c1.rand(); // "Random: 432" -- oops!!!

这种行为只有在 你已经知道了 关于委托的性质,而不是期待从“真的类”中 拷贝 时,才看起来合理。那么你要问自己的问题是,为什么你为了根本上就和类不同的东西选择class语法?

ES6的class语法不是使观察和理解传统的类和委托对象间的不同 变得更困难 了吗?

class语法 没有 提供声明类的属性成员的方法(仅对方法有)。所以如果你需要跟踪对象间分享的状态,那么你最终会回到丑陋的.prototype语法,像这样:

class C {
    constructor() {
        // 确保修改的是共享状态
        // 不是设置实例上的遮蔽属性
        C.prototype.count++;

        // 这里,`this.count`通过委托如我们期望的那样工作
        console.log( "Hello: " + this.count );
    }
}

// 直接在原型对象上添加一个共享属性
C.prototype.count = 0;

var c1 = new C();
// Hello: 1

var c2 = new C();
// Hello: 2

c1.count === 2; // true
c1.count === c2.count; // true

这里最大的问题是,由于它将.prototype作为实现细节暴露(泄露!)出来,而背叛了class语法的初衷。

而且,我们还依然面临着那个令人诧异的陷阱:this.count++将会隐含地在c1c2两个对象上创建一个分离的遮蔽属性.count,而不是更新共享的状态。class没有在这个问题上给我们什么安慰,除了(大概是)通过缺少语法支持来暗示你 根本 就不应该这么做。

另外,无意地遮蔽依然是个灾难:

class C {
    constructor(id) {
        // 噢,一个坑,我们用实例上的属性值遮蔽了`id()`方法
        this.id = id;
    }
    id() {
        console.log( "Id: " + id );
    }
}

var c1 = new C( "c1" );
c1.id(); // TypeError -- `c1.id` 现在是字符串"c1"

还有一些关于super如何工作的微妙问题。你可能会假设super将会以一种类似与this得到绑定的方式(间第二章)来被绑定,也就是super总是会绑定到当前方法在[[Prototype]]链中的位置的更高一层。

然而,因为性能问题(this绑定已经很耗费性能了),super不是动态绑定的。它在声明时,被有些“静态地”绑定。不是什么大事儿,对吧?

恩……可能是,可能不是。如果你像大多数JS开发者那样,开始把函数赋值给不同的(来自于class定义的)对象,以各种不同的方式,你可能不会意识到在所有这些情况下,底层的super机制会不得不每次都重新绑定。

而且根据你每次赋值采取的语法方式不同,很有可能在某些情况下super不能被正确地绑定(至少不会像你期望的那样),所以你可能(在写作这里时,TC39正在讨论这个问题)会不得不用toMethod(..)来手动绑定super(有点儿像你不得不用bind(..)绑定this —— 见第二章)。

你曾经可以给不同的对象赋予方法,来通过 隐含绑定 规则(见第二章),自动地利用this的动态性。但对于使用super的方法,同样的事情很可能不会发生。

考虑这里super应当怎样动作(对DE):

class P {
    foo() { console.log( "P.foo" ); }
}

class C extends P {
    foo() {
        super();
    }
}

var c1 = new C();
c1.foo(); // "P.foo"

var D = {
    foo: function() { console.log( "D.foo" ); }
};

var E = {
    foo: C.prototype.foo
};

// E链接到D来进行委托
Object.setPrototypeOf( E, D );

E.foo(); // "P.foo"

如果你(十分合理地!)认为super将会在调用时自动绑定,你可能会期望super()将会自动地认识到E委托至D,所以使用super()E.foo()应当调用D.foo()

不是这样。 由于实用主义的性能原因,super不像this那样 延迟绑定(也就是动态绑定)。相反它从调用时[[HomeObject]].[[Prototype]]派生出来,而[[HomeObject]]实在声明时静态绑定的。

在这个特定的例子中,super()依然解析为P.foo(),因为方法的[[HomeObject]]仍然是C而且C.[[Prototype]]P

可能 会有方法手动地解决这样的陷阱。在这个场景中使用toMethod(..)来绑定/重绑定方法的[[HomeObject]](设置这个对象的[[Prototype]]一起!)似乎会管用:

var D = {
    foo: function() { console.log( "D.foo" ); }
};

// E链接到D来进行委托
var E = Object.create( D );

// 手动绑定`foo`的`[[HomeObject]]`到
// `E`, 因为`E.[[Prototype]]`是`D`,所以
// `super()`是`D.foo()`
E.foo = C.prototype.foo.toMethod( E, "foo" );

E.foo(); // "D.foo"

注意: toMethod()克隆这个方法,然后将它的第一个参数作为homeObject(这就是为什么我们传入E),第二个参数(可选)用来设置新方法的name(保持“foo”不变)。

除了这种场景以外,是否还有其他的极端情况会使开发者们陷入陷阱还有待观察。无论如何,你将不得不费心保持清醒:在哪里引擎自动为你确定super,和在哪里你不得不手动处理它。噢!

静态优于动态?

但是关于ES6的最大问题是,所有这些种种陷阱意味着class有点儿将你带入一种语法,它看起来暗示着(像传统的类那样)一旦你声明一个class,它是一个东西的静态定义(将来会实例化)。使你完全忘记了这个事实:C是一个对象,一个你可以直接互动的具体的东西。

在传统面向类的语言中,你从不会在晚些时候调整类的定义,所以类设计模式不提供这样的能力。但是JS的 一个最强大的部分 就是它 动态的,而且任何对象的定义都是(除非你将它设定为不可变)不固定的可变的 东西

class看起来在暗示你不应该做这样的事情,通过强制你使用.prototype语法才能做到,或强制你考虑super的陷阱,等等。而且它对这种动态机制可能带来的一切陷阱 几乎不 提供任何支持。

换句话说,class好像在告诉你:“动态太坏了,所以这可能不是一个好主意。这里有看似静态语法,把你的东西静态编码。”

关于JavaScript的评论是多么悲伤啊:动态太难了,让我们假装成(但实际上不是!)静态吧

这些就是为什么ES6的class伪装成一个语法头痛症的解决方案,但是它实际上把水搅得更浑,而且更不容易对JS形成清晰简明的认识。

注意: 如果你使用.bind(..)工具制作一个硬绑定函数(见第二章),那么这个函数是不能像普通函数那样用ES6的extend扩展的。

复习

class在假装修复JS中的类/继承设计模式的问题上做的很好。但他实际上做的却正相反:它隐藏了许多问题,而且引入了其他微妙而且危险的东西

class为折磨了JavaScript语言将近20年的“类”的困扰做出了新的贡献。在某些方面,它问的问题比它解决的多,而且在[[Prototype]]机制的优雅和简单之上,它整体上感觉像是一个非常不自然的匹配。

底线:如果ES6class使稳健地利用[[Prototype]]变得困难,而且隐藏了JS对象机制最重要的性质 —— 对象间的实时委托链接 —— 我们不应该认为class产生的麻烦比它解决的更多,并且将它贬低为一种反模式吗?

我真的不能帮你回答这个问题。但我希望这本书已经在你从未经历过的深度上完全地探索了这个问题,而且已经给出了 你自己回答这个问题 所需的信息。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,671评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,442评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,524评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,623评论 1 275
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,642评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,584评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,953评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,621评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,865评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,608评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,698评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,378评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,958评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,940评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,173评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,419评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,425评论 2 342

推荐阅读更多精彩内容