JavaScript 深度
30 Days of JS

JS 算法學習復盤
閉包、高階函式與那些坑

從 LeetCode 30 Days of JavaScript 學習紀錄出發,
整理那些讓我卡關最久、理解最深的 JS 核心觀念

J

Joseph Chen

2026·LeetCode 30 Days of JavaScript·5 min read·1.2k views
閉包
高階函式
函式組合

一、學習背景與難點

這份復盤的素材,來自我在 LeetCode 30 Days of JavaScript 學習計畫中, 與 AI 的實際對話紀錄。從最基礎的 Hello World、Counter,到函式組合的 reduceRight, 我把每一個卡關的地方都留了下來。

本次學習覆蓋的題目

#2667

Create Hello World Function

閉包基礎
#2620

Counter

n++ vs ++n
#2704

To Be Or Not To Be

物件方法語法
#2665

Counter II

變數遮蔽 Bug
#2634

Filter Elements from Array

Truthy/Falsy
#2635

Apply Transform Over Each Element

高階函式
#2703

Return Length of Arguments Passed

Rest 參數
#2629

Function Composition

reduceRight

我在這個學習計畫中遇到的難點,大致可以分成三類:

🔒

閉包直覺

知道「閉包會記住外層變數」,但實際遇到 n++ 的回傳值、變數遮蔽,腦袋就打結。

🧩

語法誤用

物件方法的 key: value 語法、箭頭函式隱式回傳、rest 參數 ...args,每個都有細節地雷。

🔄

函式組合

reduceRight 的執行順序、高階函式為什麼要 return function,腦袋需要建立新的抽象層。

二、核心知識問題點

2-1 閉包(Closure)的本質

閉包最難的不是定義,而是「它記住的是變數本身,不是值」。 每次呼叫內層函式時,讀取的是外層變數的當前狀態,不是建立時的快照。

closure-counter.js
function makeCounter() {
  let n = 0;          // 這個 n 被內層函式「記住」

  return function() {
    return n++;       // 先回傳 n 的當前值,然後 n 才加 1
  };
}

const counter = makeCounter();
console.log(counter()); // 0  ← 回傳 0,n 變成 1
console.log(counter()); // 1  ← 回傳 1,n 變成 2
console.log(counter()); // 2  ← 回傳 2,n 變成 3
⚠️
n++ 與 ++n 的差異:
  • n++(後置遞增):先回傳 n 的舊值,再將 n 加 1
  • ++n(前置遞增):先將 n 加 1,再回傳新值

Counter 題目要求「第一次呼叫回傳 init」,所以必須用 return n++,不能用 return ++n

2-2 物件方法的 key: value 語法

我在 #2704 To Be Or Not To Be 的時候,完全搞不清楚: 的用途。 後來理解了:在物件字面值(object literal)裡,: 是「鍵值對的分隔符號」,不是 TypeScript 型別標注。

object-methods.js
// 物件字面值:{ key: value }
// value 可以是任何東西,包含函式(方法)

const expect = (val) => {
  return {
    // toBe 是 key,後面的箭頭函式是 value
    toBe: (expected) => {
      if (val !== expected) throw new Error("Not Equal");
      return true;
    },
    // notToBe 也是一個 key,value 同樣是函式
    notToBe: (expected) => {
      if (val === expected) throw new Error("Equal");
      return true;
    },
  };
};

// 鏈式呼叫:expect(5).toBe(5)true
// expect(5).notToBe(6)true
💡
throw vs return 的差異throw new Error("msg") 會中斷執行並拋出例外;return false 只是回傳一個值,函式繼續正常結束。 LeetCode 要求「不相等時拋出例外」,必須用 throw,不能用 return

2-3 Truthy / Falsy:JS 的真假判斷規則

#2634 Filter Elements 中,filter 函式的核心就是: 把 fn 回傳值為 truthy 的元素留下來。 JS 的 Falsy 值只有 6 個,其他所有值都是 Truthy。

JS 的 6 個 Falsy 值

false

布林假值

0

數字零

""

空字串

null

無值

undefined

未定義

NaN

非數字

易混淆"0"(字串的零)、[](空陣列)、{}(空物件)通通是 Truthy!只有數字 0 是 Falsy。

2-4 Rest 參數 ...args

#2703 argumentsLength 讓我第一次認識 rest 參數。...args會把所有傳入的參數收集成一個真正的陣列,可以直接用 .length

rest-params.js
// ...args 收集所有參數成陣列
const argumentsLength = (...args) => args.length;

argumentsLength(1, 2, 3);        // 3
argumentsLength("a", "b");       // 2
argumentsLength();               // 0

// ⚠️ 舊寫法:arguments 物件(類陣列,不是真正的陣列)
function old() {
  console.log(arguments);      // Arguments [1, 2, 3]
  console.log(arguments.length); // 3
  // arguments.map(...)  ← 這會報錯!不是真陣列
}

// ✅ 現代寫法:用 rest params,是真正的陣列
const modern = (...args) => {
  args.map(x => x * 2); // ✅ 可以用所有陣列方法
};

2-5 函式組合與 reduceRight

#2629 Function Composition 是這批題目中概念最難的一題。 函式組合 compose(f, g, h)(x) 的數學定義是 f(g(h(x)))—— 從右到左依序執行。reduceRight 就是專門為這個場景設計的。

function-composition.js
const compose = (functions) => {
  // 如果沒有函式,就直接回傳輸入值
  if (functions.length === 0) return (x) => x;

  return function(x) {
    // reduceRight 從陣列最後一個元素開始往左走
    // 每次把上一輪的結果餵給下一個函式
    return functions.reduceRight(
      (result, fn) => fn(result),
      x  // 初始值是 x
    );
  };
};

// 使用範例
const double = (x) => x * 2;
const addOne = (x) => x + 1;
const square = (x) => x * x;

// compose([double, addOne, square])(4)
// = double(addOne(square(4)))
// = double(addOne(16))
// = double(17)
// = 34
💡
為什麼要 return function(x)
因為 compose 本身接收的是「函式陣列」,它必須回傳一個新函式, 等之後傳入 x 才真正執行。這是高階函式的核心模式: 「接收函式、回傳函式」。

三、經典例題與程式碼復盤

#2704

To Be Or Not To Be

閉包 + 物件方法

這題要求實作一個 expect(val) 函式,回傳一個物件, 物件有 toBenotToBe 兩個方法, 分別用 === 嚴格比較,不相等就 throw Error。

#2704 solution
var expect = function(val) {
  return {
    toBe: (expected) => {
      if (val !== expected) {
        throw new Error("Not Equal");
      }
      return true;
    },
    notToBe: (expected) => {
      if (val === expected) {
        throw new Error("Equal");
      }
      return true;
    },
  };
};

// 測試
expect(5).toBe(5);      // true
expect(5).notToBe(6);   // true
expect(5).toBe(6);      // throws Error: "Not Equal"
這題的關鍵學習點:鏈式呼叫(chaining)的底層邏輯。expect(5).toBe(5) 等同於先呼叫 expect(5) 拿到物件, 再對這個物件呼叫 .toBe(5). 就是在讀取物件的屬性(這裡是個函式)。

#2665

Counter II

變數遮蔽

這題要實作有 incrementdecrementreset 三個方法的計數器。 我在這題犯了一個經典的變數遮蔽(variable shadowing)錯誤。

#2665 solution
// ✅ 正確寫法
var createCounter = function(init) {
  let value = init;  // 外層變數

  return {
    increment: () => ++value,
    decrement: () => --value,
    reset: () => {
      value = init;  // 重置回初始值
      return value;
    },
  };
};

// ❌ 我犯的錯誤:
// increment: (value) => { ... }
// 參數名稱 'value' 遮蔽了外層的 let value
// 內部對 value 的修改不會影響閉包裡的那個 value!
變數遮蔽(Variable Shadowing):當內層作用域宣告了和外層同名的變數(或參數), 外層變數就被「遮住」了。在這個作用域內,你操作的是內層那個,和外層完全無關。 這是閉包相關 Bug 中最常見的一種。

#2634

Filter Elements from Array

Truthy/Falsy + 陣列操作

不能用原生 .filter(),要自己從零實作。 我在這題犯了一個傻錯:把 fn 的回傳值(boolean)直接賦值給 newArray, 把陣列蓋掉了。

#2634 solution
// ✅ 正確寫法
var filter = function(arr, fn) {
  const newArray = [];
  for (let i = 0; i < arr.length; i++) {
    if (fn(arr[i], i)) {      // fn 回傳 truthy 才放入
      newArray.push(arr[i]);  // 放入的是 arr[i],不是 fn 的回傳值
    }
  }
  return newArray;
};

// ❌ 我的錯誤寫法:
// newArray = fn(arr[i], i);   ← 把整個 newArray 蓋掉成 boolean!

#2629

Function Composition

reduceRight + 高階函式

最難的一題。除了 reduceRight 的方向要搞清楚, 我還犯了一個打字錯誤:function[i](把陣列名稱打成了關鍵字 function)。

#2629 solution
var compose = function(functions) {
  if (functions.length === 0) return (x) => x;

  return function(x) {
    return functions.reduceRight(function(acc, fn) {
      return fn(acc);  // ← fn,不是 function(關鍵字)
    }, x);
  };
};

// reduceRight 執行順序示意:
// functions = [f, g, h], x = 5
// 第1步:h(5)  → result1
// 第2步:g(result1) → result2
// 第3步:f(result2) → 最終結果
⚠️
拼字陷阱function 是 JS 的保留關鍵字, 如果你把變數名取成 function 會直接語法錯誤。 正確做法是用不衝突的名稱,例如 fnfunccb(callback 縮寫)。

四、容易錯誤點與避坑指南

整理這批題目中反覆出現的坑,每個都附上錯誤寫法 vs 正確寫法的對比。

坑 #1

n++ 後置遞增的回傳值

❌ 錯誤寫法
// 想要「第一次回傳 init,之後每次加 1」
return {
  count: () => {
    n++;       // 先加 1
    return n;  // 回傳已經加過的 n → 第一次回傳 init+1,答案錯了
  }
}
✅ 正確寫法
// 正確:後置遞增 n++
return {
  count: () => {
    return n++;
    // 等同於:先儲存舊值,回傳舊值,然後 n+1
    // 第一次呼叫:回傳 init,n 變成 init+1
  }
}

n++ 是「先回傳,再遞增」;++n 是「先遞增,再回傳」。Counter 題目要求第一次回傳初始值,所以必須用 n++。

坑 #2

物件方法參數名稱遮蔽閉包變數

❌ 錯誤寫法
var createCounter = function(init) {
  let value = init;

  return {
    // ❌ 參數名叫 value,遮蔽了外層 let value
    increment: (value) => {
      return ++value;  // 修改的是參數 value,不是閉包裡的 value
    },
  };
};
✅ 正確寫法
var createCounter = function(init) {
  let value = init;

  return {
    // ✅ 不需要任何參數,直接讀取閉包的 value
    increment: () => ++value,
  };
};

方法不應該接受和閉包變數同名的參數。命名衝突時,內層的參數會遮蔽(shadow)外層的變數,讓閉包失效。

坑 #3

filter 回傳 fn 的結果而非元素本身

❌ 錯誤寫法
for (let i = 0; i < arr.length; i++) {
  // ❌ fn() 回傳的是 true/false,不是元素
  newArray.push(fn(arr[i], i));
}
✅ 正確寫法
for (let i = 0; i < arr.length; i++) {
  // ✅ fn() 只用來判斷,push 的是 arr[i]
  if (fn(arr[i], i)) {
    newArray.push(arr[i]);
  }
}

filter 的語義是「保留元素」,fn 只是「判斷是否保留」的謂詞(predicate)函式。push 的一定是原始元素 arr[i],不是 fn 的回傳值。

坑 #4

== vs === 的嚴格性

❌ 錯誤寫法
// ❌ == 會做型別轉換,結果出乎意料
5 == "5"    // true ← 字串被轉成數字
0 == false  // true ← false 被轉成 0
null == 0   // false ← 不一致的行為
✅ 正確寫法
// ✅ === 嚴格比較,不做型別轉換
5 === "5"   // false ← 型別不同,直接 false
0 === false // false ← 型別不同,直接 false
null === undefined // false ← 就是不一樣

JavaScript 預設應該始終用 ===(三個等號)。== 的型別轉換規則極其複雜且容易出錯。只有在刻意要利用寬鬆比較(例如 null == undefined)時才考慮 ==。

坑 #5

function 是關鍵字,不能當變數名

❌ 錯誤寫法
// ❌ function 是保留字,這行直接語法錯誤
functions.reduceRight((acc, function) => {
  return function(acc);
}, x);
✅ 正確寫法
// ✅ 用 fn 或 func 等非保留字
functions.reduceRight((acc, fn) => {
  return fn(acc);
}, x);

JS 的保留關鍵字(function、return、class、const、let 等)不能用作變數名或參數名。一般慣例用 fn 代表「某個函式參數」,cb 代表 callback。

快速複習:本次學到的 JS 特性一覽

n++

後置遞增,先回傳舊值,再加 1

基礎
閉包(Closure)

內層函式記住外層變數的「引用」,不是快照

核心
變數遮蔽

內層同名變數/參數會遮蔽外層,閉包操作的是那個被遮住的

陷阱
Truthy/Falsy

Falsy 只有 6 個:false、0、""、null、undefined、NaN

核心
throw vs return

throw 拋出例外中斷執行,return 正常回傳值

基礎
=== vs ==

永遠用 ===,== 有型別轉換容易出 bug

習慣
...args(rest 參數)

收集所有參數成真陣列,可以用所有陣列方法

現代語法
reduceRight

從陣列末端往前累積,函式組合(compose)的標準工具

進階
高階函式

接收函式或回傳函式的函式,JS 的核心抽象機制

核心

學習心得

30 Days of JavaScript 的前幾題看起來很簡單,但每一題都藏著一個 JS 的核心觀念。 我犯的每一個錯誤——n++ 回傳值搞混、變數遮蔽、filter push 錯對象、function 關鍵字打字錯誤—— 在真實的前端專案中都會出現,只是那時候可能更難找到。

這份復盤的意義不只是記錄錯誤,而是幫助未來的自己在看到類似程式碼時, 能夠第一眼就認出潛在的問題點。 刷題的價值,在於把這些直覺刻進肌肉記憶裡。

下一步建議:繼續完成 LeetCode 30 Days of JavaScript 的後半段, 重點放在 Promiseasync/awaitEvent Loop,這三個主題在非同步 JS 開發中至關重要。

JavaScript Deep Dive Series