天天看點

JavaScript初探系列(五)——this指向

一、涵義

this

關鍵字是一個非常重要的文法點。毫不誇張地說,不了解它的含義,大部分開發任務都無法完成。

this

可以用在構造函數之中,表示執行個體對象。除此之外,

this

還可以用在别的場合。但不管是什麼場合,

this

都有一個共同點:它總是傳回一個對象。簡單說,

this

就是屬性或方法“目前”所在的對象。

this.property       

上面代碼中,

this

就代表

property

屬性目前所在的對象。下面是一個實際的例子。

var person = {
  name: '張三',
  describe: function () {
    return '姓名:'+ this.name;
  }
};

person.describe()
// "姓名:張三"      

this.name

表示

name

屬性所在的那個對象。由于

this.name

是在

describe

方法中調用,而

describe

方法所在的目前對象是

person

,是以

this

指向

person

this.name

就是

person.name

。由于對象的屬性可以賦給另一個對象,是以屬性所在的目前對象是可變的,即

this

的指向是可變的。

var A = {
  name: '張三',
  describe: function () {
    return '姓名:'+ this.name;
  }
};

var B = {
  name: '李四'
};

B.describe = A.describe;
B.describe()
// "姓名:李四"      

A.describe

屬性被賦給

B

,于是

B.describe

就表示

describe

B

,是以

this.name

就指向

B.name

。稍稍重構這個例子,

this

的動态指向就能看得更清楚。

function f() {
  return '姓名:'+ this.name;
}

var A = {
  name: '張三',
  describe: f
};

var B = {
  name: '李四',
  describe: f
};

A.describe() // "姓名:張三"
B.describe() // "姓名:李四"      

上面代碼中,函數

f

内部使用了

this

關鍵字,随着

f

所在的對象不同,

this

的指向也不同。隻要函數被賦給另一個變量,

this

的指向就會變。

var A = {
  name: '張三',
  describe: function () {
    return '姓名:'+ this.name;
  }
};

var name = '李四';
var f = A.describe;
f() // "姓名:李四      

A.describe

被指派給變量

f

,内部的

this

就會指向

f

運作時所在的對象(本例是頂層對象)。再看一個網頁程式設計的例子。

<input type="text" name="age" size=3 onChange="validate(this, 18, 99);">

<script>
function validate(obj, lowval, hival){
  if ((obj.value < lowval) || (obj.value > hival))
    console.log('Invalid Value!');
}
</script>      

上面代碼是一個文本輸入框,每當使用者輸入一個值,就會調用

onChange

回調函數,驗證這個值是否在指定範圍。浏覽器會向回調函數傳入目前對象,是以

this

就代表傳入目前對象(即文本框),然後就可以從

this.value

上面讀到使用者的輸入值。

總結一下,JavaScript 語言之中,一切皆對象,運作環境也是對象,是以函數都是在某個對象之中運作,

this

就是函數運作時所在的對象(環境)。這本來并不會讓使用者糊塗,但是 JavaScript 支援運作環境動态切換,也就是說,

this

的指向是動态的,沒有辦法事先确定到底指向哪個對象,這才是最讓初學者感到困惑的地方。

二、實質

JavaScript 語言之是以有 this 的設計,跟記憶體裡面的資料結構有關系。

var obj = { foo:  5 };      

上面的代碼将一個對象指派給變量

obj

。JavaScript 引擎會先在記憶體裡面,生成一個對象

{ foo: 5 }

,然後把這個對象的記憶體位址指派給變量

obj

。也就是說,變量

obj

是一個位址(reference)。後面如果要讀取

obj.foo

,引擎先從

obj

拿到記憶體位址,然後再從該位址讀出原始的對象,傳回它的

foo

屬性。

原始的對象以字典結構儲存,每一個屬性名都對應一個屬性描述對象。舉例來說,上面例子的

foo

屬性,實際上是以下面的形式儲存的。

{
  foo: {
    [[value]]: 5
    [[writable]]: true
    [[enumerable]]: true
    [[configurable]]: true
  }
}      

注意,

foo

屬性的值儲存在屬性描述對象的

value

屬性裡面。

這樣的結構是很清晰的,問題在于屬性的值可能是一個函數。

var obj = { foo: function () {} };      

這時,引擎會将函數單獨儲存在記憶體中,然後再将函數的位址指派給

foo

屬性的

value

{
  foo: {
    [[value]]: 函數的位址
    ...
  }
}      

由于函數是一個單獨的值,是以它可以在不同的環境(上下文)執行。

var f = function () {};
var obj = { f: f };

// 單獨執行
f()

// obj 環境執行
obj.f()      

JavaScript 允許在函數體内部,引用目前環境的其他變量。

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

上面代碼中,函數體裡面使用了變量

x

。該變量由運作環境提供。

現在問題就來了,由于函數可以在不同的運作環境執行,是以需要有一種機制,能夠在函數體内部獲得目前的運作環境(context)。是以,

this

就出現了,它的設計目的就是在函數體内部,指代函數目前的運作環境。

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

上面代碼中,函數體裡面的

this.x

就是指目前運作環境的

x

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

var x = 1;
var obj = {
  f: f,
  x: 2,
};

// 單獨執行
f() // 1

// obj 環境執行
obj.f() // 2      

f

在全局環境執行,

this.x

指向全局環境的

x

;在

obj

環境執行,

this.x

obj.x

三、使用場合

this

主要有以下幾個使用場合。

(1)全局環境

全局環境使用

this

,它指的就是頂層對象

window

this === window // true

function f() {
  console.log(this === window);
}
f() // true      

上面代碼說明,不管是不是在函數内部,隻要是在全局環境下運作,

this

就是指頂層對象

window

(2)構造函數

構造函數中的

this

,指的是執行個體對象。

var Obj = function (p) {
  this.p = p;
};      

上面代碼定義了一個構造函數

Obj

。由于

this

指向執行個體對象,是以在構造函數内部定義

this.p

,就相當于定義執行個體對象有一個

p

var o = new Obj('Hello World!');
o.p // "Hello World!"      

(3)對象的方法

如果對象的方法裡面包含

this

this

的指向就是方法運作時所在的對象。該方法指派給另一個對象,就會改變

this

的指向。

但是,這條規則很不容易把握。請看下面的代碼。

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

obj.foo() // obj      

obj.foo

方法執行時,它内部的

this

obj

但是,下面這幾種用法,都會改變

this

// 情況一
(obj.foo = obj.foo)() // window
// 情況二
(false || obj.foo)() // window
// 情況三
(1, obj.foo)() // window      

obj.foo

就是一個值。這個值真正調用的時候,運作環境已經不是

obj

了,而是全局環境,是以

this

不再指向

obj

可以這樣了解,JavaScript 引擎内部,

obj

obj.foo

儲存在兩個記憶體位址,稱為位址一和位址二。

obj.foo()

這樣調用時,是從位址一調用位址二,是以位址二的運作環境是位址一,

this

obj

。但是,上面三種情況,都是直接取出位址二進行調用,這樣的話,運作環境就是全局環境,是以

this

指向全局環境。上面三種情況等同于下面的代碼。

// 情況一
(obj.foo = function () {
  console.log(this);
})()
// 等同于
(function () {
  console.log(this);
})()

// 情況二
(false || function () {
  console.log(this);
})()

// 情況三
(1, function () {
  console.log(this);
})()      

如果

this

所在的方法不在對象的第一層,這時

this

隻是指向目前一層的對象,而不會繼承更上面的層。

var a = {
  p: 'Hello',
  b: {
    m: function() {
      console.log(this.p);
    }
  }
};

a.b.m() // undefined      

a.b.m

方法在

a

對象的第二層,該方法内部的

this

不是指向

a

,而是指向

a.b

,因為實際執行的是下面的代碼。

var b = {
  m: function() {
   console.log(this.p);
  }
};

var a = {
  p: 'Hello',
  b: b
};

(a.b).m() // 等同于 b.m()      

如果要達到預期效果,隻有寫成下面這樣。

var a = {
  b: {
    m: function() {
      console.log(this.p);
    },
    p: 'Hello'
  }
};      

如果這時将嵌套對象内部的方法指派給一個變量,

this

依然會指向全局對象。

var a = {
  b: {
    m: function() {
      console.log(this.p);
    },
    p: 'Hello'
  }
};

var hello = a.b.m;
hello() // undefined      

m

是多層對象内部的一個方法。為求簡便,将其指派給

hello

變量,結果調用時,

this

指向了頂層對象。為了避免這個問題,可以隻将

m

所在的對象指派給

hello

,這樣調用時,

this

的指向就不會變。

var hello = a.b;
hello.m() // Hello      

四、使用注意點

(一)、避免多層 this

由于

this

的指向是不确定的,是以切勿在函數中包含多層的

this

var o = {
  f1: function () {
    console.log(this);
    var f2 = function () {
      console.log(this);
    }();
  }
}

o.f1()
// Object
// Window      

上面代碼包含兩層

this

,結果運作後,第一層指向對象

o

,第二層指向全局對象,因為實際執行的是下面的代碼。

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

var o = {
  f1: function () {
    console.log(this);
    var f2 = temp();
  }
}      

一個解決方法是在第二層改用一個指向外層

this

的變量。

var o = {
  f1: function() {
    console.log(this);
    var that = this;
    var f2 = function() {
      console.log(that);
    }();
  }
}

o.f1()
// Object
// Object      

上面代碼定義了變量

that

,固定指向外層的

this

,然後在内層使用

that

,就不會發生

this

指向的改變。事實上,使用一個變量固定

this

的值,然後内層函數調用這個變量,是非常常見的做法,請務必掌握。JavaScript 提供了嚴格模式,也可以硬性避免這種問題。嚴格模式下,如果函數内部的

this

指向頂層對象,就會報錯。

var counter = {
  count: 0
};
counter.inc = function () {
  'use strict';
  this.count++
};
var f = counter.inc;
f()
// TypeError: Cannot read property 'count' of undefined      

inc

方法通過

'use strict'

聲明采用嚴格模式,這時内部的

this

一旦指向頂層對象,就會報錯。

(二)、避免數組處理方法中的 this

數組的

map

foreach

方法,允許提供一個函數作為參數。這個函數内部不應該使用

this

var o = {
  v: 'hello',
  p: [ 'a1', 'a2' ],
  f: function f() {
    this.p.forEach(function (item) {
      console.log(this.v + ' ' + item);
    });
  }
}

o.f()
// undefined a1
// undefined a2      

foreach

方法的回調函數中的

this

,其實是指向

window

對象,是以取不到

o.v

的值。原因跟上一段的多層

this

是一樣的,就是内層的

this

不指向外部,而指向頂層對象。

解決這個問題的一種方法,就是前面提到的,使用中間變量固定

this

var o = {
  v: 'hello',
  p: [ 'a1', 'a2' ],
  f: function f() {
    var that = this;
    this.p.forEach(function (item) {
      console.log(that.v+' '+item);
    });
  }
}

o.f()
// hello a1
// hello a2      

另一種方法是将

this

當作

foreach

方法的第二個參數,固定它的運作環境。

var o = {
  v: 'hello',
  p: [ 'a1', 'a2' ],
  f: function f() {
    this.p.forEach(function (item) {
      console.log(this.v + ' ' + item);
    }, this);
  }
}

o.f()
// hello a1
// hello a2      

(三)、避免回調函數中的 this

回調函數中的

this

往往會改變指向,最好避免使用。

var o = new Object();
o.f = function () {
  console.log(this === o);
}

// jQuery 的寫法
$('#button').on('click', o.f);      

上面代碼中,點選按鈕以後,控制台會顯示

false

。原因是此時

this

o

對象,而是指向按鈕的 DOM 對象,因為

f

方法是在按鈕對象的環境中被調用的。這種細微的差别,很容易在程式設計中忽視,導緻難以察覺的錯誤。

為了解決這個問題,可以采用下面的一些方法對

this

進行綁定,也就是使得

this

固定指向某個對象,減少不确定性。

五、綁定 this 的方法

this

的動态切換,固然為 JavaScript 創造了巨大的靈活性,但也使得程式設計變得困難和模糊。有時,需要把

this

固定下來,避免出現意想不到的情況。JavaScript 提供了

call

apply

bind

這三個方法,來切換/固定

this

(一)、Function.prototype.call()

函數執行個體的

call

方法,可以指定函數内部

this

的指向(即函數執行時所在的作用域),然後在所指定的作用域中,調用該函數。

var obj = {};

var f = function () {
  return this;
};

f() === window // true
f.call(obj) === obj // true      

上面代碼中,全局環境運作函數

f

時,

this

指向全局環境(浏覽器為

window

對象);

call

方法可以改變

this

的指向,指定

this

指向對象

obj

,然後在對象

obj

的作用域中運作函數

f

call

方法的參數,應該是一個對象。如果參數為空、

null

undefined

,則預設傳入全局對象。

var n = 123;
var obj = { n: 456 };

function a() {
  console.log(this.n);
}

a.call() // 123
a.call(null) // 123
a.call(undefined) // 123
a.call(window) // 123
a.call(obj) // 456      

a

函數中的

this

關鍵字,如果指向全局對象,傳回結果為

123

。如果使用

call

方法将

this

關鍵字指向

obj

對象,傳回結果為

456

。可以看到,如果

call

方法沒有參數,或者參數為

null

undefined

,則等同于指向全局對象。

call

方法的參數是一個原始值,那麼這個原始值會自動轉成對應的包裝對象,然後傳入

call

方法。

var f = function () {
  return this;
};

f.call(5)
// Number {[[PrimitiveValue]]: 5}      

call

的參數為

5

,不是對象,會被自動轉成包裝對象(

Number

的執行個體),綁定

f

内部的

this

call

方法還可以接受多個參數。

func.call(thisValue, arg1, arg2, ...)      

call

的第一個參數就是

this

所要指向的那個對象,後面的參數則是函數調用時所需的參數。

function add(a, b) {
  return a + b;
}

add.call(this, 1, 2) // 3      

call

方法指定函數

add

this

綁定目前環境(對象),并且參數為

1

2

,是以函數

add

運作後得到

3

call

方法的一個應用是調用對象的原生方法。

var obj = {};
obj.hasOwnProperty('toString') // false

// 覆寫掉繼承的 hasOwnProperty 方法
obj.hasOwnProperty = function () {
  return true;
};
obj.hasOwnProperty('toString') // true

Object.prototype.hasOwnProperty.call(obj, 'toString') // false      

hasOwnProperty

obj

對象繼承的方法,如果這個方法一旦被覆寫,就不會得到正确結果。

call

方法可以解決這個問題,它将

hasOwnProperty

方法的原始定義放到

obj

對象上執行,這樣無論

obj

上有沒有同名方法,都不會影響結果。

(二)、Function.prototype.apply()

apply

方法的作用與

call

方法類似,也是改變

this

指向,然後再調用該函數。唯一的差別就是,它接收一個數組作為函數執行時的參數,使用格式如下。

func.apply(thisValue, [arg1, arg2, ...])       

apply

方法的第一個參數也是

this

所要指向的那個對象,如果設為

null

undefined

,則等同于指定全局對象。第二個參數則是一個數組,該數組的所有成員依次作為參數,傳入原函數。原函數的參數,在

call

方法中必須一個個添加,但是在

apply

方法中,必須以數組形式添加。

function f(x, y){
  console.log(x + y);
}

f.call(null, 1, 1) // 2
f.apply(null, [1, 1]) // 2      

f

函數本來接受兩個參數,使用

apply

方法以後,就變成可以接受一個數組作為參數。

利用這一點,可以做一些有趣的應用。

(1)找出數組最大元素

JavaScript 不提供找出數組最大元素的函數。結合使用

apply

方法和

Math.max

方法,就可以傳回數組的最大元素。

var a = [10, 2, 4, 15, 9];
Math.max.apply(null, a) // 15      

(2)将數組的空元素變為

undefined

通過

apply

方法,利用

Array

構造函數将數組的空元素變成

undefined

Array.apply(null, ['a', ,'b'])
// [ 'a', undefined, 'b' ]      

空元素與

undefined

的差别在于,數組的

forEach

方法會跳過空元素,但是不會跳過

undefined

。是以,周遊内部元素的時候,會得到不同的結果。

var a = ['a', , 'b'];

function print(i) {
  console.log(i);
}

a.forEach(print)
// a
// b

Array.apply(null, a).forEach(print)
// a
// undefined
// b      

(3)轉換類似數組的對象

另外,利用數組對象的

slice

方法,可以将一個類似數組的對象(比如

arguments

對象)轉為真正的數組。

Array.prototype.slice.apply({0: 1, length: 1}) // [1]
Array.prototype.slice.apply({0: 1}) // []
Array.prototype.slice.apply({0: 1, length: 2}) // [1, undefined]
Array.prototype.slice.apply({length: 1}) // [undefined]      

上面代碼的

apply

方法的參數都是對象,但是傳回結果都是數組,這就起到了将對象轉成數組的目的。從上面代碼可以看到,這個方法起作用的前提是,被處理的對象必須有

length

屬性,以及相對應的數字鍵。

(4)綁定回調函數的對象

前面的按鈕點選事件的例子,可以改寫如下。

var o = new Object();

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

var f = function (){
  o.f.apply(o);
  // 或者 o.f.call(o);
};

// jQuery 的寫法
$('#button').on('click', f);      

上面代碼中,點選按鈕以後,控制台将會顯示

true

apply

方法(或者

call

方法)不僅綁定函數執行時所在的對象,還會立即執行函數,是以不得不把綁定語句寫在一個函數體内。更簡潔的寫法是采用下面介紹的

bind

(三)、Function.prototype.bind()

bind

方法用于将函數體内的

this

綁定到某個對象,然後傳回一個新函數。

var d = new Date();
d.getTime() // 1481869925657

var print = d.getTime;
print() // Uncaught TypeError: this is not a Date object.      

上面代碼中,我們将

d.getTime

方法賦給變量

print

,然後調用

print

就報錯了。這是因為

getTime

方法内部的

this

,綁定

Date

對象的執行個體,賦給變量

print

以後,内部的

this

已經不指向

Date

對象的執行個體了。

bind

方法可以解決這個問題。

var print = d.getTime.bind(d);
print() // 1481869925657      

bind

getTime

this

綁定到

d

對象,這時就可以安全地将這個方法指派給其他變量了。

bind

方法的參數就是所要綁定

this

的對象,下面是一個更清晰的例子。

var counter = {
  count: 0,
  inc: function () {
    this.count++;
  }
};

var func = counter.inc.bind(counter);
func();
counter.count // 1      

counter.inc

方法被指派給變量

func

。這時必須用

bind

inc

this

,綁定到

counter

,否則就會出錯。

this

綁定到其他對象也是可以的。

var counter = {
  count: 0,
  inc: function () {
    this.count++;
  }
};

var obj = {
  count: 100
};
var func = counter.inc.bind(obj);
func();
obj.count // 101      

bind

inc

this

obj

對象。結果調用

func

函數以後,遞增的就是

obj

count

bind

還可以接受更多的參數,将這些參數綁定原函數的參數。

var add = function (x, y) {
  return x * this.m + y * this.n;
}

var obj = {
  m: 2,
  n: 2
};

var newAdd = add.bind(obj, 5);
newAdd(5) // 20      

bind

方法除了綁定

this

對象,還将

add

函數的第一個參數

x

綁定成

5

,然後傳回一個新函數

newAdd

,這個函數隻要再接受一個參數

y

就能運作了。

bind

方法的第一個參數是

null

undefined

,等于将

this

綁定到全局對象,函數運作時

this

指向頂層對象(浏覽器為

window

)。

function add(x, y) {
  return x + y;
}

var plus5 = add.bind(null, 5);
plus5(10) // 15      

add

内部并沒有

this

,使用

bind

方法的主要目的是綁定參數

x

,以後每次運作新函數

plus5

,就隻需要提供另一個參數

y

就夠了。而且因為

add

内部沒有

this

bind

的第一個參數是

null

,不過這裡如果是其他對象,也沒有影響。

bind

方法有一些使用注意點。

(1)每一次傳回一個新函數

bind

方法每運作一次,就傳回一個新函數,這會産生一些問題。比如,監聽事件的時候,不能寫成下面這樣。

element.addEventListener('click', o.m.bind(o));      

click

事件綁定

bind

方法生成的一個匿名函數。這樣會導緻無法取消綁定,是以,下面的代碼是無效的。

element.removeEventListener('click', o.m.bind(o));       

正确的方法是寫成下面這樣:

var listener = o.m.bind(o);
element.addEventListener('click', listener);
//  ...
element.removeEventListener('click', listener);      

(2)結合回調函數使用

回調函數是 JavaScript 最常用的模式之一,但是一個常見的錯誤是,将包含

this

的方法直接當作回調函數。解決方法就是使用

bind

方法,将

counter.inc

綁定

counter

var counter = {
  count: 0,
  inc: function () {
    'use strict';
    this.count++;
  }
};

function callIt(callback) {
  callback();
}

callIt(counter.inc.bind(counter));
counter.count // 1      

callIt

方法會調用回調函數。這時如果直接把

counter.inc

傳入,調用時

counter.inc

this

就會指向全局對象。使用

bind

counter.inc

counter

以後,就不會有這個問題,

this

總是指向

counter

還有一種情況比較隐蔽,就是某些數組方法可以接受一個函數當作參數。這些函數内部的

this

指向,很可能也會出錯。

var obj = {
  name: '張三',
  times: [1, 2, 3],
  print: function () {
    this.times.forEach(function (n) {
      console.log(this.name);
    });
  }
};

obj.print()
// 沒有任何輸出      

obj.print

内部

this.times

this

是指向

obj

的,這個沒有問題。但是,

forEach

方法的回調函數内部的

this.name

卻是指向全局對象,導緻沒有辦法取到值。稍微改動一下,就可以看得更清楚。

obj.print = function () {
  this.times.forEach(function (n) {
    console.log(this === window);
  });
};

obj.print()
// true
// true
// true      

解決這個問題,也是通過

bind

方法綁定

this

obj.print = function () {
  this.times.forEach(function (n) {
    console.log(this.name);
  }.bind(this));
};

obj.print()
// 張三
// 張三
// 張三      

(3)結合

call

方法使用

利用

bind

方法,可以改寫一些 JavaScript 原生方法的使用形式,以數組的

slice

方法為例。

[1, 2, 3].slice(0, 1) // [1]
// 等同于
Array.prototype.slice.call([1, 2, 3], 0, 1) // [1]      

上面的代碼中,數組的

slice

方法從

[1, 2, 3]

裡面,按照指定位置和長度切分出另一個數組。這樣做的本質是在

[1, 2, 3]

上面調用

Array.prototype.slice

方法,是以可以用

call

方法表達這個過程,得到同樣的結果。

call

方法實質上是調用

Function.prototype.call

方法,是以上面的表達式可以用

bind

方法改寫。

var slice = Function.prototype.call.bind(Array.prototype.slice);
slice([1, 2, 3], 0, 1) // [1]      

上面代碼的含義就是,将

Array.prototype.slice

變成

Function.prototype.call

方法所在的對象,調用時就變成了

Array.prototype.slice.call

。類似的寫法還可以用于其他數組方法。

var push = Function.prototype.call.bind(Array.prototype.push);
var pop = Function.prototype.call.bind(Array.prototype.pop);

var a = [1 ,2 ,3];
push(a, 4)
a // [1, 2, 3, 4]

pop(a)
a // [1, 2, 3]      

如果再進一步,将

Function.prototype.call

方法綁定到

Function.prototype.bind

對象,就意味着

bind

的調用形式也可以被改寫。

function f() {
  console.log(this.v);
}

var o = { v: 123 };
var bind = Function.prototype.call.bind(Function.prototype.bind);
bind(f, o)() // 123      

Function.prototype.bind

方法綁定在

Function.prototype.call

上面,是以

bind

方法就可以直接使用,不需要在函數執行個體上使用。

參考資料:https://wangdoc.com/javascript/oop/this.html