javaScript設計模式 : Iterator(迭代器)

 


寫程式時,你多半已經用過 for...of、展開運算子(...)、或是把東西丟進 Promise.all。這些看似日常的小語法,其實都靠一個共同的基礎:Iterator 與可迭代協定(Iterable)。

懂它們,等於把「資料一個一個被消費」這件事說清楚:什麼時候會產生下一個值、什麼時候該結束、遇到錯誤怎麼收尾。這篇文章不走玄學,只從實務出發:先把協定原理講清,再示範自訂迭代器與 Generator,最後帶你看非同步迭代(for await...of)在抓分頁、讀串流時的用法。

也會整理常見雷點,例如把 for...in 當 for...of、next() 回傳格式不對、或忽略 return() 造成資源沒釋放。讀完,你會把迭代當成工具,而不是黑箱。希望本篇文章能夠幫助到需要的您。


目錄

{tocify} $title={目錄} 


為什麼你需要關心 Iterator?

Iterator 讓「資料一筆一筆被消費」成為語言一等公民。它的價值在於:

統一的存取介面:陣列、字串、Map/Set、TypedArray、DOM NodeList、甚至你自訂的資料結構,都能以相同方式走訪。

惰性(Lazy)處理:不必一次產出所有結果,能在需要時才計算(適合大資料、串流、分頁)。

語言整合:for...of、展開(...)、解構、yield*、Promise.allSettled 等都可直接吃「可迭代」。


兩個協定:Iterable 與 Iterator

1.    Iterable(可迭代)

物件只要實作 obj[Symbol.iterator]() 並回傳 Iterator,就被視為 可迭代。常見內建可迭代:

        Array、String、Map、Set、TypedArray

        arguments、NodeList(多數現代瀏覽器)

        Generator 物件本身


2.    Iterator(迭代器)

迭代器是一個有 next() 方法的物件,每次呼叫回傳 { value, done }:

        done: false 表示還有值,value 是當前資料。

        done: true 表示結束;value 可省略。

可選的清理鉤子:

        return():提早結束迭代時被呼叫(例如 break)。

        throw(e):迭代器可選支援錯誤傳遞。

極簡範例:

const iterable = {
  [Symbol.iterator]() {
    let i = 0;
    return {
      next() {
        if (i < 3) return { value: i++, done: false };
        return { value: undefined, done: true };
      }
    };
  }
};

for (const n of iterable) console.log(n); // 0, 1, 2


for...of、for...in、forEach 差在哪?

for...of:走 值(values),要求目標是 Iterable。尊重 Symbol.iterator。

for...in:走 可列舉鍵(enumerable keys),包含原型鏈上的可列舉屬性。與迭代器無關,不適合遍歷陣列元素。

Array.prototype.forEach:只適用陣列;不能 break/continue/return 提早中止,也不支援 await(同步呼叫 callback)。


結論:大多數情境用 for...of 更語意清楚、也更通用。


展開運算子、解構、Promise.all 與「可迭代」

展開(Spread):[...iterable]、new Set(iterable)、fn(...iterable)。

解構:const [first, second] = iterable;

多數語法與 API 都倚賴 Iterable:舉例 Array.from(iterable)、Promise.all(iterableOfPromises)(要求可迭代集合)。


小示例:

const set = new Set([1, 2, 3]);
const arr = [...set];          // [1, 2, 3]
const [a, b] = '你好';          // a = '你', b = '好'


自訂 Iterator:從入門到進階

1.    可重入的 Range

每次迭代都應產生 獨立 的迭代器,否則不同 for...of 會互相干擾。

function range(start, end, step = 1) {
  return {
    [Symbol.iterator]() {
      let i = start;
      return {
        next() {
          if (i < end) {
            const v = i;
            i += step;
            return { value: v, done: false };
          }
          return { done: true };
        },
        // 可選:清理(當外界 break/throw 時被調用)
        return() {
          // 釋放資源、關閉連線...(視需求)
          return { done: true };
        }
      };
    }
  };
}

console.log([...range(0, 5)]); // [0,1,2,3,4]


2.    單次迭代 vs 可重複迭代

若 obj[Symbol.iterator] 回傳 this,且 next() 狀態存在於 this,那它通常是 一次性 的(迭完即壞)。多數情況建議每次回傳 新物件,以達到可重入。


3.    進階:樹的 DFS 迭代器

function makeTreeDFS(root) {
  return {
    [Symbol.iterator]() {
      const stack = [root];
      return {
        next() {
          if (stack.length === 0) return { done: true };
          const node = stack.pop();
          // 後推入的會先被彈出 => DFS
          if (node.children) {
            for (let i = node.children.length - 1; i >= 0; i--) {
              stack.push(node.children[i]);
            }
          }
          return { value: node, done: false };
        }
      };
    }
  };
}

// 用法
const tree = { value: 'A', children: [{ value: 'B' }, { value: 'C', children: [{ value: 'D' }] }] };
for (const node of makeTreeDFS(tree)) console.log(node.value); // A B C D


Generator 與 Iterator 的關係

Generator(產生器) 是一種更好寫的迭代器工廠:function* 會回傳 同時具備 Iterator 與 Iterable 的物件。yield 用來逐步產生值;yield* 可委派到另一個可迭代。

function* fib(limit = 10) {
  let a = 0, b = 1;
  while (limit-- > 0) {
    yield a;
    [a, b] = [b, a + b];
  }
}

console.log([...fib(5)]); // [0,1,1,2,3]


雙向溝通:

function* ask() {
  const name = yield "你的名字?";
  yield `Hello, ${name}`;
}

const g = ask();
console.log(g.next().value);      // "你的名字?"
console.log(g.next("JS").value);  // "Hello, JS"


清理與錯誤傳遞:

function* task() {
  try {
    yield 1;
    yield 2;
  } finally {
    console.log('cleanup');
  }
}

const it = task();
console.log(it.next()); // {value:1, done:false}
console.log(it.return()); // 觸發 finally,並結束


非同步迭代:AsyncIterator 與 for await...of

當資料逐批、非同步到達(如 API 分頁、檔案串流),用 Async Iterator 最自然。實作 obj[Symbol.asyncIterator](),迭代時搭配 for await...of。

const api = {
  async *[Symbol.asyncIterator]() {
    let page = 1;
    while (page <= 3) {
      const data = await fetch(`/v1/items?page=${page}`).then(r => r.json());
      if (data.items.length === 0) return; // 沒資料就結束
      yield data.items;
      page++;
    }
  }
};

(async () => {
  for await (const batch of api) {
    console.log('批次資料', batch);
  }
})();


Node.js 也把串流與 Async Iterator 打通,例如 Readable.from(iterable),或對 stream.Readable 使用 for await...of 來讀資料。


惰性運算工具:以迭代組出 map / filter / take / drop / chunk

用 Iterables 可做「不創建中間大陣列」的資料管線。

const mapIter = (iterable, fn) => ({
  [Symbol.iterator]() {
    const it = iterable[Symbol.iterator]();
    return {
      next() {
        const { value, done } = it.next();
        if (done) return { done: true };
        return { value: fn(value), done: false };
      },
      return() { return it.return ? it.return() : { done: true }; }
    };
  }
});

const filterIter = (iterable, pred) => ({
  [Symbol.iterator]() {
    const it = iterable[Symbol.iterator]();
    return {
      next() {
        while (true) {
          const { value, done } = it.next();
          if (done) return { done: true };
          if (pred(value)) return { value, done: false };
        }
      },
      return() { return it.return ? it.return() : { done: true }; }
    };
  }
});

const take = (iterable, n) => ({
  [Symbol.iterator]() {
    const it = iterable[Symbol.iterator]();
    let remaining = n;
    return {
      next() {
        if (remaining-- <= 0) return { done: true };
        return it.next();
      },
      return() { return it.return ? it.return() : { done: true }; }
    };
  }
});

// 用法:只取平方後的前三個偶數
const src = [1,2,3,4,5,6];
const pipe = take(mapIter(filterIter(src, x => x % 2 === 0), x => x * x), 3);
console.log([...pipe]); // [4, 16, 36]


效能、記憶體與測試除錯心法

惰性優勢:不建立中間陣列,逐筆計算,對大資料友善。

短路(short-circuit):配合 take(n)、break 可提早停止,避免多餘工作。

測試:把迭代器「抽離資料來源」,以假資料(小集合)驗證 next() 行為;測 done 邏輯尤其重要。

清理:支援 return()/finally,確保出錯或提前結束時釋放資源。


常見錯誤與雷點

1.    把 for...in 當 for...of 用

for (const k in [10, 20]) console.log(k); // "0","1"(鍵)不是值
for (const v of [10, 20]) console.log(v); // 10, 20(值)


2.    「不是可迭代」導致 TypeError: obj is not iterable

const obj = { a: 1, b: 2 };
// [...obj] // TypeError
const iterableObj = {
  data: obj,
  [Symbol.iterator]() {
    const entries = Object.entries(this.data);
    let i = 0;
    return {
      next() {
        if (i < entries.length) return { value: entries[i++], done: false };
        return { done: true };
      }
    };
  }
};
console.log([...iterableObj]); // [ ['a',1], ['b',2] ]


3.    實作 next() 回傳格式錯誤

next() 必須回傳 物件,且含 done 布林欄位,否則報錯或行為不定。


4.    覆寫 Symbol.iterator 卻回傳 this

若迭代狀態存在於 this,多次迭代會競爭同一指標,導致結果錯亂。請回傳 新 迭代器物件。


5.    迭代時修改集合

對 Array/Map/Set 邊走訪邊新增/刪除,結果可能不如預期(有時跳過元素、有時重複)。建議:先複製快照或收集要處理的鍵再迭代。

const set = new Set([1,2,3]);
for (const v of set) {
  if (v === 2) set.add(4); // 4 是否被訪問取決於實作時機,避免這樣做
}


6.    forEach 不支援 break/continue、也不搭配 await

// 錯誤期待:這不會等待每個 async callback
[1,2,3].forEach(async (x) => {
  await new Promise(r => setTimeout(r, 100));
  console.log(x);
});
// 正解
for (const x of [1,2,3]) {
  await new Promise(r => setTimeout(r, 100));
  console.log(x);
}


7.     忽略 return() 導致資源未釋放

自訂迭代器若握有資源(檔案、連線),沒實作 return(),用戶在中途 break 時無法清理。


8.    字串與 Unicode 代理對(Surrogate Pairs)

用索引走字串會切裂 emoji;用 for...of 就安全(以 Code Point 迭代)。

const s = 'A😊B';
console.log([...s]); // ['A','😊','B']
console.log(s[1]);   // 可能是半個代理對,錯


9.    Async Iterator 忘記 try/finally

for await...of 中若拋錯,迭代器端應以 finally 做收尾。


實戰:可中止的「分頁 API 抓取器」(Async Iterator + AbortController)

場景:你要逐頁從後端抓資料,但想要:

        邊抓邊用(不用等全部結束)

        隨時可取消(例如使用者切換頁面)

function createPagedFetcher(fetchPage) {
  return function paged({ from = 1, to = Infinity, signal } = {}) {
    return {
      async *[Symbol.asyncIterator]() {
        const controller = new AbortController();
        const composite = signal
          ? new AbortSignal.any([signal, controller.signal])
          : controller.signal;

        try {
          for (let page = from; page <= to; page++) {
            const data = await fetchPage(page, { signal: composite });
            if (!data || data.items?.length === 0) return;
            yield data.items;
          }
        } finally {
          // 確保中止底層請求或釋放資源
          controller.abort();
        }
      }
    };
  };
}

// ===== 使用範例 =====
async function fetchPage(page, { signal }) {
  const res = await fetch(`/api/items?page=${page}`, { signal });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

const paged = createPagedFetcher(fetchPage);
const ac = new AbortController();

(async () => {
  try {
    let count = 0;
    for await (const items of paged({ from: 1, to: 100, signal: ac.signal })) {
      console.log('拿到批次', ++count, items.length);
      if (count === 3) ac.abort(); // 模擬用戶中止
    }
  } catch (e) {
    if (e.name === 'AbortError') {
      console.log('已取消');
    } else {
      console.error('發生錯誤', e);
    }
  }
})();


重點:

以 Async Iterator 暴露逐批資料。

以 AbortController 實作可取消。

在 finally 中確保釋放(避免資源洩漏)。


相容性

相容性:ES2015 起,現代瀏覽器與 Node.js 均支援 Iterator/Iterable;for await...of 與 Symbol.asyncIterator 需較新版本(Node 10+,現代瀏覽器)。舊環境可考慮 Babel/TypeScript 轉譯。

Polyfill:若要在非常舊環境支援 Symbol 與迭代協定,請使用官方 polyfill(如 core-js)並確認體積與授權。


問題集

Q1.    我要遍歷一般物件?

用 Object.entries(obj) 取得可迭代陣列,或自訂 Symbol.iterator。

Q2.    何時用 Generator、何時手寫迭代器?

大多數情況用 Generator 可讀性更高;手寫適合需要細膩控制 next/return/throw 或避免引入 function* 的場景。

Q3.    Async Iterator 與 RxJS 差異?

Async Iterator 強調 拉(pull) 模式、單消費者;RxJS 強調 推(push)、多播、操作子豐富。串流事件、背壓控制需求重時,RxJS 可能更合適。


附錄:更多實用小範例

1.    用 yield* 委派迭代

function* combine(...iterables) {
  for (const it of iterables) {
    yield* it;
  }
}
console.log([...combine([1,2], new Set([3,4]), '56')]); // [1,2,3,4,'5','6']


2.    以 Generator 寫 chunk(分塊)

function* chunk(iterable, size) {
  let buf = [];
  for (const x of iterable) {
    buf.push(x);
    if (buf.length === size) {
      yield buf;
      buf = [];
    }
  }
  if (buf.length) yield buf;
}
console.log([...chunk([1,2,3,4,5], 2)]); // [[1,2],[3,4],[5]]


3.    以 Async Generator 包裝檔案逐行讀取(Node.js)

import { createReadStream } from 'node:fs';
import readline from 'node:readline';

async function* readLines(path) {
  const rl = readline.createInterface({
    input: createReadStream(path),
    crlfDelay: Infinity
  });
  try {
    for await (const line of rl) yield line;
  } finally {
    rl.close(); // 保證清理
  }
}


延伸閱讀推薦:

javaScript設計模式 : Factory Method   (工廠方法)

javaScript設計模式 : Abstract Factory(抽象工廠)

javaScript設計模式 : Builder(建造者模式)

javaScript設計模式 : Prototype(原型)

javaScript設計模式 : Singleton (單例模式)

javaScript設計模式 : Adapter(轉接器模式)

javaScript設計模式 : Bridge( 橋接模式 )

javaScript設計模式 : Composite(組合模式)

 javaScript設計模式 : Decorator(裝飾者)

javaScript設計模式 : Facade(外觀模式)

javaScript設計模式 : Flyweight(享元模式)

javaScript設計模式 : Proxy(代理模式)

javaScript設計模式 : Chain of Responsibility(責任鏈)

javaScript設計模式 : Command Pattern(命令模式)

javaScript設計模式 : Iterator(迭代器)

javaScript設計模式 : Mediator(仲裁者)

javaScript設計模式 : Memento(備忘錄)

javaScript設計模式 : Observer( 觀察者 )

javaScript設計模式 : State(狀態模式)

javaScript設計模式 : Strategy(策略模式)

javaScript設計模式 : Template Method (模板方法)

javaScript設計模式 : Visitor(訪問者模式)


張貼留言 (0)
較新的 較舊