this到底指向啥?看完這篇就知道了!

蔣鵬飛發表於2020-07-17

JS中的this是一個老生常談的問題了,因為它並不是一個確定的值,在不同情況下有不同的指向,所以也經常使人困惑。本篇文章會談談我自己對this的理解。

this到底是啥

其實this就是一個指標,它指示的就是當前的一個執行環境,可以用來對當前執行環境進行一些操作。因為它指示的是執行環境,所以在定義這個變數時,其實是不知道它真正的值的,只有執行時才能確定他的值。同樣一段程式碼,用不同的方式執行,他的this指向可能是不一樣的。我們來看看如下程式碼:

function func() {
  this.name = "小小飛";
  
  console.log(this);    // 看一下this是啥
}

這個方法很簡單,只是給this新增了一個name屬性,我們把這個方法複製到Chrome除錯工具看下結果:

image-20200226153116734

上圖中我們直接呼叫了func(),發現this指向的是window,name屬性新增到了window上。下面我們換一種呼叫方式,我們換成new func()來呼叫:

image-20200226153812137

我們看到輸出了兩個func {name: "小小飛"},一個是我們new返回的物件,另一個是方法裡面的console。這兩個值是一樣的,說明這時候方法裡面this就指向了new返回的物件,而不是前面例子的window了。這是因為當你使用new去呼叫一個方法時,這個方法其實就作為建構函式使用了,這時候的this指向的是new出來的物件。

下面我們分別講解下幾種情況

使用new呼叫時,this指向new出來的物件

這個規則其實是JS物件導向的一部分,JS使用了一種很曲折的方式來支援物件導向。當你用new來執行一個函式時,這個函式就變成了一個類,new關鍵字會返回一個類的例項給你,這個函式會充當建構函式的角色。作為物件導向的建構函式,必須要有能夠給例項初始化屬性的能力,所以建構函式裡面必須要有某種機制來操作生成的例項,這種機制就是this。讓this指向生成的例項就可以通過this來操作例項了。關於JS的物件導向更詳細的解釋可以看這篇文章。

this的這種特性還有一些妙用。一個函式可以直接呼叫,也可以用new呼叫,那假如我只想使用者通過new呼叫有沒有辦法呢?下圖擷取自Vue原始碼:

image-20200226160322071

Vue巧妙利用了this的特性,通過檢查this是不是Vue的一個例項來檢測使用者是通過new呼叫的還是直接呼叫的。

沒有明確呼叫者時,this指向window

這個其實在最開始的例子就講過了,那裡沒有明確呼叫者,this指向的是window。我們這裡講另外一個例子,函式裡面的函式,this指向誰?

function func() {
  function func2() {
    console.log('this:', this);   // 這裡的this指向誰?
  }
  
  func2();
}

我們執行一下看看:

直接執行:

image-20200226162443098

使用new執行:

image-20200226162626673

我們發現無論是直接執行,還是使用new執行,this的值都指向的window。直接執行時很好理解,因為沒有明確呼叫者,那this自然就是window。需要注意的是使用new時,只有被new的func才是建構函式,他的this指向new出來的物件,他裡面的函式的this還是指向window

有明確呼叫者時,this指向呼叫者

看這個例子:

var obj = {
  myName: "小小飛",
  func: function() {
    console.log(this.myName);
  }
}

obj.func();    // 小小飛

上述例子很好理解,因為呼叫者是obj,所以func裡面的this就指向obj,this.myName就是obj.myName。其實這一條和上一條可以合在一起,沒有明確呼叫者時其實隱含的呼叫者就是window,所以經常有人說this總是指向呼叫者

下面我們將這個例子稍微改一下:

var myName = "大飛哥";

var obj = {
  myName: "小小飛",
  func: function() {
    console.log(this.myName);
  }
}

var anotherFunc = obj.func;

anotherFunc();   // 輸出是啥?

這裡的輸出應該是“大飛哥”,因為雖然anotherFunc的函式體跟obj.func一樣,但是他的執行環境不一樣,他其實沒有明確的呼叫者,或者說呼叫者是window。這裡的this.myName其實是window.myName,也就是“大飛哥”。

我們將這個例子再改一下:

let myName = "大飛哥";

var obj = {
  myName: "小小飛",
  func: function() {
    console.log(this.myName);
  }
}

var anotherFunc = obj.func;

anotherFunc();   // 注意這裡輸出是undefined

這次我們只是將第一個var改成了let,但是我們的輸出卻變成了undefined。這是因為let,const定義變數,即使在最外層也不會變成window的屬性,只有var定義的變數才會成為window的屬性。

箭頭函式並不會繫結this

這句話的意思是箭頭函式本身並不具有this,箭頭函式在被申明確定this,這時候他會直接將當前作用域的this作為自己的this。還是之前的例子我們將函式改為箭頭函式:

var myName = "大飛哥";

var obj = {
  myName: "小小飛",
  func: () => {
    console.log(this.myName);
  }
}

var anotherFunc = obj.func;

obj.func();      // 大飛哥
anotherFunc();   // 大飛哥

上述程式碼裡面的obj.func()輸出也是“大飛哥”,是因為obj在建立時申明瞭箭頭函式,這時候箭頭函式會去尋找當前作用域,因為obj是一個物件,並不是作用域,所以這裡的作用域是window,this也就是window了。

再來看一個例子:

var myName = "大飛哥";

var obj = {
  myName: "小小飛",
  func: function () {
    return {
      getName: () => {
        console.log(this.myName);
      }
    }
  }
}

var anotherFunc = obj.func().getName;

obj.func().getName();      // 小小飛
anotherFunc();   // 小小飛

兩個輸出都是“小小飛”,obj.func().getName()輸出“小小飛”很好理解,這裡箭頭函式是在obj.func()的返回值裡申明的,這時他的this其實就是func()的this,因為他是被obj呼叫的,所以this指向obj。

那為什麼anotherFunc()輸出也是“小小飛”呢?這是因為anotherFunc()輸出的this,其實在anotherFunc賦值時就確定了:

  1. var anotherFunc = obj.func().getName;其實是先執行了obj.func()
  2. 執行obj.func()的時候getName箭頭函式被申明
  3. 這時候箭頭函式的this應該是當前作用域的this,也就是func()裡面的this
  4. func()因為是被obj呼叫,所以this指向obj
  5. 呼叫anotherFunc時,其實this早就確定了,也就是obj,最終輸出的是obj.myName

再來看一個建構函式裡面的箭頭函式,前面我們說了建構函式裡面的函式,直接呼叫時,他的this指向window,但是如果這個函式時箭頭函式呢:

var myName = "大飛哥";

function func() {
  this.myName = "小小飛";
  
  const getName = () => {
    console.log(this.myName);
  }
  
  getName();
}

new func(); // 輸出啥?

這裡輸出的是“小小飛”,原理還是一樣的,箭頭函式在申明時this確定為當前作用域的this,在這裡就是func的作用域,跟func的this一樣指向new出來的例項。如果不用new,而是直接呼叫,這裡的this就指向window。

DOM事件回撥裡面,this指向繫結事件的物件

function func(e) {
  console.log(this === e.currentTarget);   // 總是true
  console.log(this === e.target);          // 如果target等於currentTarget,這個就為true
}

const ele = document.getElementById('test');

ele.addEventListener('click', func);

currentTarget指的是繫結事件的DOM物件,target指的是觸發事件的物件。DOM事件回撥裡面this總是指向currentTarget,如果觸發事件的物件剛好是繫結事件的物件,即target === currentTarget,this也會順便指向target。如果回撥是箭頭函式,this是箭頭函式申明時作用域的this。

嚴格模式下this是undefined

function func() {
  "use strict"
  console.log(this);
}

func();   // 輸出是undefined

注意這裡說的嚴格模式下this是undefined是指在函式體內部,如果本身就在全域性作用域,this還是指向window。

<html>
  ...
  <script>
    "use strict"
    console.log(this);     // window
  </script>
  ...
</html>

this能改嗎

this是能改的,callapply都可以修改this,ES6裡面還新增了一個bind函式。

使用call和apply修改this

const obj = {
  myName: "大飛哥",
  func: function(age, gender) {
    console.log(`我的名字是${this.myName}, 我的年齡是${age},我是一個${gender}`);
  }
}

const obj2 = {
  myName: "小小飛"
}

obj.func.call(obj2, 18, "帥哥");  // 我的名字是小小飛, 我的年齡是18,我是一個帥哥

注意上面輸出的名字是"小小飛",也就是obj2.myName。正常直接呼叫obj.func()輸出的名字應該是obj.myName,也就是"大飛哥"。但是如果你使用call來呼叫,call的第一個引數就是手動指定的this。我們將它指定為obj2,那在函式裡面的this.myName其實就是obj2.myName了。

apply方法跟call方法作用差不多,只是後面的函式引數形式不同,使用apply呼叫應該這樣寫,函式引數應該放到一個陣列或者類陣列裡面:

obj.func.apply(obj2, [18, "帥哥"]);   // 我的名字是小小飛, 我的年齡是18,我是一個帥哥

之所以有call和apply兩個方法實現了差不多的功能,是為了讓大家使用方便,如果你拿到的引數是一個一個的,那就使用call吧,但是有時候拿到的引數是arguments,這是函式的一個內建變數,是一個類陣列結構,表示當前函式的所有引數,那就可以直接用apply,而不用將它展開了。

使用bind修改this

bind是ES5引入的一個方法,也可以修改this,但是呼叫它並不會立即執行方法本身,而是會返回一個修改了this的新方法:

const obj = {
  myName: "大飛哥",
  func: function(age, gender) {
    console.log(`我的名字是${this.myName}, 我的年齡是${age},我是一個${gender}`);
  }
}

const obj2 = {
  myName: "小小飛"
}

const func2 = obj.func.bind(obj2);   // 返回一個this改為obj2的新方法
func2(18, "帥哥");    // 我的名字是小小飛, 我的年齡是18,我是一個帥哥

bind和call,apply最大的區別就是call,apply會立即執行方法,而bind並不會立即執行,而是會返回一個新方法供後面使用。

bind函式也可以接收多個引數,第二個及以後的引數會作為新函式的引數傳遞進去,比如前面的bind也可以這樣寫:

const func3 = obj.func.bind(obj2, 18);   // 注意我們這裡已經傳了一個年齡引數
func3("帥哥");    //注意這裡只傳了性別引數,年齡引數已經在func3裡面了,輸出還是:我的名字是小小飛, 我的年齡是18,我是一個帥哥

自己寫一個call

知道了call的作用,我們自己來寫一個call:

Function.prototype.myCall = function(...args) {
  // 引數檢查
  if(typeof this !== "function") {
    throw new Error('Must call with a function');
  }
  
  const realThis = args[0] || window;
  const realArgs = args.slice(1);
  const funcSymbol = Symbol('func');
  realThis[funcSymbol] = this;   // 這裡的this是原方法,儲存到傳入的第一個引數上
  
  //用傳入的引數來調方法,方法裡面的this就是傳入的引數了
  const res = realThis[funcSymbol](...realArgs); 
  
  delete realThis[funcSymbol];  // 最後刪掉臨時儲存的原方法
  
  return res;  // 將執行的返回值返回
}

自己寫一個apply

apply方法跟call方法很像,區別只是在取呼叫引數上:

Function.prototype.myApply = function(...args) {
  if(typeof this !== "function") {
    throw new Error('Must call with a function');
  }
  
  const realThis = args[0] || window;
  // 直接取第二個引數,是一個陣列
  const realArgs = args[1];        
  const funcSymbol = Symbol('func');
  realThis[funcSymbol] = this;   
  
  const res = realThis[funcSymbol](...realArgs); 
  
  delete realThis[funcSymbol]; 
  
  return res; 
}

自己寫一個bind

自己寫一個bind需要用到前面的apply,注意他的返回值是一個方法

Function.prototype.myBind = function(...args) {
  if(typeof this !== "function") {
    throw new Error('Must call with a function');
  }
  
  const _func = this;    // 原方法
  const realThis = args[0] || window;   // 繫結的this
  const otherArgs = args.slice(1);    // 取出後面的引數作為新函式的預設引數
  
  return function(...args2) {   // 返回一個方法
    return _func.apply(realThis, [...otherArgs,...args2]);  // 拼接儲存引數和新引數,然後用apply執行
  }
}

總結

  1. 函式外面的this,即全域性作用域的this指向window。
  2. 函式裡面的this總是指向直接呼叫者。如果沒有直接呼叫者,隱含的呼叫者是window。
  3. 使用new呼叫一個函式,這個函式即為建構函式。建構函式裡面的this是和例項物件溝通的橋樑,他指向例項物件。
  4. 箭頭函式裡面的this在它申明時確定,跟他當前作用域的this一樣。
  5. DOM事件回撥裡面,this指向繫結事件的物件(currentTarget),而不是觸發事件的物件(target)。當然這兩個可以是一樣的。如果回撥是箭頭函式,請參考上一條,this是它申明時作用域的this。
  6. 嚴格模式下,函式裡面的this指向undefined,函式外面(全域性作用域)的this還是指向window。
  7. call和apply可以改變this,這兩個方法會立即執行原方法,他們的區別是引數形式不一樣。
  8. bind也可以修改this,但是他不會立即執行,而是返回一個修改了this的函式。

文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。

作者博文GitHub專案地址: https://github.com/dennis-jiang/Front-End-Knowledges

相關文章