進擊JavaScript核心 --- (2)函式和預解析機制

rogerwu發表於2019-05-25

一、函式

每個函式都是 Function型別的例項,也具有屬性和方法。由於函式也是一個物件,因此函式名實際上也是一個指向函式物件的指標,不會與某個函式繫結

1、函式的定義方式
(1)、函式宣告
function add(a, b) {
  return a + b;
}
函式宣告提升:在執行程式碼之前,會先讀取函式宣告,也就是說,可以把函式宣告放在呼叫它的程式碼之後
fn();   // 1
function fn() {console.log(1)}
 
(2)、函式表示式
var add = function(a, b) {
  return a + b;
};
 
函式表示式看起來像是常規的變數賦值,由於其function關鍵字後面沒有指定函式名,因此是一個匿名函式
函式表示式必須先賦值,不具備函式宣告提升的特性
fn();   // Uncaught TypeError: fn is not a function
var fn = function(){console.log(1)};
 
由於函式宣告提升這一特性,導致在某些情況下會出現意想不到的結果,例如:
var flag = true;
if(flag) {
  function fn() {
    console.log('flag 為true')
  }
} else{
  function fn() {
    console.log('flag 為false')
  }
}
fn();
// chrome, firefox, ie11  輸出 flag 為true
// ie10及以下 輸出 flag 為false
 
本意是想flag為true時輸出 'flag 為true', flag為false時輸出 'flag 為false',為何結果卻不盡相同呢?究其原因就在於函式宣告提升,執行程式碼時首先讀取函式宣告,而 if...else...程式碼塊同屬於全域性作用域,因此後面的同名函式會覆蓋前面的函式,最終函式fn就只剩下一個 function fn(){console.log('flag 為false')}
 
由於函式宣告提升導致的這一結果令人大為意外,因此,js引擎會嘗試修正錯誤,將其轉換為合理狀態,但不同瀏覽器版本的做法並不一致
此時,函式表示式就可以解決這個問題
var flag = true;
var fn;

if(flag) {
  fn = function() {
    console.log('flag 為true');
  }
} else{
  fn = function() {
    console.log('flag 為false');
  }
}
fn()

//chrome, firefox, ie7-11 均輸出 flag 為true
 
其實這個也很好理解,js預解析時,fn和flag均被初始化為undefined,然後程式碼從上到下逐行執行,首先給flag賦值為true,進入if語句,為fn賦值為 function fn(){console.log('flag 為true')}

關於函式表示式,還有一種寫法,命名函式表示式
var add = function f(a, b) {
  console.log(a + b);
}
add(1,2);     // 3
f(1,2);       // Uncaught ReferenceError: f is not defined

var add = function f(a, b) {
  console.log(f);
}
console.log(add);
add(3, 5);


// ƒ f(a, b) {
//   console.log(f);
// }
由此可見,命名函式f也是指向函式的指標,只在函式作用域內部可用

(3)、Function建構函式
var add = new Function('a', 'b', 'return a + b');
 
不推薦這種寫法,因為這種語句會導致解析兩次程式碼,第一次是解析js程式碼,第二次解析傳入建構函式中的字串,從而影響效能
 
2、沒有過載
在java中,方法具有過載的特性,即一個類中可以定義有相同名字,但引數不同的多個方法,呼叫時,會根據不同的引數選擇不同的方法
public void add(int a, int b) {
  System.out.println(a + b);
}

public void add(int a, int b, int c) {
  System.out.println(a * b * c);
}

// 呼叫時,會根據傳入引數的不同,而選擇不同的方法,例如傳入兩個引數,就會呼叫第一個add方法
 
而js則沒有函式過載的概念
function add(a, b) {
  console.log(a + b);
}

function add(a, b, c) {
  c = c || 2;
  console.log(a * b * c);
}

add(1, 2);   // 4  (直接呼叫最後一個同名的函式,並沒有過載)
 
由於函式名可以理解成一個指向函式物件的指標,因此當出現同名函式時,指標就會指向最後一個出現的同名函式,就不存在過載了(如下圖所示)
3、呼叫匿名函式
對於函式宣告和函式表示式,呼叫函式的方式就是在函式名(或變數名)後加一對圓括號
function fn() {
  console.log('hello')
}
fn()

// hello

 

既然fn是一個函式指標,指代函式的程式碼段,那能否直接在程式碼段後面加一對圓括號呢?

function fn() {
  console.log('hello')
}()

// Uncaught SyntaxError: Unexpected token )

var fn = function() {
  console.log('hello')
}()

// hello

 

分別對函式宣告和函式表示式執行這一假設,結果出人意料。另外,前面也提到函式宣告存在函式宣告提升,函式表示式不存在,如果在函式宣告前加一個合法的JS識別符號呢?
console.log(fn);   // ƒ fn() {console.log('hello');}
function fn() {
  console.log('hello');
}

// 在function關鍵字前面加一個合法的字元,結果就把fn當做一個未定義的變數了
console.log(fn);   // Uncaught ReferenceError: fn is not defined
+function fn() {
  console.log('hello');
}

 

基於此可以大膽猜測,只要是function關鍵字開頭的程式碼段,js引擎就會將其宣告提前,所以函式宣告後加一對圓括號會認為是語法錯誤。結合函式表示式後面直接加圓括號呼叫函式成功的情況,做出如下嘗試:
+function() {
  console.log('hello')
}()

-function() {
  console.log('hello')
}()

*function() {
  console.log('hello')
}()

/function() {
  console.log('hello')
}()

%function() {
  console.log('hello')
}()

// hello
// hello
// hello
// hello
// hello

 

竟然全部成功了,只是這些一元運算子在此處並無實際意義,看起來令人費解。換成空格吧,又會被js引擎給直接跳過,達不到目的,因此可以用括號包裹起來
(function() {
  console.log('hello');
})();

(function() {
  console.log('hello');
}());

// hello
// hello
無論怎麼包,都可以成功呼叫匿名函式了,我們也不用再困惑呼叫匿名函式時,圓括號該怎麼加了
 
4、遞迴呼叫
遞迴函式是在一個函式通過名字呼叫自身的情況下構成的
 
一個經典的例子就是計算階乘
// 3! = 3*2*1
// 4! = 4*3*2*1 = 4*3!

function factorial(num) {
  if(num <= 1) {
    return 1
  }
  return num * factorial(num - 1)
}

console.log(factorial(5))   // 120
console.log(factorial(4))   // 24
 
如果現在把函式名factorial換成了jieCheng,執行jieCheng(5) 就會報錯了,外面改了,裡面也得改,如果是遞迴的層次較深就比較麻煩。事實上,這樣的程式碼也是不夠健壯的

這裡有兩種解決方案:
 
(1)、使用 arguments.callee
arguments.callee 是一個指向正在執行的函式的指標,函式名也是指向函式的指標,因此,可以在函式內部用 arguments.callee 來替代函式名
function fn() {
  console.log(arguments.callee)
}
fn()

// ƒ fn() {
//   console.log(arguments.callee)
// }

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

console.log(factorial(5))   // 120

 

但在嚴格模式下,不能通過指令碼訪問 arguments.callee,訪問這個屬性會導致錯誤
'use strict'

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

console.log(factorial(5))   
// Uncaught TypeError: 'caller', 'callee', and 'arguments' properties may not be accessed on strict mode functions or the arguments objects for calls to them

 

(2)、命名函式表示式
var factorial = function jieCheng(num) {
  if(num <= 1) {
    return 1
  }
  return num * jieCheng(num - 1)
};
console.log(factorial(5))  // 120

var result = factorial;
console.log(result(4));    // 24

 

5、間接呼叫
apply()和 call()。這兩個方法的用途都是在特定的作用域中呼叫函式,實際上等於設定函式體內 this 物件的值。
 
首先,apply()方法接收兩個引數:一個是在其中執行函式的作用域,另一個是引數陣列。其中,第二個引數可以是 Array 的例項,也可以是arguments 物件。
function add(a, b) {
  console.log(a + b);
}

function sum1(a, b) {
  add.apply(window, [a, b]);
}

function sum2(a, b) {
  add.apply(this, arguments)
}

sum1(1, 2);    // 3
sum2(3, 5);    // 8

 

call()方法與 apply()方法的作用相同,它們的區別僅在於接收引數的方式不同。對於 call()方法而言,第一個引數是 this 值沒有變化,變化的是其餘引數都直接傳遞給函式。換句話說,在使用call()方法時,傳遞給函式的引數必須逐個列舉出來
var color = 'red';
var obj = {
  color: 'blue'
};

function getColor() {
  console.log(this.color)
}

getColor.call(this)    // red
getColor.call(obj)     // blue

 

二、預解析機制

第一步:js執行時,會找所有的var和function關鍵字
  --、把所有var關鍵字宣告的變數提升到各自作用域的頂部並賦初始值為undefined,簡單說就是 “宣告提前,賦值留在原地”
  --、函式宣告提升
 
第二步:從上至下逐行解析程式碼
 
var color = 'red';
var size = 31;

function fn() {
  console.log(color);
  var color = 'blue';
  var size = 29;
}

fn();    // undefined

 

// 第一步:在全域性作用域內查詢所有使用var和function關鍵字宣告的變數,把 color、size、fn 提升到全域性作用域頂端併為其賦初始值;同理,在fn函式作用域內執行此操作
// 第二步:從上至下依次執行程式碼,呼叫fn函式時,按序執行程式碼,函式作用域內的輸出語句中color此時僅賦初始值undefined
注意:
(1)、如果函式是通過 “函式宣告” 的方式定義的,遇到與函式名相同的變數時,不論函式與變數的位置順序如何,預解析時函式宣告會覆蓋掉var宣告的變數
console.log(fn)    // ƒ fn() {}

function fn() {}

var fn = 32

 

(2)、如果函式是通過 “函式表示式” 的方式定義的,遇到與函式名相同的變數時,會視同兩個var宣告的變數,後者會覆蓋前者
console.log(fn);         // undefined
var fn = function() {};
var fn = 32;
console.log(fn)          // 32

 

(3)、兩個通過 “函式宣告” 的方式定義的同名函式,後者會覆蓋前者
console.log(fn);     // ƒ fn() {console.log('你好 世界')}

function fn() {console.log('hello world')}

function fn() {console.log('你好 世界')}

 

預解析練習一:

var fn = 32

function fn() {
  alert('eeee')
}

console.log(fn)          // 32
fn()                     // Uncaught TypeError: fn is not a function
console.log(typeof fn)   // number

// 按照上面的預解析規則,預解析第一步時,fn會被賦值為 function fn() {alert('eeee')};第二步從上到下逐步執行時,由於函式fn宣告提前,優於var宣告的fn執行了,
// 所以fn會被覆蓋為一個Number型別的基本資料型別變數,而不是一個函式,其值為32

 

預解析練習二:

console.log(a);        // function a() {console.log(4);}

var a = 1;

console.log(a);       // 1

function a() {
  console.log(2);
}

console.log(a);       // 1

var a = 3;

console.log(a);       // 3

function a() {
  console.log(4);
}

console.log(a);       // 3

a();                  // 報錯:不是一個函式
 
預解析步驟:
(1)、找出當前相同作用域下所有使用var和function關鍵字宣告的變數,由於所有變數都是同名變數,按照規則,權值最高的是最後一個宣告的同名的function,所以第一行輸出 function a() {console.log(4);}
(2)、從上至下逐步執行程式碼,在第二行為變數a 賦值為1,因此輸出了一個1
(3)、執行到第一個函式a,由於沒有呼叫,直接跳過不會輸出裡面的2,執行到下一行輸出1
(4)、繼續執行,為a重新賦值為3,因此輸出了一個3
(5)、執行到第二個函式a,還是沒有呼叫,直接跳過不會輸出裡面的4,執行到下一行輸出3
(6)、最後一行呼叫函式a,但由於預解析時率先把a賦值為一個函式程式碼段,後面依次為a賦值為1和3,因此,a是一個Number型別的基本變數,而不是一個函式了

 

預解析練習三:
var a = 1;
function fn(a) {
  console.log(a);     // 999
  a = 2;
  console.log(a)      // 2
}

fn(999);
console.log(a);       // 1
 
預解析步驟:
(1)、全域性作用域內,為a賦值為undefined,把函式fn提升到最前面;fn函式作用域內,函式引數在預解析時也視同區域性變數,為其賦初始值 undefined
(2)、執行fn函式,傳入實參999,為區域性變數a賦值為999並輸出;重新為a賦值為2,輸出2
(3)、由於全域性作用域下的a被賦值為1,而函式作用域內部的a是訪問不到的,因此直接輸出1
預解析練習四:
var a = 1;
function fn() {
  console.log(a);
  var a = 2;
}

fn();            // undefined
console.log(a);  // 1
var a = 1;
function fn() {
  console.log(a);
  a = 2;
}

fn();            // 1
console.log(a);  // 2
 
對比兩段程式碼,唯一的區別就是fn函式內的變數a的作用域問題,前者屬於函式作用域,後者屬於全域性作用域,所以導致輸出結果完全不同
 

相關文章