本页目录

JavaScript进阶特性

参考资料

this指向问题引入

这一部分将引入一些例子来讨论普通函数中的this指向问题。箭头函数的this指向规则与普通函数不同,后面会单独讨论。

对象方法中

在对象的方法中,this通常指向调用该方法的对象。

JavaScript
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)。

JavaScript
function hello() {
    console.log(this);
}
hello(); // Window {...}
JavaScript
function hello() {
    console.log(this);
}
const obj = { f: (callback) => callback() };
obj.f(hello); // Window {...}

严格模式下,未作为对象方法调用的函数中的thisundefined

JavaScript
'use strict';
function hello() {
    console.log(this);
}
hello(); // undefined

this丢失

this的指向由调用点决定,即便是对于同一个函数,不同的调用方式(如obj.f()g = obj.f, g())会导致this指向不同。

当将对象的方法赋值给一个变量或作为回调函数传递时,this可能会丢失其原始指向。

JavaScript
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 {...}

你或许还见过这样的代码:

JavaScript
(0, obj1.f)(); // Window {...}

这里利用了逗号运算符,尽管表达式(0, obj1.f)的值与obj1.f是对同一个函数的引用,但由于调用点不再是对象方法调用,此时this指向全局对象。这个“解绑定”技巧常用于将对象方法转换为独立函数调用。

apply/bind/call

applybindcall是JavaScript中用于改变函数执行时this指向的三种方法。

applycall非常相似,只是传递参数的方式不同;applycall都会立刻执行函数,而bind则返回一个新的函数,供稍后调用。

JavaScript
fn.apply(context, [arg1, arg2, ...])
fn.call(context, arg1, arg2, ...)
const f = fn.bind(context, arg1, arg2, ...)
JavaScript
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!

JavaScript
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定义为对象方法,参数textthis.text取得。addGoodbye装饰器未做改动,但是装饰后的obj.greet方法在调用时丢失了this指向,导致this.textundefined

JavaScript
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!

想修复此问题,仅需将wrapperFnfn(...args)改为fn.call(this, ...args)

JavaScript
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(),这相当于:

JavaScript
const fn = obj.greet;
fn();

这正是前面提到的this丢失的问题,由于调用点不是对象方法调用,非严格模式下this指向全局对象。

wrapperFn中使用fn.call(this, ...args)时,我们把wrapperFn中的this绑定给了fn,从而修复了this丢失的问题。

bind与偏函数

bind方法可以用来预设函数的部分参数,这种技术称为偏函数(Partial Function)

JavaScript
function mul(x, y) {
    return x * y;
}

const mul2 = mul.bind(null, 2);
console.log(mul2(3)); // 6

new表达式:“函数的构造调用”

new表达式可以创建一个对象:

JavaScript
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指向全局对象,此时会在全局对象上创建属性xtext

当使用new调用的函数有return语句时,如果返回的是一个对象,则该对象会作为new表达式的结果返回;如果返回的是一个原始类型,则忽略该返回值,仍然返回新创建的对象。

JavaScript
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后面还会进一步讨论。

JavaScript
const f = () => {};
const obj = new f(); // TypeError: f is not a constructor

普通函数的this绑定

现在系统地总结一下普通函数的this绑定规则。

确定普通函数的this绑定时,按照优先级从高到低依次考虑以下几种绑定方式:

1.

new绑定:如果函数是通过new调用的,this指向新创建的对象;

2.

显式绑定applybindcall可以显式地改变this指向;

3.

隐式绑定:如果调用点处函数作为对象的方法调用(如obj.func()),那么this指向该对象;

4.

默认绑定:在非严格模式下,未通过上述三种方式绑定的函数调用中,this指向全局对象;在严格模式下为undefined

箭头函数的this绑定

箭头函数不遵循标准的this绑定规则。当创建箭头函数的语句被执行时,箭头函数会从外层作用域中捕获this,此时其this值就被确定,且无法被applybindcall改变。

有些文章会表述为“箭头函数的this值在箭头函数定义时即确定”,此处的“定义”应理解为动态的行为(箭头函数被创建时),而非静态的、源代码层面的函数定义。

另一方面,有些书籍或文档中会提到“箭头函数的this词法式绑定”的,“词法”是静态的概念,此处的“绑定”应理解为this指向的“来源”可以静态确定(即来源于外层作用域),而非this的值确定。

下面的例子对理解箭头函数的this绑定很有帮助:

JavaScript
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一旦捕获后就永久固定下来,不会再被applybindcall改变。

JavaScript
fn3.call({ anything: "anything" }); // {y: 3}

下面的例子直观对比了箭头函数和普通函数的this绑定行为的差异:

JavaScript
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对象提供了两个函数getNormalFngetArrowFn,这两个函数分别返回一个普通函数和一个箭头函数。创建出的两个函数实例为normalFnarrowFn,我们给obj1obj2也都分别添加上这两个函数实例作为对象方法。为了便于演示我们给涉及到的三个对象factoryobj1obj2都添加了一个text属性。

普通函数的this指向由调用方式确定。尽管normalFnobj1.normalFnobj2.normalFn引用的都是同一个函数,但由于他们的调用方式不同,每次调用时的this指向也不同。

箭头函数的this指向在被定义(被创建)时确定arrowFnfactory.getArrowFn()的方式被创建,此时getArrowFn中的this指向factory,因此arrowFnthis也被永久绑定为factory

原型

继承与原型链

JavaScript基于对象实现继承。每一个对象都有一个原型对象,原型对象要么是另一个对象,要么是null

原型对象也是普通对象,因此也会有自己的原型对象、自己的原型对象的原型对象...,直到到达原型链的顶端null。这种通过原型对象形成的链式结构称为原型链。当访问一个对象的属性时,如果该对象本身没有这个属性,JavaScript引擎会沿着原型链向上查找原型对象上的属性。

下面的例子手动创造了一条原型链:objx -> objy -> objz

JavaScript
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,而不会修改原型链上objzz属性。这也被称为属性遮蔽

JavaScript
objx.z = 100;
console.log(objx.z); // 100
console.log(objy.z); // 3
console.log(objz.z); // 3

prototype与constructor

默认情况下,函数对象有一个prototype属性,fn.prototype是一个普通对象,这个对象又有一个constructor属性,指向函数自身。

JavaScript
function fn() {};
console.log(fn.prototype.constructor === fn); // true
console.log(fn.prototype.constructor.prototype.constructor === fn); // true

当使用new调用函数时,会设置新建对象的原型对象为该函数的prototype属性。

JavaScript
function fn() {};
const obj = new fn();
console.log(obj.__proto__ === fn.prototype); // true

当有时只能得到一个对象实例,却不知道具体的构造函数时,由于obj.__proto__指向fn.prototype,而fn.prototype.constructor指向fn,因此可以通过下面的方式找到一个对象的构造函数:

JavaScript
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属性。

JavaScript
function fn1() {};
console.log(fn1.prototype); // {...}

const fn2 = () => {};
console.log(fn2.prototype); // undefined

内建的原型

JavaScript内建了一些构造函数,如ObjectArrayNumberFunction等,这些构造函数都有自己的prototype属性,定义了该类型实例对象所共有的方法和属性。当写出const arr = [];这样的代码时,内部会使用new Array()来创建一个数组对象,并将该对象的原型对象设置为Array.prototypeArray.prototype上已经定义好了数组实例所共有的方法,如pushpop等。因此,当我们调用arr.pop()时,JavaScript引擎会沿着原型链找到Array.prototype.pop方法并执行它。

JavaScript
const arr = [];
console.log(arr.__proto__ === Array.prototype); // true
console.log(arr.pop === Array.prototype.pop); // true

创建一个新对象时,该对象的原型对象会指向Object.prototypeObject是构造函数)。

JavaScript
const obj = {};
console.log(obj.__proto__ === Object.prototype); // true

前面提到Array.prototypeNumber.prototype等本质上也是普通对象,因此它们也有原型对象。它们的原型对象指向Object.prototype。这也是继承在JavaScript中的一种体现。

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,它位于原型链的顶端。

JavaScript
console.log(Object.prototype.__proto__); // null

想一下,Object.__proto__是什么?

这里我们实际上是在查询Object的原型对象。Object是一个函数,因此它的原型对象是Function.prototype

JavaScript
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__

img

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

img

由于pure的原型对象是null,没有继承Object.prototype,也就无法通过__proto__访问原型对象[[Prototype]]。同理,它也无法使用定义在Object.prototype上的方法,如toString

杂项

创建立即执行函数

也叫立即调用函数表达式(Immediately-Invoked Function Expressions, IIFE)

JavaScript
(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可以重复声明同一变量。

JavaScript
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

JavaScript
console.log(x); // undefined
if (0) {
    var x = 1;
}

Symbol

JavaScript中对象的键要么是字符串,要么是一个SymbolSymbol表示一个独一无二的标识符,这在需要确保对象属性不会被意外覆盖时非常有用。例如,在大型代码库或使用第三方库时,使用Symbol作为键可以避免命名冲突的问题。

JavaScript
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.forSymbol.keyFor方法用于创建和访问全局注册的Symbol

JavaScript
console.log(Symbol("x") === Symbol("x")); // false
console.log(Symbol.for("x") === Symbol.for("x")); // true

使用Symbol作为对象的键时,这些属性不会出现在for...in循环、Object.keysJSON.stringify中。可以通过Object.getOwnPropertySymbolsReflect.ownKeys方法访问这些Symbol属性。

遍历对象属性的方法

这一节系统地测试一下各种类型的对象属性以及遍历对象属性的方法。测试的属性包括:

prop_normal:普通属性

prop_method:方法属性

prop_symbolSymbol属性

prop_accessor:访问器属性

prop_non_enumerable:不可枚举属性

prop_proto:原型对象上的属性

测试的遍历对象属性的方式包括:

for...in循环

JSON.stringify(这个严格来说不是遍历属性的方法,但可以反映出哪些属性会被序列化)

Object.keys

Object.getOwnPropertyNames

Object.getOwnPropertySymbols

Reflect.ownKeys

测试结果见注释。

JavaScript
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)]