871 字
4 分鐘

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 搜尋回應競態。參數遺失與請求先後順序是兩個故障點,應分別用「解析後的狀態」及「最後呈現的結果」驗收。

參考資料:

WHATWG:URLSearchParams

MDN:URLSearchParams

MDN:Object.fromEntries()

Node.js:new URLSearchParams(obj)

URLSearchParams 重複參數遺失:getAll 與物件轉換排錯
https://laplusda.com/posts/urlsearchparams-duplicate-keys-object-fromentries/
作者
Zero
發佈於
2026-10-10
許可協議
CC BY-NC-SA 4.0