Loading

JS 详谈This指向

从原理出发

什么是this? this是被调用函数对调用它上下文对象的引用。
首先我们围绕耳熟能详的“this始终指向它的调用者”开始。这句结论虽然没有什么问题,但是说得过于笼统,还是得深入到背后的执行原理才能举一反三解决问题。

举个简单的例子:

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

var foo = obj.foo;
var num = 3;

obj.foo();//2

foo();//3

①obj.foo();是对象obj调用自身的属性(方法)foo,this指向调用者obj。
②由var foo = obj.foo;获取函数,并用foo();在全局作用域中调用函数,所以this指向浏览器全局对象Window。

内存数据结构的概述

JS之所以设计this,是因为其内存的数据结构的特征。

var obj = { foo:  5 };

①上面的对象赋值给变量,其实质是JS引擎先在内存中生成{foo: 5},然后再把该对象的内存地址赋值给变量obj。也就是说onj.foo实质上是先从obj获取对象的内存地址然后再从地址读取原始对象,最后返回对象属性foo的值。
②原始对象以词典结构保存,一个属性(数据属性和存取属性: get&set)对应一个属性描述符对象。如下图,foo属性的描述对象就包含4个描述属性,而最重要的值保存在描述对象[[value]]中。
image
当foo属性值为函数时,JS引擎同样会先将原始函数保存在内存中,然后把该函数的内存地址存放于foo属性的描述对象中的[[value]]里面。
image
属性描述符对象共有的可选键值:

可选键 键值 说明 数据属性 存取属性
configurable 布尔值,默认为 false 所有可选键是否可配置以及是否能删除该属性 ✔ ✔
enumerable 布尔值,默认为 false 是否可枚举 ✔ ✔
value JS有效值,默认为 undefined 该属性的值 ✔
writable 布尔值,默认为 false 是否可写 ✔
get getter函数,默认值 undefined 访问该属性时,会调用此函数。执行时不传入任何参数,但是会传入 this 对象。该函数的返回值会被用作属性的值 ✔
set setter函数,默认值 undefined 当属性值被修改时,会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的 this 对象 ✔

从原理出发章节参考于JavaScript 的 this 原理——阮一峰的网络日志

为何设计this

当我们我们需要 函数A 执行在 上下文环境B 中时就需要利用 this 为 A 指定执行环境 B,而不是又在 环境B 中再声明一边 函数A。就像这样:

var f = function (){
  console.log(this.x)
};

var x = 1;//全局变量 x
var obj = {
  x: 2,
  f: f
}

f();//结果为1,执行于全局环境,所以 this.x 指向 Window.x
obj.f();//结果为2,执行于 obj 内部环境,所以 this.x 指向 obj.x

由此可见,this是一个指针型变量,它指向当前函数的运行环境。

不同使用/调用情况下的this

在一个对象的方法中,this指向该对象。
直接单独使用,this指向全局对象。
在一个函数中,this指向全局对象(strict mode指向undefined)。
在JS事件中,this指向接受了该事件的元素对象。
最后使用方法call(), apply(), bind()可以将this指向任何对象。

可能你会有疑问,为什么不直接说指向全局对象window,而仅说明指向全局对象。
这是因为在JS中全局对象也分三种情况:

  • 在浏览器中,JS没有专门用作后台任务启动的任何代码都将Window作为其全局对象。web应用绝大多数代码如此。
  • 在Worker中运行的代码将WorkerGlobalScope对象作为其全局对象。
  • 在Node.js环境下运行的脚本具有一个称为global的对象作为其全局对象。

注意:this不是一个JS变量而是指针型变量,它仅是访问函数执行环境的关键字,您不能更改它的值

this的扩展延伸

new关键字与this关键字

例1:

function name () {
  this.fne = "yulin"
};

var a = new name();
console.log(a.fne);//yulin

构造函数中的 this 指向:
①:首先new关键字会创建一个用户定义或者具有构造函数实例的内置对象。在上面的例子new关键字创建一个空对象name {}。

②:然后构造函数name()的this指向对象name {}的内存地址,再执行name()并返回执行结果,所以空对象成为实例对象name {fine: 'yulin'}。

③:最后整个对象name {fne: 'yulin'}赋值给变量a,变量a引用对实例象name,实质上是变量a取得了实例对象name的内存地址。

例2:MDN官方示例解析:

function Product(name, price) {
  this.name = name;
  this.price = price;
}

function Food(name, price) {
  Product.call(this, name, price);// 给 product() 指定一个调用 Food() 的 this 对象。
  this.category = 'food';
}

console.log(new Food('cheese', 5).name);// 构造一个 food() 实例,food() 内的 this 指向 food() 实例对象,所以 product() 变更 this 指向 food() 实例
// expected output: "cheese"

this与return的问题

上文说到构造函数执行的返回结果的有几种情况:

  1. 构造函数返回对象时,this会引用返回的对象,null除外,否则指向起始调用者。
function name (){
  this.fne = "yulin";
  return {}
};
var a = new name();
console.log(a.fne);//undefined,指向 {}

function name (){
  this.fne = "yulin";
  return function () {}
};
var a = new name();
console.log(a.fne);//undefined,指向 function () {}

function name (){
  this.fne = "yulin";
  return null
};
var a = new name();
console.log(a.fne);//yulin,指向 name {fne: 'yulin'},null是特殊的对象不会更改 this

function name () {
  this.fne = "yulin";
  return undefined
};
var a = new name();
console.log(a.fne);//yulin,指向 name {fne: 'yulin'}

指定this的值

  1. 当我们需要在指定的环境中执行一个函数时,我们就需要用到call()方法指定this的值,第一个参数为this的值,第二个参数是传入该函数的分隔参数。

    const a = {
      name: 'yulin',
      fn: function (e, q) {
      console.log(this.name);
      console.log(e + q);
      }
    };
    
    const b = {
      name: 'yhh'
    }
    
    var x = a.fn
    x.call(b, 2, 3);//yhh 5
    
  2. apply()方法与call()类似,但传入apply的第二个参数必须是数组或类数组对象(数组、集合、参数对象等等),就像[1, 2, 3], [a , b, c]以及数组变量。

    2.1 参数上的特性给数组对象带去了极大的便利:

    // 获取数组中最大值
    Math.max.apply(null, [1, 2, 3])// 3
    Math.max.apply(Math, [1, 2, 3])// 3
    Math.max.apply('', [1, 2, 3])// 3
    Math.max.apply(0 , [1, 2, 3])// 3
    
    // 用 apply 将数组各项添加到另一个数组
    const array = ['a', 'b'];
    const elements = [0, 1, 2];
    array.push.apply(array, elements);
    
    

注意:如果按上面方式调用 apply,有超出 JavaScript 引擎参数长度上限的风险。一个方法传入过多参数(比如一万个)时的后果在不同 JavaScript 引擎中表现不同。(JavaScriptCore 引擎中有被硬编码的参数个数上限:65536)。

2.2 如果你的参数数组可能非常大,那么推荐使用下面这种混合策略:将数组切块后循环传入目标方法

function minOfArray(arr) {
  let min = Infinity;
  let QUANTUM = 32768; //最大切割长度,刚好是参数数量限定的最大值一半

  //数组长度小于32768只切割一次,大于32768且小于65536切割两次,大于65536小于(65536+32768)三次,以此类推
  for (let i = 0, len = arr.length; i < len; i += QUANTUM) {
    const submin = Math.min.apply(null, arr.slice(i, Math.min(i + QUANTUM, len)));
    min = Math.min(submin, min);
  }

  return min;
}

let min = minOfArray([5, 6, 2, 3, 7]);

2.3 使用apply来链接构造器
下面例子中我创建一个全局Function对象的construct方法 ,让构造器能够使用一个类数组对象参数而非参数列表。

// 给全局函数对象添加 construct 方法
Function.prototype.construct = function (aArgs) {
  const oNew = Object.create(this.prototype); // 目的是得到原型为 MyConstructor 的对象
  this.apply(oNew, aArgs);
  /* “原汤化原食”
  通过 apply 链接 this 指向的构造器函数,
  调用者是该构造器函数创建的对象实例
  */
  return oNew;
};

function MyConstructor() { // 传入不确定数量的参数,使用arguments无需arguments作为形参
  for (let nProp = 0; nProp < arguments.length; nProp++) {
    this['property' + nProp] = arguments[nProp];// this 指向的调用者对象更新属性
  }
}

let myArray = [4, 'Hello world!', false];
let myInstance = MyConstructor.construct(myArray);

console.log(myInstance.property1);                // logs 'Hello world!'
console.log(myInstance instanceof MyConstructor); // logs 'true'
console.log(myInstance.constructor);              // logs 'MyConstructor'

同理,我将construct方法稍加修改也能得到我预期的结果。

Function.prototype.construct = function (arr) {
  const oNew = Object.create({});// 以字面量对象 Object 为原型创建“白板”对象
  this.apply(oNew, arr);
  return oNew;
}

function MyConstructor() {
  let len = arguments.length
  for (let i=0; i<len; i++) {
    this['property' + i] = arguments[i];
  }
}

let myArray = [4, 'Hello world!', false];
let myInstance = MyConstructor.construct(myArray);

console.log(myInstance.property1);                // logs 'Hello world!'
console.log(myInstance instanceof MyConstructor); // logs 'false'
console.log(myInstance.constructor);              // logs 'Object'
  1. bind()创建一个新的函数,在bind()被调用时,这个新函数的this被指定为bind()的第一个参数,而其余参数将作为新函数的参数,供调用时使用。
    3.1 普通使用情形
    const a = {
      name: 'yulin',
      fn: function (e, q) {
      console.log(this.name);
      console.log(e + q);
      }
    };
    
    const b = {
      name: 'yhh'
    }
    
    var x = a.fn
    
    x.bind(b);
    /*ƒ (e, q) {
      console.log(this.name);
      console.log(e + q);
    }*/
    
    let y = x.bind(b);
    y(2, 3);//yhh 5
    
    3.2 bind解决回调函数丢失this
    一个函数不论原先它的this指向谁。一旦作为回调函数(或定时回调)执行,它将遗忘原有的this并指向全局对象(严格模式同样适用)
    例子1:
    const person = {
      firstName:"John",
      lastName: "Doe",
      display: function () {
        console.log(this.firstName + " " + this.lastName);
      }
    }
    
    person.display();// 'John Doe'
    setTimeout(person.display, 3000);// undefined undefined
    
    例子2:
    var firstName = 'yu', lastName = 'lin';
    
    const person = {
      firstName:"John",
      lastName: "Doe",
      display: function () {
    	console.log(this.firstName + " " + this.lastName);
      }
    }
    
    person.display(); // 'John Doe'
    setTimeout(person.display, 3000); // 'yu lin'
    
    对于上面this的丢失,有两种方法可以解决:
    推荐使用箭头函数作为回调函数,箭头函数不会创建自己的this,它只会从自己的作用域链的父执行上下文继承this。并且作为回调函数不会丢失this值:
    var firstName = 'yu', lastName = 'lin';
    
    const person = {
      firstName:"John",
      lastName: "Doe",
      display: () => {
    	console.log(this.firstName + " " + this.lastName);
      }
    }
    
    let display = person.display.bind(person);// 返回 this 指向 person 的绑定函数 display
    setTimeout(display, 3000); // 'yu lin'
    
    使用函数bind方法绑定this:
    var firstName = 'yu', lastName = 'lin';
    
    const person = {
      firstName:"John",
      lastName: "Doe",
      display: function () {
    	console.log(this.firstName + " " + this.lastName);
      }
    }
    
    let display = person.display.bind(person);// 返回 this 指向 person 的绑定函数 display
    setTimeout(display, 3000); // 'John Doe'
    
posted @ 2022-03-21 23:05  mx羽林  阅读(108)  评论(0)    收藏  举报