JavaScript因為其語法松散,導致函數(尤其是this)看似簡單,其實里面花頭很多。本篇介紹一下JavaScript函數及其調用方法。
- 函數聲明和函數表達式
- arguments
- this
- this補充說明
函數聲明和函數表達式
JavaScript里對象字面量產生的對象將被連接到Object.prototype,函數對象將被連接到Function.prototype(但該對象本身也連接到Object.prototype)。先看一下函數聲明和函數表達式(分匿名和命名):
function count(a,b){ return a*b; } //函數聲明
var d1 = function(n) { return n*2; }; //匿名函數表達式
var d2 = function double(n) { return n*2; }; //命名函數表達式
console.log(count(3,4)); //12
console.log(d1(3)); //6
console.log(d2(3)); //6
console.log(double(3)); //error,double未定義
上面代碼可以看出函數聲明和函數表達式在后續(xù)的調用中,效果是沒有差別的。除語法不同外,兩者的區(qū)別在于JS解析器讀取的順序。
解析器會事先讀取函數聲明,即使你把函數聲明放在代碼的末端也沒關系。而對于函數表達式,同其它基本類型的變量一樣,只有在執(zhí)行到該行語句時才解析。因此用函數表達式時,必須確保它在調用語句之前,否則會報錯。
再看匿名和命名函數表達式的區(qū)別。上例中命名函數表達式將函數綁定到變量d2上,而非變量double上,因此double(3);會出現未定義error。
那命名函數表達式有什么用呢?比如上面的變量double有什么用呢?函數名double可用于在函數內部做遞歸,但可惜仍舊沒必要,因為變量d2同樣也可以在函數內部遞歸。因此命名函數表達式真正的作用在于調試,JavaScript環(huán)境提供對Error對象的棧追蹤功能,可以用double進行棧追蹤。
但命名函數表達式仍舊有很多問題,類似with一樣。因此通常推薦用匿名函數表達式,不推薦用命名函數表達式:
var d1 = function(n) { return n*2; }; //Yes,推薦
var d2 = function double(n) { return n*2; }; //No,不推薦
arguments
每個函數都接受2個附加參數:this和arguments。先看arguments。JS的函數參數其實就是個類似數組的arguments對象,是對形參的一個映射,但是值是通過索引來獲取的。因此JS的函數天然支持可變參數。
arguments對象看似像數組,但請不要使用arguments.shift()等方法來修改arguments。修改arguments對象將可能導致命名參數失去意義。
例如person(name, age),參數name是arguments[0]的別名,age是arguments[1]的別名,如果用shift移除arguments后,name仍舊是arguments[0]的別名,age仍舊是arguments[1]的別名,函數開始失控。
因此,如果你無論如何要修改arguments,需要先將arguments對象轉化為真正的數組:
var args = [].slice.call(arguments);
之后對args對象進行shift()等操作。這也常見于獲取可變參數值,同樣需要上述那樣將arguments對象轉化為真正的數組。
另外每個arguments對象都有兩個額外的屬性:arguments.callee和arguments.caller。前者指向使用該arguments對象被調用的函數。后者指向調用該arguments對象的函數。
其實arguments.callee除允許匿名函數遞歸調用自身外,并沒有什么太大用處。但可惜用函數名也能實現遞歸,所以它真沒什么用處:
//用arguments.callee來遞歸
var factorial = (function(n) {
return (n <= 1) ? 1 : (n * arguments.callee(n - 1)); //遞歸
});
//但也可以直接用函數名來遞歸
function factorial(n) {
return (n <= 1) ? 1 : (n * factorial(n - 1));
}
用arguments.caller可以跟蹤棧信息,但它不可靠,如果某函數在棧中出現了不止一次,很容易陷入死循環(huán),大多數環(huán)境已經移除了此特性。
JS嚴格模式下禁止使用arguments.callee和arguments.caller,因此這兩個屬性就不多廢話了。
this
arguments介紹完后,再來看看this。在JS中this取決于調用的方式,不同的函數調用方式,this綁定的對象也不同。有5種調用方式:
- 方法調用
- 函數調用
- 構造器調用
- apply / call / bind調用
- =>箭頭函數調用
方法調用:當函數作為對象方法時,函數里的this被綁定到該對象上
var myNum = {
value: 0,
increment: function(inc) { //函數作為對象方法
this.value += inc;
}
};
myNum.increment(2);
console.log(myNum.value); //2,this被綁定到myNum
函數調用:函數非對象方法時,this被綁定到全局對象window。這其實是語言設計上的一個錯誤(或曰特性),導致this不能調用內部函數。要調用內部函數,可以將that = this保存起來。
function double(n){ return n*2; } //普通函數,this綁定到全局對象window
//錯誤的例子
myNum.count = function() {
var helper = function() {
this.value = double(this.value);
};
helper();
}
myNum.count();
console.log(myNum.value); //value不變
//正確的例子:
myNum.count = function() {
var that = this;
var helper = function() {
that.value = double(that.value); //現在參數是myNum.value
};
helper();
}
myNum.count();
console.log(myNum.value); //4
錯誤的例子中,期望this綁定的是對象myNum,但由于double是普通函數,因此this綁定的是window,而window顯然沒有value。即helper里this是window,因此double(this.value);不會被執(zhí)行。最終myNum的value值并沒有變。
正確的例子在對象myNum方法里,this綁定的是myNum對象,因此先用that將this保存起來。然后在內部傳遞的都是that,回避了helper函數內this發(fā)生改變的問題。
構造函數調用:用new調用構造函數,會先創(chuàng)建一個連接到構造函數的prototype的新對象,再將this會綁定到該新對象
var Name = function(n) {
this.name = n;
}
Name.prototype.getName = function() {
return this.name;
}
var myName = new Name("Jack"); //this綁定到myName對象
console.log(myName.getName()); //Jack
apply / call / bind調用:允許我們自己綁定想要的this
var friend = {
name: "Betty"
};
console.log(Name.prototype.getName.apply(friend)); //Betty
console.log(Name.prototype.getName.call(friend)); //Betty
console.log(Name.prototype.getName.bind(friend)()); //Betty
=>箭頭函數調用:ES6里的箭頭函數里的this指向定義時所在的對象,而非使用時所在的對象。這意味著=>里的this是固定不變的。例如:
function foo() {
setTimeout(() => {
console.log('id:', this.id);
}, 100);
}
var id = 10;
foo.call({ id: 20 }); // id: 20
上例中,setTimeout的參數是一個箭頭函數,該箭頭函數被定義在普通函數foo內。如果不是ES6的箭頭函數,而是ES5普通函數的話,參照上面函數調用的this的說明, 100毫秒后執(zhí)行時this應該指向全局對象window,應該輸出10。但ES6的箭頭函數里this總是指向被定義時所在的對象({id: 20}),所以結果是20。
為了更清晰地分辨ES6箭頭函數和ES5普通函數對this綁定的區(qū)別,再看一個例子:
function Timer() {
this.count1 = 0;
this.count2 = 0;
setInterval(function () { this.count1++; }, 1000); // 普通函數
setInterval(() => this.count2++, 1000); // 箭頭函數
}
var timer = new Timer();
setTimeout(() => console.log('count1: ', timer.count1), 3100); // count1: 0
setTimeout(() => console.log('count2: ', timer.count2), 3100); // count2: 3
上例中,Timer函數內部的兩個定時器,分別用了ES6箭頭函數和ES5普通函數。前者的this指向運行時所在的作用域window,后者的this指向定義時所在的作用域Timer函數。結果普通函數內的count一次都沒被更新,而箭頭函數的內的count被正確更新了3次。
ES6的箭頭函數顯然更能預防this錯誤綁定的問題,因此推薦用ES6的新語法來寫JS。例如以前在定義回調函數前,總是先寫:
var _this = this;
var that = this;
var self = this;
你一定見過這些先將this保存起來,再在回調函數里需要用this的地方用_this / that / self來代替,就是為了解決(更精確地說是回避)回調函數執(zhí)行時this綁定的問題。現在用ES6的箭頭函數就不需要這么麻煩了:
var handler = {
id: '123456',
init: function() {
document.addEventListener('click',
event => this.doSomething(event.type), false);
},
doSomething: function(type) {console.log('Handling ' + type + ' for ' + this.id);}
};
上例中init方法內用了箭頭函數,因此內部的this,總是指向handler對象。否則,回調函數運行時,由于this指向的是window,所以this.doSomething會報錯。
究其本質,ES6的箭頭函數能將this綁定固化,并不是增加了什么新語法,本質上就是ES5的語法糖。箭頭函數沒有自己的this,它的this其實就是外層代碼塊的this。將箭頭函數用Babel轉碼一下:
// ES6的代碼
function foo() {
setTimeout(() => {
console.log('id:', this.id);
}, 100);
}
// 轉碼后ES5的代碼
function foo() {
var _this = this;
setTimeout(function () {
console.log('id:', _this.id);
}, 100);
}
戲法拆穿就顯得了無生趣了,仍舊是ES5那套_this / that / self的把戲。
function foo() {
return () => {
return () => {
return () => {
console.log('id:', this.id);
};
};
};
}
var f = foo.call({id: 1});
var t1 = f.call({id: 2})()(); // id: 1
var t2 = f().call({id: 3})(); // id: 1
var t3 = f()().call({id: 4}); // id: 1
上例中,看似嵌套了很多箭頭函數,有點暈。但只要知道了這是ES5老把戲的語法糖的話,就不會暈了。整個foo里只有一個this,指向foo對象。不論嵌套多少層,語法糖里箭頭函數沒有自己的this,只有_this即指向foo的this。
箭頭函數沒有自己的this,因此不能作為構造函數被使用,new會報錯。也沒有自身的arguments,super,new.target,它們指向的都是外層代碼塊的對應變量。也不能使用yield用作Generator函數。也不能用call() / apply() / bind()這些方法去改變this的指向。
this補充說明
這一節(jié)并無任何新的內容,只不過對this進一步補充說明一下。我們知道對象都有prototype俗稱原型對象。那prototype里的this綁定誰呢?其實原則沒有變,從上面構造函數調用的例子就能看出this仍舊是綁定調用的對象。
為了更清晰一點,將上面構造函數調用的例子稍微改一下:
var Name = function() {};
Name.prototype = {
name: "(not set)",
setName: function(n) {
this.name = n;
}
}
var myName = new Name();
console.log(myName.name); //(not set)
console.log(myName.hasOwnProperty("name")); //false
console.log(myName.hasOwnProperty("setName")); //false
myName.setName("Jack");
console.log(myName.name); //Jack
console.log(myName.hasOwnProperty("name")); //true
console.log(myName.hasOwnProperty("setName")); //false
先看第一段結果代碼,Name本身沒有任何屬性,name和setName是在它的原型prototype中定義的。因此用hasOwnProperty來檢查全是false。這與我們的預想完全一致,沒什么可奇怪的。
再看第二段結果代碼,由于執(zhí)行了myName.setName("Jack");。原型prototype中的this不是綁定原型對象,而是綁定調用的對象。即setName中的this綁定的是對象myName,會給對象增加一個name屬性。所以hasOwnProperty("name")會為true。
明白這些原理后,再回過頭看看以前不明白的代碼里this,that,self等就輕松多了。