Published on

什麼是閉包(closure)?程式中的隔山打牛!【JavaScript基礎】

Authors
  • avatar
    Name
    走歪的工程師James
    Twitter

相關資源

搭配影片學習更佳!

正文

首先,大家能看懂這段程式碼嗎?如果執行這段程式碼,會印出什麼結果呢?

function makeCounter() {
  let count = 0
  return function inner() {
    count++
    console.log(count)
  }
}

const counter1 = makeCounter()
const counter2 = makeCounter()

counter1()
counter1()

counter2()

如果還不太確定,別擔心!這段程式碼涉及到JavaScript中一個重要的概念:閉包(closure)。只要理解他的原理,其實是個很簡單的概念!接下來我們就會一起來了解這個概念。

Scope與Closure

在進入閉包的概念之前,我們先來複習一下scope的概念。Scope決定了變數存活的範圍。 例如再下面的範例中,globalVar是在最外層定義的,所以在第4與第7行都可以存取到。

let globalVar = 'I am global'

function testScope() {
  console.log(globalVar) // Accessible
}

console.log(globalVar) // Accessible

testScope()

在下面的範例中,functionVar是在函式內部定義的,所以只有在函式內部(第三行)才可以存取到。但是在函式外部(第六行)就無法存取到。

function testScope() {
  let functionVar = 'I am local'
  console.log(functionVar) // Accessible
}

console.log(functionVar) // Unaccessible, would throw an error

testScope()

什麼是Closure?

greet範例

Closure是指函式記得它被創建時的環境。它允許內部函式存取外部函式的變數,即使外部函式已經執行完畢。在下面的範例中,我們在第7行使用了greet,並傳入'John'作為參數。後面我們可以重複使用greetJohn函式,它會記得之前傳入的name'John'。這就是closure的概念。

function greet(name) {
  return function () {
    console.log(`Hello, ${name}`)
  }
}

const greetJohn = greet('John')
greetJohn() // Hello, John
greetJohn() // Hello, John

counter範例

回到一開始的counter範例。在第9行我們使用了makeCounter,例面宣告了一個count變數,最後會返回裡面的inner函式。當makecounter返回過後,根據剛剛說的scope概念,我們已經無法從外面去存取到count變數。但別忘了這時候我們把inner存到counter變數中,所以當我們去呼叫counter1時,它還是可以存取到count變數。每一次我們呼叫counter1count變數就會加一。

function makeCounter() {
  let count = 0
  return function inner() {
    count++
    console.log(count)
  }
}

const counter1 = makeCounter()

counter1() // 1
counter1() // 2

要注意的是,當我們再次呼叫makeCounter時,它會重新宣告一個新的count變數,所以下面的範例中,counter2counter1是獨立的。所有他們會有各自的count。當呼教counter1時,並不會影響到counter2count。因為他們是在不同的scope中。

function makeCounter() {
  let count = 0
  return function inner() {
    count++
    console.log(count)
  }
}

const counter1 = makeCounter()
const counter2 = makeCounter()

counter1() // 1
counter1() // 2

counter2() // 1

與class的差異

因為有不少人是比較熟悉OOP的,就會以OOP中的class來做類比,來理解closure的概念。例如在使用express框架時,我們可能會使用closure來建立一個logger:

function createLogger(logLevel) {
  return function (req, res, next) {
    if (logLevel === 'verbose') {
      console.log(`${req.method} ${req.url}`)
    }
    next()
  }
}

const logger = createLogger('verbose')
app.use(logger)

如果使用class,也能達成類似的功能:

class Logger {
  constructor(logLevel) {
    this.logLevel = logLevel
  }

  log(req, res, next) {
    if (this.logLevel === 'verbose') {
      console.log(`${req.method} ${req.url}`)
    }
    next()
  }
}

因為這個原因,滿多人會不太理解兩者的差異。我自己的經驗上會覺得,兩者的差異主要是在寫程式的思維上面。當使用closure的時候,我們會比較注重於function的概念,目的最後還是產生一個function,且常常會是pure function。

而class則是比較注重於物件的概念,像這個class中,就算未來我們又加入了一個新的method也不奇怪。在構思整個程式的時候,我們會比較注重於物件之間的關係,以及物件的行為。

使用closure的範例

接下來我們来看一個實際使用closure的範例。在main中,我們有一個products陣列,裡面包含了商品的id、category、以及價格。

我們先用getUserDiscountsgetCategoryDiscounts取得折扣的資訊。這兩個function是模擬從server取得資料,所以我們使用了asyncawait

我們想要計算出每個商品的最終價格,並且過濾掉價格超過80的商品。這部份的邏輯寫在processProducts中。

async function getUserDiscounts() {
  return {
    P001: 5,
    P002: 10,
  }
}

async function getCategoryDiscounts() {
  return {
    electronics: 15,
    clothing: 5,
  }
}

function processProducts(products, userDiscounts, categoryDiscounts) {
  return products
    .map((product) => {
      const userDiscount = userDiscounts[product.id] || 0 // Specific discount for the product for this user
      const categoryDiscount = categoryDiscounts[product.category] || 0 // Discount for the product category

      const totalDiscount = userDiscount + categoryDiscount
      const finalPrice = product.price - product.price * (totalDiscount / 100)

      return {
        ...product,
        finalPrice: finalPrice.toFixed(2),
      }
    })
    .filter((product) => product.finalPrice <= 80)
    .map((product) => product.id)
}

async function main() {
  // List of products with their categories and base prices
  const products = [
    { id: 'P001', category: 'electronics', price: 100 },
    { id: 'P002', category: 'clothing', price: 50 },
    { id: 'P003', category: 'electronics', price: 100 },
    { id: 'P004', category: 'clothing', price: 80 },
  ]

  const userDiscounts = await getUserDiscounts()
  const categoryDiscounts = await getCategoryDiscounts()

  const processedProducts = processProducts(products, userDiscounts, categoryDiscounts)

  console.log(processedProducts)
}

main()

現在,假如我們想要把計算折扣後價格的邏輯抽出來,我們可以這樣寫:

function calculateDiscount(userDiscounts, categoryDiscounts, product) {
  const userDiscount = userDiscounts[product.id] || 0 // Specific discount for the product for this user
  const categoryDiscount = categoryDiscounts[product.category] || 0 // Discount for the product category

  const totalDiscount = userDiscount + categoryDiscount
  const finalPrice = product.price - product.price * (totalDiscount / 100)

  return finalPrice.toFixed(2)
}

processProducts則會變成這樣:

function processProducts(products, userDiscounts, categoryDiscounts) {
  return products
    .map((product) => {
-     const userDiscount = userDiscounts[product.id] || 0; // Specific discount for the product for this user
-     const categoryDiscount = categoryDiscounts[product.category] || 0; // Discount for the product category

-     const totalDiscount = userDiscount + categoryDiscount;
-     const finalPrice = product.price - product.price * (totalDiscount / 100);
+     const finalPrice = calculateDiscount(userDiscounts, categoryDiscounts, product);

      return {
        ...product,
        finalPrice: finalPrice.toFixed(2)
      };
    })
    .filter((product) => product.finalPrice <= 80)
    .map((product) => product.id);
}

不過這樣會有一個問題,就是每次呼叫calculateDiscount時,都要傳入userDiscountscategoryDiscounts。每刺史用要從資料庫去抓這些東西的話,使用上不是很方便。

而且我們從main把這兩個東西傳給processProducts,只是為了可以再傳給calculateDiscount。這叫做argument drilling。

前面這些問題,都可以可以使用closure來解決。我們可以把calculateDiscount改成這樣:

function createDiscountCalculator(userDiscounts, categoryDiscounts) {
  return function (product) {
    const userDiscount = userDiscounts[product.id] || 0 // Specific discount for the product for this user
    const categoryDiscount = categoryDiscounts[product.category] || 0 // Discount for the product category

    const totalDiscount = userDiscount + categoryDiscount
    const finalPrice = product.price - product.price * (totalDiscount / 100)

    return finalPrice.toFixed(2)
  }
}

改成這樣之後就可以用closure保存userDiscountscategoryDiscounts,不用每次都傳入。 改完之後完整的code是這樣:

async function getUserDiscounts() {
  // Example user-specific discounts and category discounts
  return {
    P001: 5,
    P002: 10,
  }
}

async function getCategoryDiscounts() {
  // Example user-specific discounts and category discounts
  return {
    electronics: 15,
    clothing: 5,
  }
}

function createDiscountCalculator(userDiscounts, categoryDiscounts) {
  return function (product) {
    const userDiscount = userDiscounts[product.id] || 0 // Specific discount for the product for this user
    const categoryDiscount = categoryDiscounts[product.category] || 0 // Discount for the product category

    const totalDiscount = userDiscount + categoryDiscount
    const finalPrice = product.price - product.price * (totalDiscount / 100)

    return finalPrice.toFixed(2)
  }
}

// Business logic function to process products
function processProducts(products, discountCalculator) {
  return products
    .map((product) => {
      const finalPrice = discountCalculator(product)

      return {
        ...product,
        finalPrice: finalPrice,
      }
    })
    .filter((product) => product.finalPrice <= 80)
    .map((product) => product.id)
}

async function main() {
  // List of products with their categories and base prices
  const products = [
    { id: 'P001', category: 'electronics', price: 100 },
    { id: 'P002', category: 'clothing', price: 50 },
    { id: 'P003', category: 'electronics', price: 100 },
    { id: 'P004', category: 'clothing', price: 80 },
  ]

  const userDiscounts = await getUserDiscounts()
  const categoryDiscounts = await getCategoryDiscounts()

  const discountCalculator = createDiscountCalculator(userDiscounts, categoryDiscounts)

  // Use the business logic function to calculate final prices
  const processedProducts = processProducts(products, discountCalculator)

  // Display processed products
  console.log(processedProducts)
}

main()

這樣我們就解決了argument drilling,同時計算價錢的邏輯使用起來也更方便,只要把userDiscountscategoryDiscounts傳給createDiscountCalculator,透過closure保存起來後,就可以把discountCalculator當作一個pure function來使用,他只有1個input跟1個output,要當作參數傳到其他地方使用也非常方便。

結論

在這次的文章中,我們了解了:

  1. 什麼是Closure?
  2. closure與class的差別
  3. 如何在程式碼中使用closure

希望大家有得到一些幫助。如果有任何問題,歡迎在下面留言!