- Published on
什麼是閉包(closure)?程式中的隔山打牛!【JavaScript基礎】
- Authors
- Name
- 走歪的工程師James
相關資源
搭配影片學習更佳!
正文
首先,大家能看懂這段程式碼嗎?如果執行這段程式碼,會印出什麼結果呢?
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
變數。每一次我們呼叫counter1
,count
變數就會加一。
function makeCounter() {
let count = 0
return function inner() {
count++
console.log(count)
}
}
const counter1 = makeCounter()
counter1() // 1
counter1() // 2
要注意的是,當我們再次呼叫makeCounter
時,它會重新宣告一個新的count
變數,所以下面的範例中,counter2
與counter1
是獨立的。所有他們會有各自的count
。當呼教counter1
時,並不會影響到counter2
的count
。因為他們是在不同的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、以及價格。
我們先用getUserDiscounts
與getCategoryDiscounts
取得折扣的資訊。這兩個function是模擬從server取得資料,所以我們使用了async
與await
。
我們想要計算出每個商品的最終價格,並且過濾掉價格超過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
時,都要傳入userDiscounts
與categoryDiscounts
。每刺史用要從資料庫去抓這些東西的話,使用上不是很方便。
而且我們從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保存userDiscounts
與categoryDiscounts
,不用每次都傳入。 改完之後完整的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,同時計算價錢的邏輯使用起來也更方便,只要把userDiscounts
與categoryDiscounts
傳給createDiscountCalculator
,透過closure保存起來後,就可以把discountCalculator
當作一個pure function來使用,他只有1個input跟1個output,要當作參數傳到其他地方使用也非常方便。
結論
在這次的文章中,我們了解了:
- 什麼是Closure?
- closure與class的差別
- 如何在程式碼中使用closure
希望大家有得到一些幫助。如果有任何問題,歡迎在下面留言!