合格前端系列第六彈-從指向看JavaScript

玄學醬發表於2018-06-14

前言

開寫前大家先來理解一下指向:指向,即目標方向、所對的方位。

很多人剛剛接觸前端甚至一些“老”前端都經常會在JavaScript中所謂的難點,如this,原型,繼承,閉包等這些概念中迷失了自我。接下來這篇文章會把我自己對於JavaScript中這些點通過指向的概念做個總結並分享給大家,希望可以幫助大家更好的瞭解這些所謂的難點。

一、this

this是什麼?其實它本身就是一種指向。this指向可以分為以下幾種情況

  • 普通呼叫,this指向為呼叫者
  • call/apply呼叫,this指向為當前thisArg引數
  • 箭頭函式,this指向為當前函式的this指向

這個怎麼理解呢?接下來我會一一做解析。

1、普通呼叫

通俗理解一下,就是誰呼叫,則this便指向誰。這裡又大致分為幾種情況,分別為

1.1、物件方法的呼叫

即某方法為某物件上的一個屬性的屬性,正常情況當改方法被呼叫的時候,this的指向則是掛載該方法的物件。廢話不多說,直接看程式碼可能會更好的理解。

var obj = {
  a: `this is obj`,
  test: function () {
    console.log(this.a);
  }
}
obj.test();
// this is obj

1.2、“單純”函式呼叫

即該函式為自己獨立的函式,而不是掛載到物件上的屬性(window除外),也不會被當成建構函式來使用,而僅僅是當成函式來使用,此時的this指向則是window物件。例子如下

var a = `this is window`
function test () {
  console.log(this.a);
}
test();
// this is window

這個我們來理解一下,其實也很簡單,我們都知道,window物件是全域性物件。其實整個程式碼塊等同於

window.a = `this is window`
window.test = function test () {
  console.log(this.a);
  // 此時是window為呼叫者,即this會指向window
}
window.test();

1.3、建構函式呼叫

即該函式被當成建構函式來呼叫,此時的this指向該構造器函式的例項物件。我們來看一個例子,先上一個屬於第二種情況的例子

function test () {
  this.a = `this is test`;
  console.log(this.a);
  console.log(this);
}
test();
// this is test
// Window {}

按照上面的來理解,此時的this的確指向window物件,但是如果我換種形式,將其換成建構函式來呼叫呢,結果又會如何呢,直接上程式碼

function Test () {
  this.a = `this is test`;
  console.log(this.a);
  console.log(this);
}
var test = new Test();
// this is test
// Test {a: `this is test`}

OK,好像的確沒有問題了,此時的this的確指向了該建構函式的例項物件。具體這裡的一些解釋後面我會在原型鏈繼承裡面詳細講解。

2、call/apply呼叫

2.1、call呼叫

call方法形式,fun.call(thisArg[, arg1[, arg2[, …]]])

  • thisArg,當前this指向
  • arg1[, arg2[, …]],指定的引數列表

詳細介紹請猛戳MDN

示例程式碼如下

function Test () {
  this.a = `this is test`;
  console.log(this.a);
  console.log(this);
}
function Test2 () {
  Test.call(this);
}
var test = new Test2();
// this is test
// Test2 {a: `this is test`}

2.2、apply呼叫

和call類似,唯一的一個明顯區別就是call引數為多個,apply引數則為兩個,第二個引數為陣列或類陣列形式, fun.apply(thisArg, [argsArray])

  • thisArg,當前this指向
  • 一個陣列或者類陣列物件,其中的陣列元素將作為單獨的引數傳給fun函式

詳細介紹請猛戳MDN

但是終究apply裡面的陣列引數會轉變為call方法的引數形式,然後去走下面的步驟,這也是為什麼call執行速度比apply快。這邊詳情有篇文章有介紹,點選連結

另外,提及到call/apply,怎麼能不提及一下bind呢,bind裡面的this指向,會永遠指向bind到的當前的thisArg,即context上下文環境引數不可重寫。這也是為什麼a.bind(b).call(c),最終的this指向會是b的原因。至於為什麼,其實就是bind實現實際上是通過閉包,並且配合call/apply進行實現的。具體的請參考bind MDN裡面的用法及 Polyfill實現。

3、箭頭函式

首先需要介紹的一點就是,在箭頭函式本身,它是沒有繫結本身的this的,它的this指向為當前函式的this指向。怎麼理解呢,直接上個程式碼看下

function test () {
  (() => {
    console.log(this);
  })()
}
test.call({a: `this is thisArg`})
// Object {a: `this is thisArg`}

這樣看聯想上面的call/apply呼叫的理解,好像是沒有問題了,那如果我設定一個定時器呢,會不是this指向會變成Window全域性物件呢?答案肯定是不會的,因為箭頭函式裡面的this特殊性,它依舊會指向當前函式的this指向。不多BB,直接看程式碼

function test () {
  setTimeout(() => {
    console.log(this);
  }, 0)
}
test.call({a: `this is obj`})
// Object {a: `this is obj`}

當然普通函式使用setTimeout的話會讓this指向指向Window物件的。demo程式碼如下

function test () {
  setTimeout(function () {
    console.log(this);
  }, 0)
}
test.call({a: `this is obj`})
// Window {...}

這裡可能會牽扯到setTimeout的一些點了,具體這裡我就不講了,想深入瞭解的猛戳這裡

箭頭函式裡面還有一些特殊的點,這裡由於只提及this這一個點,其他比如不繫結arguments,super(ES6),抑或 new.target(ES6),他們都和this一樣,他會找尋到當前函式的arguments等。

關於箭頭函式裡面的this這裡也有詳細的介紹,想深入瞭解的可以自行閱讀

二、原型/原型鏈

其實我們一看到原型/原型鏈都能和繼承聯想到一起,我們這裡就把兩塊先拆開來講解,這裡我們就先單獨把原型/原型鏈拎出來。首先我們自己問一下自己,什麼是原型?什麼是原型鏈?

  • 原型:即每個function函式都有的一個prototype屬性。
  • 原型鏈:每個物件和原型都有原型,物件的原型指向原型物件,而父的原型又指向父的父,這種原型層層連線起來的就構成了原型鏈。

好像說的有點繞,其實一張圖可以解釋一切
180558_1Kw8_2912341.png

那麼這個東西有怎麼和指向這個概念去聯絡上呢?其實這裡需要提及到的一個點,也是上面截圖中存在的一個點,就是__proto__,我喜歡把其稱為原型指標。終歸到頭,prototype只不過是一個屬性而已,它沒有什麼實際的意義,最後能做原型鏈繼承的還是通過__proto__這個原型指標來完成的。我們看到的所謂的繼承只不過是將需要繼承的屬性掛載到繼承者的prototype屬性上面去的,實際在找尋繼承的屬性的時候,會通過__proto__原型指標一層一層往上找,即會去找__proto__原型指標它的一個指向。看個demo

function Test () {
  this.a = `this is Test`;
}
Test.prototype = {
  b: function () {
    console.log("this is Test`s prototype");
  }
}
function Test2 () {
  this.a = `this is Test2`
}
Test2.prototype = new Test();
var test = new Test2();
test.b();
console.log(test.prototype);
console.log(test);

其執行結果如下
183327_xgql_2912341.png

更多關於繼承的點,這裡就不提及了,我會在繼承這一章節做詳細的講解。那麼“單獨”關於原型/原型鏈的點就這些了。
總結:原型即prototype,它只是所有function上的一個屬性而已,真正的“大佬”是__proto__,“大佬”指向誰,誰才能有言語權(當然可能因為“大佬”過於霸道,所以在ECMA-262之後才被Standard化)。
183846_VUw8_2912341.png

三、繼承

關於繼承,之前我有寫過一篇博文對繼承的一些主流方式進行過總結。想詳細瞭解的請點選傳送門。這裡我們通過指向這個概念來重新理解一下繼承。這裡我們就談兩個萬變不離其宗的繼承方式,一個是建構函式繼承,一個是原型鏈繼承。

1、建構函式繼承

其實就是上面提及到的通過call/apply呼叫,將this指向變成thisArg,具體看上面的解釋,這裡直接上程式碼

function Test () {
  this.a = `this is test`;
  console.log(this.a);
  console.log(this);
}
function Test2 () {
  Test.apply(this)
  // or Test.apply(this)
}
var test = new Test2();
// this is test
// Test2 {a: `this is test`}

2、原型鏈繼承

一般情況,我們做原型鏈繼承,會通過子類prototype屬性等於(指向)父類的例項。即

Child.prototype = new Parent();

那麼這樣的做法具體是怎麼實現原型鏈繼承的呢?

首先在講解繼承前,我們需要get到一個點,那就是物件{ }它內部擁有的一些屬性,這裡直接看張圖
190124_Jwpy_2912341.png
如上圖所示,我們看到物件{ }它本身擁有的屬性就是上面我們提及到的__proto__原型指標以及一些方法。
接下來我先說一下new關鍵字具體做的一件事情。其過程大致分為三步,如下

var obj= {}; // 初始化一個物件obj
obj.__proto__ = Parent.prototype; // 將obj的__proto__原型指標指向父類Parent的prototype屬性
Parent.call(obj); // 初始化Parent建構函式

從這裡我們看出來,相信大家也能理解為什麼我在上面說__proto__才是真正的“大佬”。

這裡我額外提一件我們經常乾的“高階”的事情,那就是通過原型prototype做monkey patch。即我想在繼承父類方法的同時,完成自己獨立的一些操作。具體程式碼如下

function Parent () {
  this.a = `this is Parent`
}
Parent.prototype = {
  b: function () {
    console.log(this.a);
  }
}
function Child () {
  this.a = `this is Child`
}
Child.prototype = {
  b: function () {
    console.log(`monkey patch`);
    Parent.prototype.b.call(this);
  }
}
var test = new Child()
test.b()
// monkey patch
// this is Child

這個是我們對於自定義的類進行繼承並重寫,那麼如果是類似Array,Number,String等內建類進行繼承重寫的話,結果會是如何呢?關於這個話題我也有寫過一篇博文進行過講解,傳送門

四、閉包

對於閉包,我曾經也做過總結和分享,簡單的一些東西和概念這裡不提及了,想了解的可以猛戳這裡。和原型鏈那章一張,這裡會摒棄掉原來的一些看法,這裡我依舊通過代入指向這個概念來進行理解。

一般情況下,我們理解閉包是這樣的:“為了可以訪問函式內的區域性變數而定義的內部函式”。

JavaScript語言特性,每一個function內都有一個屬於自己的執行上下文,即特定的context指向。
200015_xJoP_2912341.png

內層的context上下文總能訪問到外層context上下文中的變數,即每次內部的作用域可以往上層查詢直到訪問到當前所需訪問的變數。例子如下

var a = `this is window`
function test () {
  var b = `this is test`
  function test2 () {
    var c = `this is test2`;
    console.log(a);
    console.log(b);
    console.log(c);
  }
  test2();
}
test();
// this is window
// this is test
// this is test2

但是如果反過來訪問的話,則不能進行訪問,即變數訪問的指向是當前context上下文的指向的相反方向,且不可逆。如下

function test () {
  var b = `this is test`;
}
console.log(b); // Uncaught ReferenceError: b is not defined

這裡用一個非常常見的情況作為例子,即for迴圈配合setTimeout的非同步任務,如下

function test () {
  for (var i = 0; i < 4; i++) {
    setTimeout(function () {
      console.log(i);
    }, 0)
  }
}
test();

看到上面的例子,我們都知道說:“答案會列印4次4”。那麼為什麼會這樣呢?我想依次列印0,1,2,3又該怎麼做呢?

相信很多小夥伴們都會說,用閉包呀,就能實現了呀。對沒錯,的確用閉包就能實現。那麼為什麼出現這種情況呢?

這裡我簡單提一下,首先這邊牽扯到兩個點,一個就是for迴圈的同步任務,一個就是setTimeout的非同步任務,在JavaScript執行緒中,因為本身JavaScript是單執行緒,這個特點決定了其正常的指令碼執行順序是按照文件流的形式來進行的,即從上往下,從左往右的這樣方向。每次指令碼正常執行時,但凡遇到非同步任務的時候,都會將其set到一個task queue(任務佇列)中去。然後在執行完同步任務之後,再來執行佇列任務中的非同步任務。 

當然對於不同的非同步任務,執行順序也會不一樣,具體就看其到底屬於哪個維度的非同步任務了。這裡我就不詳細扯Event Loop了,想更詳細的瞭解請戳這裡

回到上面我們想要實現的效果這個問題上來,我們一般處理方法是利用閉包進行引數傳值,程式碼如下

function test () {
  for (var i = 0; i < 4; i++) {
    (function (e) {
      setTimeout(function () {
        console.log(e);
      }, 0)
    })(i)
  }
}
test();
// 0 -> 1 -> 2 -> 3

迴圈當中,匿名函式會立即執行,並且會將迴圈當前的 i 作為引數傳入,將其作為當前匿名函式中的形參e的指向,即會儲存對 i 的引用,它是不會被迴圈改變的。

當然還有一種常見的方式可以實現上面的效果,即從自執行匿名函式中返回一個函式。程式碼如下

function test () {
  for(var i = 0; i < 4; i++) {
    setTimeout((function(e) {
      return function() {
        console.log(e);
      }
    })(i), 0)
  }
}
test();

更多高階閉包的寫法這裡就不一一介紹了,想了解的小夥伴請自行搜尋。

文章到此差不多就要結束了
213626_EpF8_2912341.png

可是我也沒辦法,的確要結束了。下面給整篇博文做個總結吧

總結

首先基本上JavaScript中所涉及的所謂的難點,在本文中都通過指向這個概念進行了通篇的解讀,當然這是我個人對於JavaScript的一些理解,思路僅供參考。如果有什麼不對的地方,歡迎各位小夥伴指出。

其實寫該博文的好多次,我想把所有的知識點全部串起來進行講解,但又怕效果不好,所以做了一一的拆解,也進行了混合的運用。具體能領悟到多少,就要看小夥伴你們自己的了。
214542_X8em_2912341.png

本文作者:qiangdada
本文釋出時間:2017/07/23
本文來自雲棲社群合作伙伴開源中國,瞭解相關資訊可以關注oschina.net網站。


相關文章