最近在工作中碰到一个小bug, 引发了我对箭头函数使用的一些思考,在此记录与分享一下:
问题场景
我司某团队开发了一个这样的React组件A:它接收一个名为extension的props,这个extension属性是一个包含一堆扩展函数的对象,供组件A在内部调用。 在该组件的使用文档中,给出的demo是这样的:
class Extension {
f1 = () => {
...
}
f2 = () => {
...
}
f3 = () => {
...
}
}
const DemoComponent = () => {
return <A extension={new Extension()} />
}
于是,我在使用该组件的时候,按照demo的用法,写下了这样的代码:
class Extension {
f1() {
...
}
f2() {
...
}
f3() {
...
}
}
const DemoComponent = () => {
return <A extension={new Extension()} />
}
跟demo唯一的区别只是,Extension的成员函数我没有用箭头函数而已,然而我这样的代码在运行的时候却出了大问题,明明在Extension中声明过的成员函数在要调用的时候全都变成了undefined。
发现症结
后来研读了一下A组件的源码,发现该组件在使用extension的时候,其实用的是用户传入的extension和默认extension的结合:
this.state = {
extension: Object.assign({}, props.extension, defaultExtension)
}
在控制台上,对这里打了断点之后,一切真相大白,原来,当props.extension里的成员函数以箭头函数声明时,这些成员函数被挂在了实例属性上,而当成员函数不是以箭头函数的方式声明时,这些成员函数会被挂载在原型上。 也就是说
class A {
f = () => {
...
}
}
上面这个类声明如果不用es6的class语法糖,跟下面的声明是一样的:
function A() {
this.f = () => {
...
}
}
与之对应的
class A {
f() {
...
}
}
则与下面的声明等价:
function A() {
}
A.prototype.f = function () {
...
}
而在使用Object.assign时
a = Object.assign(b,c,d)
a的实例属性是b、c、d的实例属性合并之后的结果,而a的原型链继承的是b的原型链,c和d原型链上的属性都不会被挂在a上,因此解决上面这个bug的方案也就显而易见,只需写成:
this.state = {
extension: Object.assign(props.extension, defaultExtension)
}
即可。
拓展思考
事实上,ES6语法规范并不允许把类的成员函数写成箭头函数,如果你不对babel做针对性配置,把成员函数写成箭头函数是会报错的,只有在babel中配置transform-class-properties插件后,才可以将类的成员函数写成箭头函数。将类的成员函数写成箭头函数的最常用应用场景是在React组件中,由于部分父组件的成员函数需要在子组件中调用,因此函数中的this如果不绑定到父组件就会出现问题。而箭头函数的this指针自然绑定到了函数声明时的this,不用再显示绑定,因此目前的React开发中,很多人都是直接把成员函数声明成箭头函数,然而由上面的分析我们发现,这样其实会带来比较严重的内存损耗,因为成员函数成了实例属性,每new一个ReactClass实例都会在堆内存中为成员函数开辟部分空间,因此我们应该在日常开发中注意,如果你需要开发高性能应用,应该注意要不要滥用箭头函数。
我来打脸了
后来与同事交流了一下,发现上面的拓展思考其实是不太正确的,由于bind函数并非返回函数的指针,而是返回一个新的函数,因此其实函数 function f( ) { ... } 和函数f.bind(null) 其实是两个不同的对象。
- 方式一:
class RootComponent extends React.Component {
constructor() {
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
...
}
render() {
return <ChildCompoent onClick={this.handleClick}/>
}
}
这种方式下生成的每一个ReactClass实例和原型上都有handleClick函数,是内存效率最低的,同时编码效率也最低
- 方式二:
class RootComponent extends React.Component {
handleClick() {
...
}
render() {
return <ChildCompoent onClick={this.handleClick.bind(this)}/>
}
}
这种方式ReactClass只有原型上有handleClick函数, 但是看一下render函数中返回的ReactElement,每一个生成的ReactElement都将接收一个不同的handleClick函数对象
- 方式三:
class RootComponent extends React.Component {
handleClick = () {
...
}
render() {
return <ChildCompoent onClick={this.handleClick}/>
}
}
这种方式每一个ReactClass实例上都有handleClick函数, 而ReactElement接收的只是该函数的引用。
方式二和方式三两种对比那种更好呢?在React执行过程中,生成的ReactElement的数量应该是大于ReactClass的(当然了,这种对比可能不太严谨),因为ReactClass只是一个组件模板,而ReactElement实时映射真实DOM,因此方式三是优于方式二的,因此如果你“滥用”了箭头函数,其实是“滥用”得正确的。
当然了,最优方式是什么呢——当然是不用考虑混乱的this指向的函数式组件了, React hook大法好!