重讀你不知道的JS(上) 第一節三章

你不知道的JS(上卷)筆記

你不知道的 JavaScript

JavaScript 既是一門充滿吸引力、簡單易用的語言,又是一門具有許多複雜微妙技術的語言,即使是經驗豐富的 JavaScript 開發者,如果沒有認真學習的話也無法真正理解它們.

上捲包括倆節:

  • 作用域和閉包
  • this 和對象原型

作用域和閉包

希望 Kyle 對 JavaScript 工作原理每一個細節的批判性思 考會滲透到你的思考過程和日常工作中。知其然,也要知其所以然。

函數作用域和塊作用域

正如我們在第 2 章中討論的那樣,作用域包含了一系列的“氣泡”,每一個都可以作爲容 器,其中包含了標識符(變量、函數)的定義。這些氣泡互相嵌套並且整齊地排列成蜂窩 型,排列的結構是在寫代碼時定義的。

但是,究竟是什麼生成了一個新的氣泡?只有函數會生成新的氣泡嗎? JavaScript 中的其 他結構能生成作用域氣泡嗎?

函數中的作用域

  1. JavaScript 具有基於函數的作用域;
  2. 無論標識符 聲明出現在作用域中的何處,這個標識符所代表的變量或函數都將附屬於所處作用域的氣 泡。

函數作用域的含義是指,屬於這個函數的全部變量都可以在整個函數的範圍內使用及復 用(事實上在嵌套的作用域中也可以使用)。

這種設計方案是非常有用的,能充分利用 JavaScript 變量可以根據需要改變值類型的“動態”特性。這是什麼意思?

隱藏的內部實現

可以把變量和函數包裹在一個函數的作用域中,然後用這個作用域 來“隱藏”它們。

Q: 爲什麼“隱藏”變量和函數是一個有用的技術?

A: 大都是從最小特權原則中引申出來 的,也叫最小授權或最小暴露原則。這個原則是指在軟件設計中,應該最小限度地暴露必 要內容,而將其他內容都“隱藏”起來,比如某個模塊或對象的 API 設計。

  • 設計上將具體內容私有化了,設計良好的軟件都會 依此進行實現。
規避衝突
  1. 全局命名空間
    通常會在全局作用域中聲明一個名字足夠獨特的變量,通常是一個對象。這個對象 被用作庫的命名空間,所有需要暴露給外界的功能都會成爲這個對象(命名空間)的屬 性,而不是將自己的標識符暴漏在頂級的詞法作用域中。
  2. 模塊管理
    任何庫都無需將標識符加入到全局作用域中,而是通過依賴管理器 的機制將庫的標識符顯式地導入到另外一個特定的作用域中。
  • 避免同名標識符之間的衝突

函數作用域

在任意代碼片段外部添加包裝函數,可以將內部的變量和函數定義“隱
藏”起來,外部作用域無法訪問包裝函數內部的任何內容。

這種技術可以解決一些問題,但是它並不理想,因爲會導致一些額外的問題:

  • 必須聲明一個具名函數 foo(),意味着 foo 這個名稱本身“污染”了所在作用域(在這個 例子中是全局作用域)
  • 必須顯式地通過函數名(foo())調用這個函數才能運行其 中的代碼。

如果函數不需要函數名(或者至少函數名可以不污染所在作用域),並且能夠自動運行, 這將會更加理想。

(function foo(){ // <-- 添加這一行
  var a = 3;
  console.log( a ); // 3
})(); // <-- 以及這一行
console.log( a ); // 2

函數聲明和函數表達式之間最重要的區別是它們的名稱標識符將會綁定在何處。

注意:區分函數聲明和表達式最簡單的方法是看 function 關鍵字出現在聲明中的位 置(不僅僅是一行代碼,而是整個聲明中的位置)。如果 function 是聲明中 的第一個詞,那麼就是一個函數聲明,否則就是一個函數表達式。

片段中 foo 被綁定在函數表達式自身的函數中而不是所在作用域中。

類似的還有於 +function foo() {}() 對函數求值的操作,都能做到避免泄露

換句話說,(function foo(){ .. })作爲函數表達式意味着foo只能在..所代表的位置中 被訪問,外部作用域則不行。foo 變量名被隱藏在自身中意味着不會非必要地污染外部作 用域。

匿名和具名
setTimeout( function() {
         console.log("I waited 1 second!");
}, 1000 );

這叫做匿名函數表達式, 因爲function()沒有名稱標識符。函數表達式可以是匿名的,而函數聲明則不可以省略函數名.

匿名函數表達式寫起來簡單快捷,但是它有幾個缺點需要考慮:

  1. 匿名函數在棧追蹤中不會顯示出有意義的函數名,使得調試很困難。
  2. 如果沒有函數名,當函數需要引用自身時,只能使用已經過期的arguments.callee引用,比如在遞歸中。另一個函數需要引用自身的例子是在事件觸發後事件監聽器需要解綁自身。
  3. 匿名函數省略了對於代碼可讀性/可理解性很重要的函數名。一個描述性的名詞可以讓代碼不言自明。

行內函數表達式非常強大且有用——匿名和具名之間的區別並會有對這點有任何影響。 給函數表達式指定一個函數名可以有效的解決以上問題。

始終給函數表達式命名是一個最佳實踐。

setTimeout( function timeoutHandler() { // <-- 快看,我有名字了!
  console.log( "I waited 1 second!" );
}, 1000 );
立即執行函數表達式

幾年前社區給它規定了一個術語:IIFE,代表立即執行函數表達式 (Immediately Invoked Function Expression);

IIFE的形式有下面倆種:
(function(){ .. })()
(function(){ .. }())

  • 用法1, 把它們當作函數調用並傳遞參數進去
    例如:

    var a = 2;
    (function IIFE( global ) {
      var a = 3;
      console.log( a ); // 3 
      console.log( global.a ); // 2
    })( window );
    console.log( a ); // 2

    我們將 window 對象的引用傳遞進去,但將參數命名爲 global,因此在代碼風格上對全局 對象的引用變得比引用一個沒有“全局”字樣的變量更加清晰。當然可以從外部作用域傳 遞任何你需要的東西,並將變量命名爲任何你覺得合適的名字。這對於改進代碼風格是非 常有幫助的。

  • 用法2,解決 undefined 標識符的默認值被錯誤覆蓋導致的異常(雖 然不常見)。
    例如:將一個參數命名爲 undefined,但是在對應的位置不傳入任何值,這樣就可以 保證在代碼塊中 undefined 標識符的值真的是 undefined:

    undefined = true; // 給其他代碼挖了一個大坑!絕對不要這樣做!
    (function IIFE( undefined ) {
    var a;
    if (a === undefined) {

    console.log( "Undefined is safe here!" );

    }
    })();

  • 用法3:倒置代碼的運行順序
    例如:將需要運行的函數放在第二位,在 IIFE 執行之後當作參數傳遞進去。這種模式在 UMD(Universal Module Definition)項目中被廣 泛使用。儘管這種模式略顯冗長,但有些人認爲它更易理解。

    var a = 2;
    (function IIFE( def ) {
      def( window );
    })(function def( global ) {
      var a = 3;
      console.log( a ); // 3 
      console.log( global.a ); // 2
    });

塊作用域

塊作用域的用處:變量的聲明應該距離使用的地方越近越好,並最大限度地本地化。

塊作用域是一個用來對之前的最小授權原則進行擴展的工具,將代碼從在函數中隱藏信息 擴展爲在塊中隱藏信息。

爲什麼要把一個只在 for 循環內部使用(至少是應該只在內部使用)的變量 i 污染到整個
函數作用域中呢?

可惜,表面上看 JavaScript 並沒有塊作用域的相關功能。

with

with 關鍵字。它不僅是一個難於理解的結構,同時也是塊作用域的一 個例子(塊作用域的一種形式),用 with 從對象中創建出的作用域僅在 with 聲明中而非外 部作用域中有效。

try/catch

非常少有人會注意到 JavaScript 的 ES3 規範中規定 try/catch 的 catch 分句會創建一個塊作
用域,其中聲明的變量僅在 catch 內部有效。

例如:

  try {
    undefined(); // 執行一個非法操作來強制製造一個異常
  }
  catch (err) {
    console.log( err ); // 能夠正常執行! 
  }
  console.log( err ); // ReferenceError: err not found

儘管這個行爲已經被標準化,並且被大部分的標準 JavaScript 環境(除了老 版本的 IE 瀏覽器)所支持,但是當同一個作用域中的兩個或多個 catch 分句 用同樣的標識符名稱聲明錯誤變量時,很多靜態檢查工具還是會發出警告。 實際上這並不是重複定義,因爲所有變量都被安全地限制在塊作用域內部, 但是靜態檢查工具還是會很煩人地發出警告。爲了避免這個不必要的警告,很多開發者會將 catch 的參數命名爲 err1、 err2 等。也有開發者乾脆關閉了靜態檢查工具對重複變量名的檢查。

let

ES6 改變了現狀,引入了新的 let 關鍵字,提供了除 var 以外的另一種變量聲明方式。

  var foo = true;
  if (foo) {
    let bar = foo * 2;
    bar = something( bar );
    console.log( bar );
  }
  console.log( bar ); // ReferenceError

ES6中的if表達式中的{}並不具備塊級作用域的劃分,僅僅只能表明一個語句塊,因爲要在其中聲明塊級作用域變量還需要let來輔助。

let 關鍵字可以將變量綁定到所在的任意作用域中(通常是 { .. } 內部)。換句話說,let爲其聲明的變量隱式地了所在的塊作用域。

在開發和修改代碼的過 程中,如果沒有密切關注哪些塊作用域中有綁定的變量,並且習慣性地移動這些塊或者將 其包含在其他的塊中,就會導致代碼變得混亂。
爲塊作用域顯式地創建塊可以部分解決這個問題,使變量的附屬關係變得更加清晰。通常 來講,顯式的代碼優於隱式或一些精巧但不清晰的代碼。顯式的塊作用域風格非常容易書 寫,並且和其他語言中塊作用域的工作原理一致:

var foo = true;
if (foo) {
  { // <-- 顯式的快
    let bar = foo * 2;
    bar = something( bar );
    console.log( bar );
  }
}
console.log( bar ); // ReferenceError

只要聲明是有效的,在聲明中的任意位置都可以使用 { .. } 括號來爲 let 創建一個用於綁 定的塊。在這個例子中,我們在 if 聲明內部顯式地創建了一個塊,如果需要對其進行重 構,整個塊都可以被方便地移動而不會對外部 if 聲明的位置和語義產生任何影響。

垃圾收集

另一個塊作用域非常有用的原因和閉包及回收內存垃圾的回收機制相關。

function process(data) {
// 在這裏做點有趣的事情
}
var someReallyBigData = { .. };
process( someReallyBigData );
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt) {
  console.log("button clicked");
}, /*capturingPhase=*/false );

click 函數的點擊回調並不需要 someReallyBigData 變量。理論上這意味着當 process(..) 執 行後,在內存中佔用大量空間的數據結構就可以被垃圾回收了。但是,由於 click 函數形成 了一個覆蓋整個作用域的閉包,JavaScript 引擎極有可能依然保存着這個結構(取決於具體 實現)。

塊作用域可以打消這種顧慮,可以讓引擎清楚地知道沒有必要繼續保存 someReallyBigData 了:
function process(data) {
// 在這裏做點有趣的事情
}
// 在這個塊中定義的內容可以銷燬了!
{
let someReallyBigData = { .. };
process( someReallyBigData );
}
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
console.log("button clicked");
}, /capturingPhase=/false );

爲變量顯式聲明塊作用域,並對變量進行本地綁定是非常有用的工具,可以把它添加到你
的代碼工具箱中了。

let循環

for 循環頭部的 let 不僅將 i 綁定到了 for 循環的塊中,事實上它將其重新綁定到了循環 的每一個迭代中,確保使用上一個循環迭代結束時的值重新進行賦值。

每個迭代進行重新綁定的原因非常有趣,我們會在第 5 章討論閉包時進行說明。

const

除了 let 以外,ES6 還引入了 const,同樣可以用來創建塊作用域變量,但其值是固定的 (常量)。之後任何試圖修改值的操作都會引起錯誤。

小結

函數是 JavaScript 中最常見的作用域單元。本質上,聲明在一個函數內部的變量或函數會在所處的作用域中“隱藏”起來,這是有意爲之的良好軟件的設計原則。 但函數不是唯一的作用域單元。塊作用域指的是變量和函數不僅可以屬於所處的作用域,也可以屬於某個代碼塊(通常指 { .. } 內部)。

從 ES3 開始,try/catch 結構在 catch 分句中具有塊作用域。

在 ES6 中引入了 let 關鍵字(var 關鍵字的表親),用來在任意代碼塊中聲明變量。if (..) { let a = 2; } 會聲明一個劫持了 if 的 { .. } 塊的變量,並且將變量添加到這個塊 中。
有些人認爲塊作用域不應該完全作爲函數作用域的替代方案。兩種功能應該同時存在,開 發者可以並且也應該根據需要選擇使用何種作用域,創造可讀、可維護的優良代碼。

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