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