1893 字
9 分鐘

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'。讀回得到的是一個值,不是兩個標籤。

只更新 tag 與 page,保留其他 query 和 hash#

如果同一個網址還有 lang、追蹤參數或其他元件管理的欄位,前面的 serializeFilters() 會把它們捨棄。這時先用 new URL() 複製完整網址,再局部更新擁有的欄位。URLSearchParams 本身不解析完整 URL,不能直接把 https://... 丟進它的字串建構子;規則可對照 MDN 的 URLSearchParams 範例。

以下函式接收絕對網址與前面 parseFilters() 產生的狀態;外部任意物件仍須先做形狀驗證。把它接在前三個函式後面:

function updateFilterUrl(currentUrl, state) {
const url = new URL(currentUrl);
url.searchParams.delete('tag');
for (const tag of state.tags) url.searchParams.append('tag', tag);
url.searchParams.set('page', String(state.page));
return url;
}

瀏覽器整合時,可傳入 location.href,再依產品需求決定導航或更新 history;函式本身不變動畫面,也不發出請求。刪除全部舊 tag 再逐筆加入,能避免反覆切換篩選器後累積上次的標籤;set('page', ...) 則把舊的多個頁碼收束為目前唯一值。

值要以未編碼字串傳給 append()。例如 C++ 應直接傳入,讓序列化處理加號;若先做 encodeURIComponent(),百分比符號還會再次編碼。相反地,將未編碼的 C++ 直接拼成 query 字串後解析,其中的 + 會被解讀成空白。這是 URL Standard 的 form-urlencoded 解析與序列化規則,不是 getAll() 丟值。

將以下測試接在本文原本的 assertion 後面,與所有函式一起執行:

const originalUrl = new URL(
'https://example.com/search?lang=zh-TW&utm_source=mail&tag=old&page=9#results',
);
const nextState = { tags: ['C++', '台灣 & JS', 'js', 'js'], page: 2 };
const nextUrl = updateFilterUrl(originalUrl, nextState);
assert.deepEqual(parseFilters(nextUrl.search), nextState);
assert.equal(nextUrl.searchParams.get('lang'), 'zh-TW');
assert.equal(nextUrl.searchParams.get('utm_source'), 'mail');
assert.equal(nextUrl.hash, '#results');
assert.equal(originalUrl.searchParams.get('tag'), 'old');
assert.equal(originalUrl.searchParams.get('page'), '9');
const clearedUrl = updateFilterUrl(nextUrl, { tags: [], page: 1 });
assert.deepEqual(clearedUrl.searchParams.getAll('tag'), []);
assert.equal(clearedUrl.searchParams.get('lang'), 'zh-TW');
assert.deepEqual(new URLSearchParams('tag=C++').getAll('tag'), ['C ']);
console.log('局部更新、多值與特殊字元往返檢查通過');

這裡的「保留」是保留其他欄位的解析值與 hash,不保證原始 query 的位元組與順序完全不變。URL.searchParams 改寫後可能將 %20 序列化成 +,也可能改變其他字元的表示方式;被重建的 tag 位置也可能改變。若網址包含依原始字串計算的簽章,應使用該服務定義的修改/重新簽署流程,不能直接套用此函式。

本節函式與全部 query assertion 於 2026-10-18 由維護流程在 macOS、Node.js v24.13.0 執行通過;沒有操作瀏覽器 history、呼叫後端 parser 或驗證任何第三方簽章服務。

解碼只做一次,避免改變查詢值#

URLSearchParams.get() 拿到的已是解碼後的值。對它再做 decodeURIComponent(),可能把使用者原本想搜尋的字面 %2B 改成 +;原始值是 % 時,甚至會拋出 URIError。依 URL Standard 的解析步驟,先處理原始 query 中的加號,再做百分比解碼。不要在結果上補 .replaceAll('+', ' '),那會連真正的加號一起破壞。

以下是獨立的往返檢查,存成 query-encoding-check.mjs 後執行 node query-encoding-check.mjs:

import assert from 'node:assert/strict';
for (const value of ['C++', '台灣 JS', '%2B', '%', 'a&b=c']) {
const params = new URLSearchParams();
params.append('q', value); // 交入原始值,不先編碼。
const decoded = new URLSearchParams(params.toString()).get('q');
assert.equal(decoded, value);
}
assert.equal(new URLSearchParams('q=a+b%2Bc').get('q'), 'a b+c');
assert.equal(new URLSearchParams('q=%252B').get('q'), '%2B');
assert.equal(decodeURIComponent('%2B'), '+'); // 多解一次就改變查詢意義。
assert.throws(() => decodeURIComponent('%'), URIError);
console.log('單次解碼與特殊字元往返通過');

頁碼則維持「解碼後必須整段符合正整數格式」的契約。page=2%0A 會解碼成包含結尾換行的字串;前面的 /^[1-9]\d*$/ 沒有 m,會拒絕這種輸入。不要為了容忍它而加入 multiline 旗標或先 trim:Number() 雖然接受周圍空白,這裡的網址契約並不接受。測試應把這些格式明確列出。

把以下斷言接到原本的 query-check.mjs,確認格式不合契約時直接拒絕:

for (const query of [
'page=2%0A', 'page=2%0D', 'page=2%0D%0A',
'page=2%E2%80%A8', 'page=%202', 'page=2%20',
]) {
assert.throws(() => parseFilters(query), /Invalid page/);
}
assert.equal(parseFilters('page=2').page, 2);
assert.equal(/^[1-9]\d*$/m.test('2\n'), true); // 不要把此旗標加到頁碼驗證。

2026-10-29 維護流程以 macOS、Node.js v24.13.0 執行新舊 query 範例及以上回歸測試通過。這是 JavaScript 解析契約的檢查;沒有測後端框架 parser、瀏覽器導航或簽章服務。

用斷言確認往返沒有丟值#

把上面三個函式和以下程式碼存成 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)

MDN:Assertions 與輸入結尾

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