關掉 no-var——決定變數宣告關鍵字的良好方法

大家平時都是怎麼決定該用哪個關鍵字(varletconst)宣告變數的呢?受到 ESlint 建議規範的影響,我猜很多人跟我一樣,決定方式很簡單:會被再賦值(reassign)的就用 let,不會的就用 constvar 完全不在我們的選項,畢竟,var 有很多缺點,它的好處完全可以由 let 來代替,是吧?

我承認我並沒有很認真探討過這個問題,只是照著 ESlint 規範走,直到在讀書會看了頗知名的 You Don't Know JS Yet: Scope & Closures(是的,它出第二版了),發現作者 Kyle Simpson 並不派斥 var甚至認為 var 的好處無法取代,這才讓我重新思考並質疑自己不使用 var 的理由。

var 很有用

Simpson 認為,varlet 都很有用(他沒有很喜歡 const,後面會說原因),前者為函數作用域(function scope),後者為區塊作用域(block scope),因此,當一個變數在整個函數中都會用到,那就該用 var,並宣告在函數最外層(top-level,也可以說是頂層);反之,如果一個變數只會在函數中的某個區塊用到,那就該用 let,並宣告在這個區塊內,讓外層的作用域取用不到。

比方說,假如我現在要寫一個能判斷使用者網頁捲動方向的函數,按照 ESlint 建議的寫法,會是這樣:

function logScrollDirection() {
  let beforeScrollY = window.pageYOffset;
  
  window.addEventListener('scroll', () => {
    const currentScrollY = window.pageYOffset;
    const delta = currentScrollY - beforeScrollY;

    if (delta > 0) {
      console.log('scroll down!');
    } else {
      console.log('scroll up!');
    }

    beforeScrollY = currentScrollY;
  });
}

但 Simpson 會建議這樣:

function logScrollDirection() {
  // 變成 `var` 了
  var beforeScrollY = window.pageYOffset;
  
  window.addEventListener('scroll', () => {
    // 變成 `var` 了
    var currentScrollY = window.pageYOffset;
    
    // 多了大括號
    {      // 變成 `let` 了
      let delta = currentScrollY - beforeScrollY;

      if (delta > 0) {
        console.log('scroll down!');
      } else {
        console.log('scroll up!');
      }
    }
    beforeScrollY = currentScrollY;
  });
}

你可能會想說,let 其實也是函數作用域,為什麼不乾脆把上面的 var 都改成 let 呢?

理由有兩個:第一,就語義上來說,varlet 更適合扮演「這個變數是作用在整個函數中」的角色,畢竟在 let 出現前的 20 幾年,var 就一直都是如此。

第二,如果你在任何地方都使用 let,那你可能會不好判斷這個 let 所宣告的變數是作用在整個函數中,還是只在某個區塊內。

你可能還會注意到,在黃線處有個奇怪的大括號 {..}。這個括號能確保 delta 不被函數內的其它地方取用到,讓它成為名副其實的區塊變數。

一般來說,我們在函數內創建區塊變數,是在遇到 if..elsefor 迴圈的時候。但其實你可以不靠這些陳述語句(statement)來創建區塊變數,那就是直接用單獨的 {..} 大括號。

這個大括號看來有點奇怪又累贅,但它事實上是讓 varlet 的搭配發揮最大效用的關鍵,而且它還體現了軟體工程的一個原則:最小暴露原則(The Principle of Least Exposure, POLE)。

最小暴露原則

最小暴露原則與最小權限原則(The Principle of Least Privilege, POLP)有關,但它關注的層次較低。就變數而言,它想最小化暴露的是變數的作用域

為什麼?想想一個極端的問題:為什麼我們不把所有變數都宣告在全局作用域(global scope)?命名衝突、被他人非預期或惡意修改、非意圖依賴導致重構困難(詳細說明可見這裡)——當我們把區域(local)變數非必要地暴露給程式其它部分使用,這三個危險便會浮現,在日後絆你一跤。

因此,我們應遵守最小暴露原則,這意味著我們應盡可能地保持變數私有(private),也就是將變數宣告在盡可能深的嵌套作用域內

說來容易,但這其實並不是一個很直覺的作法。我常需要在寫完函數的內容後,再回過頭來重新檢視程式碼,以找出哪些變數是函數中的區域變數(或甚至是區塊中的區域變數),再用 {..} 括起。

聽來有點麻煩,但這種作法不僅能避免上述危險,還意外地擁有兩個好處。

第一個是當你不小心寫出一個肥大的函數,{..} 有助於你判斷哪些程式碼是彼此相關,從而讓你更容易理解整個函數在幹嘛。

當然,我們應盡可能避免寫出這樣的函數,但即使寫出來了,當我們在重構(refactoring),也可以相對容易判斷出哪些區塊可以提取成函數,以提升可讀性(readability)。

同時,提取函數的過程也變得容易,因為你可以很快地判斷出變數的來源,知道現在要提取的區塊裡有哪些全局變數和區域變數。比如下面這段程式碼(請想像它是一個內容很長的函數):

/**
 * 此處範例借用自 Martin Fowler 的著作 Refactoring: Improving the Design of Existing Code (2nd Edition)
 * 第六章 Extract Fucntion 一節的程式碼
 */
function printOwing(invoice) {
  let outstanding = 0;
  const today = new Date();
  
  printBanner();
  
  // ...
  
  for (const order of invoice.orders) {
    outstanding += order.amount;
  }

  invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);  
  // ...
  
  printDetails(invoice, outstanding);
  
  function printBanner() {
    // ...
  }
  
  function printDetails() {
    // ...
  }
}

假設你想重構被黃線劃起來的區塊,把它提取成一個名為 recordDueDate 的函數,但由於你不知道 today 這個變數還有在這個函數中的哪些地方用到,因此你必須一行一行檢查,還必須進到內嵌函數去看有無使用到 today,相當麻煩。

如果現在程式碼變成這樣:

function printOwing(invoice) {
  let outstanding = 0;
  
  printBanner();
  
  // ...
  
  for (const order of invoice.orders) {
    outstanding += order.amount;
  }

  {    let today = new Date();    invoice.dueDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 30);  }  
  // ...
  
  printDetails(invoice, outstanding);
  
  function printBanner() {
    // ...
  }
  
  function printDetails() {
    // ...
  }
}

清楚多了吧!現在你可以很篤定 today 絕對不會被這個區塊以外的地方用到,可以放心又快速地把這兩行程式碼提取成 recordDueDate 函數了。

取捨:評估 var 的缺點

前面說了 var 的優點以及它該怎麼跟 let 搭配,這裡來談談 var 的缺點。

先從建議禁用 var 的 ESlint 看起。為什麼不用 varESlint 文件說,因為在區塊中宣告 var 可能會意外地改變其外同名的變數。注意「區塊中」這三個字。沒錯,這點與我們前面說的一樣,var 的確不應該宣告在區塊內,那是 let 發揮作用的地方。因此,前述原則已避免掉了這個缺點(Simpson 在這篇講述了他覺得可以在區塊內宣告 var 的狀況,雖然我仍覺得宣告在外頭比較好,但值得看看)。

再來,應該就是所謂重新宣告(redeclare)的問題。letconst 將重新宣告的問題澈底消除。但,重新宣告真的是一個嚴重的問題嗎?更確切地問,這個缺點有辦法抵銷甚至壓過前述 var 的優點嗎?

至少我自己是幾乎沒有碰過重新宣告的問題。即便你的函數都寫得很長,但當你很有意識地在區分變數的作用域範圍,要出錯的機率可說是微乎其微。如果你真的很擔心程式壞掉,那你可以將所有用 var 宣告的變數都寫在函數開始處,總不會在這短短幾行中也有同名的問題吧?

function doBigThing() {
  var first = 'watch movies';
  var second = 'shopping';
  var third = 'sleep';
  var first = ''; // <-- 哦哦抓到了,太明顯了吧!

  // ..
}

同樣地,重新賦值的問題也是如此。想想看,你會在什麼情況下,不小心將一個不該被重新賦值的變數重新賦值?想不太到吧!大多數情況下,都是你想重新賦值,但卻發現這個變數被宣告成 const,於是你只好回過頭把它改成 let,再重新賦值。而且,即便你真的不小心犯錯了,會很難察覺嗎?會很難追蹤嗎?會很難改回來嗎?

我認為這兩個缺點並不足以說服我放棄前面所說的宣告方法。而且相對地,這兩個缺點也給予開發者更大的彈性,只要你謹慎使用(同樣地在這篇,Simpson 簡單示範了重新宣告作為一種註解的功用,雖然我不怎麼喜歡)。

什麼時候該用 const

你可能會發現,在上面範例中,即使我是在區塊內宣告不會再被賦值的變數,我也是用 let。為什麼不用 const?這樣語義上不是更清晰嗎?

好吧,其實我也是傾向用 const,但因為我介紹的是 Simpson 的論點,所以在宣告變數的寫法上就把他對 const 的意見也一併納入了。

Simpson 認為,const 是一個有點讓人困惑的關鍵字:你不是說這個變數是常數(constant)嗎?那為什麼它又可以被修改(mutate)呢?這個誤解的確常在 JS 新手身上發生(至少我在公司面試前端的經驗是如此)。

那該在什麼時候用 const 呢?只有當你宣告的變數已經是個不可變(immutable)的值(即基本型別值,詳情可見這篇),而且它在語義上顯然是個常數時,才該用 const

const POSTS_PAGE_SIZE = 3; // 每次要從資料庫取得的文章數量
const DEGREE_TO_PI = Math.PI / 180;
const DEFAULT_THEME = 'dark';

// 其內容之後能被修改,不建議用 `const`
const SHARE_LINK_NAMES = ['fb', 'line'];

那重新賦值的問題呢?如同上一節所說,這個錯誤很難出現,即使出現也很好察覺並修復。而且當你會用 {..} 把區塊變數包起來,這個代碼塊通常不會太長,在這短短的十幾行中要出錯實在是不太可能。

以上是 Simpson 的論點。對我來說,我認同重新賦值的問題並不大,但考量到非 JS 新手應該都能明白 const 的「不變」意指為何,我還是會繼續照一般規範走,只是要稍微忍受當看到一個由 const 宣告的變數,其內容在之後被修改時所引發的不適感。

總結:一個原則,兩個問題

當我在與別人協作,我會採用由團隊討論出來的代碼規範,或直接引入主流規範(如 AirbnbStandard)。畢竟,我所喜愛的宣告方式,它的好處還無法勝過降低團隊溝通成本所帶來的優勢。但我仍會在程式碼中盡可能地用 {..} 縮小變數的作用域。

如果是我獨立開發的專案,當我碰到要宣告變數,我會問自己兩個問題。第一個:

這是整個函數內都會使用到的變數嗎?

是,就用 var 宣告在函數開始處;不是,遵循最小暴露原則,先用 {..} 括起,再問:

這個變數會被重新賦值嗎?

會,就用 let;不會,就用 const

整個決策過程並不複雜,至少我最近在開發瀏覽器擴充套件 Notion+ 標記管理器,是使用得挺愉快的,除了程式碼變得更易讀,修改起來也較容易。

你是怎麼決定該用哪個關鍵字宣告變數的呢?你對我上面所說的一切,有什麼看法?歡迎留言給我!

(有讀者回饋,覺得將作用於整個函數的變數都用 let 宣告在函數開頭,並記得將區塊變數都用 {..} 括起來,也能達到同樣的效果,並能避免掉 var 的缺點。我同意這個看法,如果你真的覺得 var 的缺陷不容忽視,那可以這麼做。總之,本篇最重要的觀念是最小暴露原則,遵守這個原則,其它差異都非大事,只要你有深思熟慮過自己這麼做的原因就好)

相關資料