Day 42-44:遞迴重構;TDD 流程;Caesar Cipher;Array.fill vs Array.from;分組計數;Map vs Object;解構賦值;JSDoc 型別;JSFuck

整理日期: 2026/01/22


一、遞迴函式程式碼重構:追求簡潔與可讀性

原始問題:過度複雜的物件組裝

當遞迴函式需要回傳多個值時,新手常會將所有資料包裝成物件,並額外建立一個包裝函式 。這會讓程式碼變得複雜且難以維護: jsdevspace.substack

// ❌ 避免:巢狀函式與過度包裝
export function calculateFormula(n) {
  // 應避免在 function 內部定義 function(巢狀函式)
  const result = recursiveUnit(n);
  // 考慮在主程式層級組裝字串,而非在遞迴內部
  return `${result.formula} = ${result.sum}`;
}

function recursiveUnit(cur) {
  if (cur === 4) {
    const value = 2 * 4;
    return {
      formula: `2*4`,
      sum: value,
    };
  }
  const prev = recursiveUnit(cur - 2);
  const a = cur - 2;
  const b = cur;
  const value = a * b;
  return {
    formula: `${prev.formula} + ${a}*${b}`,
    sum: prev.sum + value,
  };
}

重構建議

關鍵原則: 讓遞迴函式專注於單一職責,字串組裝交給外層處理 。 javascript.plainenglish

Function 建構式替代 eval

如果需要動態執行字串運算,可使用 new Function() 建構式,它比 eval() 更安全,因為無法存取呼叫端的作用域 : stackoverflow

// ✅ 較安全的動態執行方式
const calculate = new Function('a', 'b', 'return a * b');
console.log(calculate(3, 4)); // 12

Function 建構式只能存取全域作用域與傳入的參數,避免了 eval 可能存取外層變數的安全風險 。 dfkaye.github

單一職責原則

遞迴函式應該只負責計算,不要混雜格式化邏輯 : javascript.plainenglish

// ✅ 推薦:職責分離
function calculateSum(n) {
  if (n === 4) return 2 * 4;
  return (n - 2) * n + calculateSum(n - 2);
}

// 格式化由外層處理
function formatResult(n) {
  const sum = calculateSum(n);
  return `計算結果 = ${sum}`;
}

二、TDD 思考流程:先定義 I/O,再開始實作

核心概念:黑盒子設計思維

所有程式碼都應該用相同的思考流程:看到需求時,先定義清楚輸入(Input)與輸出(Output),實作不通再回頭調整計畫 。 moldstud

TDD 強制先寫測試的本質,就是訓練「實作前先想清楚」的思維模式 。 moldstud

關於程式碼重用的思考

何時該抽出共用函式?

回到自然語言判斷「這是一件獨立的事」,而非單純看到重複程式碼就抽出來 。 moldstud

  • 正確思維: 「我要取奇數索引值的元素」是一件獨立且完整的事,後續被重複使用只是結果

  • 錯誤思維: 看到兩處程式碼一樣就立刻抽成函式

程式碼的表達方式要考慮閱讀者感受,所以對「單獨一件事」的定義很重要。


三、Caesar Cipher 加密演算法實作

問題描述

實作 Caesar Cipher 加密函式,將輸入字串的英文字母(a-z, A-Z)依指定位移量平移,其他字元保持原樣 。 caesar-cipher

需求:

  • 支援小寫、大寫字母

  • 處理字母包回(wrap around),如 xyz → abc

  • 非字母字元(數字、標點、空白)原樣回傳

解題思路

  1. 產生小寫字母陣列 lowerAlphabet(a-z)與大寫字母陣列 upperAlphabet(A-Z)

  2. 對每個字元判斷:

    • 若是小寫 → 位移加密並處理包回

    • 若是大寫 → 位移加密並處理包回

    • 其他字元 → 原樣回傳

  3. 使用 Array.prototype.map 處理字元陣列,最後用 join('') 組回字串 caesar-cipher

JavaScript API 選用理由

  • Array.from:產生字母陣列,搭配 String.fromCharCode 建立 Unicode 字元 delicious-insights

  • split(''):將字串拆成字元陣列

  • map:逐字元處理加密邏輯 developer.mozilla

  • indexOf:判斷字元是否為英文字母並取得索引

  • join(''):組回加密後字串

實作程式碼

export function caesarCipher(publicString, shift = 3) {
  // 產生 A-Z
  const upperAlphabet = Array.from({ length: 26 }, (_, i) =>
    String.fromCharCode(65 + i),
  );
  
  // 產生 a-z
  const lowerAlphabet = Array.from({ length: 26 }, (_, i) =>
    String.fromCharCode(97 + i),
  );
  
  const privateString = publicString
    .split('')
    .map((char) => {
      if (lowerAlphabet.indexOf(char) !== -1) {
        const originIndex = lowerAlphabet.indexOf(char);
        const shiftIndex = (originIndex + shift) % 26;
        return lowerAlphabet[shiftIndex];
      } else if (upperAlphabet.indexOf(char) !== -1) {
        const originIndex = upperAlphabet.indexOf(char);
        const shiftIndex = (originIndex + shift) % 26;
        return upperAlphabet[shiftIndex];
      }
      return char;
    })
    .join('');

  return privateString;
}

學習重點

  1. 陣列操作: 熟悉 map、indexOf 與字串處理方法

  2. TDD 設計: 覆蓋正常、邊界、例外情境,確保程式碼健壯性 moldstud

  3. 早期回傳(Guard Clause): 讓程式更易讀、易維護

  4. 分類處理: 支援多種輸入時,先分類再處理,避免複雜的條件判斷


四、Array.fill() vs Array.from() 的選擇時機

核心概念:原始值 vs 物件參考

原始值/純值(Primitive) developer.mozilla

string、number、boolean、null、undefined、symbol、bigint

  • 賦值時複製值本身

  • 每個變數獨立,互不影響

物件(Object)

array、object、function

  • 賦值時複製參考(記憶體位址)

  • 多個變數可能指向同一物件,修改會互相影響 developer.mozilla

fill() 的陷阱

fill(value) 只對 value 求值一次,然後把同一個值放進每個位置 。 developer.mozilla

// ✅ 原始值:安全
const nums = Array(3).fill(0);
nums[0] = 99;
console.log(nums); // [99, 0, 0] ← 互不影響

// ❌ 物件:共享參考
const arrs = Array(3).fill([]);
arrs[0].push("A");
console.log(arrs); // [["A"], ["A"], ["A"]] ← 全部都被改了!

Array.from() 的安全性

callback 在每次迭代時執行,每次產生新的獨立物件 。 delicious-insights

// ✅ 每個元素是獨立的新陣列
const arrs = Array.from({ length: 3 }, () => []);
arrs[0].push("A");
console.log(arrs); // [["A"], [], []] ← 只有第一個被改

判斷原則

要填入的值是什麼?
├─ 原始值 → Array(n).fill(value)
│   - 簡潔、效能較好
│   - 語意:「建立 n 個相同值」
│
└─ 物件 → Array.from({ length: n }, () => 新物件)
    - 每次迭代產生獨立實例
    - 語意:「根據邏輯產生 n 個元素」

額外情況:每個元素的值依賴 index

即使是原始值,若需要根據 index 計算,也要用 Array.from : delicious-insights

// 產生 [0, 1, 2, 3, 4]
Array.from({ length: 5 }, (_, i) => i)

// 產生 [0, 3, 6, 9, 12](每人分配 3 個元素的起始位置)
Array.from({ length: 5 }, (_, i) => i * 3)

快速查表

情境

建議寫法

範例

n 個相同原始值

Array(n).fill(value)

Array(5).fill(0)

n 個獨立空陣列

Array.from + callback

Array.from({ length: 5 }, () => [])

n 個獨立空物件

Array.from + callback

Array.from({ length: 5 }, () => ({}))

根據 index 計算值

Array.from + callback

Array.from({ length: 5 }, (_, i) => i * 2)


五、分組計數演算法設計

累加器模式(Accumulator Pattern)

遍歷資料時用「累加到容器」的方式統計資料,這是最常見的計數模式:

// 經典結構
const counts = {};
items.forEach((item) => {
  const key = generateKey(item);
  if (counts[key]) {
    counts[key] += 1;
  } else {
    counts[key] = 1;
  }
});

Key 生成策略(Composite Key)

當需要把「多欄位」或「陣列」轉成唯一的字串 key:

// 多欄位組合
const key = `${item.name}-${item.spec}`;

// 陣列排序後組合(順序無關的組合)
const key = dancer.slice().sort().join('-');

為什麼需要這樣?
因為 JS 物件的 key 只能是字串,Map 的 key 雖然可以是任意值,但陣列比較是 reference,不是 value 。所以都選擇「序列化成字串」來當 key。 developer.mozilla


六、Map vs Object 完整對照

API 語法對照表

操作

Object

Map

初始化

const obj = {}

const map = new Map()

設定值

obj[key] = value

map.set(key, value)

取值

obj[key]

map.get(key)

檢查存在

key in obj

map.has(key)

刪除

delete obj[key]

map.delete(key)

大小

Object.keys(obj).length

map.size

取所有 values

Object.values(obj)

[...map.values()]

遍歷

Object.entries(obj).forEach(...)

map.forEach((value, key) => ...)

常見陷阱

陷阱

說明

map[key] = value

不會存進 Map,變成物件屬性

key in obj

會檢查原型鏈(如 'toString' in obj 是 true)

map.has(key)

只檢查你自己設的 key,更純粹 developer.mozilla

// Object 原型鏈干擾
const obj = {};
'toString' in obj;       // true(原型鏈上的)

// Map 乾淨
const map = new Map();
map.has('toString');     // false

效能比較

根據實際測試,Map 在多數情況下有更好的效能表現 : zhenghao

記憶體使用:

  • Map 比 Object 少 20-50% 記憶體使用 zhenghao

  • Map 不儲存屬性描述符(writable/enumerable/configurable)

操作速度:

  • 字串 key:Map 在插入、刪除、迭代都較快 zhenghao

  • 數字 key:Object 在小範圍整數可能較快,但 Map 更穩定

  • 大量資料(>100000):Map 優勢明顯 zhenghao

選擇時機

情境

建議

value 是純數字/字串

Map 語意清晰

value 是複雜物件

兩者差異不大,選熟悉的

key 是動態/使用者輸入

Map 較安全(避免 __proto__ 衝突)

需要轉 JSON

Object(JSON.stringify(map) 會失敗)

需要頻繁增刪 key

Map 效能較好 zhenghao

需要保持插入順序

Map(語意更明確) developer.mozilla


七、解構賦值(Destructuring Assignment)

一句話定義

從陣列或物件中「拆出」值,直接賦給變數,省去一個個取值的步驟。

兩種解構方式

類型

匹配方式

語法

陣列解構

靠「位置」

const [a, b] = arr

物件解構

靠「屬性名稱」

const { name, age } = obj

陣列解構:位置對應

// 傳統寫法
const pair = ['紅-藍', 3];
const key = pair[0];
const value = pair [jsdevspace.substack](https://jsdevspace.substack.com/p/how-to-master-recursion-in-javascript);

// 解構寫法
const [key, value] = ['紅-藍', 3];
// key = '紅-藍'(位置 0)
// value = 3(位置 1)

物件解構:名稱對應(順序無關)

// 傳統寫法
const dancer = { name: '小明', color: '紅' };
const name = dancer.name;
const color = dancer.color;

// 解構寫法(順序可以不同)
const { color, name } = { name: '小明', color: '紅' };
// name = '小明', color = '紅'

函式參數中解構

最常用的情境,直接在參數位置拆值:

// 陣列解構
const pairs = [['a', 1], ['b', 2]];
pairs.forEach(([key, value]) => {
  console.log(key, value);  // 'a' 1, 'b' 2
});

// 物件解構
const people = [{ name: '小明', age: 20 }, { name: '小華', age: 25 }];
people.forEach(({ name, age }) => {
  console.log(name, age);  // '小明' 20, '小華' 25
});

實際應用:Map 轉陣列

const map = new Map();
map.set('紅-藍', 3);
map.set('綠-黃', 2);

// map.entries() 展開後是 [['紅-藍', 3], ['綠-黃', 2]]
// 用陣列解構把每個 [key, value] 拆開,轉成物件
const result = [...map.entries()].map(([key, value]) => ({ key, value }));
// [{ key: '紅-藍', value: 3 }, { key: '綠-黃', value: 2 }]

八、JSDoc 型別定義:漸進式導入型別安全

引入型別的好處

  1. 提示(開發中):IDE 自動完成與參數提示 stefanjudis

  2. 文件(開發後):註解即文件,易於維護

  3. 檢查與拋錯(運行中):TypeScript 編譯時檢查

JSDoc:用註解完成定義與得到提示

JSDoc 的優勢在於導入成本相對低,因為寫在註解,程式運作不會壞掉,可以只寫其中一部分也沒關係 。 stefanjudis

基礎型別定義語法

使用 @typedef 定義自訂型別:

/**
 * @typedef {object} User
 * @property {string} name - 使用者名稱
 * @property {string} email - 使用者信箱
 * @property {number=} age - 可選的年齡
 */

/** @type {User} */
const user = {
  name: "Alex",
  email: "alex@example.com"
};

配合 Copilot 生成 JSDoc

  1. 寫好函式或變數

  2. 在上方輸入 /** 按 Enter

  3. Copilot 自動生成對應的 JSDoc 註解

  4. 已經可以在開發過程得到足夠的正確型別提示 stefanjudis

VS Code 型別檢查設定

方式 1:單檔啟用

// @ts-check
const x = 123;
x = "hello"; // ❌ Type 'string' is not assignable to type 'number'

方式 2:專案啟用(jsconfig.json) stefanjudis

{
  "compilerOptions": {
    "checkJs": true,
    "strict": true
  },
  "exclude": ["node_modules", "**/node_modules/*"]
}

方式 3:全域設定(settings.json) stefanjudis

{
  "javascript.implicitProjectConfig.checkJs": true
}

進階:用單獨檔案管理型別定義

Schemas.js(型別定義檔):

/**
 * @typedef {object} User
 * @property {string} name - 使用者名稱
 * @property {string} gender - 使用者性別
 */

/**
 * @typedef {object} Product
 * @property {string} id - 產品編號
 * @property {number} price - 產品價格
 */

export const Types = {}

main.js(使用型別):

import { Types } from './Schemas.js';

/** @type {import('./Schemas.js').User} */
const user = {
  name: "Alex",
  gender: "male"
};

// VS Code 會提示型別錯誤
user.age = 25; // ❌ Property 'age' does not exist

使用 ES Module 確保型別一致性

以 ES module export/import 在開發過程確保宣告的變數符合事先定義的型別 : stefanjudis

// types/schemas.js
/** @typedef {object} Config */
export const ConfigType = {};

// utils.js
/** @param {import('./types/schemas.js').Config} config */
export function init(config) {
  // 型別安全的操作
}

這樣即使不用 TypeScript,也能在純 JavaScript 專案中獲得型別提示與錯誤檢查,開發速度與穩定性兼顧 。 stefanjudis


九、JavaScript 型別系統冷知識:JSFuck

什麼是 JSFuck?

JSFuck 是一種極限編程風格,只用 6 個字元 []()!+ 就能寫出任何 JavaScript 程式。

// 正常寫法
alert(1)

// JSFuck 寫法(部分)
[][(![]+[])[+[]]+([![]]+[][[]])[+!+[]+[+[]]]...][(...)...](...)()

為什麼可行?

核心原理:JavaScript 的動態弱型別 + 隱性型別轉換 developer.mozilla

  1. 任何程式都能轉成字串

  2. 字串可以用 Function() 建構子執行

  3. 六個符號透過型別轉換能產生所有需要的字元

// 原理示範
Function('alert(1)')()  // 等同 alert(1)

基礎型別轉換規則

產生布林值

![]        // false - 空陣列轉布林後取反
!![]       // true  - 雙重否定

產生數字

+[]        // 0     - 空陣列轉數字
+!+[]      // 1     - false 轉數字後取反再轉數字
!+[]+!+[]  // 2     - 兩個 1 相加

產生特殊值

[][[]]     // undefined - 空陣列存取不存在的屬性
+[![]]     // NaN       - false 轉數字失敗

字串來源與可用字母

表達式

結果字串

可取字母

[]+{}

"[object Object]"

b c e j O o t

false+[]

"false"

a e f l s

true+[]

"true"

e r t u

[][[]]+[]

"undefined"

d e f i n u

+[![]]+[]

"NaN"

a N

與 JS 型別系統的連結

JSFuck 完美展示了以下概念 : developer.mozilla

  1. 隱性強制轉型(Implicit Coercion):+ 運算子自動判斷要做字串連接或數字相加

  2. 動態型別:同一變數可以是陣列、數字、字串,執行時才決定

  3. 弱型別:自動轉換型別,不需手動宣告

  4. 原型鏈:透過 constructor 存取建構函式

  5. Function 即物件:函式也是物件,有屬性可存取

核心領悟: JSFuck 不是實用工具,而是 JavaScript 動態弱型別特性的極致藝術展示。它提醒我們:理解型別轉換規則很重要,否則程式行為會超出預期。


延伸閱讀

MDN 官方文件

最佳實踐

ECMAScript 規範