Skip to content

JS 基础:神奇的 this

前言

误以为 this 指向函数自身

根据 this 的英语语法,很容易将函数中出现的 this 理解为函数自身。在 javascript 当中函数作为一等公民,确实可以在调用的时候将属性值存储起来。但是如果使用方法不对,就会发生与实际预期不一致的情况。具体情况,请看下面代码:

js
function fn(num) {
  this.count++;
}

fn.count = 0;

for (var i = 0; i < 3; i++) {
  fn(i);
}
console.log(fn.count); // 0

如果 fn 函数里面的 this 指向自身函数,那么 count 属性的属性值就应该产生变化,但实际上却是纹丝不动。对于这个问题,有些人会利用作用域来解决,比如这么写:

js
var data = {
  count: 0,
};

function fn(num) {
  data.count++;
}

for (var i = 0; i < 3; i++) {
  fn(i);
}

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

又或者更直接的这么写:

js
function fn(num) {
  fn.count++;
}

fn.count = 0;

for (var i = 0; i < 3; i++) {
  fn(i);
}

console.log(fn.count); //3

虽然这两种方式都输出了正确的结果,但是却避开了 this 到底绑定在哪里的问题。如果对一个事物的工作原理不清晰,就往往会产生头痛治头,脚痛治脚的问题,从而导致代码变得的丑陋,而且维护性也会变得很差。

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

绑定规则

默认绑定规则

无论是 全局环境 还是 函数独立调用(包括嵌套函数,IIFE,闭包等)this 默认绑定到 window。在严格模式下,会将函数体里的 this 默认绑定为 undefined。以避免全局变量的污染。

全局环境

js
console.log(this === window); //true

函数独立调用

js
function fn() {
  console.log(window === this); //浏览器环境
}
fn(); //true

函数 fn 是直接在全局作用域下调用的,没有带其他任何修饰,这种情况下,函数调用的时候使用了 this 的默认绑定,指向了全局对象。

fn 函数中的 this 指向了全局变量,所以 this.count++ 相当于 window.count++(浏览器环境下),当然不会对 fn 函数的 count 属性产生影响。

嵌套函数
js
//虽然test()函数被嵌套在obj.foo()函数中,但test()函数是独立调用,而不是方法调用。所以this默认绑定到window
var a = 0;
var obj = {
  a: 2,
  foo: function () {
    function test() {
      console.log(this.a);
    }
    test();
  },
};
obj.foo(); //0
IIFE(立即执行函数)
js
var a = 0;
function foo() {
  (function test() {
    console.log(this.a);
  })();
}
var obj = {
  a: 2,
  foo: foo,
};
obj.foo(); //0
//等价于上例
var a = 0;
var obj = {
  a: 2,
  foo: function () {
    function test() {
      console.log(this.a);
    }
    test();
  },
};
obj.foo(); //0
闭包
js
var a = 0;
function foo() {
  function test() {
    console.log(this.a);
  }
  return test;
}
var obj = {
  a: 2,
  foo: foo,
};
obj.foo()(); //0

严格模式

js
'use strict';
function F() {
  this.a = 1; // 这种指向全局的 this 不对
}
F();
//Uncaught TypeError: Cannot set property 'a' of undefined

严格模式下在 函数体里的 this 不允许指向全局对象。

隐式绑定规则

如果函数在以对象为上下文进行调用,那么 this 会绑定到调用这个函数的对象:

js
var obj = {
  a: 1,
  fn: function () {
    console.log(this.a);
  },
};

obj.fn(); //1

即使函数声明不在对象当中,this 指向仍会产生变化:

js
function fn() {
  console.log(this.a);
}
var obj = {
  a: 1,
  fn: fn,
};
obj.fn(); //1

由此可见,this 的绑定,不与函数定义的位置有关,而是与调用者和调用方式有关。

在隐式的绑定规则下,有一些特殊的地方,需要注意:

多层对象调用

在多层对象引用下,this 指向的是调用的函数的那个对象。

js
function fn() {
  console.log(this.a);
}

var obj3 = {
  a: 3,
  fn: fn,
};

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

var obj = {
  a: 1,
  obj2: obj2,
};

obj.obj2.obj3.fn(); //3

隐式丢失

[函数别名]
js
var a = 0;
function foo() {
  console.log(this.a);
}
var obj = {
  a: 2,
  foo: foo,
};
//把obj.foo赋予别名bar,造成了隐式丢失,因为只是把foo()函数赋给了bar,而bar与obj对象则毫无关系
var bar = obj.foo;
bar(); //0
//等价于
var a = 0;
var bar = function foo() {
  console.log(this.a);
};
bar(); //0

虽然 bar 引用了 obj.foo,但是函数的调用方式,仍是不带任何修饰的,所以 this 还是绑定在了 window 上。

[参数传递]
js
var a = 0;
function foo() {
  console.log(this.a);
}
function bar(fn) {
  fn();
}
var obj = {
  a: 2,
  foo: foo,
};
//把obj.foo当作参数传递给bar函数时,有隐式的函数赋值fn=obj.foo。与上例类似,只是把foo函数赋给了fn,而fn与obj对象则毫无关系
bar(obj.foo); //0
//等价于
var a = 0;
function bar(fn) {
  fn();
}
bar(function foo() {
  console.log(this.a);
});
[内置函数]
js
var a = 0;
function foo() {
  console.log(this.a);
}
var obj = {
  a: 2,
  foo: foo,
};
setTimeout(obj.foo, 100); //0
//等价于
var a = 0;
setTimeout(function foo() {
  console.log(this.a);
}, 100); //0

setTimeoutwindow 的方法,setTimeout 在调用传入函数的时候,如果这个函数没有指定了的 this,那么它会做一个隐式的操作 —- 自动地注入全局上下文 window

隐式绑定 this 不是一种很推荐的方式,因为很有可能就发生丢失的情况,如果业务当中对 this 的绑定有要求,建议还是使用显示绑定的方式。

显式绑定规则

显示绑定就是利用函数原型上的 applycall 方法来对 this 进行绑定。用法就是把想要绑定的对象作为第一个参数传进去。如果传入了一个原始值(字符串,布尔类型,数字类型),来当做 this 的绑定对象,这个原始值转换成它的对象形式。

如果你把 null 或者 undefined 作为 this 的绑定对象传入 call/apply/bind,这些值会在调用时被忽略,实际应用的是默认绑定规则。

js
function fn() {
  console.log(this);
}

var obj = {};

fn.call(obj); //{}

有些时候会想将函数的 this 绑定在某个对象上,但是不需要立即调用,这样的话,直接利用 call 或者 apply 是无法做的。

js
function fn() {
  console.log(this);
}

function bind(fn) {
  fn();
}

var obj = {
  fn: fn,
};

bind.call(obj, fn); //window

上面这个例子,看似好像可以,但实际上是 bind 函数的 this 绑定到了 obj 这个对象,但是 fn 仍然是没有任何修饰的调用,所以 fn 仍然是默认的绑定方式。

js
function fn() {
  console.log(this);
}

function bind(fn, obj) {
  return function () {
    fn.apply(obj, arguments);
  };
}

var obj = {
  fn: fn,
};

var fun = bind(fn, obj);
fun(); //obj

这样调用,就可以将灵活多变的 this,牢牢的控制住了,因为 fn 的调用方式为 apply 调用。所以,this 就被绑定在传入的 obj 对象上,在 ES5 当中,函数的原型方法上多了一个 bind,效果与上面的函数基本一致。

apply/call/bind 实现

注: call 性能比 apply 好。

js
Function.prototype.apply2 = function (context = window) {
  if (this === Function.prototype) {
    return undefined; // 用于防止 Function.prototype.apply2() 直接调用
  }
  const fn = Symbol();
  context[fn] = this;
  let result;
  //let args = [...arguments].slice(1);   // call 则仅仅是参数不同
  let args = arguments[1];
  // 判断是否有第二个参数
  if (args) {
    result = context[fn](...args);
  } else {
    result = context[fn]();
  }
  delete context[fn];
  return result;
};

Function.prototype.bind2 = function (content) {
  if (this === Function.prototype) {
    throw new TypeError('Error');
  }
  let fn = this;
  let args = [...arguments].slice(1);

  let resFn = function () {
    return fn.apply(this instanceof resFn ? this : content, args.concat(...arguments));
  };
  function tmp() {}
  tmp.prototype = this.prototype;
  resFn.prototype = new tmp();

  return resFn;
};

其他

new

new 是一个被很多人误解的一个关键字,但实际上 javascriptnew 与传统面向对象的语言完全不同。 个人把 new 理解为一种特殊的函数调用,当使用 new 关键字来调用函数的时候,会执行下面操作:

  1. 创建一个全新的对象;
  2. 将构造函数的作用域赋给新对象(this 就指向了这个新对象),将新对象的 __proto__ 指向构造函数的 prototype,设置新对象的内部属性,可访问性等;
  3. 执行构造函数中的代码(为这个新对象添加属性);
  4. 如果构造函数返回值为基本类型或者为 this 又或者不返回任何值,那么将会返回这个创建的新对象;如果返回了一个对象,那么则会返回这个对象。
new 实现
js
function New(func) {
  var res = {};
  if (func.prototype !== null) {
    res.__proto__ = func.prototype;
  }
  var ret = func.apply(res, Array.prototype.slice.call(arguments, 1));
  if ((typeof ret === 'object' || typeof ret === 'function') && ret !== null) {
    return ret;
  }
  return res;
}
var obj = New(A, 1, 2);
// equals to
var obj = new A(1, 2);

故构造函数中的 this 指向构造函数创建的对象实例。

js
function fn(a) {
  this.a = a;
}
fn.prototype.hi = function () {
  console.log('hi');
};

var obj = new fn(2);

console.log(obj);

class

类和模块的内部,默认就是 严格模式。上文说到在严格模式下,会将 this 默认绑定为 undefined

js
class Logger {
  printName(name = 'there') {
    this.print(`Hello ${name}`);
  }

  print(text) {
    console.log(text);
  }
}

const logger = new Logger();
const { printName } = logger;
printName(); // TypeError: Cannot read property 'print' of undefined

可以将 this 绑定到实例上:

js
class Logger {
  constructor() {
    this.printName = this.printName.bind(this);
  }

  // ...
}
class 实现
js
function inherit(subType, superType) {
  subType.prototype = Object.create(superType.prototype, {
    constructor: {
      enumerable: false,
      configurable: true,
      writable: true,
      value: subType,
    },
  });
  Object.setPrototypeOf(subType, superType);
}

//ES6 的 class 内部是基于寄生组合式继承,它是目前最理想的继承方式,通过 Object.create 方法创造一个空对象,并将这个空对象继承 Object.create 方法的参数,再让子类(subType)的原型对象等于这个空对象,就可以实现子类实例的原型等于这个空对象,而这个空对象的原型又等于父类原型对象(superType.prototype)的继承关系

//而 Object.create 支持第二个参数,即给生成的空对象定义属性和属性描述符/访问器描述符,我们可以给这个空对象定义一个 constructor 属性更加符合默认的继承行为,同时它是不可枚举的内部属性(enumerable:false)

//而 ES6 的 class 允许子类继承父类的静态方法和静态属性,而普通的寄生组合式继承只能做到实例与实例之间的继承,对于类与类之间的继承需要额外定义方法,这里使用 Object.setPrototypeOf 将 superType 设置为 subType 的原型,从而能够从父类中继承静态方法和静态属性
寄生组合式继承
js
function inheritPrototype(subType, superType) {
  var prototype = Object.create(superType.prototype);
  prototype.constructor = subType;
  subType.prototype = prototype;
}
function SuperType(name) {
  this.name = name;
  this.colors = ['red', 'blue', 'green'];
}
SuperType.prototype.sayName = function () {
  console.log(this.name);
};

function SubType(name, age) {
  SuperType.call(this, name);
  this.age = age;
}
inheritPrototype(SubType, SuperType);
SubType.prototype.sayAge = function () {
  console.log(this.age);
};

箭头函数

箭头函数本身不创建 this,在它声明时可以捕获别人的 this 供自己使用。this 一旦被捕获,以后将不再变化,即不会被 call、apply 或 bind 影响。

传统写法:

js
function fn() {
  var _this = this;
  setTimeout(function () {
    console.log(_this.a);
  }, 100);
}

var obj = {
  a: 2,
};

fn.call(obj); //2

箭头函数写法:

js
function fn() {
  setTimeout(() => {
    //this 来源于 fn 函数的作用域
    console.log(this.a);
  }, 100);
}

var obj = {
  a: 2,
};

fn.call(obj); //2

如果用 apply 显式绑定:

js
var a = 1;
let obj = { a: 2 };
let fn = () => {
  console.log(this.a);
};
fn.apply(obj);

事件函数

如果是在事件函数当中,this 的绑定是指向触发事件的 DOM 元素的,

js
$('body')[0].addEventListener(
  'click',
  function () {
    console.log(this);
  },
  false,
);

点击 body 元素之后,控制台则会显示 body 元素。

小结

如果想判断一个函数的 this 绑定在哪里,首先是找到函数的调用位置,之后是按照规则来判断。

  • 如果函数调用时没有任何修饰条件,那么在严格模式下则会绑定到 undefined,非严格模式下会绑定到全局 window
  • 如果是用对象做上下文,来对函数进行调用,那么则会绑定到调用的这个对象上。
  • 如果是用 call 或者 apply 方法来进行调用的,则会绑定到第一个传入参数上。
  • 如果是使用 new 关键字来调用函数的,则会绑定到新创建的那个对象上.
  • 如果是在事件函数内,则会绑定到触发事件的那个 DOM 元素上。
  • 绑定优先级:显式绑定 > 隐式绑定 > 默认绑定, new 绑定 > 隐式绑定 > 默认绑定