URLSearchParams 重複參數遺失:getAll 與物件轉換排錯
搜尋頁勾了兩個標籤,網址也是 tag=js&tag=node,重新載入卻只剩一個。若中間用了 Object.fromEntries(new URLSearchParams(...)),先檢查這次物件轉換:同名 key 會覆寫,最後留下 node。
多值欄位用 getAll() 保留陣列;只允許一個值的欄位,先檢查出現次數再接受。 不要等參數已經壓平成物件才補驗證,遺失的值無法從那個物件還原。
get 拿第一個,物件轉換留下最後一個
WHATWG URL Standard 定義 get() 回傳第一筆符合名稱的值,getAll() 則依順序回傳所有值。這兩種讀法和物件轉換有不同結果:
const params = new URLSearchParams('tag=js&tag=node');params.get('tag'); // 'js'params.getAll('tag'); // ['js', 'node']Object.fromEntries(params).tag; // 'node'Object.fromEntries() 依 entries 建立物件屬性。同一個字串 key 再次出現,就會改寫同一個屬性;原本的 params 仍保留兩筆,遺失發生在新物件。
因此,前端用 get()、後端先轉物件再讀值,可能各自看到不同標籤。這個例子只證明 JavaScript API 的行為,不能推論 Laravel、PHP 或其他伺服器 parser 對重複參數的處理;前後端要另訂相同契約。
依欄位解析,再用 append 寫回
下面假設 tag 可重複、page 最多出現一次且必須是正整數。readOne() 區分缺少、空字串與重複值;頁碼驗證才決定哪些值能接受。
function readOne(params, name) { const values = params.getAll(name); if (values.length > 1) throw new TypeError(`Duplicate ${name}`); return values.length === 0 ? null : values[0];}
function parseFilters(query) { const params = new URLSearchParams(query); const rawPage = readOne(params, 'page'); const pageText = rawPage === null ? '1' : rawPage; if (!/^[1-9]\d*$/.test(pageText)) throw new TypeError('Invalid page'); const page = Number(pageText); if (!Number.isSafeInteger(page)) throw new TypeError('Invalid page'); return { tags: params.getAll('tag'), page };}
function serializeFilters({ tags, page }) { // 輸入是 parseFilters 產出的狀態;外部資料要先另做形狀驗證。 const params = new URLSearchParams(); for (const tag of tags) params.append('tag', tag); params.set('page', String(page)); return params.toString();}這個範例只序列化標籤與頁碼,會捨棄其他欄位,適合已定義的篩選表單。它不會自動去除重複標籤,也沒有替你的產品限制標籤數量或允許清單。若要保留其他 query,應複製原 params,只刪除並重建自己擁有的欄位。
不要把 new URLSearchParams({ tag: ['js', 'node'] }) 當成多值序列化。Node.js 文件 說明物件值會轉成字串,陣列在這裡成為 'js,node'。讀回得到的是一個值,不是兩個標籤。
用斷言確認往返沒有丟值
把上面三個函式和以下程式碼存成 query-check.mjs,執行 node query-check.mjs。本文維護流程於 2026-10-10 使用 macOS、Node.js v24.13.0 執行這些測試,沒有向真實 API 發送請求。
import assert from 'node:assert/strict';
const params = new URLSearchParams('tag=js&tag=node');assert.equal(params.get('tag'), 'js');assert.equal(Object.fromEntries(params).tag, 'node');assert.deepEqual(params.getAll('tag'), ['js', 'node']);
const state = parseFilters('tag=js&tag=node&page=2');assert.deepEqual(parseFilters(serializeFilters(state)), state);assert.throws(() => parseFilters('page=1&page=2'), /Duplicate page/);assert.throws(() => parseFilters('page='), /Invalid page/);assert.throws(() => parseFilters('page=1.5'), /Invalid page/);assert.throws(() => parseFilters('page=9007199254740992'), /Invalid page/);assert.equal(parseFilters('').page, 1);assert.equal(readOne(new URLSearchParams('empty='), 'empty'), '');assert.equal(readOne(params, 'missing'), null);
const collapsed = new URLSearchParams({ tag: ['js', 'node'] });assert.deepEqual(collapsed.getAll('tag'), ['js,node']);const replaced = new URLSearchParams(params);replaced.set('tag', 'css');assert.deepEqual(replaced.getAll('tag'), ['css']);assert.deepEqual(params.getAll('tag'), ['js', 'node']);console.log('重複值、頁碼與序列化往返檢查通過');最後兩個斷言也確認 set() 會把同名參數收束為一筆;要保留多個值,使用逐次 append()。這些行為可對照 MDN 的 URLSearchParams 說明。
若標籤陣列在解析後仍正確,畫面卻被舊搜尋結果覆蓋,接著檢查 fetch 搜尋回應競態。參數遺失與請求先後順序是兩個故障點,應分別用「解析後的狀態」及「最後呈現的結果」驗收。
參考資料: