一文詳解JavaScript函數中的引數

2022-08-03 22:00:32
函數引數是函數內部跟函數外部溝通的橋樑。下面本篇文章就來帶大家瞭解一下JavaScript函數中的引數,希望對大家有所幫助!

一、函數的形參和實參

函數的引數會出現在兩個地方,分別是函數定義處和函數呼叫處,這兩個地方的引數是有區別的。

  • 形參(形式引數)

    在函數定義中出現的引數可以看做是一個預留位置,它沒有資料,只能等到函數被呼叫時接收傳遞進來的資料,所以稱為形式引數,簡稱形參。

  • 實參(實際引數)

    函數被呼叫時給出的引數包含了實實在在的資料,會被函數內部的程式碼使用,所以稱為實際引數,簡稱實參。

形參和實參的區別和聯絡

  • 1) 形參變數只有在函數被呼叫時才會分配記憶體,呼叫結束後,立刻釋放記憶體,所以形參變數只有在函數內部有效,不能在函數外部使用。

  • 2) 實參可以是常數、變數、表示式、函數等,無論實參是何種型別的資料,在進行函數呼叫時,它們都必須有確定的值,以便把這些值傳送給形參,所以應該提前用賦值、輸入等辦法使實參獲得確定值。

  • 3) 實參和形參在數量上、型別上、順序上必須嚴格一致,否則會發生「型別不匹配」的錯誤。當然,如果能夠進行自動型別轉換,或者進行了強制型別轉換,那麼實參型別也可以不同於形參型別。

  • 4) 函數呼叫中發生的資料傳遞是單向的,只能把實參的值傳遞給形參,而不能把形參的值反向地傳遞給實參;換句話說,一旦完成資料的傳遞,實參和形參就再也沒有瓜葛了,所以,在函數呼叫過程中,形參的值發生改變並不會影響實參。

  • 5) 形參和實參雖然可以同名,但它們之間是相互獨立的,互不影響,因為實參在函數外部有效,而形參在函數內部有效。

形參和實參的功能是傳遞資料,發生函數呼叫時,實參的值會傳遞給形參。

二、引數傳遞

函數允許我們將資料傳遞進去,通過傳遞的資料從而影響函數執行結果,使函數更靈活、複用性更強。

function foo(a, b) {
    console.log([a, b]);
}

foo(1, 2); // 輸出 [1, 2]

這個例子中,ab 屬於函數中的區域性變數,只能在函數中存取。呼叫函數時,傳遞的資料會根據位置來匹配對應,分別賦值給 ab

建立函數時,function 函數名 後面括號中設定的引數被稱為形參;呼叫函數時,函數名後面括號中傳入的引數被稱為實參。上面例子中,ab 是形參,傳入的 12 是實參。

因為形參是已宣告的變數,所以不能再用 letconst 重複宣告。

function foo(a, b) {
    let a = 1; // 報錯,a 已宣告
    const b = 1; // 報錯,b 已宣告
}

JavaScript 中所有函數傳遞都是按值傳遞的,不會按參照傳遞。所謂的值,就是指直接儲存在變數上的值,如果把物件作為引數傳遞,那麼這個值就是這個物件的參照,而不是物件本身。這裡實際上是一個隱式的賦值過程,所以給函數傳遞引數時,相當於從一個變數賦值到另一個變數

原始值:

function add(num) {
    return num + 1;
}

let count = 5;
let result = add(count); // 此處引數傳遞的過程可以看作是 num = count

console.log(count); // 5
console.log(result); // 6

參照值:

function setName(obj) {
    obj.name = "小明";
}

let person = {};

setName(person); // 此處引數傳遞的過程可以看作是 obj = person;
console.log(person); // {name: "小明"}

三、理解引數

JavaScript 中的函數既不會檢測引數的型別,也不會檢測傳入引數的個數。定義函數時設定兩個形參,不意味著呼叫時必須傳入兩個引數。實際呼叫時不管是傳了一個還是三個,甚至不傳引數也不會報錯。

所有函數(非箭頭)中都有一個名為 arguments 的特殊的類陣列物件(不是 Array 的範例),它儲存著所有實參的副本,我們可以通過它按照陣列的索引存取方式獲取所有實參的值,也可以存取它的 arguments.length 屬性來確定函數實際呼叫時傳入的引數個數。

例如:

function foo(a, b) {
	console.log(arguments[0]);
    console.log(arguments[1]);
    console.log(arguments.length);
}

foo(10, 20); // 依次輸出 10、20、2

上面例子中,foo() 函數的第一個引數是 a,第二個引數是b ,可以通過 arguments[x] 的方式來分別獲取同樣的值 。因此,你甚至可以在宣告函數時不設定形參。

function foo() {
	console.log(arguments[0]);
    console.log(arguments[1]);
}

foo(10, 20); // 依次輸出 10、20

由此可見,JavaScript 函數的形參只是方便使用才寫出來的。想傳多少個引數都不會產生錯誤。

還有一個要注意的是,arguments 可以跟形參一起使用,並且 arguments 物件中的值會和對應的形參保持同步。例如:

function foo(a) {
	arguments[0] ++;
    console.log(a);
}

foo(10); // 輸出 11
//------------------------------------
function foo2(a) {
	a++;
    console.log(arguments[0]);
}

foo2(10); // 輸出 11

當修改 arguments[0] 或 a 的值時,另一個也被改變了。這並不意味著它們存取同一個記憶體地址,畢竟我們傳入的是一個原始值。它們在記憶體中還是分開的,只是由於內部的機制使它們的值保持了同步。

另外,如果缺少傳參,那這個形參的值就不會和 arguments 物件中的對應值進行同步。例如下面這個例子,只傳了一個引數,那麼arguments 中只有一個實參值,這時候在函數中把 arguments[1] 設定為某個值,這個值並不會同步給第二個形參,例如:

function foo(a,b) {
    arguments[1] = 2;
    console.log(b);
}

foo(1); // 輸出 undefined

這個例子中,形參 b 沒有傳入實參,它的值會預設為 undefined。但如果:

foo(1, undefined); // 輸出 2

手動傳入 undefined 時, arguments 陣列中會出現一個值為 undefined 的元素,依然能和 b 的值進行同步。

嚴格模式下,arguments 物件中的值和形參不會再同步,當然,如果傳入的是參照值,它們依然會互相影響,但這只是參照值的特性而已。因此,在開發中最好不要依賴這種同步機制,也就是說不要同時使用形參和它在arguments 物件中的對應值。

箭頭函數中沒有 arguments

如果函數是使用箭頭語法定義的,那麼函數中是沒有 arguments 物件的,只能通過定義的形參來存取。

let foo = () => {
    console.log(arguments[0]);
}foo(); // 報錯,arguments 未定義

在某些情況可能會存取到 arguments

function fn1(){
    let fn2 = () => {
    	console.log(arguments[0]);
    }
    
    fn2();
}fn1(5);

但這個 arguments,並不是箭頭函數的,而是屬於外部普通函數的,當箭頭函數中存取 arguments 時,順著作用域鏈找到了外部函數的arguments

四、將物件屬性用作實參

當一個函數包含的形參有多個時,呼叫函數就成了一種麻煩,因為你總是要保證傳入的引數放在正確的位置上,有沒有辦法解決傳參順序的限制呢?

由於物件屬性是無序的,通過屬性名來確定對應的值。因此可以通過傳入物件的方式,以物件中的屬性作為真正的實參,這樣引數的順序就無關緊要了。

function foo(obj) {
    console.log(obj.name, obj.sex, obj.age);
}

foo({ sex: '男', age: 18, name: '小明' }); // 小明 男 18

五、引數預設值

如果呼叫函數時缺少提供實參,那麼形參預設值為 undefined

有時候我們想要設定特定的預設值,在 ES6 之前還不支援顯式地設定預設值的時候,只能採用變通的方式:

function sayHi(name) {
    name = name || 'everyone';
    
	console.log( 'Hello ' + name + '!');
}

sayHi(); // 輸出 'Hello everyone!'

通過檢查引數值的方式判斷有沒有賦值,上面的做法雖然簡便,但缺點在於如果傳入的實參對應布林值為 false ,實參就不起作用了。需要更精確的話可以用 if 語句或者三元表示式,判斷引數是否等於 undefined,如果是則說明這個引數缺失 :

// if 語句判斷
function sayHi(name) {
	if (name === undefined) {
		name = 'everyone';
	}
    
	console.log( 'Hello ' + name + '!');
}

// 三元表示式判斷
function sayHi(name) {
	name =  (name !== undefined) ? name : 'everyone';
	
    console.log( 'Hello ' + name + '!');
}

ES6 就方便了許多,因為它支援了顯式的設定預設值的方式,就像這樣:

function sayHi(name = 'everyone') { // 定義函數時,直接給形參賦值
	console.log( 'Hello ' + name + '!');
}

sayHi(); // 輸出 'Hello everyone!' 
sayHi('Tony'); // 輸出 'Hello Tony!' 
sayHi(undefined); // 輸出 'Hello everyone!'

這些結果表明了,它也是通過引數是否等於 undefined 來判定引數是否缺失的。

預設值不但可以是一個值,它還可以是任意合法的表示式,甚至是函數呼叫:

function sayHi(name = 'every'+'one') {
	console.log( 'Hello ' + name + '!');
}

sayHi(); // 輸出 'Hello everyone!' 
//--------------------------------------
function foo() {
    console.log('呼叫foo');
    return 'Tony';
}

function sayHi(name = foo()) {
	console.log( 'Hello ' + name + '!');
}
		  
sayHi(); // 輸出 '呼叫foo'
         // 輸出 'Hello Tony!' 

sayHi(undefined); // 輸出 '呼叫foo'
                  // 輸出 'Hello Tony!' 

sayHi('John'); // 輸出 'Hello John!'

可以看到,函數引數的預設值只有在函數呼叫時,引數的值缺失或者是 undefined 才會求值,不會在函數定義時求值。

引數預設值的位置

通常我們給引數設定預設值,是為了呼叫函數時可以適當省略引數的傳入,這裡要注意的是,有多個引數時,設定了預設值的引數如果不是放在尾部,實際上它是無法省略的。

function fn(x = 1, y) {
	console.log([x, y]);
}

fn(); // 輸出 [1, undefined]
fn(2); // 輸出 [2, undefined]
fn(, 2); // 報錯,語法錯誤(這裡不支援像陣列那樣的空槽)
fn(undefined, 2); // 輸出 [1, 2] (那還不如傳個 1 方便呢!)

上面例子中,給形參 x 設定的預設值就顯得沒有任何意義了。因此,設定預設值的引數放在尾部是最好的做法:

function fn(x, y = 2) {
	console.log([x, y]);
}

fn(); // 輸出 [undefined, 2]
fn(1); // 輸出 [1, 2]
fn(1, 1) // 輸出 [1, 1]

引數的省略問題

在多個引數設定了預設值的情況下,那麼問題又來了,你並不能省略比較靠前的引數,而只給最後的一個引數傳入實參。

function fn(x, y = 2, z = 3) {
	console.log([x, y, z]);
}

fn(1, , 10) // 報錯

前面我們知道,可以通過傳入物件的這種方式去避免引數順序的限制。那引數預設值如何實現呢?用 ||if 語句或者三元表示式去判斷也是解決辦法,但這樣就顯得有些落後了。接下來要討論的是另外兩種 ES6 中的全新方式。

引數預設值和 Object.assign() 結合使用

function fn(obj = {}) {
    let defaultObj = {
        x: undefined,
        y: 2,
        z: 3
    }
    
    let result = Object.assign(defaultObj, obj);
    
	console.log([result.x, result.y, result.z]);
}

fn(); // 輸出 [undefined, 2, 3]
fn({ x: 1, z: 10 }); // 輸出 [1, 2, 10]

上面的例子中,在函數中定義了一個物件 defaultObj ,變通地利用其中的屬性作為引數的預設值,然後利用 Object.assagin() 把傳入的物件和預設物件進行合併,defaultObj 中的屬性會被 obj 的相同屬性覆蓋,obj 中如果有其他屬性會分配給 defaultObj 。這裡用一個變數接收返回的合併物件。

同時形參 obj 也設定了預設值為一個空物件,防止函數呼叫時不傳任何引數,因為這會導致 Object.assign() 接收的第二個引數是 undefined ,從而產生報錯。

引數預設值和解構賦值結合使用

函數呼叫時,實參和形參的匹配實際上是一個隱式的賦值過程,所以,引數傳遞也可以進行解構賦值:

function fn({ x, y = 2, z = 3 }) {
    console.log([x, y, z]);
}

fn({}); // 輸出 [undefined, 2, 3]
fn({ x: 1, z: 10 }); // 輸出 [1, 2, 10]

在這個例子中,使用的只是物件的解構賦值預設值,還沒有使用函數引數的預設值。如果函數呼叫時不傳任何引數,也會產生報錯,因為這導致了引數初始化時解構賦值失敗,相當於執行了 {x, y = 2, z = 3} = undefined 這樣的程式碼。

同樣的,你可以利用引數預設值的語法,給 {x, y = 2, z = 3} 設定一個預設的解構物件,使得不傳參函數也能夠順利執行:

function fn({ x, y = 2, z = 3 } = {}) {
    console.log([x, y, z]);
}

fn(); // 輸出 [undefined, 2, 3]

這裡出現了雙重的預設值,可能有些繞,那麼用一段虛擬碼來解釋以上的引數初始化過程就是:

if( 實參 === {...} ) { // 當 fn({...});     
    { x, y = 2, z = 3 } = {...};
                        
} else if ( 實參 === undefined ){ // 當 fn();
    { x, y = 2, z = 3 } = {};

}

雙重預設值有一點細節需要特別注意,就是解構賦值預設值和函數引數預設值的差別,看下面例子:

function fn ({ x = 1 } = {}, { y } = { y: 2 }){
    console.log(x, y);
}

fn(); // 輸出 1 2
fn({ x: 10 }, { y: 20 }); // 輸出 10 20
fn({},{}); // 1 undefined

這個函數中,有兩組引數採用瞭解構賦值的方式,看似 x 和 y 都設定了預設值,雖然是不同的兩種形式,但顯然不是任何情況下結果都相同的。當傳入的引數是{}時,y 並沒有獲取到預設值 2 ,為什麼會這樣呢?結合前面的虛擬碼例子來看:

fn({ x: 10 }, { y: 20 }); // 初始化時: { x = 1 } = { x: 10 }, { y } = { y: 20 }

fn({},{}); // 初始化時: { x = 1 } = {}, { y } = {}

當傳入的引數是{}時,函數引數沒有缺失也不是 undefined ,所以函數引數預設值是不起作用的。同時 {} 裡面也沒有 x 和 y 的對應值,x 得到的 1 是解構賦值預設值,而 y 由於沒有設定解構賦值預設值,所以它預設是 undefined

引數預設值的作用域與暫時性死區

還有一個小細節,一旦有引數設定了預設值,那麼它們會形成自己的作用域(包裹在(...)中),因此不能參照函數體中的變數:

function foo(a = b) {
    let b = 1;
}

foo(); // 報錯,b 未定義

但這個作用域只是臨時的,引數初始化完畢後,這個作用域就不存在了。

它也符合普通作用域的規則:

let b = 2;

function foo(a = b) {
    let b = 1;
    return a;
}

foo(); // 2

上面例子中,存在一個全域性變數 b,那麼形參 a 會獲取到全域性變數 b 的值。

當然,如果形參作用域中存在一個形參 b 的話,它優先獲取到的是當前作用域的:

let b = 2;

function foo(b = 3 ,a = b) {
    return a;
}

foo(); // 3

給多個引數設定預設值,它們會按順序初始化的,遵循「暫時性死區」的規則,即前面的引數不能參照後面的引數:

function foo(a = b, b = 2) {
    return a + b;
}

foo(); // 報錯,b 在初始化之前不能存取

六、引數的收集與展開

剩餘引數

ES6 提供了**剩餘引數(rest)**的語法(...變數名),它可以收集函數多餘的實參(即沒有對應形參的實參),這樣就不再需要使用 arguments 物件來獲取了。形參使用了 ... 操作符會變成一個陣列,多餘的實參都會被放進這個陣列中。

剩餘引數基本用法:

function sum(a, ...values) {
 
    for (let val of values) {
        a += val;
    }
    
    return a;
}

sum(0, 1, 2, 3); // 6

上面例子中,在引數初始化時,首先根據引數位置進行匹配,把 0 賦值給 a ,然後剩餘的引數 1、2、3 都會被放進陣列 values 中。

下面是分別用 arguments 物件和剩餘引數來獲取引數的對比例子:

// arguments 的寫法
function sortNumbers() {
	return Array.prototype.slice.call(arguments).sort();
}

// 剩餘引數的寫法
const sortNumbers = (...numbers) => {
    return numbers.sort();
}

可以看出剩餘引數的寫法更加簡潔。儘管 arguments 是一個類陣列,也是可迭代物件,但它終究不是陣列。它不支援陣列方法,當我們使用 arguments 時,如果想要呼叫陣列方法,就必須使用Array.prototype.slice.call先將其轉為陣列。

而剩餘引數它不同於 arguments 物件,它是真正的 Array 範例,能夠很方便地使用陣列方法。並且箭頭函數也支援剩餘引數。

另外,使用剩餘引數不會影響 arguments 物件的功能,它仍然能夠反映呼叫函數時傳入的引數。

  • 剩餘引數的位置

剩餘引數必須是最後一個形參,否則會報錯。

// 報錯
function fn1(a, ...rest, b) {
	console.log([a, b, rest]);
} 

// 正確寫法
function fn2(a, b, ...rest) {
    console.log([a, b, rest]);
}

fn2(1, 2, 3, 4) // 輸出 [1, 2, [3, 4]]

展開語法

前面我們知道了如何把多餘的引數收集為一個陣列,但有時候我們需要做一些相反的事,例如要把一個陣列中的元素分別傳入給某個函數,而不是傳入一個陣列,像這樣:

function sum(...values) {
    let sum = 0;
    
    for (let val of values) {
        sum += val;
    }
    
    return sum;
}

let arr = [1, 2, 3, 4];

sum(arr); // "01,2,3,4"

上面例子的函數會把所有傳進來的數值累加,如果直接傳入一個陣列,就得不到我們想要的結果。

例子中傳入一個陣列, values 的值會變成 [[1, 2, 3, 4]],導致陣列 values 中只有一個元素,而這個元素的型別是陣列。那麼函數返回值就是數值 0 和陣列 [1, 2, 3, 4]相加的結果了,兩者各自進行了型別的隱式轉換變成字串,然後再相加,是一個字串拼接的效果。

要實現把陣列拆解傳入給函數,首先不可能一個個傳入引數——sum(arr[0], arr[1], arr[2], arr[3]);,因為不是任何時候都知道陣列中有多少個元素的,而且陣列中可能會非常多的元素,手動傳是不明智的。

比較可行的是藉助 apply() 方法:

sum.apply(null, arr); // 10

但這還不是最優解,那麼重點來了!

ES6 新增的**展開語法(spread)**可以幫助我們面對這種情況。它也是使用 ...變數名 的語法,雖然跟剩餘引數語法一樣,但是用途完全相反,它能夠把一個可迭代物件拆分成逗號分隔的引數序列。

在函數呼叫時,它的應用是這樣子的:

sum(...arr); // 10

// 相當於 sum(1,2,3,4);

它甚至可以隨意搭配常規值使用,沒有前後位置限制,還可以同時傳入多個可迭代物件:

sum(-1, ...arr); // 9
sum(...arr, 5); // 15
sum(-1, ...arr, 5); // 14
sum(-1, ...arr, ...[5, 6, 7]); // 27

展開操作符 ... 相當於替我們完成了手動分別傳參的操作,函數只知道接收的實參是單獨的一個個值,不會因為展開操作符的存在而產生其他影響。

上面的範例雖然都是針對於陣列的,但展開語法能做的還不止這些,其他可迭代物件例如字串、字面量物件都可以展開,深入瞭解請參見 → 展開語法

總結

  • 形參是函數中已宣告的區域性變數,傳遞給函數的實參會被賦值給形參,函數引數傳遞實際上是一個隱式的賦值過程。

  • 形參和實參的數量可以不相等:

    ● 缺失實參的形參會得到預設值 undefined

    ● 額外的實參,可以通過 arguments 物件存取,箭頭函數除外。

  • 可以通過傳入物件的方式讓傳參順序不再重要,讓物件中的屬性作為真正的實參。

  • ES6 的引數預設值——函數呼叫時引數的值缺失或者是 undefined ,才會獲取預設值。

    ● 設定預設值的形參只有放在最後一位才可以省略傳參。

    ● 形參設定預設值不能參照函數體中的變數,但可以參照前面的形參和外部變數。

    ● 通過 Object.assign() 或者解構賦值實現預設值,能讓傳參的方式更加靈活。

  • 剩餘引數和 arguments 的主要區別:

    ● 剩餘引數只包含那些沒有對應形參的實參,而 arguments 物件包含了傳給函數的所有實參。

    ● 剩餘引數是真正的 Array 範例,而 arguments 只是類陣列物件。

  • 剩餘引數和展開語法都採用 ... 操作符,在函數的相關場景中:

    ● 出現在函數形參列表的最後,它是剩餘引數。

    ● 出現在函數呼叫時,它是展開語法。

【相關推薦:

以上就是一文詳解JavaScript函數中的引數的詳細內容,更多請關注TW511.COM其它相關文章!