JS 原型链、this 与 class
原型链
目的
实现属性、方法共享
方法
为(构造)函数的 prototype 属性增加字段。
用 new 关键字 + 构造函数,实现创建一个对象。这个语句就会返回一个“含有”构造函数 prototype 里属性和方法的新对象。
1 | function Dog(name) { |
这样Alaska
这个对象就可以调用getName
方法了,它由Dog
函数共享。
1 | Alaska.getName(); // las |
new 操作中究竟发生了什么?
1 | const Alaska = new Dog("las"); |
原型链
执行Alaska.getName()
会首先在Alaska
本身查找getName
属性,找不到,则寻找原型。
想要获得其原型,可以通过Object.getPrototypeOf
方法,或者更常见但已经被弃用的直接访问__proto__
属性。
1 | Object.getPrototypeOf(Alaska); |
它们指向同一个对象,也就是Dog.prototype
。其中包含了getName
属性,调用它。
这就是「链」的概念。自身找不到的属性/方法,就去原型上找;原型上找不到,就再在原型的原型上找。
原型本身就是对象,原型链的顶端指向Object.prototype
,其中有hasOwnProperty
、toString
等方法。而Object.prototype
的原型指向 null。
1 | Alaska.__proto__; // {getName: ƒ, constructor: ƒ} |
创建对象的方法
用语法结构创建
1
2
3
4
5
6
7
8
9
10let o = { a: 1 };
// o 继承了 Object.prototype 里的所有属性
let a = ["N", "M", "$", "L"];
// a 继承于 Array.prototype,比如 indexOf、map
function f() {}
// 函数也是一种对象,f 继承于 Function.prototype(call、bind)
let r = /^[a-z|A-Z]&/;用构造函数创建
用
new
操作符作用的函数被称为构造函数。1
2
3
4
5
6
7
8
9
10
11
12
13function Graph() {
this.x = 0;
}
Graph.prototype.move = function () {
this.x += 1;
};
let g = new Graph();
// g.__proto__ === Graph.prototype,g 的原型指向 Graph 的 prototype 属性
// g.__proto__.__proto__ === Object.prototype
// Graph.__proto__ === Function.prototype
// Graph.__proto__.__proto__ === Object.prototype其中,构造函数不带括号
new Foo
和new Foo()
的写法一样,相当于没有指定参数的情况下调用。用 Object.create 创建
ES5 引入的方法,返回值是一个对象,原型为第一个参数。
1
2
3
4
5
6
7
8
9let a = { c: 1, d: 2 };
let b = Object.create(a);
// b.__proto__ === a
let c = Object.create(b);
// c.__proto__ === b几乎所有的 JS 对象都是原型链顶端
Object
的实例(共享它的属性和方法),但也有例外:1
2
3
4
5
6let n = Object.create(null);
// c.__proto__ === null
n.hasOwnProperty();
// error: n.hasOwnProperty is not a function
// n 原型链上没有 hasOwnProperty,而 Object.prototype 中有。用 class 关键字创建
ES6 新特性,见后文。
Function 与 Object 的关系
由于 Object
可以被 new
作用,所以是构造「函数」
1 | Object.__proto__ === Function.prototype // true |
Function
是一个「对象」,也是一个「函数」。作为对象,有自己的原型对象 __proto__
,作为函数,它也会通过原型链从 Function.prototype 继承一些属性和方法
1 | Function.__proto__ === Function.prototype // true |
总结一下
构造函数将想共享的属性和方法写入原型对象,其生成的实例就可以调用他们。
构造函数定义 prototype,而实例则访问__proto__
或者Object.getPrototypeOf
,查找原型链上的属性和方法。
在原型链中查找属性对性能有副作用;有两个方法不会遍历原型链:Object.prototype.hasOwnProperty()
和Object.keys
this
this
是一个大坑,在各个语境下的值都可能不同,在浏览器环境或者 Node 运行时中也不一样,因此会分情况讨论。
总的来说this
大都出现在函数内部,
通常,在普通函数中指向被调用时的对象,在箭头函数中取决于被定义时的上下文
全局环境
this 指向全局对象globalThis
,在浏览器环境中它就是window
,它实现了 setTimeout 等函数。
函数内部
函数内部的 this 值取决于函数被调用的方式。
构造函数中的 this
构造函数通常是不写返回值的。这样在函数内 this 定义的属性/方法,就会被绑定到新构造出的对象上,this 自身指向这个对象。
1 | let F = function () { |
构造函数如果有了返回值,那对 this 设置的属性/方法都没意义了。
对象内方法中的 this
形如a.b()
,this 指向这个方法的对象。
1 | let a = { |
非箭头函数的简单调用
严格模式下保持进入执行环境(execution context)时的值,如果执行环境未定义,则为 undefined
1 | ; |
非严格模式下指向调用函数的对象
1 | // "use strict"; |
可以通过 call、apply 来传递不同环境下的 this 值。常用于调用对象内部方法之外的情况
1
2
3
4
5
6
7
8
9function logA() {
console.log(this.a); // this 的值取决于函数的调用方式(非严格模式)
}
let x = { a: 1 };
let y = { a: 2 };
logA.call(x);
logA.call(y);call、apply 的区别在于第一个参数后边的写法,是传一个参数数组,还是一个一个把参数写明。
也可以用 bind 创建一个 this 被永久绑定的函数。
这一点在 React 的 class component 里非常常见1
2
3
4
5
6
7
8
9
10
11
12function logA() {
console.log(this.a);
}
let binded1 = logA.bind({ a: 2 });
binded1(); // 2
let binded2 = logA.bind({ a: 3 });
binded2(); // 3
let binded3 = binded2.bind({ a: 4 });
binded3(); // 3 ,bind 只会生效一次,this 已经被永久绑定到 { a: 3 } 了
箭头函数和其中的 this
箭头函数不会创建 this,而是从作用域链上层继承 this。箭头函数和普通的声明function
的函数区别之一是,箭头函数的 this 在其被定义时就确定了。
1 | let that = this; |
logB
是一个箭头函数,由于对象字面量内部不会开辟新的作用域,所以logB
所在作用域就是全局作用域,箭头函数里的 this 指向全局对象。logB2
是一个普通函数,在调用时才会决定 this 值,所以执行a.logB2()
时,logB2()
里的 this 指向 a
可见,在对象字面量里用箭头函数定义方法,可能会有预期之外的错误。如果是在构造函数里用箭头函数定义方法,则不会有这个问题:
1 | function F(name) { |
构造函数还是函数,会开辟自己的作用域,在里边 this 指向被新构造的对象,因此用箭头函数内的 this 也会指向这个新对象。
箭头函数不可以作为构造函数
和new
一起用会报错
箭头函数没有 prototype 属性
1 | let F = () => {}; |
1 | let F = () => {}; |
箭头函数中不能用 arguments
arguments
是用于非箭头函数的局部变量,用来引用函数的实参(实际参数,和形式参数对应)
DOM 事件处理函数、HTML 内联事件处理函数
非箭头函数下 this 都指向 DOM 元素本身。
1 | let root = document.getElementById("root"); |
1 | <div onclick="console.log(this)">点击</div> |
箭头函数下为 window
1 | let root = document.getElementById("root"); |
面试题解析
1 | let obj = { |
用字面量创建一个对象,其中 bar 方法是用 function 关键字定义的普通函数,所以 bar 内部的 this 会在其被调用时决定。
fn 是 obj.bar() 的返回值,也就是说调用 bar 的是 obj,this 指向 obj
而 bar() 本身返回一个箭头函数,箭头函数内的 this 是继承自外部作用域的,所以指向 bar 内部的 this,根据前边的分析,指向 obj
fn2 则是单纯的做了一个引用赋值操作,将 fn2 指向一个函数对象所在的内存。上边的写法和直接定义 fn2 为函数没区别。
1 | // 另一种写法 |
需要注意的是,执行 fn2()() 已经不是对象内调用方法了(与 obj 无关),是全局环境的直接调用,所以 fn2()() 指向全局对象。
Class
类是特殊的函数。类的声明class A {}
或表达式const A = class {}
不会变量提升。类内部的代码都是在严格模式下运行的
constructor
类的构造函数,在 new
关键字创建实例时执行。如果没有显式指定构造函数,则会添加一个默认构造函数
1 | // 基类 |
属性、方法和 static
声明
可以在类内部直接声明属性或方法
1 | class A { |
不过这种在类内部声明属性的方式,在当前(2022年4月)浏览器支持有限,需要 Babel 等构建一下
static
可以用 static
声明静态方法和属性。
静态方法不能在类实例上调用静态方法,只能用类名调用
静态属性同理,不能在类实例中访问,只能用类名访问
静态属性/方法是与实例无关的
1 | class A { |
静态属性似乎是比较新的特性,暂时没查到具体的兼容性,在新版的 Chrome 和 Safari 上已经可用了
子类的静态方法
子类可以在自己的静态方法里调用父类的静态方法
1 | class A { |
extends
用于扩展子类。子类的原型会是父类的 prototype
1 | class View { |
Button extends View
创建了两个原型引用
1 | Button.__proto__ === View // 子类的原型等于父类(而不是父类的 prototype) |
super
super 用于在子类访问父类的方法
构造函数中
在子类的构造函数里 super()
可以调用父类的构造函数。
注意一定要在访问 this
之前调用 super
1 | class A { |
复写方法
在子类的普通方法中,super[key]
super.key
可以调用父类上的方法。
即使不使用 super
,子类通过 this
也同样可以访问到父类的方法(和属性)。
区别在于,super.good
直接调用父类的函数实现,而 this.good
会在函数作用域链上寻找 good
最近的实现,两者的函数实现可能不同。
因此 super
常用在复写父类的同名方法上
1 | class A { |
constructor
本身也是一个复写的例子,想要调用父类的构造函数,不能直接 this.constructor
,而是借助 super
class与普通函数的区别
class 必须使用 new 操作符
class 创建对象时需要用 new
,而普通函数可以直接调用,浏览器环境内会以 window
作为 this
1 | function A() { |
class 声明不会提升
function 的声明是会提升的,所以可以先调用函数,再声明函数。class 则不会提升
1 | A() // window.x === 1 |
class 不能用 call、apply 改变 this
1 | const a = {} |