[翻译]Arrow Functions in Class Properties Might Not Be As Great As We Think

Javascript 新增了 arrow functionclass,这两者的概念可参考:arrow function
React 里面的方法想要在JSX中使用,必须手动绑定 this,有些人会在这儿使用箭头函数简写掉 this.method = this.method.bind(this); 这行代码,本文作者对这种写法进行了分析,原文在此:Arrow Functions in Class Properties Might Not Be As Great As We Think

自2016年以来, Class Properties Proposal 让我们的代码简洁了许多,特别是在 Reactstate 书写或者在声明静态成员 propTypesdefaultProps 的时候。
看下代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Class properties 以前
class Greeting extends Component {
constructor(props) {
super(props);

this.state = {
isLoading: false
};
}

render() {
return <h1>Hello, {this.props.name}</h1>;
}
}

Greeting.protoTypes = {
name: ProtoTypes.String.Required
};

Greeting.defaultProps = {
name: 'Stranger'
};

// Class properties 之后
class Greeting extends Component {
static protoTypes = {
name: ProtoTypes.String.Required
};

static defaultProps = {
name: 'Stranger'
};

state = {
isLoading: false
};

render() {
return <h1>Hello, {this.props.name}</h1>;
}
}

另外,在 React 里面需要处理this绑定的时候,直接以属性声明而不再使用 constructor 赋值方式在过去的几个月里面也越来越成为一种趋势。
Twitter链接
在React 中怎么绑定this到方法

1
2
3
4
5
6
7
8
9
10
// Usage example of arrow function in a class property.
class ComponentA extends Component {
handleClick = () => {
// ...
}

render() {
// ...
}
}

箭头函数在声明属性时候很方便,自动绑定 this,不需要在 constructor 里添加 this.handleClick = this.handleClick.bind(this).
但是,我们真的可以这么用吗?
首先来看一下类属性究竟是怎么实现自动绑定的:

箭头函数是怎么编译到ES2017的

编写一个包含静态属性,实例属性,箭头函数属性,另外一个常见成员方法的类,使用 transform-class-properties 插件编译.

1
2
3
4
5
6
7
8
9
10
11
12
class A {
static color = "red";
counter = 0;

handleClick = () => {
this.counter++;
}

handleLongClick() {
this.counter++;
}
}

使用 Babel REPL 编译这个类到ES2017 格式,编译插件:es2017 stage-2,我们得到以下代码:

打开 Babel 里面查看代码时如果编译出错,请在左下角添加 Plugin : babel-plugin-transform-class-properties

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class A {
constructor() {
this.counter = 0;

this.handleClick = () => {
this.counter++;
};
}

handleLongClick() {
this.counter++;
}
}
A.color = "red";

有兴趣的同学可以看一下:点击查看

我们可以看到, 实例属性被移动到 constructor 内部,静态成员被移动到类声明完成之后
static 关键字可以省略额外的静态属性声明代码,然后可以直接export class XXX extends Component ,个人比较喜欢这点
实例属性表现也不错,在没有编译的版本中,类声明简洁了许多
但是,箭头函数声明的属性 handleClick 也被移动到了 constructor,与普通的实例属性类似
正常的成员方法 handleLongClick 没有改变
属性自动初始化对于正常的属性没有问题,但是使用箭头函数声明的属性,属于Hack得到一个 this 绑定
你有没有发现问题?我们来看下我的发现

Mockability

如果我们想要 mock 一个类的某一个方法, 最简单的方式就是修改 prototype,这会对这个类原型链上所有的对象生效
我们看下使用这种方式测试之前的类 A 会发生什么:
我们继续把之前的类 A 编译到 ES2015: 点击查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class A {
static color = "red";
counter = 0;

handleClick = () => {
this.counter++;
}

handleLongClick() {
this.counter++;
}
}

// 编译输出
"use strict";

function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}

var A = (function() {
function A() {
var _this = this;

_classCallCheck(this, A);

this.counter = 0;

this.handleClick = function() {
_this.counter++;
};
}

A.prototype.handleLongClick = function handleLongClick() {
this.counter++;
};

return A;
})();

A.color = "red";

A.prototype.handleLongClick 有效.
A.prototype.handleClick 未定义.

叮~, 使用箭头函数定义的 handleClick 编译之后被移动到构造函数里面使用实例声明,而不是类方法。所以,我们在实例上 Mock 这种方式定义的方法,这些更改无法同步到其他对象。

Inheritance

首先定义 class A .

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class A {
handleClick = () => {
console.log("A.handleClick");
}

handleLongClick() {
console.log("A.handleLongClick");
}
}

console.log(A.prototype);
// {constructor: ƒ, handleLongClick: ƒ}

new A().handleClick();
// A.handleClick

new A().handleLongClick();
// A.handleLongClick

如果 class B 继承 class A,由于 handleClick 是不在 prototype 上定义的,class B 内部无法通过 super.handleClick 调用父类方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class B extends A {
handleClick = () => {
super.handleClick();

console.log("B.handleClick");
}

handleLongClick() {
super.handleLongClick();

console.log("B.handleLongClick");
}
}

console.log(B.prototype);
// A {constructor: ƒ, handleLongClick: ƒ}

console.log(B.prototype.__proto__);
// {constructor: ƒ, handleLongClick: ƒ}

new B().handleClick();
// Uncaught TypeError: (intermediate value).handleClick is not a function

new B().handleLongClick();
// A.handleLongClick
// B.handleLongClick

class C 继承 class A,但是使用类成员而不是箭头函数重新实现了 handleClick 方法,这时 handleClick 将只会执行 super.handleClick, 而不会执行自己定义的代码(只执行父类定义的方法),奇怪了?

这时因为父类的构造函数重写了handleClick方法。

C.prototype.handleClick()会调用class C的 handleClick方法,但是会报错: Uncaught TypeError: (intermediate value).handleClick is not a function .

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class C extends A {
handleClick() {
super.handleClick();

console.log("C.handleClick");
}
}

console.log(C.prototype);
// A {constructor: ƒ, handleClick: ƒ}

console.log(C.prototype.__proto__);
// {constructor: ƒ, handleLongClick: ƒ}

new C().handleClick();
// A.handleClick

感兴趣的同学可以点击链接查看原因。

class D 以字面量对象方式继承 class A,D 将拥有一个空的 prototype,new D().handleClick() 会打印 A.handleClick:

1
2
3
4
5
6
7
8
9
10
class D extends A {
}
console.log(D.prototype);
// A {constructor: ƒ}

console.log(D.prototype.__proto__);
// {constructor: ƒ, handleLongClick: ƒ}

new D().handleClick();
// A.handleClick

Performance

现在我们来看一下比较有趣的部分:性能

我们知道方法通常都是定义在原型上面,这样定义的方法会在所有实例上共享. 一个组件列表里面的所有组件都分享相同的方法. 列表里面的每个组件被点击, 都会执行点击事件, 但是无论哪个组件点击都是调用原型链上同一个方法. 因为是同一个方法多次调用, JS引擎会优化这个方法.

与此相对, 类中箭头函数定义的方法, 在多个组件实例中会创建多个方法. 还记得刚才我们看到的编译输出吗, 箭头函数成员在构造方法中声明. 每个组件被点击, 执行的都是不同的方法.

我们看下在V8 Engine(Chrome)下二者的表现差异:

第一个测试比较简单, 只测试初始化时间, 然后调用方法一次.

注意这个测试中数字并不重要, 因为单个实例化过程在应用程序中无关大碍, 我们讨论的是每秒能执行多少次操作并且频率足够高. 我比较担心方法之间的差异.
Ops/s of initialization and call

For the second one, I used a representative use case. The instantiation of 100 components — like a list — which after we called the method one time on each.

第二个测试, 使用一个比较有代表性的使用示例. 初始化100个组件形成一个组件列表, 对每个组件调用一次方法.
Ops/s of instantiation of 100 components and method call

All benchmarks were run on a MacBook Pro 13” 2016 2GHz on Mac OS X 10.13.1 and Chrome 62.0.3202.

总的来说, 为了更好的代码执行性能, 我们需要在原型上声明方法然后在需要时bind到实例上(当作属性或者回调函数传递). 在原型上声明方法并在构造方法中声明并绑定自己的属性是有意义的, 而且方法并不是特别多.
我们讨论的是高频操作, 但是我们清楚看到箭头函数在类声明中性能表现并不是我们期待的情况.
有人会说这点儿性能损失并无大碍, 我们绝大多数情况并不会对一个组件声明很多实例, 的确这样.
你也可以认为这是一个提前优化(提前优化是万恶之源), 但是, 我们能把箭头函数在类属性中的使用当作简化使用还是不当使用呢? 希望JS引擎将来会优化类属性中的箭头函数.

P.S: Class properties for properties are such a great improvement!

我在很多应用程序和软件包中都见到过这类使用方式, 很多可以多实例方式使用的组件也是. 既然已经知道这种方式现在会影响程序性能, 我们真的应该继续使用吗?

我个人认为这是一个牺牲性能, 而且并不总是那么好用的做法.

解决方案是 autobind-decorator, 不幸的是当前只能借助babel 使用, 它还只是一个stage 2的提案.

1
2
3
4
5
6
7
8
9
10
class Component {
constructor(value) {
this.value = value;
}

@autobind
method() {
return this.value;
}
}

尽管如此, 他们也建议最好不要滥用 autobind:

It is unnecessary to do that to every function. This is just as bad as autobinding (on a class). You only need to bind functions that you pass around. e.g. onClick={this.doSomething}. Or fetch.then(this.hanldeDone)  — Dan Abramov. ‏
I was the guy who came up with autobinding in older Reacts and I’m glad to see it gone. It might save you a few keystrokes but it allocates functions that’ll never be called in 90% of cases and has noticeable performance degradation. Getting rid of autobinding is a good thing — Peter Hunt

Conclusion

使用箭头函数声明的类成员会被编译到构造函数中定义到当前对象上面而不是在当前类的原型上.

我们无法使用super调用这些方法.

箭头函数声明类属性比绑定方法慢很多, 它们都比正常方法要慢.

我们只需要.bind()可能会被传递的方法.


知道箭头函数声明的类属性是如何工作以及真实的性能表现之后, 你应该可以做出聪明的选择并清楚这些选择的结果.
你觉得呢? 你会继续这么使用吗? 这篇文章会不会让你改变想法?
欢迎交流想法.