【理解】一道 JS 面試題

士心發表於2019-04-17

最近在一個前端學習群裡,有人丟擲了這麼一道 JS 面試題。

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

我一看,這不很簡單嗎?IIFE 區域性的 foo 本來指向函式本身,但後來被修改成 100 了,所以區域性的 foo 列印 100。全域性的 foo 還是保留原來的值,所以全域性的 foo 列印 1。

然後我複製程式碼到控制檯執行,發現先列印函式體 foo(){...},然後再列印 1

我猜想的第一個列印結果錯了,一番查詢資料終於搞懂了,於是有了這篇文章。

函式宣告式 vs 函式表示式

以下表示形式的是函式宣告式,簡單說就是 function 前面沒有任何運算子,其實就下面一種形式。

function name() {
    ...
}
複製程式碼

以下表示形式的是函式表示式,有多種形式。

var fun = function name() {
    ...
}

// 函式前帶有 + - * / () && || 等運算子號
(function name(){
    ...
}())

// 又或者
+function name(){
    ...
}()
複製程式碼

能認出函式宣告式與函式表示式後,我們來看看兩者有什麼區別。

函式提升

稍微瞭解 JS 的都知道變數提升(variable hoisting),除此之外還有函式提升(function hoisting),也就是說下面的程式碼是正常執行的。

foo(); // running

function foo() {
    console.log('running');
}
複製程式碼

但是函式提升只對函式宣告式有效,對函式表示式不生效,下面的程式碼就會報錯。

foo(); // Uncaught TypeError: foo is not a function

var foo = function () {
    console.log('running');
}
複製程式碼

區別一:函式宣告式會提升函式定義,而函式表示式不提升函式定義。這一區別只是想給大家複習知識點,並不是本文的重點。

函式名繫結的作用域

先看看下面函式的表示形式,記住它有助於接下來的說明。

function BindingIdentifier (FormalParameters) { FunctionBody }
複製程式碼

函式宣告式和函式表示式的另外一個關鍵區別是,看函式名(BindingIdentifier)繫結到哪個作用域下。

先看下 ECMAScript 是怎麼描述這一區別的。

The BindingIdentifier in a FunctionExpression can be referenced from inside the FunctionExpression's FunctionBody to allow the function to call itself recursively. However, unlike in a FunctionDeclaration, the BindingIdentifier in a FunctionExpression cannot be referenced from and does not affect the scope enclosing the FunctionExpression.

上面說 BindingIdentifier(函式的引用) 可以用於在函式表示式內遞迴呼叫自身。而且函式表示式的 BindingIdentifier 只繫結在該函式內部,不汙染外部的作用域,外部作用域也無法訪問到 BindingIdentifier。

區別二:函式宣告式的 BindingIdentifier 繫結在宣告時的作用域下,函式表示式的 BindingIdentifier 繫結在函式內部的作用域下

背後的原因

說了這麼多,好像還沒說的真正的原因。是的,前面的內容只是鋪墊,有了上面的內容,才能更好理解背後的原因。

解釋前先說原因:

  • 函式表示式的函式名是不可修改的(ImmutableBinding)。但如果你真修改了,在非嚴格模式下會靜默失敗,在嚴格模式下會報錯(Uncaught TypeError: Assignment to constant variable)。
  • 函式宣告式的函式名是可修改的(MutableBinding)。

原因出自《You-Dont-Know-JS》的一個 issue,這一 issue 已被作者納入第二版(second edition)的編寫中。

The production FunctionExpression : function Identifier ( FormalParameterListopt ) { FunctionBody } is evaluated as follows: ... Call the CreateImmutableBinding concrete method of envRec passing the String value of Identifier as the argument. ...

呼叫 CreateImmutableBinding 建立 Immutable's 函式名。

For each FunctionDeclaration f in code, in source text order do ... If funcAlreadyDeclared is false, call env’s CreateMutableBinding concrete method passing fn and configurableBindings as the arguments. ...

呼叫 CreateMutableBinding 建立 Mutable's 函式名。

當然也可以從 ECMAScript 規範中找到原因:Runtime Semantics: Evaluation

至於語言為什麼要這麼規定,我也沒想明白,如果有知道的同學可以分享一下。

分析下程式碼

那回頭再分析下一開始的示例,從每一行註釋可以幫助理解背後的原因。

var foo = 1; // 在外部作用域宣告foo=1

// IIFE是典型的函式表示式
(function foo(){ // 函式名foo,引用函式自身,繫結在函式內部,不汙染外部作用域
    foo = 100; // 這裡修改了foo,但規範規定不能修改,但不會報錯
    console.log(foo); // 還是引用函式自身
}())

console.log(foo); // 外部作用域一直是1
複製程式碼

同樣的程式碼,當函式執行在嚴格模式下,報錯提示說:“不能賦值給常量”。也就是說函式表示式的函式名被定義成常量,無法再修改了。

var foo = 1;

(function foo(){
    'use strict'; // 嚴格模式
    foo = 100; // Uncaught TypeError: Assignment to constant variable
    console.log(foo);
}())

console.log(foo);
複製程式碼

為了幫助對比理解,下面給出了函式宣告式的示例及解釋,下面的程式碼無論在非嚴格模式還是嚴格模式下都列印100,也就是說函式宣告式的函式名可以被修改。

// foo是函式宣告式
function foo(){ // 函式名foo,引用函式自身,繫結在宣告時的作用域下
    foo = 100; // 修改了foo,函式宣告式內可以重新修改函式名
    console.log(foo); // 100
}
foo();
複製程式碼

如果在函式表示式內使用 var foo = 100; 來重新宣告變數,那這個變數就不是不可修改的(ImmutableBinding),所以內部的 foo 列印 100。

var foo = 1;

(function foo(){
    var foo = 100; // 重新宣告變數
    console.log(foo); // 100
}())

console.log(foo); // 1
複製程式碼

通過上面的分析解釋,希望你可以掌握這道面試題,舉一反三。


如果你喜歡這篇文章,請關注我,我會持續輸出更多原創且高質量的內容。

原文連結:【理解】一道 JS 面試題

相關文章