JavaScript 遞迴到底是什麼?用最簡單的方式告訴你

Published July 19, 2025 by 徐培鈞
JavaScript

很多人一聽到「遞迴」這個詞就覺得很難,好像是數學家或資工系學生才會碰到的東西。

但其實,遞迴是一個很生活化的概念,你早就不知不覺用過它了。

簡單一句話:

遞迴就是「做同樣的事,但每次都往更小、更簡單的方向做下去,最後停在一個最簡單的情況」。

還是覺得有點抽象?別急,下面用幾個生活小例子帶你秒懂。

生活中的遞迴例子

俄羅斯套娃——一層層打開的遊戲

想像你正在玩俄羅斯套娃:

  1. 你拿起最大的一個,打開它;
  2. 裡面還有一個更小的套娃;
  3. 你再打開那個小一點的,發現裡面還有更小的;
  4. 你重複「打開」這個動作,一層一層往下,直到最裡面那個最小的娃娃,再也打不開為止。

整個過程中,你做的事情始終一樣:「打開套娃 → 看裡面有沒有更小的 → 如果有就繼續打開」

而「最小的娃娃」就是一個結束點,因為它不需要再打開。

找東西的方法——抽屜一層層翻找

假設你要在一個有很多層抽屜的大衣櫃裡找外套:

  1. 先打開最上層抽屜,看看是不是你要的外套;
  2. 如果不是,就打開下一層抽屜,重複一樣的檢查動作;
  3. 你一層層打開,一直找下去,直到:
  • 找到了外套 → 你停止動作
  • 所有抽屜都打開完卻沒找到 → 你也停止,因為再也沒有抽屜可以查了

這個例子告訴我們:只要每次都用同樣的方法去檢查,最終一定會停在一個「不用再找」的狀態。

小朋友問「為什麼」——一層層追問原因

你一定遇過小朋友不斷問「為什麼」的情況:

  • 小孩:「為什麼天會黑?」
  • 你:「因為太陽下山了。」
  • 小孩:「為什麼太陽要下山?」
  • 你:「因為地球在轉啊。」
  • 小孩:「那為什麼地球會轉?」
  • 你:「因為就是這樣啊!」(終於回答不下去了)

這個「問為什麼」的過程,就是典型的遞迴:

每次都重複「問為什麼」這件事,每次問的內容都更深入一層,直到一個「不能再追問」的原因為止。

打電話轉接客服——一個人接著一個人

你打客服電話,想解決某個問題:

  1. 總機先接聽,檢查自己能不能幫你;
  2. 如果不能,總機就把電話轉給技術部門;
  3. 技術部門接起來後,也做同樣的檢查:「能處理嗎?不能就再轉給另一位負責人」;
  4. 一直轉,直到電話接到真正能處理的人,才停止。

在這個過程中,每個人做的事都一模一樣——「檢查 → 能處理就結束,不能就轉給下一個」,而最後那個「能處理的人」就是終點

家族傳話——一輩輩往上告知

想像你想通知家族裡所有人一件事,但你只能告訴爸爸:

  1. 你先跟爸爸說;
  2. 爸爸再用同樣的方式告訴爺爺;
  3. 爺爺再告訴曾祖父……
  4. 一直傳到家族裡最年長的那一輩就結束,因為再也沒有人可以告訴了。

每個人做的動作都是相同的:「收到 → 再往上傳」,只是傳到的人一輩輩變得更年長,直到「最高一輩」停下來。

剝洋蔥——一層層往內

剝洋蔥的時候,你每次做的事情都是「剝掉一層皮」。
剝完一層後,你拿起裡面那層洋蔥,再做一樣的動作——「剝掉一層皮」。
重複這個動作,一層層往內,直到只剩下最裡面的那一層小洋蔥。

這也是遞迴的典型結構:每次做的事完全相同,但每次處理的是更「內層」的部分,最後停在最小的那一層。

遞迴的精神

看完上面那些生活例子,你應該已經感受到一種「共同的模式」:

不管是打開套娃、剝洋蔥,還是小朋友問為什麼,它們都遵守同一種思考方式。這就是「遞迴精神」。

我們可以用三個簡單的重點來拆解:

重複同樣的事情

遞迴的第一個精神是:

不管到哪一層,你做的動作都一樣。

  • 打開套娃時,每一層都是「打開 → 看裡面有沒有更小的」。
  • 翻抽屜時,每一層都是「打開 → 檢查是不是外套」。
  • 小朋友問為什麼時,每一層都是「再問一次為什麼」。

換句話說,每一層的規則是固定的,只有處理的對象變得更小或更內層

一步步往更簡單的情況推進

遞迴不是原地打轉,它一定會每次都往更接近終點的地方前進

  • 剝洋蔥時,每剝一次,洋蔥就變得更小一點。
  • 找抽屜時,每次都往更下層的抽屜走,終於會到最底層。
  • 客服轉接時,每轉一次,就更接近那個真正能處理問題的人。

如果每次做完動作還是回到原點,那就不是遞迴,而是「死循環」。

一定有一個「停止點」

遞迴最重要的精神是:它一定會在某個最簡單的情況下結束

  • 最小的套娃不能再打開 → 結束。
  • 最後一層抽屜找完 → 結束。
  • 小朋友問到「因為就是這樣啊!」 → 結束。
  • 剝到最裡層的小洋蔥 → 結束。

如果沒有這個「停止點」,整件事情就會沒完沒了。

一句話記住遞迴精神:「重複同樣的動作 → 每次往更簡單的情況前進 → 最終在一個最簡單的情況停下來。」

程式中遞迴的基本概念

程式裡的遞迴也是這樣:

你每次都在做同樣的事(打開、剝一層、問為什麼)
每次都更接近終點(套娃越來越小、洋蔥越剝越少、原因越問越深入)
最後一定會在一個最簡單的情況停下來

唯一的不同是:我們用函式來一層一層幫自己完成這件事。

因此在開始學遞迴之前,我們先要搞清楚一個最基本的概念:什麼是「呼叫函式」

為什麼要先學這個?

因為遞迴的核心,就是「同一個函式重複執行自己,直到完成工作」

如果你還不熟悉「函式是怎麼被執行的、怎麼把資料交給函式處理」,遞迴看起來就會像一種很神秘的魔法。

遞迴就像重複做同樣的事

你可以把遞迴想成 「每次做同樣的動作,並且一步步接近結束」

它沒有什麼特別神奇的地方,跟我們在生活中做的事很像。

以打開套娃為例:

  1. 你先打開最大的套娃;
  2. 如果裡面還有更小的,就再打開下一個;
  3. 一直重複這個動作,直到最裡面那個最小的套娃,才停下來。

程式中的遞迴也一樣:

函式每次處理一個小步驟,如果還沒完成,就再執行一次自己,直到「不需要再繼續」為止。

什麼是呼叫函式?

在 JavaScript 中,函式(function)就像一個小工具,可以幫你完成某件事

如果你只是寫下函式名稱,程式只會知道「有這個工具」,但它不會自動執行。

要執行這個工具,你需要在函式名稱後加上一對小括號 (),這個動作就叫做「呼叫函式」

舉個簡單的例子:

function sayHello() {
  console.log("Hello!");
}

sayHello(); // 呼叫函式 → 會印出 Hello!

當你寫 sayHello(); 時,程式就會跑進這個函式,執行裡面的動作。

✅ 傳遞「參數」給函式

有些時候,函式需要額外的資訊才能完成任務,這些資訊會放在括號裡,我們稱為參數(parameter)

你可以把參數想成「交給函式的材料」。

例如,我們要寫一個可以對不同人打招呼的函式:

function sayHelloTo(name) {
  console.log("Hello, " + name + "!");
}

sayHelloTo("Tom");  // 會印出 Hello, Tom!
sayHelloTo("Amy");  // 會印出 Hello, Amy!

當我們呼叫 sayHelloTo("Tom") 時,就是把「Tom」這個名字交給函式,函式再用它完成工作。

✅ 遞迴也用同樣的方式呼叫自己

理解了「函式可以接收參數」之後,你就能明白:

遞迴不過是「函式自己呼叫自己,並且把一個新的參數交給自己」而已

接下來,我們就用一個最簡單的例子來示範這種思路。

用最簡單的例子:從 1 數到 5

我們的目標是:

從 1 開始,一直往上數,最後數到 5,並且印出每一個數字。

程式碼

function countUp(number) {
  console.log(number);        // 印出當前的數字

  if (number < 5) {           // 如果還沒數到 5,就繼續
    countUp(number + 1);      // 呼叫自己,並把「下一個數字」交給自己
  }
}

countUp(1); // 從 1 開始數

執行過程一步步拆解

你可以把這個過程想像成「一個人一階一階往上爬樓梯,每走一階都會報一次數字」:

第一次呼叫 countUp(1)

  • 程式跑進函式,看到 console.log(number)印出 1
  • 接著判斷:1 < 5 成立 → 還沒到目標,準備繼續往上爬
  • 於是執行 countUp(2)呼叫自己,帶著下一個數字 2 再次執行

第二次呼叫 countUp(2)

  • 這是「新的執行」,就好像你到了下一階樓梯,重新開始做同樣的動作。
  • 印出 2
  • 判斷:2 < 5 成立 → 繼續往上
  • 呼叫 countUp(3)

第三次呼叫 countUp(3)

  • 再做一次一模一樣的事情:
  • 印出 3
  • 判斷:3 < 5 → 還沒結束 → 呼叫 countUp(4)

第四次呼叫 countUp(4)

  • 一樣重複:
  • 印出 4
  • 判斷:4 < 5 → 繼續 → 呼叫 countUp(5)

第五次呼叫 countUp(5)

  • 印出 5
  • 判斷:5 < 5條件不成立不再呼叫自己,程式在這一階停止。

為什麼程式會結束?

  • 因為每次呼叫時,number 都會比上一次大 1(number + 1)。
  • 最終當數字變成 5 時,if (number < 5) 這個條件不成立 → 程式就不會再執行新的呼叫,所有執行結束。

最終印出的結果

每次呼叫都先印出一個數字,所以完整輸出為:

1
2
3
4
5

為什麼這就是「遞迴」?

你會發現,這段程式其實跟我們剛剛提到的「打開套娃」或「剝洋蔥」沒什麼不同:

每次做同樣的事 → 印出數字並決定要不要繼續
一步步往更接近終點的方向 → 每次數字加 1,更接近 5
有停止點 → 當數字等於 5,就不再呼叫自己

所以,遞迴就是讓函式一層層幫自己完成任務,直到最簡單的情況為止

遞迴的基本結構

所有遞迴程式,無論多複雜,都只有兩個最重要的部分

  1. 基底條件(Base Case) → 告訴程式「什麼時候該停下來」。
  2. 遞迴呼叫(Recursive Case) → 告訴程式「如果還沒結束,接下來要怎麼繼續做」。

只要搞懂這兩件事,就能理解幾乎所有遞迴程式。

接下來,我們直接用「數到 5」的例子幫你完整拆解。

基底條件:什麼時候要停?

基底條件就像是設定一個「終點」。

當這個條件達成時,程式就會乖乖停下來,不再繼續呼叫自己。

在「從 1 數到 5」這個例子裡,我們的終點很清楚:

當數字已經等於 5,就不需要再繼續數。

程式裡的判斷方式是:

if (number < 5) {
  // 還沒到 5 → 繼續
}

換句話說:

  • number 還小於 5 → 代表還沒結束,要繼續
  • number 不再小於 5(也就是等於 5) → 不再呼叫自己,整個任務結束

這個條件非常重要。如果沒有基底條件,程式會一直呼叫自己,永遠停不下來。

遞迴呼叫:如果還沒結束,接下來要做什麼?

當條件判斷「還沒到終點」時,就要告訴程式:下一步該怎麼繼續做

在這個例子裡,我們的目標是「一個數字一個數字往上數到 5」。
所以每次都要做兩件事:

  1. 數字加 1 → 因為我們要慢慢接近 5;
  2. 再執行一次同樣的函式 → 讓程式繼續往下數。

在程式裡就是這一行:

countUp(number + 1);

你可以把它理解成:「把下一個數字交給同一個函式,請它繼續幫我數」。

把基底條件和遞迴呼叫合起來

完整的遞迴結構,看起來就是這樣:

function countUp(number) {
  console.log(number);        // 先做當前這一步:印出現在的數字

  if (number < 5) {           // 還沒到終點嗎?
    countUp(number + 1);      // 還沒 → 數字加 1,繼續呼叫自己
  }
}

這段程式的運作邏輯可以用一句超簡單的話來概括:

「先做現在的事,如果還沒做完,就繼續做下一步。」

為什麼這樣寫就能結束?

很多初學者會擔心:「函式不是一直呼叫自己嗎?那不會跑不完嗎?

不會的,因為這裡有兩個保證:

  1. 每次呼叫都更接近終點 → 每呼叫一次,number 就會加 1,最終一定會變成 5。
  2. 有明確的停止條件 → 當數字等於 5,if (number < 5) 這個條件就不成立,程式自然停下來。

所以你可以放心,這個遞迴永遠不會陷入無限循環。

呼叫堆疊與返回順序

呼叫堆疊是什麼?

想像你桌上放著一疊便利貼,這疊便利貼是你正在處理的所有工作清單,而且有一個簡單但嚴格的規則:

  1. 只能把新的便利貼貼在最上面
    → 新工作一定要壓在最上層,不能插隊到中間或底下。
  2. 只能從最上面撕下便利貼
    → 你必須把「最新的工作」做完才能繼續做下面的事。

這種只能「從上面加、從上面拿」的規則,就是堆疊(Stack)的特性,電腦世界也遵守同樣的原則。

✅ 每張便利貼代表什麼?

每當程式呼叫一個函式,就好像你拿起一張便利貼,寫下:

我要執行:某個函式(還帶著目前的參數)

然後把它貼到最上面。

這代表:「我現在要專心做這件事,等它完全做完再說。」

當這個函式的工作做完時,程式就會把最上面那張撕掉,然後回到下一張便利貼上,繼續處理那張寫的工作。

✅ 為什麼只能從最上面撕?

這是因為電腦必須「後進先出」來保持工作有序:

  • 如果你中途硬把中間的便利貼抽掉,下面所有工作會亂成一團,因為它們可能還在等上面那件事的結果。
  • 所以電腦規定:一定要把最新加的工作先完成(先撕掉),才能回去做更早的事

呼叫階段──便利貼一張張往上貼

執行 countUp(1),程式一路呼叫到 countUp(5),便利貼堆疊長這樣(最上面是最新的工作):

新貼了什麼?countUp(1)
便利貼堆疊(下 → 上)1
新貼了什麼?countUp(2)
便利貼堆疊(下 → 上)21
新貼了什麼?countUp(3)
便利貼堆疊(下 → 上)321
新貼了什麼?countUp(4)
便利貼堆疊(下 → 上)4321
新貼了什麼?countUp(5)
便利貼堆疊(下 → 上)5 ← 最新貼在最上面4321

此刻最頂那張便利貼是 countUp(5),它正印出數字 5

返回階段──便利貼一張張往下撕

countUp(5) 做完後,不再貼新便利貼,程式開始從最上面往下撕:

撕掉了什麼?撕掉 countUp(5)
便利貼堆疊(下 → 上)4321
撕掉了什麼?撕掉 countUp(4)
便利貼堆疊(下 → 上)321
撕掉了什麼?撕掉 countUp(3)
便利貼堆疊(下 → 上)21
撕掉了什麼?撕掉 countUp(2)
便利貼堆疊(下 → 上)1
撕掉了什麼?撕掉 countUp(1)
便利貼堆疊(下 → 上)(空) ← 堆疊清空,程式結束

🚀 一張對照表秒懂

呼叫(往上貼)          返回(往下撕)
countUp(5)             撕 countUp(5)
countUp(4)             撕 countUp(4)
countUp(3)             撕 countUp(3)
countUp(2)             撕 countUp(2)
countUp(1)             撕 countUp(1)   ← 全部完成

📝 白話重點

  1. 先把便利貼一張張往上貼
    每呼叫一次自己(countUp(number + 1)),就多貼一張「下一步要做的事」在最上層。
  2. 最上面那張先做好先撕掉
    countUp(5) 完成 → 撕掉;接著輪到 countUp(4),依序往下。
  3. 全部撕光就收工
    當最後撕掉 countUp(1),堆疊清空,程式完全結束。

把它想成「先把所有待辦便利貼貼滿,最後一張先做完並撕掉,再一路往回撕」,就能直覺理解呼叫堆疊在遞迴中的工作方式。

遞迴 vs. 迴圈:該如何選擇?

很多初學者學完遞迴後都會疑惑:

「這不是用迴圈也能做到嗎?那我什麼時候該用遞迴?」

事實上,遞迴和迴圈只是解決問題的兩種不同思路

  • 迴圈像是在原地「一直重複做事」。
  • 遞迴則像「把問題拆成一層層的小任務,慢慢往下處理」。

我們先看優缺點,再用一個簡單例子對比兩種寫法。

遞迴的優點

1. 思路更直觀、貼近人類的邏輯

例如找資料夾裡的所有檔案:

  • 每打開一個資料夾,就再檢查它裡面有沒有檔案或其他資料夾。
    這種「一層層深入」的思維,用遞迴寫起來最自然。

2. 程式碼簡潔

對於樹狀結構、迷宮尋路這種「分層、分支」的問題,遞迴通常只要幾行就能完成。

遞迴的缺點

1. 執行效率較慢

每次呼叫函式都要額外耗費記憶體,對於簡單重複的問題,迴圈更快。

2. 呼叫太深可能「堆疊溢位」

每呼叫一次自己,呼叫堆疊就多貼一張「待辦便利貼」。
如果條件寫錯或呼叫次數太多,程式可能直接報 Stack Overflow

3. 容易因為基底條件錯誤陷入無限呼叫

初學者常忘了設定「什麼時候該停下來」,導致函式一直呼叫自己。

用代碼比較:同樣是「數到 5」

我們用剛剛的「從 1 數到 5」這個簡單任務,分別用 迴圈遞迴來寫,觀察差異。

✅ 迴圈寫法

function countUpLoop() {
  for (let i = 1; i <= 5; i++) {
    console.log(i);
  }
}

countUpLoop();

程式怎麼跑?

  • 一開始就知道要跑幾次(1 到 5),直接在同一個函式裡重複印出數字。
  • 適合這種「明確知道要重複幾次」的任務。

✅ 遞迴寫法

function countUpRecursion(number) {
  console.log(number);        // 印出當前的數字

  if (number < 5) {           // 還沒到 5 → 繼續
    countUpRecursion(number + 1); // 呼叫自己,讓數字加 1
  }
}

countUpRecursion(1);

程式怎麼跑?

  • 每次只負責處理「當下的這個數字」,然後把「下一個數字」交給同一個函式處理。
  • 最終會先一路呼叫到 countUp(5),再從最上層一層層返回。

哪種寫法比較好?

適合迴圈✅(如從 1 數到 5、計算總和)
適合遞迴
適合迴圈
適合遞迴✅(如檔案搜尋、迷宮尋路)
適合迴圈✅(迴圈通常更快)
適合遞迴
適合迴圈❌(語法偏「機械式」)
適合遞迴✅(「做這一步→還沒結束就再做下一步」的思維很直觀)

延伸比較:走資料夾結構

假設你要做一個功能:

印出某個資料夾裡的所有檔案名稱

但這個資料夾裡可能還有子資料夾,子資料夾裡又可能再有更多資料夾……

你必須一層層往下找,直到最裡層都找完。

這時候可以用兩種方法來解決:遞迴迴圈

遞迴寫法(簡單又直觀)

先看資料結構

假設我們有一個像這樣的資料夾結構:

const myFolder = [
  { type: "file", name: "a.txt" },
  { type: "folder", name: "sub1", children: [
      { type: "file", name: "b.txt" },
      { type: "folder", name: "sub2", children: [
          { type: "file", name: "c.txt" }
      ]}
  ]}
];

你可以把它想成這樣的資料夾圖:

myFolder
├── a.txt
└── sub1
    ├── b.txt
    └── sub2
        └── c.txt

遞迴程式碼

function printFiles(folder) {
  for (let item of folder) {
    if (item.type === "file") {
      console.log("檔案:", item.name);
    } else if (item.type === "folder") {
      // 如果是資料夾 → 再呼叫自己
      printFiles(item.children);
    }
  }
}

printFiles(myFolder);

執行流程一步步解釋

假設你是這個程式,它其實就像一個很專心的整理員,每次只做兩件事:

  1. 如果是檔案 → 印出名稱
  2. 如果是資料夾 → 再進去這個資料夾,做同樣的事

以這個例子來看,流程是這樣:

  1. 進入 myFolder看到 a.txt 是檔案 → 印出「檔案:a.txt」
  2. 看到 sub1 是資料夾 → 再呼叫 printFiles(sub1.children)
  3. 進入 sub1印出 b.txt
  4. 看到 sub2 是資料夾 → 再呼叫 printFiles(sub2.children)
  5. 進入 sub2印出 c.txt
  6. 最後沒有更多資料夾,程式自動返回上一層,一層層結束。

最終輸出結果:

檔案: a.txt
檔案: b.txt
檔案: c.txt

為什麼說「簡單又直觀」?

因為它的程式碼就跟人類做事的邏輯一模一樣

「遇到檔案就印出名字;遇到資料夾?那就打開資料夾,裡面也做一樣的事。」

不需要管資料夾有幾層,函式自己會一層層往下探到底

迴圈寫法(比較繁瑣)

迴圈程式碼

function printFilesLoop(folder) {
  let stack = [...folder]; // 自己準備一個「小型堆疊」

  while (stack.length > 0) {
    const item = stack.pop(); // 從最上面拿出一個

    if (item.type === "file") {
      console.log("檔案:", item.name);
    } else if (item.type === "folder") {
      // 如果是資料夾 → 手動把裡面的東西放進堆疊
      stack.push(...item.children);
    }
  }
}

printFilesLoop(myFolder);

執行流程簡單描述

因為迴圈沒有自帶「呼叫自己」的功能,所以你必須自己準備一個小堆疊(stack,模擬遞迴的行為:

  1. 把第一層的檔案、資料夾先放進 stack
  2. 每次從最上面拿一個出來處理
  3. 如果是資料夾,就再把裡面的檔案放到 stack 最上面
  4. 一直重複這個過程,直到 stack 空了。

結果輸出跟遞迴一樣,但寫法繞了一大圈

為什麼說「繁瑣」?

  • 你必須自己準備額外的變數(stack,而遞迴會自動幫你用「呼叫堆疊」做到這件事。
  • 程式邏輯比較「機械化」:初學者很難一眼看出這段程式碼到底在做什麼。

總結:該選哪一個?

遞迴✅ 就像「遇到資料夾就打開」的直觀邏輯
迴圈❌ 需要自己管理一個「模擬堆疊」,看起來繞
遞迴✅ 短而簡潔
迴圈❌ 額外變數、邏輯比較多
遞迴❌ 多次函式呼叫略慢
迴圈✅ 省略函式呼叫,速度更快
遞迴探索巢狀結構(檔案、樹狀資料)
迴圈大量簡單、固定次數的重複

✅ 最簡單的選擇口訣

「要一層層往下探,就用遞迴; 只是簡單重複,乖乖用迴圈。」

總結

遞迴其實就是一種「拆問題」的思維:

「重複做同樣的事 → 每次處理更小的情況 → 最後停在最簡單的情況」

當你用這種角度去看程式,遞迴就不再神秘。

所以,下次當你遇到一個「可以一層層往下拆解」的問題時,不妨想想——也許遞迴就是最直觀的解法。