dwqs/blog

ES6 Class Methods 定义方式的差异

dwqs opened this issue · 15 comments

dwqs commented

引言

在 JavaScript 中有两条不成文的说法:

  • 一切皆对象
  • 函数是一等公民

因而函数不仅是一等公民,也是具有属性的特殊对象。这一点,也可以从原型链上得到佐证:

function t () {}

t.__proto__ === Function.prototype  // true
t.__proto__.__proto__ === Object.prototype // true

函数是继承自 Object 的,因而函数也具备 toStirngvalueOf 等方法。因为函数是对象,所以在 ES6 之前,JavaScript 中的 OOP 编程则纯粹是基于函数的,直到 ES6 提供了 classsuper 以及 extends 等关键字,不仅精简了语法,也使得 OOP 的编程形式逐渐趋近于 Java/C++ 等语言。

class 的背后

ES6 虽然提供了 class 等关键字,但只是语法糖,JavaScript 的 OOP 编程仍然是基于函数的,继承则是基于原型的。

看一个示例:

class A {
	print () {
    	console.log('print a');
    }
}

上述代码经过 babel 转换之后:

var A = function () {
   function A() {
      _classCallCheck(this, A);
   }

   _createClass(A, [{
      key: 'print',
      value: function print() {
         console.log('print a');
      }
   }]);

   return A;
}();

可以看到,转换后的 class A 就是一个函数,所以理论上就可以把 A 当作函数调用,但 _classCallCheck 的作用就是禁止将类作为普通函数调用:

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

A() // throw a error
const a = new A(); // work well

然后看下 _createClass 都做了什么:

var _createClass = function () { 
  function defineProperties(target, props) { 
    for (var i = 0; i < props.length; i++) { 
      var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; 
      descriptor.configurable = true; 
      if ("value" in descriptor) descriptor.writable = true; 
      Object.defineProperty(target, descriptor.key, descriptor); 
    } 
  } 
  return function (Constructor, protoProps, staticProps) { 
    if (protoProps) defineProperties(Constructor.prototype, protoProps); 
    if (staticProps) defineProperties(Constructor, staticProps); return Constructor; 
  }; 
}();

通过上述代码可知,_createClass 的功能主要是通过 Object.defineProperty 定义了类的普通属性和静态属性。需要注意的是普通属性是定义在了类的原型对象上,静态属性是定义在了类本身上

所以,类 A 的定义就等同于如下代码:

function A () {}

A.prototype.print = function () {
    console.log('print a');
}

两种定义 Methods 的方式

ES6 中有两种常见的定义 Methods 的方式:

// 方式一
class A {
    print () {
    	console.log('print a');
    }
}

// 方式二
class B {
    print = () => {
    	console.log('print b');
    }
}

const a = new A() 
a.print();  // print a

const b = new B() 
b.print();  // print b

咋一看,二者没什么区别。方式一是常规方式,方式二是通过箭头函数来定义方法,如果你写过 React 应用,应该接触过这种方式。

区别1:this 的绑定

在箭头函数出现之前,每个新定义的函数都有它自己的 this 值,但箭头函数不会创建自己的 this,它从会从自己的作用域链的上一层继承 this。举个粟子:

import React, { Component } from 'react';
class Test extends Component {
    testClick () {
	console.log('testClick', this);
    }
	
    render () {
	return <div onClick={this.testClick}>Test</div>
    }
}

当点击 div 元素时,会触发 testClick,该方法会输出当前的 this,而(严格模式下)此时输出的 this 值是 undefined,显然这不是我们要的结果。怎么修改呢?这里至少有三种修改方式,其中之一就是通过箭头函数来定义方式。

区别2:继承

先看方式一的继承:

class A {
    print () {
    	console.log('print a');
    }
}

class C extends A {
    print () {
	super.print();
	console.log('print c');
    }
}

const c = new C();
c.print();
// print a
// print c

对于上述结果的输出应该没有什么疑问,这是符合我们预期的。然后看下另一段代码:

class B {
    print = () => {
    	console.log('print b');
    }
}

class D extends B {
    print () {
	  super.print();
          console.log('print d');
    }
}

const d = new D();
d.print();
// ???

上述的输出会是什么呢?按照常规思路,应该是先输出 print b,再输出 print d,但其实不是的。

上文有提到,类的继承依然是基于原型的。上文也分析过 babel 转换过的代码,常规的写法中,类的非静态属性都是定义在类的原型对象上,而不是类的实例上的。但箭头函数不一样,通过箭头函数定义的方法时绑定在 this 上,而 this 是指向当前创建的类实例对象,而不是类的原型对象。可以查看类 B 转换后的代码:

var B = function B() {
   _classCallCheck(this, B);

   this.print = function () {
      console.log('print b');
   };
};

可以看到,print 方法是定义在 this 上的,而不是定义在 B.prototype 上。

D 继承 B,不仅会继承类 B 原型上的属性和方法,也会继承其实例上的属性和方法。那么,此时类 D 等效的伪代码如下:

function D () {
    // 继承自 B
    this.print = function () {
	console.log('print b');
    }
}

// 通过原型实现继承
D.__proto__ = B;
D.prototype.__proto__ === B.prototype;

D.prototype.print = function () {
    // 类 D 自身定义的 print 方法
}
const d = new D();
d.print();

综上,当 d.print() 执行时,只会输出 print b,而不会输出 print d

因为当访问一个对象实例的属性时,会先在实例上进行查找,如果没有,则顺着原型链往上查找,直到原型链的顶端。若在实例上查找到对应属性,则会返回,停止查找。即使原型上定义了同一个属性,该属性也不会被访问到,这种情况称为"属性遮蔽 (property shadowing)"。

<正文完>

相关资料

// 方式二
class B {
    print = () => {
    	console.log('print b');
    }
}

有个小细节,上面这种写法ES6里面应该没有吧,应该只是静态属性的提案中的写法,在使用babel时,如果plugins中只有transform-es2015-classes没有transform-class-properties的话,会报错的。

dwqs commented

@towersxu es6 官方提案目前没有 但在实际应用中 这种写法很常见了 所以一般都会配置对应的 babel plugin

方式二是通过箭头函数来定义方法,如果你写过 React 应用,应该接触过这种方式。

dwqs commented

提案 已经处于 s3 箭头函数的写法就类似给类定义了 public method fields 是需要配置 transform-class-properties

“转换后的 class A 就是一个函数,所以理论上就可以把 A 当作函数调用,但 _classCallCheck 的作用就是禁止将类作为函数调用”。这里应该改成禁止将类当作普通函数调用更合理。毕竟是当作构造函数调用呀。

dwqs commented

@lz-lee 已纠正

我觉得class不仅仅是个语法糖,应该还是加了一些东西的。

class A extends Array {};
var a = new A;
var B = function () {};
B.prototype = Object.create(Array.prototype);
var b = new B;
var c = new Array;

console.log(Array.isArray(a)) //true;
console.log(Array.isArray(b)) //false;
console.log(Array.isArray(c))  //true;

厉害

'use strict';

class Animal {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }

    sayName = function(){console.log(this.name);};

    sayAge = ()=>{console.log(this.age);}
}

let tom = new Animal('tom',19);
let [sayName, sayAge] = [tom.sayName, tom.sayAge];
sayAge();   // 正常输出19
sayName();  // this为undefined

我明白字段函数是属于对象本身而不是原型的,但我不明白为什么箭头函数能绑定this值????
在上面的例子中sayName会丢失this值我可以理解,但为什么sayAage不会丢失???

箭头函数作用域问题

'use strict';

let tom =  {
    name: 'tom',
    age: 19,
    sayName: function(){console.log(this.name);},
    sayAge: ()=>{console.log(this.age);},
};

let [sayName, sayAge] = [tom.sayName, tom.sayAge];
sayAge();   // this指向外面的空对象{},输出undefined
sayName();  // 抛出错误,this为undefined

那为什么这里面的sayAge就无法绑定对象tom

@ChaosGT 箭头函数的this是在定义时就决定的,你的代码中 sayAge 处时 this 执行全局,strict 下就是 undefined
你再上面是 class 语法,那里面的定义时, this 为 Animal 的实例。

@beiatisi 这跟作用域完全没关系。
@cdswyda 我觉得你的回答完全不对。箭头函数本身根本就没有this,需要在上下文中查找,表现出来就是对this透明;而普通函数是有this的,默认为undefined,通过对象点号调用时赋值this为点号前的对象。可能你连我想要问什么都没看明白。

我仔细想了一下,自己尝试回答一下自己的问题:
之所以字段中的箭头函数能绑定对象的this,应该是和javascript引擎的class语法实现有关。

class Animal extends BaseClass {
    constructor() {
        //构造函数的执行过程大概如下:
        let animal = {};
        animal.__proto__ = Animal.prototype;
        super.constructor.call(animal);
        初始化字段(字段属于animal,而不是Animal.prototype)
        用户自定义初始化语句
        return animal;
    }
}

引擎在初始化字段过程中,对箭头函数,会用animal进行替换,导致绑定this。使用函数重写Animal如下:

function Animal(){
    let animal = {};
    animal.__proto__ = Animal.prototype;
    //如果Animal是继承来的,还需要调用原型链上的构造函数
   //Animal.prototype.__proto__.constructor.call(animal);
    animal.name = 'animal';
    animal.age = 17;
    animal.sayName = ()=>console.log(animal.name);   //因为是字段,可以确定属于animal,因此可以直接绑定
    return animal;
}

Animal.prototype.sayAge = function(){
    console.log(this.age);  // 属于原型链上的方法,不能直接确定到某个对象
}

Animal.prototype.__proto__ = BaseClass.prototype; //继承

let tom = new Animal();
let [sayName, sayAge] = [tom.sayName, tom.sayAge];
sayName();  //输出animal
sayAge();  //出错,this为undefined

@ChaosGT 用 babel 把上面你写的这段代码编译到 ES5 看一下就能明白了
省略掉无关编译结果,下面这段是你疑惑的解答应该:

var Animal = function Animal(name, age) {
  var _this = this;

  _classCallCheck(this, Animal);

  _defineProperty(this, "sayName", function () {
    console.log(this.name);
  });

  _defineProperty(this, "sayAge", function () {
    console.log(_this.age);
  });

  this.name = name;
  this.age = age;
};

注意两个_defineProperty 的回调函数内对于 this 的使用方式。一个是直接用了 this,一个用了构造实例的闭包,所以一个 this 会丢失(成为undefined),而另一个不会。
结合「箭头函数不具有自己的 this,它的 this 永远是定义时包裹它的代码块的 this」,就可以理解为什么 ES6 编译到 ES5 是这样的实现。其实还是作用域、执行上下文、闭包的问题。

@phyzess 感谢你给出的babel结果,这样就很清楚了,说到底还是解释器硬绑定,和语法本身关系不大,虽然很方便,但个人觉得这破坏了语言的统一性。
「箭头函数不具有自己的 this,它的 this 永远是定义时包裹它的代码块的 this」这句话的后半句是不对的,箭头函数只有作为class的field时候,才会绑定当前对象,是解释器的trick,其它时候都是从执行的上下文中获取,而不是定义时的代码块。

@ChaosGT 用 babel 把上面你写的这段代码编译到 ES5 看一下就能明白了
省略掉无关编译结果,下面这段是你疑惑的解答应该:

var Animal = function Animal(name, age) {
  var _this = this;

  _classCallCheck(this, Animal);

  _defineProperty(this, "sayName", function () {
    console.log(this.name);
  });

  _defineProperty(this, "sayAge", function () {
    console.log(_this.age);
  });

  this.name = name;
  this.age = age;
};

注意两个_defineProperty 的回调函数内对于 this 的使用方式。一个是直接用了 this,一个用了构造实例的闭包,所以一个 this 会丢失(成为undefined),而另一个不会。
结合「箭头函数不具有自己的 this,它的 this 永远是定义时包裹它的代码块的 this」,就可以理解为什么 ES6 编译到 ES5 是这样的实现。其实还是作用域、执行上下文、闭包的问题。

同意。