【JS 口袋書】第 3 章:JavaScript 函式

前端小智發表於2019-10-08

函式是什麼

函式是完成某個特定功能的一組語句。如沒有函式,完成任務可能需要五行、十行、甚至更多的程式碼。這時我們就可以把完成特定功能的程式碼塊放到一個函式裡,直接呼叫這個函式,就省重複輸入大量程式碼的麻煩。

函式可以概括為:一次封裝,四處使用。

函式的定義

函式的定義方式通常有三種:函式宣告方式、函式表示式、 使用Function建構函式 。

函式宣告方式

語法:

function 函式名(引數1,引數2,...){  
    //要執行的語句  
}  
複製程式碼

例:

// 宣告
function sum(num1, num2) {
  return num1 + num2;
}

// 呼叫
sum(1, 2)  // 3
複製程式碼

函式表示式

語法:

var fn = function(引數1,引數2,...){  
    //要執行的語句  
};
複製程式碼

例:

// 宣告
var sum = function(num1,num2){  
  return num1+num2;  
};
// 呼叫
sum(1, 2) // 3
複製程式碼

使用Function建構函式

Function建構函式可以接收任意數量的引數,最後一個引數為函式體,其他的引數則列舉出新函式的引數。其語法為:

new Function("引數1","引數2",...,"引數n","函式體"); 
複製程式碼

例:

// 宣告
var sum = new Function("num1","num2","return num1+num2");  
// 呼叫
sum(1, 2) // 3
複製程式碼

三種定義方式的區別

三種方式的區別,可以從作用域、效率以及載入順序來區分。

從作用域上來說,函式宣告式和函式表示式使用的是區域性變數,而 Function()建構函式卻是全域性變數,如下所示:

var name = '我是全域性變數 name';

// 宣告式
function a () {
  var name = '我是函式a中的name';
  return name;
}
console.log(a()); // 列印: "我是函式a中的name"

// 表示式
var b = function() {
  var name = '我是函式b中的name';
  return name; // 列印: "我是函式b中的name"
}
console.log(b())

// Function建構函式
function c() {
  var name = '我是函式c中的name';
  return new Function('return name')
}
console.log(c()()) // 列印:"我是全域性變數 name",因為Function()返回的是全域性變數 name,而不是函式體內的區域性變數。
複製程式碼

從執行效率上來說Function()建構函式的效率要低於其它兩種方式,尤其是在迴圈體中,因為建構函式每執行一次都要重新編譯,並且生成新的函式物件。

來個例子:

var start = new Date().getTime()

for(var i = 0; i < 10000000; i++) {
  var fn = new Function('a', 'b', 'return a + b')
  fn(i, i+1)
}
var end = new Date().getTime();
console.log(`使用Function建構函式方式所需要的時間為:${(end - start)/1000}s`)
// 使用Function建構函式方式所需要的時間為:8.646s

start = new Date().getTime();
var fn = function(a, b) {
  return a + b;
}
for(var i = 0; i < 10000000; i++) {
  fn(i, i+1)
}
end = new Date().getTime();
console.log(`使用表示式的時間為:${(end - start)/1000}s`)
// 使用表示式的時間為:0.012s
複製程式碼

由此可見,在迴圈體中,使用表示式的執行效率比使用 Function()建構函式快了很多很多。所以在 Web 開發中,為了加快網頁載入速度,提高使用者體驗,我們不建議選擇 Function ()建構函式方式來定義函式。

最後是載入順序,function 方式(即函式宣告式)是在 JavaScript 編譯的時候就載入到作用域中,而其他兩種方式則是在程式碼執行的時候載入,如果在定義之前呼叫它,則會返回 undefined

console.log(typeof f) // function
console.log(typeof c) // undefined
console.log(typeof d) // undefined

function f () {
  return 'JS 深入淺出'
}
var c = function () {
  return 'JS 深入淺出'
}
console.log(typeof c) // function
var d = new Function('return "JS 深入淺出"')
console.log(typeof d) // function
複製程式碼

函式的引數和返回值

函式的引數-arguments

JavaScript 中的函式定義並未指定函式形參的型別,函式呼叫也未對傳入的實參值做任何型別檢查。實際上,JavaScript 函式呼叫甚至不檢查傳入形參的個數。

function sum(a) {
  return a + 1;
}
console.log(sum(1)); // 2
console.log(sum('1')); // 11
console.log(add()); // NaN
console.log(add(1, 2)); // 2
複製程式碼

當實參比形參個數要多時,剩下的實參沒有辦法直接獲得,需要使用即將提到的arguments物件。

JavaScript中的引數在內部用一個陣列表示。函式接收到的始終都是這個陣列,而不關心陣列中包含哪些引數。在函式體內可以通過arguments物件來訪問這個引數陣列,從而獲取傳遞給函式的每一個引數。arguments物件並不是Array的例項,它是一個類陣列物件,可以使用方括號語法訪問它的每一個元素。

function sum (x) {
  console.log(arguments[0], arguments[1], arguments[2]); // 1 2 3
}

sum(1, 2, 3)
複製程式碼

arguments物件的length屬性顯示實參的個數,函式的length屬性顯示形參的個數。

function sum(x, y) {
  console.log(arguments.length); // 3
  return x + 1;
}
sum(1, 2, 3)
console.log(sum.length) // 2
複製程式碼

函式的引數-arguments

JavaScript 中的函式定義並未指定函式形參的型別,函式呼叫也未對傳入的實參值做任何型別檢查。實際上,JavaScript 函式呼叫甚至不檢查傳入形參的個數。

function sum(a) {
  return a + 1;
}
console.log(sum(1)); // 2
console.log(sum('1')); // 11
console.log(add()); // NaN
console.log(add(1, 2)); // 2
複製程式碼

函式的引數-同名引數

在非嚴格模式下,函式中可以出現同名形參,且只能訪問最後出現的該名稱的形參。

function sum(x, x, x) {
  return x;
}
console.log(sum(1, 2, 3)) // 3
複製程式碼

而在嚴格模式下,出現同名形參會丟擲語法錯誤。

function sum(x, x, x) {
  'use strict';
  return x;
}
console.log(sum(1, 2, 3)) // SyntaxError: Duplicate parameter name not allowed in this context
複製程式碼

函式的引數-引數個數

當實參比函式宣告指定的形參個數要少,剩下的形參都將設定為undefined值。

function sum(x, y) {
  console.log(x, y);
}
sum(1); // 1 undefined
複製程式碼

函式的返回值

所有函式都有返回值,沒有return語句時,預設返回內容為undefined

function sum1 (x, y) {
  var total = x + y
} 
console.log(sum1()) // undefined

function sum2 (x, y) {
  return x + y
}
console.log(sum2(1, 2)) // 3
複製程式碼

如果函式呼叫時在前面加上了new字首,且返回值不是一個物件,則返回this(該新物件)。

function Book () {
  this.bookName = 'JS 深入淺出'
}

var book = new Book();
console.log(book); // Book { bookName: 'JS 深入淺出' }
console.log(book.constructor); // [Function: Book]
複製程式碼

如果返回值是一個物件,則返回該物件。

function Book () {
  return {bookName: JS 深入淺出}
}

var book = new Book();
console.log(book); // { bookName: 'JS 深入淺出' }
console.log(book.constructor); // [Function: Book]
複製程式碼

函式的呼叫方式

JS 一共有4種呼叫模式:函式呼叫、方法呼叫、構造器呼叫和間接呼叫。

函式呼叫

當一個函式並非一個物件的屬性時,那麼它就是被當做一個函式來呼叫的。對於普通的函式呼叫來說,函式的返回值就是呼叫表示式的值

function sum (x, y) {
  return x + y;
}
var total = sum(1, 2);
console.log(total); // 3
複製程式碼

使用函式呼叫模式呼叫函式時,非嚴格模式下,this被繫結到全域性物件;在嚴格模式下,thisundefined

// 非嚴格模式
function whatIsThis1() {
  console.log(this);
}
whatIsThis1(); // window

// 嚴格模式
function whatIsThis2() {
  'use strict';
  console.log(this);
}
whatIsThis2(); // undefined
複製程式碼

方法呼叫

當一個函式被儲存為物件的一個屬性時,稱為方法,當一個方法被呼叫時,this被繫結到該物件。

function printValue(){
  console.log(this.value);  
}
var value=1;
var myObject = {value:2};
myObject.m = printValue;
//作為函式呼叫
printValue();
//作為方法呼叫
myObject.m();
複製程式碼

我們們注意到,當呼叫printValue時,this繫結的是全域性物件(window),列印全域性變數value1。但是當呼叫myObject.m()時,this繫結的是方法m所屬的物件Object,所以列印的值為Object.value,即2

建構函式呼叫

如果函式或者方法呼叫之前帶有關鍵字new,它就構成建構函式呼叫。

function fn(){
    this.a = 1;
};
var obj = new fn();
console.log(obj.a);//1
複製程式碼

引數處理:一般情況構造器引數處理和函式呼叫模式一致。但如果建構函式沒用形參,JavaScript建構函式呼叫語法是允許省略實參列表和圓括號的。

如:下面兩行程式碼是等價的。

var o = new Object();
var o = new Object;
複製程式碼

函式的呼叫上下文為新建立的物件。

function Book(bookName){
  this.bookName = bookName;
}
var bookName = 'JS 深入淺出';
var book = new Book('ES6 深入淺出');
console.log(bookName);// JS 深入淺出
console.log(book.bookName);// ES6 深入淺出
Book('新版JS 深入淺出');
console.log(bookName); // 新版JS 深入淺出
console.log(book.bookName);// ES6 深入淺出
複製程式碼

1.第一次呼叫Book()函式是作為建構函式呼叫的,此時呼叫上下文this被繫結到新建立的物件,即 book。所以全域性變數bookName值不變,而book新增一個屬性bookName,值為'ES6 深入淺出'

2.第二次呼叫Book()函式是作為普通函式呼叫的,此時呼叫上下為this被繫結到全域性物件,在瀏覽器中為window。所以全域性物件的bookNam值改變為' 新版JS 深入淺出',而book的屬性值不變。

間接呼叫

JS 中函式也是物件,函式物件也可以包含方法,call()apply()方法可以用來間接地呼叫函式。

這兩個方法都允許顯式指定呼叫所需的this值,也就是說,任何函式可以作為任何物件的方法來呼叫,哪怕這個函式不是那個物件的方法。兩個方法都可以指定呼叫的實參。call()方法使用它自有的實參列表作為函式的實參,apply()方法則要求以陣列的形式傳入引數。

var obj = {};
function sum(x,y){
    return x+y;
}
console.log(sum.call(obj,1,2));//3
console.log(sum.apply(obj,[1,2]));//3
複製程式碼

詞法(靜態)作用域與動態作用域

作用域

通常來說,一段程式程式碼中所用到的名字並不總是有效/可用的,而限定這個名字的可用性的程式碼範圍就是這個名字的作用域。

詞法作用域

詞法作用域,也叫靜態作用域,它的作用域是指在詞法分析階段就確定了,不會改變。而與詞法作用域相對的是動態作用域,函式的作用域是在函式呼叫的時候才決定的。

來個例子,如下程式碼所示:

var blobal1 = 1;
function fn1 (param1) {
  var local1 = 'local1';
  var local2 = 'local2';
  
  function fn2(param2) {
    var local2 = 'inner local2';
    console.log(local1)
    console.log(local2)
  }

  function fn3() {
    var local2 = 'fn3 local2';
    fn2(local2)
  }
  fn3()
}

fn1()
複製程式碼

當瀏覽器看到這樣的程式碼,不會馬上去執行,它會先生成一個抽象語法樹。上述程式碼生成的抽象語法樹大概是這樣的:

【JS 口袋書】第 3 章:JavaScript 函式

執行fn1函式,fn1中呼叫 fn3(),從fn3函式內部查詢是否有區域性變數 local1,如果沒有,就根據抽象樹,查詢上面一層的程式碼,也就是 local1 等於 'local1' ,所以結果會列印 'local1'

同樣的方法查詢是否有區域性變數 local2,發現當前作用域內有local2變數,所以結果會列印 'inner local2

思考

有如下的程式碼:

var a = 1;
function fn() {
  console.log(a)
}
複製程式碼

兩個問題:

  1. 函式 fn 裡面的變數 a, 是不是外面的變數 a
  2. 函式 fn 裡面的變數 a的值, 是不是外面的變數 a的值。

對於第一個問題:

分析一個語法,就能確定函式 fn裡面的 a 就是外面的 a

對於第二個問題:

函式 fn 裡面的變數 a的值, 不一定是外面的變數 a的值,假設我們們這樣做:

var a = 1;
function fn() {
  console.log(a)
}
a = 2
fn()
複製程式碼

這時候當我們們執行 fn() 的時候,列印 a 的值為 2。所以如果沒有看到最後,一開始我們們是不知道列印的 a 值到底是什麼。

所以詞法作用域只能確定變數所在位置,並不能確定變數的值。

呼叫棧(Call Stack)

什麼是執行上下文

執行上下文就是當前JavaScript程式碼被解析和執行是所在環境的抽象概念,JavaScript中執行任何的程式碼都是在執行上下文中執行。

執行上下文的型別,主要有兩類:

  • 全域性執行上下文:這是預設的,最基礎的執行上下文。不在任何函式中的程式碼都位於全域性執行上下文中。共有兩個過程:1.建立有全域性物件,在瀏覽器中這個全域性物件就是window物件。2.將this指標指向這個全域性物件。一個程式中只能存在一個執行上下文。

  • 函式執行上下文:每次呼叫函式時,都會為該函式建立一個新的執行上下文。每個函式都擁有自己的執行上下文,但是隻有在函式被呼叫的時候才會被建立。一個程式中可以存在多個函式執行上下文,這些函式執行上下文按照特定的順序執行一系列步驟,後文具體討論。

呼叫棧

呼叫棧,具有LIFO(Last in, First out 後進先出)結構,用於儲存在程式碼執行期間建立的所有執行上下文。

當JavaScript引擎首次讀取指令碼時,會建立一個全域性執行上下文並將其push到當前執行棧中。每當發生函式呼叫時,引擎都會為該函式建立一個新的執行上下文並push到當前執行棧的棧頂。

引擎會執行執行上下文在執行棧棧頂的函式,根據LIFO規則,當此函式執行完成後,其對應的執行上下文將會從執行棧中pop出,上下文控制權將轉到當前執行棧的下一個執行上下文。

看看下面的程式碼:

var myOtherVar = 10;

function a() {
  console.log('myVar', myVar);
  b();
}

function b() {
  console.log('myOtherVar', myOtherVar);
  c();
}

function c() {
  console.log('Hello world!');
}

a();

var myVar = 5;
複製程式碼

有幾個點需要注意:

  • 變數宣告的位置(一個在上,一個在下)
  • 函式a呼叫下面定義的函式b, 函式b呼叫函式c

當它被執行時你期望發生什麼? 是否發生錯誤,因為ba之後宣告或者一切正常? console.log列印的變數又是怎麼樣?

以下是列印結果:

"myVar" undefined
"myOtherVar" 10
"Hello world!"
複製程式碼

1. 變數和函式宣告(建立階段)

第一步是在記憶體中為所有變數和函式分配空間。 但請注意,除了undefined之外,尚未為變數分配值。 因此,myVar在被列印時的值是undefined,因為JS引擎從頂部開始逐行執行程式碼。

函式與變數不一樣,函式可以一次宣告和初始化,這意味著它們可以在任何地方被呼叫。

所以以上程式碼在建立階段時,看起來像這樣子:

var myOtherVar = undefined
var myVar = undefined

function a() {...}
function b() {...}
function c() {...}
複製程式碼

這些都存在於JS建立的全域性上下文中,因為它位於全域性作用域中。

在全域性上下文中,JS還新增了:

  • 全域性物件(瀏覽器中是 window 物件,NodeJs 中是 global 物件)
  • this 指向全域性物件

2. 執行

接下來,JS 引擎會逐行執行程式碼。

myOtherVar = 10在全域性上下文中,myOtherVar被賦值為10

已經建立了所有函式,下一步是執行函式 a()

每次呼叫函式時,都會為該函式建立一個新的上下文(重複步驟1),並將其放入呼叫堆疊。

function a() {
  console.log('myVar', myVar)
  b()
}
複製程式碼

如下步驟:

  • 建立新的函式上下文
  • a 函式裡面沒有宣告變數和函式
  • 函式內部建立了 this 並指向全域性物件(window)
  • 接著引用了外部變數 myVarmyVar 屬於全域性作用域的。
  • 接著呼叫函式 b函式b的過程跟a一樣,這裡不做分析。

下面呼叫堆疊的執行示意圖:

【JS 口袋書】第 3 章:JavaScript 函式

  • 建立全域性上下文,全域性變數和函式。
  • 每個函式的呼叫,會建立一個上下文,外部環境的引用及 this。
  • 函式執行結束後會從堆疊中彈出,並且它的執行上下文被垃圾收集回收(閉包除外)。
  • 當呼叫堆疊為空時,它將從事件佇列中獲取事件。

程式碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具Fundebug

交流(歡迎加入群,群工作日都會發紅包,互動討論技術)

乾貨系列文章彙總如下,覺得不錯點個Star,歡迎 加群 互相學習。

github.com/qq449245884…

因為篇幅的限制,今天的分享只到這裡。如果大家想了解更多的內容的話,可以去掃一掃每篇文章最下面的二維碼,然後關注我們們的微信公眾號,瞭解更多的資訊和有價值的內容。

clipboard.png

每次整理文章,一般都到2點才睡覺,一週4次左右,挺苦的,還望支援,給點鼓勵

【JS 口袋書】第 3 章:JavaScript 函式

相關文章