深入理解 JavaScript 函數

函數定義

到目前爲止,定義普通函數總共有四種方式:

  • 函數聲明
  • 函數表達式
  • 箭頭函數
  • Function構造函數

另外 ES6 新增了Generator函數,是一種不同於Promise的異步編程解決方案,語法行爲與傳統函數完全不同:

// Generator 函數聲明
function* name(param[, ...param]) {
  statements
}

// Generator 函數表達式
var temp = function* [name](param[, ...param]) {
  statements
}

不過Generator並非這篇文章需要掌握的內容,因此不做具體介紹。

函數聲明

對於函數聲明語句,需要注意如下幾點:

  • 函數聲明語句必須定義函數名稱,函數的名稱爲函數內部的一個局部變量,指代該函數對象本身;
  • 函數聲明語句不能出現在循環、判斷,或者 try/cache/finally 以及 with 語句中;
  • 函數聲明語句定義的函數,會被提前到外部腳本或者外部函數作用域的頂端,因此可以在定義之前使用。
// 函數聲明語句
function name(param[, ...param]) {
  statements
  [return xxx]
}

大部分函數中會包含一條 return 語句,該語句用來停止函數的執行,如果一個函數不包含 return 語句或者 return 語句沒有一個與之相關的表達式,則函數默認返回 undefined。

函數表達式

與函數聲明語句相比,函數定義表達式具有以下特點:

  • 函數定義表達式可以省略函數名稱;
  • 函數定義表達式可以出現在任何地方;
  • 函數定義表達式定義的函數,由於變量提升作用的存在,該表達式變量被提前,但是函數本身並未提前,因此無法在定義前調用。
// 函數表達式
var temp = function [name](param[, ...param]) {
  statements
  [return xxx]
}

箭頭函數

使用箭頭函數需要注意:

  • 箭頭函數沒有自己的 this,arguments,super 或 new.target 關鍵字綁定;
  • 函數體內的 this 對象,就是定義時所在的對象,而不是使用時所在的對象,因此箭頭函數不適合作爲方法;
  • 無法當作構造函數使用,也就是說,不可以使用 new 命令,否則會拋出一個錯誤;
  • 不可以使用yield命令,因此箭頭函數不能用作 Generator 函數。
// 基本用法
(param1, param2,, paramN) => {statements}
(param1, param2,, paramN) => expression // 等同於: => {return expression;}

// 當只有一個參數時括號是可選的
(singleParam) => {statements}
singleParam => {statements}

// 沒有參數時括號是不可以省略的
() => {statements}

// 將函數體用大括號括起來返回對象字面量
params => ({foo: bar})

Function 構造函數

使用Function定義函數時可以傳入任意數量的字符串實參,最後一個實參就是函數體:

var fn = new Function("x", "y", "return x + y;");

// 等同於

var fn = function(x, y) {return x + y;}

使用Function構造函數定義函數時只需要注意一個問題:由Function構造函數定義的函數只繼承全局作用域。

var foo = 1;
function myFunc() {
  var foo = 2;

  function decl() {
    console.log(foo);
  }

  var expr = function() {
    console.log(foo);
  };

  var cons = new Function('\tconsole.log(foo);');

  decl(); // 2
  expr(); // 2
  cons(); // 1
}
myFunc();

Function()構造函數允許 JavaScript 在運行時動態地創建並編譯函數,每次調用都會解析函數體並創建新的函數對象,而使用函數聲明語句和表達式只會解析一次,很明顯使用構造函數的效率比較低,因此,通常應儘可能避免使用Function構造函數。

函數調用

在 JavaScript 中,函數調用總共有四種方式:

  • 作爲函數
  • 作爲方法
  • 作爲構造函數
  • 通過 call() 和 apply() 方法間接調用

其中作爲函數調用是最基本的形式,只需注意以這種方式調用函數通常不使用 this 關鍵字,但是此使 this 可以用來判斷當前是否爲嚴格模式:

var strict = (function() {return !this;}());

方法調用

方法指的是保存在對象屬性中的 JavaScript 函數,使用方法調用有兩種形式,與訪問對象的屬性訪問方法一致:

  • 使用“.”:obj.f(argu);
  • 使用“[]”:obj"f"

方法調用的參數和返回值處理和函數調用一致,但是方法調用有一個重要的特點:調用上下文(context),即函數體可以使用 this 引用該對象。

構造函數調用

如果函數和方法調用之前帶有關鍵字 new,它便構成構造函數調用。構造函數調用和函數調用以及方法調用在實參處理、調用上下文和返回值方便都有不同。

如果構造函數沒有形參,則可以省略實參列表。構造函數創建的是一個新的對象,這個對象繼承自構造函數的 prototype 屬性,因此此時的調用上下文是生成的新對象而非構造函數。如果在構造函數中使用 return 語句並返回一個對象,那麼這個新對象將作爲調用結果,這一特性可用來實現私有屬性、方法。

this

任何函數只要作爲方法調用都會傳入一個隱式實參——方法調用的母體對象。可以使用 this 關鍵字訪問該母體對象的任意屬性。this 關鍵字沒有作用域的限制,嵌套的函數不會從調用它的函數中繼承 this,因此如果需要在嵌套的函數中使用 this,需要先使用一個變量來保存外部的 this。

可以使用 call() 和 apply() 顯式指定函數的調用上下文,即 this 的值。

var obj = {a: 'Custom'};

// 全局對象
var a = 'Global';

function whatsThis() {
  return this.a;
}

whatsThis();          // 'Global'
whatsThis.call(obj);  // 'Custom'
whatsThis.apply(obj); // 'Custom'

使用 bind() 方法綁定函數的調用上下文,綁定後無論如何調用該函數,不會改變其調用上下文。

function f() {
  return this.a;
}

var g = f.bind({a: 'azerty'});
console.log(g()); // azerty

var h = g.bind({a: 'yoo'}); // 綁定只生效一次
console.log(h()); // azerty

閉包

一般來說,只有在函數運行時,子函數才能訪問父函數內定義的局部變量,但是利用閉包的特性,調用父函數中返回的子函數也能訪問到父函數所有局部變量。閉包是由函數以及創建該函數的詞法環境(作用域鏈)組合而成,這個環境包含了這個閉包創建時所能訪問的所有局部變量。閉包常被用來實現私有變量和方法。

要理解閉包,首先需要理解嵌套函數的作用域規則。正常情況下,局部變量定義在 CPU 的棧中,因此函數返回後這些局部變量就不存在了。但是在 JavaScript 中,作用域鏈是以一個對象列表形式存在,而並非直接添加到棧中,因此只要有引用到這個對象的部分存在,該作用域就一直存在,否則就會被當作垃圾回收掉。

var foo = 1;
function f() {
  var foo = 2;
  function show() {return foo;}
  return show();
}
f(); // 2

因此,只需要把私有變量方法放到父函數中,公共變量和方法放到嵌套函數中,即可實現變量方法的私有化:

function addPrivateProperty(o, name, predicate) {
  var value;
  // getter 方法
  o["get" + name] = function() {return value;}
  // setter 方法
  o["set" + name] = function(v) {
    if (predicate && !predicate(v)) {
      throw Error("set" + name + ": invalid value " + v);
    } else {
      value = v;
    }
  }
}

需要注意的是,記住關聯到閉包的作用域鏈都是“活動的”,嵌套的函數不會將作用域內的私有成員複製一份,如下面的例子所示:

function f() {
  var funcs = [];
  for (var i = 0; i < 10; i++)
    funcs[i] = function() {return i;}
  return funcs;
}

var funcs = f();
f[5](); // 返回值爲10

函數參數

在 JavaScript 中,參數的傳入是非常靈活的。當調用函數的時候傳入的實參比函數聲明時指定的形參個數少,則剩下的形參都會被設置爲 undefined。

arguments 對象

在函數體內,標識符arguments指向實參對象的引用,是一個類數組對象,可以直接使用訪問數組元素的方法訪問對應位置的實參,也可以使用 length 屬性來獲取參數的個數。利用這個特性可以實現讓函數操作任意數量的參數。

function max() {
  var max = Number.NEGATIVE_INFINITY;
  for (var i = 0; i < arguments.length; i++)
    if (arguments[i] > max) max = arguments[i];
  return max;
}

儘管arguments並非真正的數組,但是可以通過一定的方法把它轉爲真正的數組:

var args = Array.prototype.slice.call(arguments);
// 等效於
var args = [].slice.call(arguments);

實參對象還有calleecaller兩個屬性,它們類似兩個指針,callee指向當前正在執行的函數,caller指向當前正在執行的函數的函數,即調用棧。

callee屬性在遞歸中的應用:

var factorial = function(x) {
  if (x <= 1) return 1;
  return x * arguments.callee(x - 1);
}

Rest 參數

ES6 引入rest參數(形式爲...變量名),用於獲取函數的多餘參數,這樣就不需要使用arguments對象了。rest參數搭配的變量是一個數組,該變量將多餘的參數放入數組中。需要注意的是,rest參數只能作爲函數的最後一個參數,否則會報錯。同時,函數的 length 屬性,不包括rest參數。

function sum(...theArgs) {
  return theArgs.reduce((previous, current) => {
    return previous + current;
  });
}

console.log(sum(1, 2, 3)); // expected output: 6

參數默認值

ES6 之前,想要爲函數指定默認值只能採用如下的辦法:

function f(a, b) {
  a = a || 0;
  b = typeof b == "undefined" ? 0 : b;
  return a + b;
}

ES6 允許爲函數的參數設置默認值,即直接寫在參數定義的後面。

function f(a = 0, b = 0) {
  return a + b;
}

無論是上面說到的rest參數,還是參數默認值,都可以使用 ES6 的解構賦值。

function foo({x = 0, y = 0} = {}) {
  console.log(x, y);
}

foo() // 0 0
foo({}) // 0 0
foo({x: 1}) // 1 0
foo({x: 1, y: 2}) // 1 2

函數的屬性和方法

length 屬性

函數的length屬性不同於arguments.length,後者表示傳入函數的實參個數,而前者表示函數定義時所需的實參個數,可以通過這個特性來判斷傳入函數的參數個數是否滿足要求:

function check(args) {
  if (args.length != args.callee.length) {
    throw Error('Expected ' + args.callee.length + " arguments");
  }
}

call、apply 和 bind

call()apply()的功能是一樣的,可以將函數作爲某個對象的方法調用,以此來改變調用上下文(context),即this的指向。它們的第一個參數是用來綁定上下文的對象,之後的參數是傳入函數的實參,二者唯一的不同便是傳入實參的形式,call()直接傳入實參,類似call(o, 1, 2, 3),而apply()傳入一個實參的數組或者類數組對象(arguments),比如apply(o, [1, 2, 3])

var obj = {
  x: 1,
  y: 2
}

function sum() {
  return this.x + this.y;
}

console.log(sum());             // NaN
console.log(sum.call(obj));     // 3
console.log(sum.apply(obj));    // 3

apply()對於任意參數的函數或者將一個函數的參數傳給另一個函數的場景會非常好用:

function calcu(m, nums) {
  this.method = m.name;
  return m.apply(this, nums);
}

function sum() {
  let total = 0;
  Array.prototype.slice.apply(arguments).forEach(n => total += n);
  return {method: this.method, result: total};
}

function cumprod() {
  let total = 1;
  Array.prototype.slice.apply(arguments).forEach(n => total *= n);
  return {method: this.method, result: total};
}

let op1 = new calcu(sum, [1, 2, 3, 4]);
let op2 = new calcu(cumprod, [1, 2, 3, 4]);
console.log("The result of " + op1.method + " is: " + op1.result);
console.log("The result of " + op2.method + " is: " + op2.result);

bind()是 ECMAScript5 中新增的方法,用於將函數綁定至某個對象,返回值是一個新的函數。

var obj = {
  x: 1,
  y: 2
}

function sum() {
  return this.x + this.y;
}

var temp = sum.bind(obj);
console.log(temp()) // 3

在 ECMAScript3 中可以通過已有的方法來模擬bind()方法:

if (!Function.prototype.bind) {
  Function.prototype.bind = function(o/*, args */) {
    let self = this, boundArgs = arguments;
    return function() {
      let args = [], i;
      for (i = 1; i < boundArgs.length; i++) args.push(boundArgs[i]);
      for (i = 0; i < arguments.length; i++) args.push(arguments[i]);
      return slef.apply(o, args);
    }
  }
}

其他

  • prototype 屬性:指向原型對象,當函數作爲構造函數時,新創建的對象會從原型對象繼承屬性;
  • toString() 方法:以字符串的形式返回函數的完整源碼。

參考資料

[1] 《JavaScript權威指南》第六版. 機械工業出版社,2015.
[2] 《JavaScript Guide》MDN web docs.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章