重新学习 javaScript 中的 this

简介: 重新学习 javaScript 中的 this

为什么要使用 this

function identify() {
  return this.name.toUpperCase();
}
function speak() {
  var greeting = "Hello, I'm " + identify.call(this);
  console.log(greeting);
}

var me = {
  name: "Kyle"
};
var you = {
  name: "Reader"
};

identify.call(me); // KYLE
identify.call(you); // READER

speak.call(me); // Hello, I'm KYLE
speak.call(you); // Hello, I'm READER

this 提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将 API 设计的更加简洁并且易于复用。

关于 this 的误解

this 指向自身

人们很容易把 this 理解成指向函数自身。

为什么需要从函数内部引用函数自身呢?常见的原因是递归(从函数内部调用这个函数)或者可以写一个在第一次被调用后自己解除绑定的事件处理器。

JavaScript 的新手开发者通常会认为,既然函数看作一个对象(JavaScript 中的所有函数都是对象),那就可以在调用函数时存储状态 (属性的值)。这是可行的,有些时候也确实有用,但是除了函数对象还有许多更合适 存储状态的地方。

// 记录函数 foo 被调用的次数
function foo () {
  console.log("foo:" + num);
  this.count++;
}
foo.count = 0;

var i;
for (i = 0; i < 10; i++) {
  if (i > 5) {
    foo(i);
  }
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9

// 试试看 foo 被调用了多少次
console.log(foo.count); // 0

从上例看出,显然从字面意思来理解 this 是错误的。这种做法会在全局意外的创建一个 count 变量。

// 试试其他方法来达到目的
function foo (num) {
  console.log('foo: ' + num);
  data.count++;
}

var data = {
  count: 0
};

var i;
for (i = 0; i < 10; i++) {
  if (i > 5) {
    foo(i);
  }
}

// foo: 6
// foo: 7
// foo: 8
// foo: 9

console.log(data.count); // 4

如果要从函数对象内部引用它自身,那只使用 this 是不够的。一般来说需要通过一个指向函数对象的词法标识符(变量)来引用它。

function foo () { // 具名函数 在内部使用函数名来引用自身
  foo.count = 0; // foo 指向自身
}

setTimeout(function () {
  // 匿名函数无法指向自身
}, 10);

所以,可以使用 foo 标识符替代 this 来引用函数对象,但是还是回避了 this 的问题。

function foo (num) {
  console.log('foo: ' + num);
  foo.count++;
}
foo.count = 0;
var i;
for (i = 0; i < 10; i++) {
  if (i > 5) {
    foo(i);
  }
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
console.log(foo.count); // 4

不过,也可以强制 this 指向 foo 函数对象:

function foo (num) {
  console.log('foo: ' + num);
  this.count++;
}
foo.count = 0;
var i;
for (i = 0; i < 10; i++) {
  if (i > 5) {
    foo.call(foo, i);
  }
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9
console.log(foo.count); // 4

this 的作用域

还有一种误解是, this 指向函数的作用域。这个问题有点复杂,因为在某种情况下它是正确的,但是其他情况下是错误的。

需要明确的是,this 在任何情况下都不指向函数的词法作用域。在 JavaScript 内部,作用域确实和对象类似,可见的标识符都是它的属性。但是作用域“对象”无法通过JavaScript代码访问,它存在于JavaScript 引擎 内部。

function foo () {
  var a = 2;
  this.bar();
}
function bar() {
  console.log(this.a);
}

foo(); // ReferenceError: a is not defined

首先,这段代码试图通过 this.bar() 来引用 bar() 函数。这是绝对不可能成功的。调用 bar() 最自然的方法是省略前面的 this ,直接使用词法引用标识符。

此外,还试图使用 this 联通 foo()bar() 的词法作用域,从而让 bar() 可以访问 foo() 作用域里的变量 a 。这是不可能实现的,不能使用 this 来引用一个词法作用域内部的东西。

:::warning
每当你想要把 this 和词法作用域的查找混合使用时,一定要提醒自己,这是无法实现的。
:::

this 到底是什么

this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法 、传入的参数等信息。this 就是记录的其中一个属性,会在函数执行的过程中用到。

:::tip
this 既不指向函数自身也不指向函数的词法作用域。this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。
:::

调用位置

调用位置 就是函数在代码中被调用的位置(而不是声明的位置)。

寻找调用位置最重要的是要分析调用栈 (就是为了到达当前执行位置所调用的所有函数)。调用位置就在当前正在执行的函数的 一个调用

function baz () {
  // 当前调用栈是 baz
  // 当前调用位置是全局作用域
  console.log('baz');
  bar(); // <-- bar 的调用位置
}
function bar () {
  // 当前调用栈是 baz --> bar
  // 当前调用位置是在 baz 中
  console.log('bar');
  foo(); // <-- foo 的调用位置
}

function foo () {
  // 当前调用栈是 baz --> bar --> foo
  // 当前调用位置是在 bar 中
  console.log('foo');
}

baz(); // <-- baz 的调用位置

:::tip
可以把调用栈想象成一个函数调用链,但是这种方法非常麻烦并且容易出错。另一个查看调用栈的方法是使用浏览器的调试工具。
:::

绑定规则

找到调用位置后,需要判断使用哪种绑定规则。

默认绑定

最常用的函数调用类型:独立函数调用。这条规则可以看作是无法应用其他规则时的默认规则。

function foo () {
  console.log(this.a); // this 默认绑定,指向全局对象
}
var a = 2;
foo(); // 2

如果使用严格模式(strict mode ),那么全局对象将无法使用默认绑定,此时 this 会绑定到 undefined

隐式绑定

另一条需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含。

function foo () {
  console.log(this.a);
}
var obj = {
  a: 2,
  foo: foo
};
obj.foo(); // 2

无论是直接在 obj 中定义还是先定义再添加为引用属性,这个函数严格来说都不属于 obj 对象。然而,调用位置会使用 obj 上下文来引用函数,因此可以 说函数被调用时 obj 对象“拥有”或者“包含”它。

foo() 被调用时,它的落脚点指向 obj 对象。当函数引用有上下文对象时,隐式绑定 规则会把函数调用中的 this 绑定到这个上下文对象。因为调用 foo()this 被绑定到 obj ,因此 this.aobj.a 是一样的。

对象属性引用链中只有最顶层或者说最后一层会影响调用位置。

function foo () {
  console.log(this.a);
}
var obj2 = {
  a: 42,
  foo: foo
};

var obj1 = {
  a: 2,
  obj2: obj2
};

obj1.obj2.foo(); // 42

隐式丢失

一个最常见的 this 绑定问题就是被隐式绑定 的函数会丢失绑定对象,也就是说它会应用默认绑定 ,从而把 this 绑定到全局对象或者 undefined 上,取决于是否是严格模式。

function foo () {
  console.log(this.a);
}
var obj = {
  a: 2,
  foo: foo
};

var bar = obj.foo; // 函数别名,实际引用的是 foo 函数本身

var a = 'oops, global!'; // a 是全局对象属性

bar(); // 'oops, global!' bar() 其实是一个不带任何修饰的函数调用

一种更微妙、更常见并且更出乎意料的情况发生在传入回调函数时:

function foo () {
  console.log(this.a);
}
function doFoo (fn) { // 参数传递,其实就是一种隐式赋值
  // fn 其实引用的是 foo
  fn(); // 调用位置
}
var obj = {
  a: 2,
  foo: foo
};
var a = 'oops, global!';

doFoo(obj.foo); // 'oops, global!'

调用回调函数的函数还可能会修改 this

显式绑定

可以使用函数的 call()apply() 方法, 在某个对象上强制调用函数。它们的第一个参数是一个对象,它们会把这个对象绑定到 this ,接着在调用函数时指定这个 this 。因为可以直接指定this 的绑定对象,因此称为显式绑定。

function foo () {
  console.log(this.a);
}
var obj = {
  a: 2,
};
foo.call(obj); // 2 强制将 `this` 绑定到 obj 上

如果传入了一个原始值(字符串类型、布尔类型或者数字类型)来当作 this 的绑定对象,这个原始值会被转换成它的对象形式(也就是new String()new Boolean() 或者 new Number() )。这通常被称为“装箱”。

硬绑定

解决丢失绑定问题

function foo () {
  console.log(this.a);
}
var obj = {
  a: 2,
};
var bar = function () {
  foo.call(obj);
};
bar(); // 2
setTimeout(bar, 100); // 2

// 硬绑定的 bar 不可能再修改它的 this
bar.call(window); // 2

这种绑定是一种显式的强制绑定,称之为硬绑定

硬绑定的典型应用场景就是创建一个包裹函数,传入所有的参数并返回接收到的所有值:

function foo (something) {
  console.log(this.a, something);
  return this.a + something;
}
var obj = {
  a: 2,
};

var bar = function () {
  return foo.apply(obj, arguments);
};

var b = bar(3); // 2 3
console.log(b); // 5

另一种使用方法是创建一个 i 可以重复使用的辅助函数:

function foo (something) {
  console.log(this.a, something);
  return this.a + something;
}
// 简单的辅助绑定函数
function bind (obj, fn) {
  return function () {
    return fn.apply(obj, arguments);
  };
}
var obj = {
  a: 2,
};
var bar = bind(obj, foo);
var b = bar(3); // 2 3
console.log(b); // 5

由于硬绑定 是一种非常常用的模式,所以在 ES5 中提供了内置的方法 Function.prototype.bind:

function foo (something) {
  console.log(this.a, something);
  return this.a + something;
}
var obj = {
  a: 2,
};
var bar = foo.bind(obj);
var b = bar(3); // 2 3
console.log(b); // 5

bind() 会返回一个硬编码的新函数,它会把参数设置为 this 的上下文并调用原始函数。

API 调用的“上下文”

第三方库的许多函数,以及 JavaScript 语言和宿主环境中许多新的内置函数,都提供了一个可选的参数,通常被称为“上下文”(context),其作用和bind() 一样,确保回调函数使用指定的 this

function foo (el) {
  console.log(el, this.id);
}

var obj = {
  id: 'awesome',
};

[1, 2, 3].forEach(foo, obj); // 调用 foo() 时把 this 绑定到 obj
// 1 awesome 2 awesome 3 awesome

new 绑定

在传统的面向类的语言中,“构造函数”是类中的一些特殊方法,使用 new 初始化类时会调用类中的构造函数。

something = new MyClass();

JavaScript 也有一个 new 操作符,使用方法看起来也和那些面向类的语言一样,绝大多数开发者都认为 JavaScript 中 new 的机制也和那些语言一样。然而,JavaScript 中 new 的机制实际上和面向类的语言完全不同。

JavaScript,构造函数只是一些使用 new 操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。实际上,它们甚至都不能说是一种特殊的函数类型,它们只是被 new 操作符调用的普通函数而已。

Number 构造函数:
当 Number 在 new 表达式中被调用时,它是一个构造函数:它会初始化新创建的对象。

所以,包括内置对象函数(比如 Number())在内的所有函数都可以用 new 来调用,这种函数调用被称为构造函数调用。这里有一个重要但是非常细微的区别:实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。

使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作:

  1. 创建(或者说构造)一个全新的对象;
  2. 这个对象会被执行 [[prototype]] 连接;
  3. 这个对象会绑定到函数调用的 this
  4. 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。
function foo () {
  this.a = a;
}

var bar = new foo(2);

console.log(bar.a); // 2

使用 new 来调用 foo() 时,会构造一个新对象并把它绑定到 foo() 调用的 this 上,称之为 new 绑定

优先级

毫无疑问,默认绑定的优先级是四条规则中最低的。

隐式绑定和显式绑定哪个优先级更高?

function foo () {
  console.log(this.a);
}
var obj1 = {
  a: 2,
  foo: foo,
};
var obj2 = {
  a: 3,
  foo: foo,
};

obj1.foo(); // 2
obj2.foo(); // 3

obj1.foo.call(obj2); // 3
obj2.foo.call(obj1); // 2

可以看到,显式绑定 优先级更高,也就是说在判断时应当先考虑是否可以应用显式绑定

new 绑定隐式绑定 的优先级谁高谁低:

function foo (something) {
  this.a = something;
}
var obj1 = {
  foo: foo,
};
var obj2 = {};

obj1.foo(2);
console.log(obj1.a); // 2

obj1.foo.call(obj2, 3);
console.log(obj2.a); // 3

var bar = new obj1.foo(4);

console.log(obj1.a); // 2
console.log(bar.a); // 4

可以看到 new 绑定比隐式绑定优先级高。但是 new 绑定和显式绑定谁的优先级更高呢?

:::tip
newcall / apply 无法一起使用,因此无法通过 new foo.call(obj1) 来直接进行测试。但是可以使用硬绑定来测试它俩的优先级。
:::

硬绑定:Function.prototype.bind() 会创建一个新的包装函数,这个函数会忽略它当前的 this 绑定(无论绑定的对象是什么),并把我们提供的对象绑定到 this 上。

看起来硬绑定(也是显式绑定的一种)似乎比 new 绑定的优先级更高,无法使用 new 来控制 this 绑定。

function foo (something) {
  this.a = something;
}
var obj1 = {};

var bar = foo.bind(obj1);

bar(2);
console.log(obj1.a); // 2

var baz = new bar(3);
console.log(obj1.a); // 2
console.log(baz.a); // 3

出乎意料!bar 被硬绑定到 obj1 上,但是 new bar(3) 并没有像我们预计的那样把 obj1.a 修改为 3。相反,new 修改了硬绑定(到obj1 的)调用 bar() 中的 this 。因为使用了 new 绑定,我们得到了一个名字为 baz 的新对象,并且 baz.a 的值是 3。

Function.prototype.bind(..) 实现中,会判断硬绑定函数是否是被 new 调用,如果是的话就会使用新创建的 this 替换硬绑定的 this

之所以要在 new 中使用硬绑定函数,主要目的是预先设置函数的一些参数,这样在使用 new 进行初始化时就可以只传入其余的参数。

判断 this

根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的顺序来进行判断:

  1. 函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。
  2. 函数是否通过 callapply (显式绑定)或者硬绑定调用?如果是的话,this 绑定的是指定的对象。
  3. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上下文对象。
  4. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined ,否则绑定到全局对象。

绑定中的特例

被忽略的 this

如果把 null 或者 undefined 作为 this 的绑定对象传入 callapply 或者 bind ,这些值在调用时会被忽略,实际应用的是默认绑定规则。一般这种情况出现在不关心 this 但使用 null 占位的情况下。

总是使用 null 来忽略 this 绑定可能产生一些副作用。如果某个函数确实使用了 this (比如第三方库中的一个函数),那默认绑定规则会把 this 绑定到全局对象(在浏览器中这个对象是 window ),这将导致不可预计的后果(比如修改全局对象)。

显而易见,这种方式可能会导致许多难以分析和追踪的 bug。

更安全的 this

一种“更安全”的做法是传入一个特殊的对象,把 this 绑定到这个对象不会对你的程序产生任何副作用。可以创建一个“DMZ”(demilitarized zone,非军事区)对象——它就是一个空的非委托的对象。

function foo (a, b) {
  console.log('a:' + a + ', b:' + b );
}

var ø = Object.create(null);

foo.apply(ø, [1, 2]); // a:1, b:2

var bar = foo.bind(ø, 2);
bar(3); // a:2, b:3

:::tip
Object.create(null){} 很像,但是并不会创建 Object.prototype 这个委托,所以它比{} “更空”
:::

间接引用

有可能(有意或者无意地)创建一个函数的“间接引用”,在这种情况下,调用这个函数会应用默认绑定规则。

function foo () {
  console.log(this.a);
}
var a = 2;
var o = { a: 3, foo: foo };
var p = { a: 4 };

o.foo(); // 3
(p.foo = o.foo)(); // 2

// 赋值表达式 p.foo = o.foo 的返回值 是目标函数的引用
// 因此调用位置是 foo() 而不是 p.foo() 或者 o.foo()
// 所以这里会应用默认绑定

:::tip
对于默认绑定来说,决定 this 绑定对象的并不是调用位置是否处于严格模式,而是函数体是否处于严格模式。如果函数体处于严格模式,this 会被绑定到 undefined ,否则 this 会被绑定到全局对象。
:::

软绑定

硬绑定会大大降低函数的灵活性,使用硬绑定之后就无法使用隐式绑定或者显式绑定来修改 this

如果可以给默认绑定指定一个全局对象和 undefined 以外的值,那就可以实现和硬绑定相同的效果,同时保留隐式绑定或者显式绑定修改 this 的能力。

if (!Function.prototype.softBind) {
  Function.prototype.softBind = function(obj) {
    var fn = this;
    var curried = [].slice.call(arguments, 1);
    var bound = function () {
      return fn.apply(
        (!this || this === (window || global)) ? obj : this,
        curried.concat.apply(curried, arguments)
      );
    };
    bound.prototype = Object.create(fn.prototype);
    return bound;
  };
}

使用 软绑定 :

function foo () {
  console.log('name: ' + this.name);
}
var obj = { name: 'obj' },
    obj2 = { name: 'obj2' },
    obj3 = { name: 'obj3' };

var fooObj = foo.softBind(obj);

fooObj(); // name: obj

obj2.foo = foo.softBind(obj);
obj2.foo(); // name: obj2

fooObj.call(obj3); // name: obj3

setTimeout(obj2.foo, 100); // name: obj

箭头函数中的 this

箭头函数并不是使用 function 关键字定义的,而是使用被称为“胖箭头”的操作符 => 定义的。箭头函数不使用 this 的四种标准规则,而是根据外层(函数或者全局)作用域来决定 this

function foo () {
  return (a) => {
    // this 继承自 foo()
    console.log(this.a);
  };
}

var obj1 = {
  a: 2,
};

var obj2 = {
  a: 3,
};

var bar = foo.call(obj1);
bar.call(obj2); // 2

foo() 内部创建的箭头函数会捕获调用时 foo()this 。由于 foo()this 绑定到 obj1bar (引用箭头函数)的 this 也会绑定到 obj1 ,箭头函数的绑定无法被修改。(new 也不行!)

箭头函数可以像 bind() 一样确保函数的 this 被绑定到指定对象,此外,其重要性还体现在它用更常见的词法作用域取代了传统的 this 机制。

function foo () {
  var self = this;
  setTimeout(function () {
    console.log(self.a);
  }, 100);
}
var obj = {
  a: 2,
};

foo.call(obj); // 2

虽然 self = this 和箭头函数看起来都可以取代 bind() ,但是从本质上来说,它们想替代的是this 机制。

如果你经常编写 this 风格的代码,但是绝大部分时候都会使用 self = this 或者箭头函数来否定 this 机制,那你或许应当:

  1. 只使用词法作用域并完全抛弃错误 this 风格的代码;
  2. 完全采用 this 风格,在必要时使用 bind() ,尽量避免使用 self = this 和箭头函数。
相关文章
|
1月前
|
JavaScript 前端开发
javascript中的this
javascript中的this
|
1月前
|
JavaScript
JS中改变this指向的六种方法
JS中改变this指向的六种方法
|
2月前
|
JavaScript
Node.js【GET/POST请求、http模块、路由、创建客户端、作为中间层、文件系统模块】(二)-全面详解(学习总结---从入门到深化)
Node.js【GET/POST请求、http模块、路由、创建客户端、作为中间层、文件系统模块】(二)-全面详解(学习总结---从入门到深化)
27 0
|
2月前
|
消息中间件 Web App开发 JavaScript
Node.js【简介、安装、运行 Node.js 脚本、事件循环、ES6 作业队列、Buffer(缓冲区)、Stream(流)】(一)-全面详解(学习总结---从入门到深化)
Node.js【简介、安装、运行 Node.js 脚本、事件循环、ES6 作业队列、Buffer(缓冲区)、Stream(流)】(一)-全面详解(学习总结---从入门到深化)
77 0
|
3天前
|
JavaScript 前端开发
js开发:请解释this关键字在JavaScript中的用法。
【4月更文挑战第23天】JavaScript的this关键字根据执行环境指向不同对象:全局中指向全局对象(如window),普通函数中默认指向全局对象,作为方法调用时指向调用对象;构造函数中指向新实例,箭头函数继承所在上下文的this。可通过call、apply、bind方法显式改变this指向。
7 1
|
3天前
|
JavaScript 前端开发 测试技术
学习JavaScript
【4月更文挑战第23天】学习JavaScript
11 1
|
11天前
|
JavaScript 前端开发 应用服务中间件
node.js之第一天学习
node.js之第一天学习
|
1月前
|
运维 JavaScript 前端开发
发现了一款宝藏学习项目,包含了Web全栈的知识体系,JS、Vue、React知识就靠它了!
发现了一款宝藏学习项目,包含了Web全栈的知识体系,JS、Vue、React知识就靠它了!
|
1月前
|
JavaScript
Vue.js学习详细课程系列--共32节(4 / 6)
Vue.js学习详细课程系列--共32节(4 / 6)
35 0
|
1月前
|
JavaScript
JS中call()、apply()、bind()改变this指向的原理
JS中call()、apply()、bind()改变this指向的原理