搜尋框輸入「C++」,結果頁卻顯示「C 」。網址看起來也有兩個加號,為什麼程式讀出來就變成空白?問題常出在把使用者的值直接接進 query string:URLSearchParams解析這類查詢參數時,會把原始加號解讀成空白。
保留真正的加號,做法是把原始值交給URLSearchParams,由它負責編碼。不要先手動拼網址,也不要先encodeURIComponent再append。這兩種處理順序,會讓加號消失或讓百分號被編碼第二次。
同樣是C++,傳入方式決定結果
下面的例子可在瀏覽器Console或支援URLSearchParams的Node.js執行。第一段直接把查詢字串交給建構子,兩個加號被解析為兩個空白;第二段把值當成資料加入,序列化時才把加號變成%2B。
const broken = new URLSearchParams('q=C++');
console.log(JSON.stringify(broken.get('q')));
// "C "
const params = new URLSearchParams();
params.append('q', 'C++');
console.log(params.toString());
// q=C%2B%2B
console.log(params.get('q'));
// C++
這不是JavaScript把所有加號都改掉,也不是網址任何位置都遵守同一條規則。這裡談的是URLSearchParams採用的application/x-www-form-urlencoded查詢參數解析與序列化。真正的空白序列化為加號,真正的加號則需要百分比編碼,兩者才能分開。
若你已經拿到一段完整query string,建構子會把它當成已編碼的資料解析。如果你手上拿的是使用者輸入、資料庫值或程式算出的文字,就應把它當成原始值交給append或set。先辨認現在拿到的是哪一層,才不會解碼或編碼兩次。
建立網址時,讓URL管理路徑與查詢參數
站內搜尋可以先建立URL,再使用searchParams設定值。這樣遇到加號、空白、&或中文時,不必自己判斷哪些字元會切開下一個參數。下例用示範網址,不會發出網路請求。
const url = new URL('https://example.com/search');
url.searchParams.set('q', 'C++ & template');
url.searchParams.set('page', '1');
console.log(url.search);
// ?q=C%2B%2B+%26+template&page=1
const received = new URL(url.href);
console.log(received.searchParams.get('q'));
// C++ & template
畫面顯示查詢內容時,使用解析後的值,例如input.value或textContent。不要把它直接塞進innerHTML;正確編碼網址,並沒有讓輸入內容變成可安全執行的HTML。網址組裝、HTML顯示與後端搜尋,是不同的處理位置,應各自使用合適的方法。
例子中的page是字串,不代表它已經成為有效頁碼。後端仍要檢查是否為允許範圍的整數,搜尋詞也要按產品需求限制長度。URLSearchParams負責結構與編碼,沒有替你完成資料驗證或存取權限檢查。
不要把encodeURIComponent結果再當原始值加入
常見修法是先把C++變成C%2B%2B,再交給append。這時URLSearchParams收到的原始資料已包含百分號,會把百分號編碼成%25,結果多出一層。接收端解析一次後,得到的是帶有%2B的文字,並不是原本的C++。
const twice = new URLSearchParams();
twice.set('q', encodeURIComponent('C++'));
console.log(twice.toString());
// q=C%252B%252B
console.log(twice.get('q'));
// C%2B%2B
encodeURIComponent本身有它的用途,但不要把它和URLSearchParams疊在同一個值上。當舊程式已經明確手動建立某個URL元件時,可以依該元件規則使用編碼;若改成URLSearchParams管理query,就讓它接收原始鍵名與值。把這個交接位置寫清楚,比在結果上不斷replace更容易維護。
同樣地,get已經回傳解析後的值,通常不應再decodeURIComponent一次。例如使用者本來搜尋字面文字「%2B」,解析一次正好保留這三個字元;再解碼就會把它變成加號,改掉原意。不要為了修一個案例,讓另一類搜尋詞出錯。
同名參數要先決定是否允許多個值
append會新增一項,同名鍵可以重複。set則設定該鍵的值,並移除該鍵其他既有項目。若篩選器允許多個分類,重複鍵可能正是你要的結構;單一搜尋詞則通常用set較清楚。讀取時get只取第一項,要取得全部才用getAll。
const filters = new URLSearchParams();
filters.append('tag', 'C++');
filters.append('tag', 'A+B');
console.log(filters.getAll('tag'));
// ["C++", "A+B"]
filters.set('tag', 'Web');
console.log(filters.getAll('tag'));
// ["Web"]
後端是否把重複鍵解讀成陣列,取決於框架與API約定,不能只看到瀏覽器getAll正常,就假設伺服器也照相同方式讀取。多選篩選最好連同請求與後端結果一起測,確定分享網址、重新整理與返回頁面時都能還原選項。
用往返測試檢查,不只看網址是否漂亮
分享搜尋網址時,還要確認重新開啟頁面後,輸入框回填的是解析後的原始值。假設網址帶著q=C%2B%2B,get回傳C++,輸入框就應顯示C++;若直接把query字串截出來填入,就會讓使用者看到百分比編碼。反過來,把輸入框中的文字重新送出時,也應重新交給set,而不是把先前編碼片段與這次輸入混在一起。
不要用全域replace把所有加號改成%2B來修網址。完整URL還包含路徑、其他參數與片段,這些位置的字元規則未必相同;已經正確代表空白的加號也會一起被改掉。可以處理單一原始值時,就在那個位置處理,避免整條網址被粗略替換。若只能接到外部已編碼字串,先確認它的格式約定,而不是猜哪一個加號原本是空白。
維護舊程式時,可以把組裝入口集中到一個小函式:輸入原始搜尋詞與頁碼,回傳URL物件或href。不要讓每個按鈕各自拼一份query,也不要有的呼叫者傳原始值、有的傳encodeURIComponent結果。函式參數是否已編碼應清楚固定,呼叫處不必知道細節;這樣新增中文或特殊符號時,只需測一個入口。
另外,URLSearchParams建構子不是完整網址解析器。把https://example.com/search?q=C++直接交給它,並不會先替你分離路徑與query。拿到完整網址時用new URL,拿到location.search時才適合直接建立URLSearchParams。這個差別與加號問題常一起出現,先把資料類型分清楚,會少掉一批看似有參數卻get不到的錯誤。
本機Node.js案例以C++、A+B、a & b和中文搜尋詞驗證原始值到query再解析的往返。正式功能也可以用相同方法:先保存原始值,序列化後重新解析,再比較是否完全相同。測試清單至少包含空白、加號、&、百分號與中文,避免只有英文單字時看不出問題。
排查實際網站時,分別記下輸入框的值、送出的完整URL、瀏覽器解析結果,以及後端讀到的值。若瀏覽器往返正確,後端卻不同,接著查框架的query parser、代理轉寫或應用程式額外decode;若出站網址已是q=C++,先修組裝位置。每次只調整有證據出錯的一層。
URL.search與searchParams重新序列化,有時會改變空白或其他字元的表示方式,例如%20改為加號。值相同不代表網址位元組一定相同。若網址由第三方簽署,或者簽名依賴原始query的字節與順序,不能隨手解析後重排;需要遵守該服務的簽名約定。一般搜尋網址可用解析後的值判斷,簽名網址則另有精確的表示要求。

評論0