從指向看JavaScript中的難點
前言
開寫前大家先來理解一下指向:指向,即目標方向、所對的方位。
很多人剛剛接觸前端甚至一些“老”前端都經常會在JavaScript中所謂的難點,如this,原型,繼承,閉包等這些概念中迷失了自我。接下來這篇文章會把我自己對于JavaScript中這些點通過指向的概念做個總結并分享給大家,希望可以幫助大家更好的了解這些所謂的難點。
一、this
this是什么?其實它本身就是一種指向。this指向可以分為以下幾種情況
- 普通調用,this指向為調用者
- call/apply調用,this指向為當前thisArg參數(shù)
- 箭頭函數(shù),this指向為當前函數(shù)的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、“單純”函數(shù)調用
即該函數(shù)為自己獨立的函數(shù),而不是掛載到對象上的屬性(window除外),也不會被當成構造函數(shù)來使用,而僅僅是當成函數(shù)來使用,此時的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、構造函數(shù)調用
即該函數(shù)被當成構造函數(shù)來調用,此時的this指向該構造器函數(shù)的實例對象。我們來看一個例子,先上一個屬于第二種情況的例子
function test () { this.a = 'this is test'; console.log(this.a); console.log(this); } test(); // this is test // Window {}
按照上面的來理解,此時的this的確指向window對象,但是如果我換種形式,將其換成構造函數(shù)來調用呢,結果又會如何呢,直接上代碼
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的確指向了該構造函數(shù)的實例對象。具體這里的一些解釋后面我會在原型鏈繼承里面詳細講解。
2、call/apply調用
2.1、call調用
call方法形式,fun.call(thisArg[, arg1[, arg2[, ...]]])
- thisArg,當前this指向
- arg1[, arg2[, ...]],指定的參數(shù)列表
詳細介紹請猛戳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類似,唯一的一個明顯區(qū)別就是call參數(shù)為多個,apply參數(shù)則為兩個,第二個參數(shù)為數(shù)組或類數(shù)組形式, fun.apply(thisArg, [argsArray])
- thisArg,當前this指向
- 一個數(shù)組或者類數(shù)組對象,其中的數(shù)組元素將作為單獨的參數(shù)傳給fun函數(shù)
詳細介紹請猛戳MDN
但是終究apply里面的數(shù)組參數(shù)會轉變?yōu)閏all方法的參數(shù)形式,然后去走下面的步驟,這也是為什么call執(zhí)行速度比apply快。這邊詳情有篇文章有介紹,點擊鏈接。
另外,提及到call/apply,怎么能不提及一下bind呢,bind里面的this指向,會永遠指向bind到的當前的thisArg,即context上下文環(huán)境參數(shù)不可重寫。這也是為什么a.bind(b).call(c),最終的this指向會是b的原因。至于為什么,其實就是bind實現(xiàn)實際上是通過閉包,并且配合call/apply進行實現(xiàn)的。具體的請參考bind MDN里面的用法及 Polyfill實現(xiàn)。
3、箭頭函數(shù)
首先需要介紹的一點就是,在箭頭函數(shù)本身,它是沒有綁定本身的this的,它的this指向為當前函數(shù)的this指向。怎么理解呢,直接上個代碼看下
function test () { (() => { console.log(this); })() } test.call({a: 'this is thisArg'}) // Object {a: 'this is thisArg'}
這樣看聯(lián)想上面的call/apply調用的理解,好像是沒有問題了,那如果我設置一個定時器呢,會不是this指向會變成Window全局對象呢?答案肯定是不會的,因為箭頭函數(shù)里面的this特殊性,它依舊會指向當前函數(shù)的this指向。不多BB,直接看代碼
function test () { setTimeout(() => { console.log(this); }, 0) } test.call({a: 'this is obj'}) // Object {a: 'this is obj'}
當然普通函數(shù)使用setTimeout的話會讓this指向指向Window對象的。demo代碼如下
function test () { setTimeout(function () { console.log(this); }, 0) } test.call({a: 'this is obj'}) // Window {...}
這里可能會牽扯到setTimeout的一些點了,具體這里我就不講了,想深入了解的猛戳這里
箭頭函數(shù)里面還有一些特殊的點,這里由于只提及this這一個點,其他比如不綁定arguments,super(ES6),抑或 new.target(ES6),他們都和this一樣,他會找尋到當前函數(shù)的arguments等。
關于箭頭函數(shù)里面的this這里也有詳細的介紹,想深入了解的可以自行閱讀
二、原型/原型鏈
其實我們一看到原型/原型鏈都能和繼承聯(lián)想到一起,我們這里就把兩塊先拆開來講解,這里我們就先單獨把原型/原型鏈拎出來。首先我們自己問一下自己,什么是原型?什么是原型鏈?
- 原型:即每個function函數(shù)都有的一個prototype屬性。
- 原型鏈:每個對象和原型都有原型,對象的原型指向原型對象,而父的原型又指向父的父,這種原型層層連接起來的就構成了原型鏈。
好像說的有點繞,其實一張圖可以解釋一切
那么這個東西有怎么和指向這個概念去聯(lián)系上呢?其實這里需要提及到的一個點,也是上面截圖中存在的一個點,就是__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);
其執(zhí)行結果如下
更多關于繼承的點,這里就不提及了,我會在繼承這一章節(jié)做詳細的講解。那么“單獨”關于原型/原型鏈的點就這些了。
總結:原型即prototype,它只是所有function上的一個屬性而已,真正的“大佬”是__proto__,“大佬”指向誰,誰才能有言語權(當然可能因為“大佬”過于霸道,所以在ECMA-262之后才被Standard化)。
三、繼承
關于繼承,之前我有寫過一篇博文對繼承的一些主流方式進行過總結。想詳細了解的請點擊傳送門。這里我們通過指向這個概念來重新理解一下繼承。這里咱就談兩個萬變不離其宗的繼承方式,一個是構造函數(shù)繼承,一個是原型鏈繼承。
1、構造函數(shù)繼承
其實就是上面提及到的通過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();
那么這樣的做法具體是怎么實現(xiàn)原型鏈繼承的呢?
首先在講解繼承前,我們需要get到一個點,那就是對象{ }它內部擁有的一些屬性,這里直接看張圖
如上圖所示,我們看到對象{ }它本身擁有的屬性就是上面我們提及到的__proto__原型指針以及一些方法。
接下來我先說一下new關鍵字具體做的一件事情。其過程大致分為三步,如下
var obj= {}; // 初始化一個對象obj obj.__proto__ = Parent.prototype; // 將obj的__proto__原型指針指向父類Parent的prototype屬性 Parent.call(obj); // 初始化Parent構造函數(shù)
從這里我們看出來,相信大家也能理解為什么我在上面說__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等內置類進行繼承重寫的話,結果會是如何呢?關于這個話題我也有寫過一篇博文進行過講解,傳送門
四、閉包
對于閉包,我曾經也做過總結和分享,簡單的一些東西和概念這里不提及了,想了解的可以猛戳這里。和原型鏈那章一張,這里會摒棄掉原來的一些看法,這里我依舊通過代入指向這個概念來進行理解。
一般情況下,我們理解閉包是這樣的:“為了可以訪問函數(shù)內的局部變量而定義的內部函數(shù)”。
JavaScript語言特性,每一個function內都有一個屬于自己的執(zhí)行上下文,即特定的context指向。
內層的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循環(huán)配合setTimeout的異步任務,如下
function test () { for (var i = 0; i < 4; i++) { setTimeout(function () { console.log(i); }, 0) } } test();
看到上面的例子,我們都知道說:“答案會打印4次4”。那么為什么會這樣呢?我想依次打印0,1,2,3又該怎么做呢?
相信很多小伙伴們都會說,用閉包呀,就能實現(xiàn)了呀。對沒錯,的確用閉包就能實現(xiàn)。那么為什么出現(xiàn)這種情況呢?
這里我簡單提一下,首先這邊牽扯到兩個點,一個就是for循環(huán)的同步任務,一個就是setTimeout的異步任務,在JavaScript線程中,因為本身JavaScript是單線程,這個特點決定了其正常的腳本執(zhí)行順序是按照文檔流的形式來進行的,即從上往下,從左往右的這樣方向。每次腳本正常執(zhí)行時,但凡遇到異步任務的時候,都會將其set到一個task queue(任務隊列)中去。然后在執(zhí)行完同步任務之后,再來執(zhí)行隊列任務中的異步任務。
當然對于不同的異步任務,執(zhí)行順序也會不一樣,具體就看其到底屬于哪個維度的異步任務了。這里我就不詳細扯Event Loop了,想更詳細的了解請戳這里
回到上面我們想要實現(xiàn)的效果這個問題上來,我們一般處理方法是利用閉包進行參數(shù)傳值,代碼如下
function test () { for (var i = 0; i < 4; i++) { (function (e) { setTimeout(function () { console.log(e); }, 0) })(i) } } test(); // 0 -> 1 -> 2 -> 3
循環(huán)當中,匿名函數(shù)會立即執(zhí)行,并且會將循環(huán)當前的 i 作為參數(shù)傳入,將其作為當前匿名函數(shù)中的形參e的指向,即會保存對 i 的引用,它是不會被循環(huán)改變的。
當然還有一種常見的方式可以實現(xiàn)上面的效果,即從自執(zhí)行匿名函數(shù)中返回一個函數(shù)。代碼如下
function test () { for(var i = 0; i < 4; i++) { setTimeout((function(e) { return function() { console.log(e); } })(i), 0) } } test();
更多高階閉包的寫法這里就不一一介紹了,想了解的小伙伴請自行搜索。
文章到此差不多就要結束了
可是我也沒辦法,的確要結束了。下面給整篇博文做個總結吧
總結
首先基本上JavaScript中所涉及的所謂的難點,在本文中都通過指向這個概念進行了通篇的解讀,當然這是我個人對于JavaScript的一些理解,思路僅供參考。如果有什么不對的地方,歡迎各位小伙伴指出。
其實寫該博文的好多次,我想把所有的知識點全部串起來進行講解,但又怕效果不好,所以做了一一的拆解,也進行了混合的運用。具體能領悟到多少,就要看小伙伴你們自己的了。