本页目录
JavaScript进阶特性
参考资料
this指向问题引入
这一部分将引入一些例子来讨论普通函数中的this指向问题。箭头函数的this指向规则与普通函数不同,后面会单独讨论。
对象方法中
在对象的方法中,this通常指向调用该方法的对象。
function hello() {
console.log(this.x);
}
const obj1 = {
x: 1,
f: hello,
};
const obj2 = { x: 2 };
obj2.f = hello;
obj1.f(); // 1
obj2.f(); // 2
函数不作为对象方法调用时
如果函数不是作为对象的方法调用,this的值(在非严格模式下)指向全局对象(浏览器中为window)。
function hello() {
console.log(this);
}
hello(); // Window {...}
function hello() {
console.log(this);
}
const obj = { f: (callback) => callback() };
obj.f(hello); // Window {...}
严格模式下,未作为对象方法调用的函数中的this为undefined。
'use strict';
function hello() {
console.log(this);
}
hello(); // undefined
this丢失
this的指向由调用点决定,即便是对于同一个函数,不同的调用方式(如obj.f()和g = obj.f, g())会导致this指向不同。
当将对象的方法赋值给一个变量或作为回调函数传递时,this可能会丢失其原始指向。
function hello() {
console.log(this);
}
const obj1 = {
x: 1,
f: hello,
};
const g = obj1.f;
g(); // Window {...}
function execute(fn) { fn() };
execute(obj1.f); // Window {...}
你或许还见过这样的代码:
(0, obj1.f)(); // Window {...}这里利用了逗号运算符,尽管表达式(0, obj1.f)的值与obj1.f是对同一个函数的引用,但由于调用点不再是对象方法调用,此时this指向全局对象。这个“解绑定”技巧常用于将对象方法转换为独立函数调用。
apply/bind/call
apply、bind和call是JavaScript中用于改变函数执行时this指向的三种方法。
apply和call非常相似,只是传递参数的方式不同;apply和call都会立刻执行函数,而bind则返回一个新的函数,供稍后调用。
fn.apply(context, [arg1, arg2, ...])
fn.call(context, arg1, arg2, ...)
const f = fn.bind(context, arg1, arg2, ...)
function hello(key1, key2) {
console.log(this[key1], this[key2]);
}
const obj1 = { x: 1, y: 10 };
const obj2 = { x: 2, y: 20 };
const obj3 = { x: 3, y: 30 };
hello("x", "y") // undefined undefined
hello.apply(obj1, ["x", "y"]); // 1 10
hello.call(obj2, "x", "y"); // 2 20
const f = hello.bind(obj3, "x", "y");
f(); // 3 30
apply/call与装饰器
下面的代码定义了一个简单的装饰器函数addGoodbye,它接收一个函数fn作为参数,并返回另一个新函数wrapperFn。新函数的功能是先执行原函数fn,再打印Goodbye!。
function greet(text) {
console.log("Hello, " + text + "!");
}
greet("Alice");
// Hello, Alice!
function addGoodbye(fn) {
function wrapperFn(...args) {
fn(...args);
console.log("Goodbye!");
}
return wrapperFn;
}
greet = addGoodbye(greet);
greet("Bob");
// Hello, Bob!
// Goodbye!
上述代码在对象方法中不能正常工作。下面的例子中,把greet定义为对象方法,参数text由this.text取得。addGoodbye装饰器未做改动,但是装饰后的obj.greet方法在调用时丢失了this指向,导致this.text为undefined。
const obj = {
text: "Carl",
greet: function () {
console.log("Hello, " + this.text + "!");
}
};
obj.greet();
// Hello, Carl!
function addGoodbye(fn) {
function wrapperFn(...args) {
fn(...args);
console.log("Goodbye!");
}
return wrapperFn;
}
obj.greet = addGoodbye(obj.greet);
obj.greet();
// Hello, undefined!
// Goodbye!
想修复此问题,仅需将wrapperFn中fn(...args)改为fn.call(this, ...args):
const obj = {
text: "Carl",
greet: function () {
console.log("Hello, " + this.text + "!");
}
};
obj.greet();
// Hello, Carl!
function addGoodbye(fn) {
function wrapperFn(...args) {
// fn(...args);
fn.call(this, ...args);
console.log("Goodbye!");
}
return wrapperFn;
}
obj.greet = addGoodbye(obj.greet);
obj.greet();
// Hello, Carl!
// Goodbye!
这样做的原理是什么?
最后一行调用obj.greet()时,实际上是在调用wrapperFn函数的逻辑。此时wrapperFn函数中的this指向obj对象。
然而,倒数第二行obj.greet作为参数fn传入addGoodbye,又在wrapperFn调用fn(),这相当于:
const fn = obj.greet;
fn();
这正是前面提到的this丢失的问题,由于调用点不是对象方法调用,非严格模式下this指向全局对象。
在wrapperFn中使用fn.call(this, ...args)时,我们把wrapperFn中的this绑定给了fn,从而修复了this丢失的问题。
bind与偏函数
bind方法可以用来预设函数的部分参数,这种技术称为偏函数(Partial Function)。
function mul(x, y) {
return x * y;
}
const mul2 = mul.bind(null, 2);
console.log(mul2(3)); // 6
new表达式:“函数的构造调用”
new表达式可以创建一个对象:
function f(val) {
this.x = val;
this.text = "hello";
}
const obj = new f(1);
console.log(obj); // {x: 1, text: 'hello'}
当使用new调用一个函数时,函数中的this会指向新创建的对象。
当使用new f(1)时,我们会把f称作“构造函数”。然而JavaScript中的面向对象和真正的OOP语言有所不同,JavaScript中没有真正的“类”的概念(即便是ES6中的class关键字也只是语法糖),此处的“构造函数”f也与普通函数没什么不同。
因此对new f(1)一个更形象的描述是“对普通函数f的构造调用”。
如果直接调用f(1),由于非严格模式下this指向全局对象,此时会在全局对象上创建属性x和text。
当使用new调用的函数有return语句时,如果返回的是一个对象,则该对象会作为new表达式的结果返回;如果返回的是一个原始类型,则忽略该返回值,仍然返回新创建的对象。
function g(val) {
this.x = val;
return { anything: "anything" };
}
console.log(new g(1)); // {anything: 'anything'}
function h(val) {
this.x = val;
return 0;
}
console.log(new h(1)); // {x: 1}
箭头函数不能使用new调用。有关constructor后面还会进一步讨论。
const f = () => {};
const obj = new f(); // TypeError: f is not a constructor
普通函数的this绑定
现在系统地总结一下普通函数的this绑定规则。
确定普通函数的this绑定时,按照优先级从高到低依次考虑以下几种绑定方式:
new绑定:如果函数是通过new调用的,this指向新创建的对象;
显式绑定:apply、bind和call可以显式地改变this指向;
隐式绑定:如果调用点处函数作为对象的方法调用(如obj.func()),那么this指向该对象;
默认绑定:在非严格模式下,未通过上述三种方式绑定的函数调用中,this指向全局对象;在严格模式下为undefined。
箭头函数的this绑定
箭头函数不遵循标准的this绑定规则。当创建箭头函数的语句被执行时,箭头函数会从外层作用域中捕获this,此时其this值就被确定,且无法被apply、bind或call改变。
有些文章会表述为“箭头函数的this值在箭头函数定义时即确定”,此处的“定义”应理解为动态的行为(箭头函数被创建时),而非静态的、源代码层面的函数定义。
另一方面,有些书籍或文档中会提到“箭头函数的this是词法式绑定”的,“词法”是静态的概念,此处的“绑定”应理解为this指向的“来源”可以静态确定(即来源于外层作用域),而非this的值确定。
下面的例子对理解箭头函数的this绑定很有帮助:
function getFn() {
// console.log(this);
const fn = () => { console.log(this) };
return fn;
}
const fn1 = getFn();
fn1(); // Window {...}
const fn2 = getFn.call({ x: 2 });
const fn3 = getFn.call({ y: 3 });
fn2(); // {x: 2}
fn3(); // {y: 3}
注意是当getFn被调用后,创建箭头函数的语句才会执行,此时箭头函数从外层作用域中捕获this(可以理解为,如果在此处打印this,会得到的值)。由于三次对getFn的调用中,getFn中的this分别指向不同的对象,因此创建的箭头函数的this也分别指向不同的对象。
箭头函数的this一旦捕获后就永久固定下来,不会再被apply、bind或call改变。
fn3.call({ anything: "anything" }); // {y: 3}
下面的例子直观对比了箭头函数和普通函数的this绑定行为的差异:
const factory = {
text: "factory",
getNormalFn: function () {
return function () { console.log(this) };
},
getArrowFn: function () {
return () => { console.log(this) };
}
}
const normalFn = factory.getNormalFn();
const arrowFn = factory.getArrowFn();
const obj1 = {
text: "obj1",
normalFn: normalFn,
arrowFn: arrowFn,
};
const obj2 = {
text: "obj2",
normalFn: normalFn,
arrowFn: arrowFn,
};
normalFn(); // Window {...}
obj1.normalFn(); // {text: 'obj1', ...}
obj2.normalFn(); // {text: 'obj2', ...}
arrowFn(); // {text: 'factory', ...}
obj1.arrowFn(); // {text: 'factory', ...}
obj2.arrowFn(); // {text: 'factory', ...}
例子中factory对象提供了两个函数getNormalFn和getArrowFn,这两个函数分别返回一个普通函数和一个箭头函数。创建出的两个函数实例为normalFn和arrowFn,我们给obj1和obj2也都分别添加上这两个函数实例作为对象方法。为了便于演示我们给涉及到的三个对象factory、obj1和obj2都添加了一个text属性。
普通函数的this指向由调用方式确定。尽管normalFn、obj1.normalFn和obj2.normalFn引用的都是同一个函数,但由于他们的调用方式不同,每次调用时的this指向也不同。
箭头函数的this指向在被定义(被创建)时确定。arrowFn以factory.getArrowFn()的方式被创建,此时getArrowFn中的this指向factory,因此arrowFn的this也被永久绑定为factory。
原型
继承与原型链
JavaScript基于对象实现继承。每一个对象都有一个原型对象,原型对象要么是另一个对象,要么是null。
原型对象也是普通对象,因此也会有自己的原型对象、自己的原型对象的原型对象...,直到到达原型链的顶端null。这种通过原型对象形成的链式结构称为原型链。当访问一个对象的属性时,如果该对象本身没有这个属性,JavaScript引擎会沿着原型链向上查找原型对象上的属性。
下面的例子手动创造了一条原型链:objx -> objy -> objz。
const objx = { x: 1 };
const objy = { y: 2 };
const objz = { z: 3 };
console.log(objx.z); // undefined
objx.__proto__ = objy;
objy.__proto__ = objz;
console.log(objx.z); // 3
只有在读取属性时,才会沿着原型链查找;在写入属性时,直接在当前对象上创建或更新该属性。下面的例子中,给objx赋值z属性,相当于在objx上创建了一个新的属性z,而不会修改原型链上objz的z属性。这也被称为属性遮蔽。
objx.z = 100;
console.log(objx.z); // 100
console.log(objy.z); // 3
console.log(objz.z); // 3
prototype与constructor
默认情况下,函数对象有一个prototype属性,fn.prototype是一个普通对象,这个对象又有一个constructor属性,指向函数自身。
function fn() {};
console.log(fn.prototype.constructor === fn); // true
console.log(fn.prototype.constructor.prototype.constructor === fn); // true
当使用new调用函数时,会设置新建对象的原型对象为该函数的prototype属性。
function fn() {};
const obj = new fn();
console.log(obj.__proto__ === fn.prototype); // true
当有时只能得到一个对象实例,却不知道具体的构造函数时,由于obj.__proto__指向fn.prototype,而fn.prototype.constructor指向fn,因此可以通过下面的方式找到一个对象的构造函数:
function fn() {};
const obj = new fn();
console.log(obj.__proto__.constructor === fn); // true
console.log(obj.constructor === fn); // true
中间的__proto__可以省略。当访问obj.constructor时,由于obj本身没有constructor属性,自然会沿着原型链查找到obj的原型对象,而obj的原型对象已经被设置为fn.prototype,因此最终会得到fn.prototype.constructor,也就是fn。
注意并不是所有函数都有prototype属性,例如箭头函数就没有prototype属性。
function fn1() {};
console.log(fn1.prototype); // {...}
const fn2 = () => {};
console.log(fn2.prototype); // undefined
内建的原型
JavaScript内建了一些构造函数,如Object、Array、Number、Function等,这些构造函数都有自己的prototype属性,定义了该类型实例对象所共有的方法和属性。当写出const arr = [];这样的代码时,内部会使用new Array()来创建一个数组对象,并将该对象的原型对象设置为Array.prototype。Array.prototype上已经定义好了数组实例所共有的方法,如push、pop等。因此,当我们调用arr.pop()时,JavaScript引擎会沿着原型链找到Array.prototype.pop方法并执行它。
const arr = [];
console.log(arr.__proto__ === Array.prototype); // true
console.log(arr.pop === Array.prototype.pop); // true
创建一个新对象时,该对象的原型对象会指向Object.prototype(Object是构造函数)。
const obj = {};
console.log(obj.__proto__ === Object.prototype); // true
前面提到Array.prototype、Number.prototype等本质上也是普通对象,因此它们也有原型对象。它们的原型对象指向Object.prototype。这也是继承在JavaScript中的一种体现。
console.log(Array.prototype.__proto__ === Object.prototype); // true
console.log(Number.prototype.__proto__ === Object.prototype); // true
console.log(Function.prototype.__proto__ === Object.prototype); // true
Object.prototype的原型对象是null,它位于原型链的顶端。
console.log(Object.prototype.__proto__); // null
想一下,Object.__proto__是什么?
这里我们实际上是在查询Object的原型对象。Object是一个函数,因此它的原型对象是Function.prototype。
console.log(Object.__proto__ === Function.prototype); // true深入理解__proto__与[[Prototype]]
内部属性 (Internal Slot) 和内部方法 (Internal Method)
在调试工具或一些文档中,可能会看到被双中括号包裹的名称,这些是JavaScript引擎内部使用的属性或方法,无法在JavaScript代码中直接访问。但是对于有些内部属性或方法,JavaScript规范会提供一些接口来读写它们。
这一节讨论的[[Prototype]]就是一个内部属性,它表示对象的原型对象。
在前面的表述中,我刻意回避了“对象的__proto__属性”这种说法,因为这会引起歧义,听起来好像每一个对象都天生带有一个__proto__属性。
事实上,__proto__可以看作是内部属性[[Prototype]]的getter/setter,它定义在Object.prototype上。我们几乎可以在任意对象上访问__proto__,本质上是沿着原型链找到了Object.prototype.__proto__。

为什么是“几乎”?我们可以通过Object.create(null);直接创建一个原型对象为null的对象,它比const obj = {};创建的空对象还要“纯净”。注意下面两个对象的区别:

由于pure的原型对象是null,没有继承Object.prototype,也就无法通过__proto__访问原型对象[[Prototype]]。同理,它也无法使用定义在Object.prototype上的方法,如toString。
杂项
创建立即执行函数
也叫立即调用函数表达式(Immediately-Invoked Function Expressions, IIFE)。
(function () { console.log("hello") })();
(function () { console.log("hello") }());
+ function () { console.log("hello") }();
- function () { console.log("hello") }();
! function () { console.log("hello") }();
(() => { console.log("hello") })();
var
使用var声明的变量具有函数作用域(或全局作用域),它们可以“穿透”块级作用域。var可以重复声明同一变量。
if (1) {
var x = 0;
var x = 1;
let y = 0;
// let y = 1; // SyntaxError: Identifier 'y' has already been declared
}
console.log(x); // 1
// console.log(y); // ReferenceError: y is not defined
var声明的变量提升,即变量声明会被提升到其作用域的顶部,因此在声明之前访问该变量不会导致错误,而是返回undefined。
console.log(x); // undefined
if (0) {
var x = 1;
}
Symbol
JavaScript中对象的键要么是字符串,要么是一个Symbol。Symbol表示一个独一无二的标识符,这在需要确保对象属性不会被意外覆盖时非常有用。例如,在大型代码库或使用第三方库时,使用Symbol作为键可以避免命名冲突的问题。
const id = Symbol("id");
const x1 = Symbol("x");
const x2 = Symbol("x");
const obj = {
[id]: 0,
[x1]: 1,
[x2]: 2,
};
console.log(obj[id]); // 0
console.log(obj[x1]); // 1
console.log(obj[x2]); // 2
Symbol可以接收一个字符串参数作为描述,但不会影响其唯一性。即使两个Symbol的描述相同,它们仍然是不同的实体。
Symbol.for和Symbol.keyFor方法用于创建和访问全局注册的Symbol。
console.log(Symbol("x") === Symbol("x")); // false
console.log(Symbol.for("x") === Symbol.for("x")); // true
使用Symbol作为对象的键时,这些属性不会出现在for...in循环、Object.keys、JSON.stringify中。可以通过Object.getOwnPropertySymbols和Reflect.ownKeys方法访问这些Symbol属性。
遍历对象属性的方法
这一节系统地测试一下各种类型的对象属性以及遍历对象属性的方法。测试的属性包括:
prop_normal:普通属性
prop_method:方法属性
prop_symbol:Symbol属性
prop_accessor:访问器属性
prop_non_enumerable:不可枚举属性
prop_proto:原型对象上的属性
测试的遍历对象属性的方式包括:
for...in循环
JSON.stringify(这个严格来说不是遍历属性的方法,但可以反映出哪些属性会被序列化)
Object.keys
Object.getOwnPropertyNames
Object.getOwnPropertySymbols
Reflect.ownKeys
测试结果见注释。
const obj = {
prop_normal: 1,
prop_method: function () {},
[Symbol("prop_symbol")]: 1,
get prop_accessor() { return 1; },
};
Object.defineProperty(obj, 'prop_non_enumerable', {
value: 1,
enumerable: false,
});
obj.__proto__ = { prop_proto: 1 };
for (let key in obj) console.log(key); // prop_normal, prop_method, prop_accessor, prop_proto
console.log(JSON.stringify(obj)); // {"prop_normal":1,"prop_accessor":1}
console.log(Object.keys(obj)); // ['prop_normal', 'prop_method', 'prop_accessor']
console.log(Object.getOwnPropertyNames(obj)); // ['prop_normal', 'prop_method', 'prop_accessor', 'prop_non_enumerable']
console.log(Object.getOwnPropertySymbols(obj)); // [Symbol(prop_symbol)]
console.log(Reflect.ownKeys(obj)); // ['prop_normal', 'prop_method', 'prop_accessor', 'prop_non_enumerable', Symbol(prop_symbol)]