Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ES6 Class Methods 定义方式的差异 #67

Open
dwqs opened this issue May 29, 2018 · 15 comments
Open

ES6 Class Methods 定义方式的差异 #67

dwqs opened this issue May 29, 2018 · 15 comments

Comments

@dwqs
Copy link
Owner

dwqs commented May 29, 2018

引言

在 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)"。

<正文完>

相关资料

@towersxu
Copy link

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

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

@dwqs
Copy link
Owner Author

dwqs commented May 31, 2018

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

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

@dwqs
Copy link
Owner Author

dwqs commented May 31, 2018

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

@lz-lee
Copy link

lz-lee commented Jun 25, 2018

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

@dwqs
Copy link
Owner Author

dwqs commented Jun 25, 2018

@lz-lee 已纠正

@Moonburni
Copy link

Moonburni commented Jul 7, 2018

我觉得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;

@okboy5555
Copy link

厉害

@ChaosGT
Copy link

ChaosGT commented Aug 16, 2020

'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不会丢失???

@beiatisi
Copy link

箭头函数作用域问题

@ChaosGT
Copy link

ChaosGT commented Aug 25, 2020

'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

@magicds
Copy link

magicds commented Aug 26, 2020

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

@ChaosGT
Copy link

ChaosGT commented Aug 26, 2020

@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

@phyzess
Copy link

phyzess commented Sep 4, 2020

@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 是这样的实现。其实还是作用域、执行上下文、闭包的问题。

@ChaosGT
Copy link

ChaosGT commented Sep 8, 2020

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

@JSjump
Copy link

JSjump commented Mar 13, 2021

@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 是这样的实现。其实还是作用域、执行上下文、闭包的问题。

同意。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

10 participants