前言
The last time, I have learned
【THE LAST TIME】一直是我想寫的一個系列,旨在厚積薄發,重温前端。
也是給自己的查缺補漏和技術分享。
歡迎大家多多評論指點吐槽。
系列文章均首發於公眾號【全棧前端精選】,筆者文章集合詳見Nealyang/personalBlog。目錄皆為暫定
講道理,這篇文章有些拿捏不好尺度。準確的説,這篇文章講解的內容基本算是基礎的基礎了,但是往往這種基礎類的文章很難在囉嗦和詳細中把持好。文中道不到的地方還望各位評論多多補充指正。
THE LAST TIME 系列
- 【THE LAST TIME】徹底吃透 JavaScript 執行機制
This
相信使用過 JavaScript 庫做過開發的同學對 this 都不會陌生。雖然在開發中 this 是非常非常常見的,但是想真正吃透 this,其實還是有些不容易的。包括對於一些有經驗的開發者來説,也都要駐足琢磨琢磨~ 包括想寫清楚 this 呢,其實還得聊一聊 JavaScript 的作用域和詞法
This 的誤解一:this 指向他自己
function foo(num) {
console.log("foo:"+num);
this.count++;
}
foo.count = 0;
for(var i = 0;i<10;i++){
foo(i);
}
console.log(foo.count);
通過運行上面的代碼我們可以看到,foo函數的確是被調用了十次,但是this.count似乎並沒有加到foo.count上。也就是説,函數中的this.count並不是foo.count。
This 的誤解二:this 指向他的作用域
另一種對this的誤解是它不知怎麼的指向函數的作用域,其實從某種意義上來説他是正確的,但是從另一種意義上來説,這的確是一種誤解。
明確的説,this不會以任何方式指向函數的詞法作用域,作用域好像是一個將所有可用標識符作為屬性的對象,這從內部來説他是對的,但是JavaScript代碼不能訪問這個作用域“對象”,因為它是引擎內部的實現
function foo() {
var a = 2;
this.bar();
}
function bar() {
console.log( this.a );
}
foo(); //undefined
全局環境中的 This
既然是全局環境,我們當然需要去明確下宿主環境這個概念。簡而言之,一門語言在運行的時候需要一個環境,而這個環境的就叫做宿主環境。對於 JavaScript 而言,宿主環境最為常見的就是 web 瀏覽器。
如上所説,我們也可以知道環境不是唯一的,也就是 JavaScript 代碼不僅僅可以在瀏覽器中跑,也能在其他提供了宿主環境的程序裏面跑。另一個最為常見的就是 Node 了,同樣作為宿主環境,node 也有自己的 JavaScript 引擎:v8.
- 瀏覽器中,在全局範圍內,
this等價於window對象 - 瀏覽器中,用
var聲明一個變量等價於給this或者window添加屬性 - 如果你在聲明一個變量的時候沒有使用
var或者let(ECMAScript 6),你就是在給全局的this添加或者改變屬性值 - 在 node 環境裏,如果使用
REPL來執行程序,那麼this就等於global - 在 node 環境中,如果是執行一個 js 腳本,那麼
this並不指向global而是module.exports為{} - 在node環境裏,在全局範圍內,如果你用
REPL執行一個腳本文件,用var聲明一個變量並不會和在瀏覽器裏面一樣將這個變量添加給this - 如果你不是用REPL執行腳本文件,而是直接執行代碼,結果和在瀏覽器裏面是一樣的
- 在
node環境裏,用REPL運行腳本文件的時候,如果在聲明變量的時候沒有使用var或者let,這個變量會自動添加到global對象,但是不會自動添加給this對象。如果是直接執行代碼,則會同時添加給global和this
這一塊代碼比較簡單,我們不用碼説話,改為用圖説話吧!
函數、方法中的 This
很多文章中會將函數和方法區分開,但是我覺得。。。沒必要啊,咱就看誰點了如花這位菇涼就行
當一個函數被調用的時候,會建立一個活動記錄,也成為執行環境。這個記錄包含函數是從何處(call-stack)被調用的,函數是 如何 被調用的,被傳遞了什麼參數等信息。這個記錄的屬性之一,就是在函數執行期間將被使用的this引用。
函數中的 this 是多變的,但是規則是不變的。
你問這個函數:”
老妹
~ oh,不,函數!誰點的你?“
”是他!!!“
那麼,this 就指向那個傢伙!再學術化一些,所以!一般情況下!this不是在編譯的時候決定的,而是在運行的時候綁定的上下文執行環境。this 與聲明無關!
function foo() {
console.log( this.a );
}
var a = 2;
foo(); // 2
記住上面説的,誰點的我!!! => foo() = windwo.foo(),所以其中this 執行的是 window 對象,自然而然的打印出來 2.
需要注意的是,對於嚴格模式來説,默認綁定全局對象是不合法的,this被置為undefined。
function foo() {
console.log( this.a );
}
var obj2 = {
a: 42,
foo: foo
};
var obj1 = {
a: 2,
obj2: obj2
};
obj1.obj2.foo(); // 42
雖然這位 xx 被點的多了。。。但是,我們只問點他的那個人,也就是 ojb2,所以 this.a輸出的是 42.
注意,我這裏的點!不是你想的那個點哦,是運行時~
構造函數中的 This
恩。。。這,就是從良了
還是如上文説到的,this,我們不看在哪定義,而是看運行時。所謂的構造函數,就是關鍵字new打頭!
誰給我 new,我跟誰
其實內部完成了如下事情:
- 一個新的對象會被創建
- 這個新創建的對象會被接入原型鏈
- 這個新創建的對象會被設置為函數調用的this綁定
- 除非函數返回一個他自己的其他對象,這個被new調用的函數將自動返回一個新創建的對象
foo = "bar";
function testThis(){
this.foo = 'foo';
}
console.log(this.foo);
new testThis();
console.log(this.foo);
console.log(new testThis().foo)//自行嘗試
call、apply、bind 中的 this
恩。。。這就是被包了
在很多書中,call、apply、bind 被稱之為 this 的強綁定。説白了,誰出力,我跟誰。那至於這三者的區別和實現以及原理呢,咱們下文説!
function dialogue () {
console.log (`I am ${this.heroName}`);
}
const hero = {
heroName: 'Batman',
};
dialogue.call(hero)//I am Batman
上面的dialogue.call(hero)等價於dialogue.apply(hero)`dialogue.bind(hero)()`.
其實也就是我明確的指定這個 this 是什麼玩意兒!
箭頭函數中的 this
箭頭函數的 this 和 JavaScript 中的函數有些不同。箭頭函數會永久地捕獲 this值,阻止 apply或 call後續更改它。
let obj = {
name: "Nealyang",
func: (a,b) => {
console.log(this.name,a,b);
}
};
obj.func(1,2); // 1 2
let func = obj.func;
func(1,2); // 1 2
let func_ = func.bind(obj);
func_(1,2);// 1 2
func(1,2);// 1 2
func.call(obj,1,2);// 1 2
func.apply(obj,[1,2]);// 1 2
箭頭函數內的 this值無法明確設置。此外,使用 call 、 apply或 bind等方法給 this傳值,箭頭函數會忽略。箭頭函數引用的是箭頭函數在創建時設置的 this值。
箭頭函數也不能用作構造函數。因此,我們也不能在箭頭函數內給 this設置屬性。
class 中的 this
雖然 JavaScript 是否是一個面向對象的語言至今還存在一些爭議。這裏我們也不去爭論。但是我們都知道,類,是 JavaScript 應用程序中非常重要的一個部分。
類通常包含一個 constructor , this可以指向任何新創建的對象。
不過在作為方法時,如果該方法作為普通函數被調用, this也可以指向任何其他值。與方法一樣,類也可能失去對接收器的跟蹤。
class Hero {
constructor(heroName) {
this.heroName = heroName;
}
dialogue() {
console.log(`I am ${this.heroName}`)
}
}
const batman = new Hero("Batman");
batman.dialogue();
構造函數裏的 this指向新創建的 類實例。當我們調用 batman.dialogue()時, dialogue()作為方法被調用, batman是它的接收器。
但是如果我們將 dialogue()方法的引用存儲起來,並稍後將其作為函數調用,我們會丟失該方法的接收器,此時 this參數指向 undefined 。
const say = batman.dialogue;
say();
出現錯誤的原因是JavaScript 類是隱式的運行在嚴格模式下的。我們是在沒有任何自動綁定的情況下調用 say()函數的。要解決這個問題,我們需要手動使用 bind()將 dialogue()函數與 batman綁定在一起。
const say = batman.dialogue.bind(batman);
say();
this 的原理
咳咳,技術文章,咱們嚴肅點
我們都説,this指的是函數運行時所在的環境。但是為什麼呢?
我們都知道,JavaScript 的一個對象的賦值是將地址賦值給變量的。引擎在讀取變量的時候其實就是要了個地址然後再從原地址讀出來對象。那麼如果對象裏屬性也是引用類型的話(比如 function),當然也是如此!
而JavaScript 允許函數體內部,引用當前環境的其他變量,而這個變量是由運行環境提供的。由於函數又可以在不同的運行環境執行,所以需要個機制來給函數提供運行環境!而這個機制,也就是我們説到心在的 this。this的初衷也就是在函數內部使用,代指當前的運行環境。
var f = function () {
console.log(this.x);
}
var x = 1;
var obj = {
f: f,
x: 2,
};
// 單獨執行
f() // 1
// obj 環境執行
obj.f() // 2
obj.foo()是通過obj找到foo,所以就是在obj環境執行。一旦var foo = obj.foo,變量foo就直接指向函數本身,所以foo()就變成在全局環境執行.
總結
- 函數是否在new中調用,如果是的話this綁定的是新創建的對象
var bar = new Foo();
- 函數是否通過call、apply或者其他硬性調用,如果是的話,this綁定的是指定的對象
var bar = foo.call(obj);
- 函數是否在某一個上下文對象中調用,如果是的話,this綁定的是那個上下文對象
var bar = obj.foo();
- 如果都不是的話,使用默認綁定,如果在嚴格模式下,就綁定到undefined,注意這裏是方法裏面的嚴格聲明。否則綁定到全局對象
var bar = foo();
小試牛刀
var number = 2;
var obj = {
number: 4,
/*匿名函數自調*/
fn1: (function() {
var number;
this.number *= 2; //4
number = number * 2; //NaN
number = 3;
return function() {
var num = this.number;
this.number *= 2; //6
console.log(num);
number *= 3; //9
alert(number);
};
})(),
db2: function() {
this.number *= 2;
}
};
var fn1 = obj.fn1;
alert(number);
fn1();
obj.fn1();
alert(window.number);
alert(obj.number);
評論區留下你的答案吧~
call & applay
上文中已經提到了 call、apply和 bind,在 MDN 中定義的 apply 如下:
apply() 方法調用一個函數, 其具有一個指定的this值,以及作為一個數組(或類似數組的對象)提供的參數
語法:
fun.apply(thisArg, [argsArray])
- thisArg:在 fun 函數運行時指定的 this 值。需要注意的是,指定的 this 值並不一定是該函數執行時真正的 this 值,如果這個函數處於非嚴格模式下,則指定為 null 或 undefined 時會自動指向全局對象(瀏覽器中就是window對象),同時值為原始值(數字,字符串,布爾值)的 this 會指向該原始值的自動包裝對象。
- argsArray:一個數組或者類數組對象,其中的數組元素將作為單獨的參數傳給 fun 函數。如果該參數的值為null 或 undefined,則表示不需要傳入任何參數。從ECMAScript 5 開始可以使用類數組對象。瀏覽器兼容性請參閲本文底部內容。
如上概念 apply 類似.區別就是 apply 和 call 傳入的第二個參數類型不同。
call 的語法為:
fun.call(thisArg[, arg1[, arg2[, ...]]])
需要注意的是:
- 調用 call 的對象,必須是個函數 Function
- call 的第一個參數,是一個對象。 Function 的調用者,將會指向這個對象。如果不傳,則默認為全局對象 window。
- 第二個參數開始,可以接收任意個參數。每個參數會映射到相應位置的 Function 的參數上。但是如果將所有的參數作為數組傳入,它們會作為一個整體映射到 Function 對應的第一個參數上,之後參數都為空。
apply 的語法為:
Function.apply(obj[,argArray])
需要注意的是:
- 它的調用者必須是函數 Function,並且只接收兩個參數
- 第二個參數,必須是數組或者類數組,它們會被轉換成類數組,傳入 Function 中,並且會被映射到 Function 對應的參數上。這也是 call 和 apply 之間,很重要的一個區別。
記憶技巧:apply,a 開頭,array,所以第二參數需要傳遞數據。
請問!什麼是類數組?
核心理念
借!
對,就是借。舉個栗子!我沒有女朋友,週末。。。額,不,我沒有摩托車🏍,週末的時候天氣很好,想出去壓彎。但是我有沒有錢!怎麼辦呢,找朋友借用一下啊~達到了目的,還節省開支!
放到程序中我們可以理解為,某一個對象沒有想用的方法去實現某個功能,但是不想浪費內存開銷,就借用另一個有該方法的對象去借用一下。
説白了,包括 bind,他們的核心理念都是借用方法,已達到節省開銷的目的。
應用場景
代碼比較簡單,就不做講解了
- 將類數組轉換為數組
const arrayLike = {
0: 'qianlong',
1: 'ziqi',
2: 'qianduan',
length: 3
}
const arr = Array.prototype.slice.call(arrayLike);
- 求數組中的最大值
var arr = [34,5,3,6,54,6,-67,5,7,6,-8,687];
Math.max.apply(Math, arr);
Math.max.call(Math, 34,5,3,6,54,6,-67,5,7,6,-8,687);
Math.min.apply(Math, arr);
Math.min.call(Math, 34,5,3,6,54,6,-67,5,7,6,-8,687);
- 變量類型判斷
Object.prototype.toString用來判斷類型再合適不過,尤其是對於引用類型來説。
function isArray(obj){
return Object.prototype.toString.call(obj) == '[object Array]';
}
isArray([]) // true
isArray('qianlong') // false
- 繼承
// 父類
function supFather(name) {
this.name = name;
this.colors = ['red', 'blue', 'green']; // 複雜類型
}
supFather.prototype.sayName = function (age) {
console.log(this.name, 'age');
};
// 子類
function sub(name, age) {
// 借用父類的方法:修改它的this指向,賦值父類的構造函數裏面方法、屬性到子類上
supFather.call(this, name);
this.age = age;
}
// 重寫子類的prototype,修正constructor指向
function inheritPrototype(sonFn, fatherFn) {
sonFn.prototype = Object.create(fatherFn.prototype); // 繼承父類的屬性以及方法
sonFn.prototype.constructor = sonFn; // 修正constructor指向到繼承的那個函數上
}
inheritPrototype(sub, supFather);
sub.prototype.sayAge = function () {
console.log(this.age, 'foo');
};
// 實例化子類,可以在實例上找到屬性、方法
const instance1 = new sub("OBKoro1", 24);
const instance2 = new sub("小明", 18);
instance1.colors.push('black')
console.log(instance1) // {"name":"OBKoro1","colors":["red","blue","green","black"],"age":24}
console.log(instance2) // {"name":"小明","colors":["red","blue","green"],"age":18}
繼承後面可能也會寫一個篇【THE LAST TIME】。也是比較基礎,不知道有沒有這個必要
簡易版繼承
ar Person = function (name, age) {
this.name = name;
this.age = age;
};
var Girl = function (name) {
Person.call(this, name);
};
var Boy = function (name, age) {
Person.apply(this, arguments);
}
var g1 = new Girl ('qing');
var b1 = new Boy('qianlong', 100);
bind
bind 和 call/apply 用處是一樣的,但是 bind 會返回一個新函數!不會立即執行!而call/apply改變函數的 this 並且立即執行。
應用場景
- 緩存參數
原理其實就是返回閉包,畢竟 bind 返回的是一個函數的拷貝
for (var i = 1; i <= 5; i++) {
// 緩存參數
setTimeout(function (i) {
console.log('bind', i) // 依次輸出:1 2 3 4 5
}.bind(null, i), i * 1000);
}
上述代碼也是一個經典的面試題,具體也不展開了。
- this 丟失問題
説道 this 丟失問題,應該最常見的就是 react 中定義一個方法然後後面要加 bind(this)的操作了吧!當然,箭頭函數不需要,這個咱們上面討論過。
手寫實現
apply
第一個手寫咱們一步一步來
- 從定義觸發,因為是 function 調用者。所以肯定是給 function 添加方法咯,並且第一個參數是未來 this 上下文
Function.prototype.NealApply = function(context,args){}
- 如果context,this 指向 window
Function.prototype.NealApply = function(context,args){
context = context || window;
args = args || [];
}
- 給 context 新增一個不可覆蓋的 key,然後綁定 this
對,我們沒有黑魔法,既然綁定 this,還是逃不掉我們上文説的那些 this 方式
Function.prototype.NealApply = function(context,args){
context = context || window;
args = args || [];
//給context新增一個獨一無二的屬性以免覆蓋原有屬性
const key = Symbol();
context[key] = this;//這裏的 this 是函數
context[key](...args);
}
其實這個時候我們用起來已經有效果了。
- 這個時候我們已經執行完了,我們需要將結果返回,並且清理自己產生的垃圾
Function.prototype.NealApply = function(context,args){
context = context || window;
args = args || [];
//給context新增一個獨一無二的屬性以免覆蓋原有屬性
const key = Symbol();
context[key] = this;//這裏的 this 是 testFun
const result = context[key](...args);
// 帶走產生的副作用
delete context[key];
return result;
}
var name = 'Neal'
function testFun(...args){
console.log(this.name,...args);
}
const testObj = {
name:'Nealyang'
}
testFun.NealApply(testObj,['一起關注',':','全棧前端精選']);
執行結果就是上方的截圖。
- 優化
一上來不説優化是因為希望大家把精力放到核心,然後再去修邊幅! 羅馬不是一日建成的,看別人的代碼多牛批,其實也是一點一點完善出來的。
道理是這麼個道理,其實要做的優化還有很多,這裏我們就把 context 的判斷需要優化下:
// 正確判斷函數上下文對象
if (context === null || context === undefined) {
// 指定為 null 和 undefined 的 this 值會自動指向全局對象(瀏覽器中為window)
context = window
} else {
context = Object(context) // 值為原始值(數字,字符串,布爾值)的 this 會指向該原始值的實例對象
}
別的優化大家可以添加各種的用户容錯。比如對第二個參數的類數組做個容錯
function isArrayLike(o) {
if (o && // o不是null、undefined等
typeof o === 'object' && // o是對象
isFinite(o.length) && // o.length是有限數值
o.length >= 0 && // o.length為非負值
o.length === Math.floor(o.length) && // o.length是整數
o.length < 4294967296) // o.length < 2^32
return true;
else
return false;
}
打住!真的不再多囉嗦了,這篇文章篇幅不應這樣的
call
丐版實現:
//傳遞參數從一個數組變成逐個傳參了,不用...擴展運算符的也可以用arguments代替
Function.prototype.NealCall = function (context, ...args) {
//這裏默認不傳就是給window,也可以用es6給參數設置默認參數
context = context || window;
args = args ? args : [];
//給context新增一個獨一無二的屬性以免覆蓋原有屬性
const key = Symbol();
context[key] = this;
//通過隱式綁定的方式調用函數
const result = context[key](...args);
//刪除添加的屬性
delete context[key];
//返回函數調用的返回值
return result;
}
bind
bind的實現講道理是比 apply 和call 麻煩一些的,也是面試頻考題。因為需要去考慮函數的拷貝。但是也還是比較簡單的,網上也有很多版本,這裏就不具體展開了。具體的,咱們可以在羣裏討論~
Function.prototype.myBind = function (objThis, ...params) {
const thisFn = this; // 存儲源函數以及上方的params(函數參數)
// 對返回的函數 secondParams 二次傳參
let fToBind = function (...secondParams) {
const isNew = this instanceof fToBind // this是否是fToBind的實例 也就是返回的fToBind是否通過new調用
const context = isNew ? this : Object(objThis) // new調用就綁定到this上,否則就綁定到傳入的objThis上
return thisFn.call(context, ...params, ...secondParams); // 用call調用源函數綁定this的指向並傳遞參數,返回執行結果
};
if (thisFn.prototype) {
// 複製源函數的prototype給fToBind 一些情況下函數沒有prototype,比如箭頭函數
fToBind.prototype = Object.create(thisFn.prototype);
}
return fToBind; // 返回拷貝的函數
};
Function.prototype.myBind = function (context, ...args) {
const fn = this
args = args ? args : []
return function newFn(...newFnArgs) {
if (this instanceof newFn) {
return new fn(...args, ...newFnArgs)
}
return fn.apply(context, [...args,...newFnArgs])
}
}
最後
別忘記了上面 this 的考核題目啊,同學,該交卷了!
參考鏈接
- What is “this” in JavaScript?
- JavaScript 的 this 原理
- JavaScript中的this陷阱的最全收集--沒有之一
- js基礎-面試官想知道你有多理解call,apply,bind?
學習交流
關注公眾號: 【全棧前端精選】 每日獲取好文推薦。還可以入羣,一起學習交流