细读 JS | this 详解

发布于 2023-05-13 14:38:59 字数 16134 浏览 56 评论 0

我相信很多人会将 this 和作用域混淆在一起理解,其实它们完全是两回事。例如 this.xxxconsole.log(xxx) 有什么不同呢?前者是查找当前 this 所指对象上的 xxx 属性,后者是在当前作用域链上查找变量 xxx

var foo = {
  bar: 'property', // 属性
  fn: function () {
    var bar = 'variable' // 变量
    console.log(this.bar)
    // 这里的 this.bar 永远不会取到 bar 变量,
    // 可能从 foo 对象或 window 对象上查找 bar 属性(视乎函数调用方式)
  }
}
foo.fn() // "property"

作用域与函数的调用方式无关,而 this 则与函数调用方式相关。就 this 问题写了两篇文章:

一、this 是什么?

MDN 上原话是:

The this keyword refers to the current object the code is being written inside.

翻译过来就是:在 JavaScript 中,关键字 this 指向了当前代码运行时的对象。

如果我是刚接触 JavaScript 的话,看到这句话应该会有很多问号???

二、为什么需要设计 this?

假设我们有一个 person 对象,该对象有一个 name 属性和 sayHi() 方法。然后我们的需求是,sayHi() 方法要打印出如下信息:

var person = {
  name: 'Frankie',
  sayHi: function() {
    // 若该方法的功能是,打印出:Hi, my name is Frankie.
    // 要如何实现?
  }
}

方案一

最愚蠢的方案,如下:

var person = {
  name: 'Frankie',
  sayHi: function(name) {
    console.log('Hi, my name is ' + name + '.')
  }
}

person.sayHi(person.name) // Hi, my name is Frankie.

方案二

方案一在每次调用方法都需要传入 person.name 参数,还不如传入一个 person 参数。试图优化一下:

var person = {
  name: 'Frankie',
  sayHi: function(context) {
    console.log('Hi, my name is ' + context.name + '.')
  }
}

person.sayHi(person) // Hi, my name is Frankie.

先卖个关子,这种方案看着是不是跟 Function.prototype.call() 有点相似?

方案三

在方案二里方法的调用方式,看着还是有点不雅。能不能把形参 context 也省略掉,然后通过 person.sayHi() 直接调用方法?

当然可以,但此时的形参 context 要换成 JavaScript 中的保留关键字 this

var person = {
  name: 'Frankie',
  sayHi: function() {
    console.log('Hi, my name is ' + this.name + '.')
  }
}

person.sayHi() // Hi, my name is Frankie.

但是为什么 this.name 的值等于 person.name 的属性值呢?还有 this 哪来的?原来 this 指向函数运行时所在的环境(执行上下文)。

实际上,this 可以理解为函数中的第一个形参(看不见的形参),在你调用 person.sayHi() 的时候,JavaScript 引擎会自动帮你把 person 绑定到 this 上。所以当你通过 this.name 访问属性时,其实就是 person.name

三、了解 this

到目前为止,好像 this 也没那么神秘,没那么难理解嘛!注意,本小节示例均在非严格模式下,除非有特殊说明。

再看:

var person = {
  name: 'Frankie',
  sayHi: function() {
    console.log('Hi, my name is ' + this.name + '.')
  }
}

var name = 'Mandy'
var sayHi = person.sayHi

// 写法一
person.sayHi() // Hi, my name is Frankie.

// 写法二,Why???
sayHi() // Hi, my name is Mandy.

上面示例中,person.sayHisayHi 明明都指向同一个函数,但为什么执行结果不一样呢?

原因就是函数内部使用了 this 关键字。上面提到 this 指的是函数运行时所在的环境。对于 person.sayHi() 来说,sayHi 运行在 person 环境,所以 this 指向了 person;对于 sayHi() 来说,sayHi 运行在全局环境,所以 this 指向了全局环境。(可在 sayHi 函数体内打印 this 来对比)

那么,函数的运行环境是怎么决定的呢?

内存的数据结构

下面先了解一下 JavaScript 内存的数据结构。JavaScript 之所以有 this 的设计,跟内存里面的数据结构有关系。

var person = { name: 'Frankie' }

上面的示例将一个对象赋值给变量 person。JavaScript 引擎会先在内存里面,生成一个对象 { name: 'Frankie' },然后把这个对象的内存地址赋值给变量 person

也就是说,变量 person 是一个地址(reference)。后面如果要读取 person.name,JavaScript 引擎先从 person 拿到内存地址,然后再从该地址读出原始的对象,返回它的 name 属性。

原始的对象以字典结构保存,每一个属性名都对应一个属性描述对象。举例来说,上面的例子 name 属性,实际上是以下面的形式保存的。

{
  name: {
    [[value]]: 'Frankie',
    [[writable]]: true,
    [[enumerable]]: true,
    [[configurable]]: true
  }
}

这样的结构是很清晰的,问题在于属性的值可能是一个函数。

var person = {
  sayHi: function() {}
}

这时,JavaScript 引擎会将函数单独保存在内存中,然后再将函数的地址赋值给 sayHi 属性的 value 属性。

{
  name: {
    [[value]]: 函数的地址,
    [[writable]]: true,
    [[enumerable]]: true,
    [[configurable]]: true
  }
}

由于函数是一个单独的值,所以它可以在不同的环境(上下文)执行。

var fn = function() {}
var obj = { fn: fn }

// 单独执行
fn()

// obj 环境执行
obj.fn()

环境变量

JavaScript 允许在函数体内部,引用当前环境的其他变量。

var sayHi = function() {
  console.log('Hi, my name is ' + name + '.')
}

上面示例中,函数体里面使用了变量 name。该变量由运行环境提供。

现在问题来了,由于函数可以在不同的运行环境执行,所以需要有一种机制,能够在函数体内部获取当前的运行环境(context)。所以, this 就出现了,它的设计目的就是在函数内部,指代函数当前的运行环境。

var sayHi = function() {
  console.log('Hi, my name is ' + this.name + '.')
}

上面的示例中,函数体内部的 this.name 就是指当前运行环境的 。

var sayHi = function() {
  console.log('Hi, my name is ' + this.name + '.')
}

var name = 'Mandy'
var person = {
  name: 'Frankie',
  sayHi: sayHi
}

// 单独执行
sayHi() // Hi, my name is Mandy.

// person 环境执行
person.sayHi() // Hi, my name is Frankie.

上面的示例中,函数 sayHi 在全局环境执行,this.name 指向全局环境的 name

person 环境执行,this.name 指向 person.name

回到本小节开头的示例中:

var person = {
  name: 'Frankie',
  sayHi: function() {
    console.log('Hi, my name is ' + this.name + '.')
  }
}

var name = 'Mandy'
var sayHi = person.sayHi

// 写法一
person.sayHi() // Hi, my name is Frankie.

// 写法二,Why???
sayHi() // Hi, my name is Mandy.

person.sayHi() 是通过 person 找到 sayHi,所以就是在 person 环境执行。一旦 var sayHi = person.sayHi,变量 sayHi 就直接指向函数本身,所以 sayHi() 就变成在全局环境执行。

在 JavaScript 中,this 的四种绑定规则,而以上的示例 this 均属于默认绑定的。

四、函数调用

上面有提到 Function.prototype.call(),下面先聊聊这个方法。顺便提一下,call()apply() 方法类似,区别只有一个,call() 方法接受的是参数列表,而 apply() 方法接受的是一个参数数组

call() 允许为不同的对象分配和调用属于一个对象的函数/方法。

1. call 语法

function.call(thisArg, arg1, arg2, ...)
  • thisArg(可选)
    在 function 函数运行时使用的 this 值。请注意,this 可能不是该方法看到的实际值。
    非严格模式下,若参数指定为 nullundefinedthis 会自动替换为指向全局对象
  • arg1, arg2, ...
    指定的参数列表。

2. 使用 call 方法调用函数,且不指定第一参数

此时分为两种情况:严格模式和非严格模式,两者在 this 的绑定上有区别。

// 非严格模式下
var thing = 'world'

function hello(thing) {
  console.log('hello ' + this.thing)
}

hello.call() // hello world
// 等价于以下三种方式,均打印出 hello world
hello()
hello.call(null)
hello.call(undefined)

但在严格模式下,this 的值将会是 undefined。所以下面示例会报错。

// 严格模式下
'use strict'

var thing = 'world'

function hello(thing) {
  console.log('hello ' + this.thing)
}

hello.call() // TypeError: Cannot read property 'thing' of undefined

3. 普通函数调用(小结)

在 JavaScript 中,其实所有的函数原始的调用方式是这样的:

function.call(thisArg, arg1, arg2, ...)
function fn() { }

// 加糖调用
fn()
fn(x)

// 脱糖调用(desugar)& 严格模式
fn.call(undefined)
fn.call(undefined, x)

// 脱糖调用(desugar)& 非严格模式
fn.call(window)
fn.call(window, x)

fn() 其实就是 fn.call() 的语法糖形式。注意,当函数被调用时,函数的 this 值才会被绑定。普通函数调用可以总结成这样:

fn(...args)
// 等价于
fn.call(window [ES5-strict: undefined], ...args)


(function() {})()
// 等价于
(function() {}).call(window [ES5-strict: undefined])

4. 成员函数调用(方法)

这也是一种非常常见的调用方式,又回到开头的那个示例。

var person = {
  name: 'Frankie',
  sayHi: function() {
    console.log('Hi, my name is ' + this.name + '.')
  }
}

person.sayHi() // Hi, my name is Frankie.
// 等价于
person.sayHi.call(person) // Hi, my name is Frankie.

根据前面讲述的函数在内存中的数据结构,以上示例跟下面动态地将 sayHi 方法绑定到 person 上是一致的。

function sayHi() {
  console.log('Hi, my name is ' + this.name + '.')
}
var person = { name: 'Frankie' }
var name = 'Mandy'

// 动态添加到 person 上
person.sayHi = sayHi

person.sayHi() // Hi, my name is Frankie.
sayHi() // Hi, my name is Mandy.

以上两者区别是,前一个例子的 sayHi 函数只能通过变量 person 在内存中根据变量存放的对象地址找到对象,该对象下有一个 sayHi 属性,该属性的 [[value]] 存放的函数地址,所以只能通过 person.sayHi() 进行调用。而后一个例子中 sayHi 函数除了前面提到的调用方式外,还可以直接通过变量 sayHi 去调用该函数(sayHi())。

注意,sayHi 函数并没有在编写代码时确定 this 的指向。this 总是在函数被调用时,根据其调用的环境进行设置。

四、this 的绑定方式

this 是 JavaScript 的一个关键字,它是函数运行时,在函数体内部自动生成的一个对象,只能在函数体内部使用。

function fn() {
  this.x = 1
}

上面示例中,函数 fn 运行时,内部会自动有一个 this 可以使用。函数的不同使用场景,this 会有不同的值。总的来说,this 就是函数运行时所在的环境对象。

1. 普通函数调用(默认绑定)

这是函数最常用的用法了,属于全局性调用,因此 this 就代表全局对象。注意,全局对象在不同宿主环境下是有区别的。在浏览器环境下全局对象是 window,而在 Node 环境下,全局对象是 global

var x = 1

function fn() {
  console.log(this.x)
}

fn() // 1

注意,如果使用了 ES6 的 letconst 去声明变量 x 时,结果又稍微有点不同了!

let x = 1

function fn() {
  // 注意,若严格模式下,this 为 undefined,所以执行 this.x 就会报错
  console.log(this.x)
}

fn() // undefined

原因很简单。首先在 fn 运行时,this 仍然指向 window,只不过 window 对象下没有 x 属性,所以打印了 undefined。为什么会这样呢?

// 以下两种方式,其实都往 window 对象添加了 x、y 属性,并赋值。
x = 1
var y = 2

// 但在 ES6 之后,做出了改变
// 使用了 let、const 或者 class 来定义变量或类,都不会往顶层对象添加对应属性
let x = 1
const y = 2
class Fn {}

关于这块内容,可以看另外一篇文章:关于 var、let 的顶层对象的属性

2. 作为对象方法调用(隐式绑定)

函数还作为某个对象的方法调用,这时 this 就指向这个上级对象。

function fn() {
  console.log(this.x)
}

var obj = {
  x: 1,
  fn: fn
}

obj.fn() // 1

// 这里我又再次提一下,但不解释了。如果还不懂,从头再看一遍。
fn() // undefined

3. 通过 call、apply 调用(显式绑定)

这两个方法的参数以及区别不再赘述,上面已经讲过了。

var x = 0

function fn() {
  console.log(this.x)
}

var obj = {
  x: 1,
  fn: fn
}

obj.fn.call() // 0,此时 this 指向全局对象
obj.fn.call(obj) // 1,此时 this 指向 obj 对象

4. 作为构造函数调用(new 绑定)

所谓构造函数,就是通过这个函数,可以生成一个新对象。这时,this 就指向这个新对象(实例)。

function Fn() {
  this.x = 1
}

var obj = new Fn()
console.log(obj.x) // 1

为了表明这时 this 不是指向全局对象,我们修改一下代码:

var x = 'global'

function Fn() {
  this.x = 'local'
}

var obj = new Fn()

console.log(obj.x) // "local"
console.log(x) // "global"

根据结果,我们可以看到全局变量 x 没有发生变化。以上几种绑定方式,优先级如下:

  1. 只要使用 new 关键字调用,无论是否还有 callapply 绑定,this 均指向实例化对象。
  2. 通过 callapply 或者 bind 显式绑定,this 指向该绑定对象(第一个参数缺省时,根据是否为严格模式,this 指向全局对象或者 undefined)。
  3. 函数通过上下文对象调用,this 指向(最后)调用它的对象。
  4. 如以上均没有,则会默认绑定。严格模式下,this 指向 undefined,否则指向全局对象。

五、其他

1. 箭头函数

箭头函数,没有自己的 thisargumentssupernew.target,并且它不能用作构造函数。由于没有 this,因此它不能绑定 this

const obj = {
  x: 1,
  fn1: () => console.log(this.x, this),
  fn2: function() {
    console.log(this.x, this)
  }
}

obj.fn1() // undefined, Window{...}
obj.fn1.call(obj) // undefined, Window{...}
obj.fn2() // 1, Object{...}

2. 事件处理函数

在事件处理函数中,不同的使用方式,会导致 this 指向不同的对象,你知道吗?详见:在事件处理函数中的 this

六:思考题

抛下两个题,可以分析分析为什么结果不一样。

例一:

var length = 1

function foo() {
  console.log(this.length)
}

const obj = {
  length: 2,
  bar: function (cb) {
    cb()
  }
}

obj.bar(foo, 'p1', 'p2') // 1

例二:

var length = 1

function foo() {
  console.log(this.length)
}

const obj = {
  length: 2,
  bar: function (cb) {
    arguments[0]()
  }
}

obj.bar(foo, 'p1', 'p2') // 3

七、参考

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

治碍

暂无简介

0 文章
0 评论
22 人气
更多

推荐作者

yili302

文章 0 评论 0

晚霞

文章 0 评论 0

LLFFCC

文章 0 评论 0

陌路黄昏

文章 0 评论 0

xiaohuihui

文章 0 评论 0

你与昨日

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文