URL 編碼(Percent Encoding)完整教學:何時要編、安全性、實例
寫給:API URL 中包中文導致 400 錯誤的後端工程師、想在 query string 傳特殊符號的前端,以及好奇
%20為什麼代表空白的所有人。
URL 編碼(又稱 Percent Encoding)是 RFC 3986 定義的編碼方案,讓你能在 URL 中安全傳遞任意字元——包括空白、中文、?、&、+ 等保留字元。看似簡單,實際每年都有大量 bug 來自編碼錯誤:double encoding、未對 path 編碼、把 + 當空白、UTF-8 編碼錯誤。本文 12 分鐘把 URL 編碼規則、踩坑、實戰技巧一次講清楚。
1. URL 編碼是什麼
URL 規範規定哪些字元可以直接出現在 URL,哪些必須編碼。Percent Encoding 把不能直接用的 byte 轉成 % + 兩位 hex:
空白 → %20
中文「你」→ %E4%BD%A0(UTF-8 三 byte)
? → %3F
& → %26
例如:
https://example.com/search?q=hello world&lang=中文
↓ URL encode
https://example.com/search?q=hello%20world&lang=%E4%B8%AD%E6%96%87
1.1 RFC 3986 字元分類
Reserved characters: :/?#[]@!$&'()*+,;=
Unreserved characters: A-Z a-z 0-9 - _ . ~
- Unreserved:永遠不用編碼
- Reserved:在 URL 結構中有特殊意義,作為 data 時要編碼
- 其他(含中文、emoji、空白):永遠要編碼
例如 ? 是 query string 分隔符,但若你的搜尋詞含 ? 就要編成 %3F。
1.2 URL 的五個區段
https://user:pass@example.com:8080/path/to/page?query=value#fragment
└─┬─┘ └─┬──┬─┘ └────┬─────┘ └┬┘└─────┬─────┘└────┬────┘└──┬──┘
scheme user info host port path query fragment
每段對「保留字元」定義略有不同。例如:
- path 中
/不要編(會被當分隔符讀) - query 中
=&不要編 - path 中
?要編(否則被當 query 起點)
2. 5 個核心觀念
2.1 何時要 encode
| 場景 | 是否 encode |
|---|---|
| query string value 含特殊字元 | ✓ |
| path 中含中文 / 空白 | ✓ |
mailto: link 的 subject / body |
✓ |
| 表單 POST body(application/x-www-form-urlencoded) | ✓(瀏覽器自動) |
| HTTP header 值 | ✗(用其他編碼如 base64) |
2.2 UTF-8 編碼規則
中文、emoji 等非 ASCII 字元需先轉 UTF-8 byte,再每個 byte percent encode:
「中」 UTF-8 = 0xE4 0xB8 0xAD
URL encode = %E4%B8%AD
import urllib.parse
urllib.parse.quote("中")
# '%E4%B8%AD'
舊系統用 BIG5 / GB2312 編碼會產不同序列,導致跨系統解析失敗。現代一律 UTF-8。
2.3 + vs %20
歷史包袱:HTML form application/x-www-form-urlencoded 規範空白編成 +,但 RFC 3986 規定空白編成 %20。兩者並存:
| 規範 | 空白編成 |
|---|---|
application/x-www-form-urlencoded(form data) |
+ |
| URL path / query value | %20 |
JS 兩個函式對應:
encodeURIComponent("hello world") // 'hello%20world' ← RFC 標準
new URLSearchParams({q: "hello world"}).toString()
// 'q=hello+world' ← form-encoded
Server 端要根據 Content-Type 解碼。Python urllib.parse.unquote_plus() 同時處理 + 與 %20。
2.4 encodeURI vs encodeURIComponent
JavaScript 內建三個函式,差異常被搞混:
encodeURI("https://例.com/path?q=hello world")
// 'https://%E4%BE%8B.com/path?q=hello%20world'
// 保留 :/?#[]@!$&'()*+,;= (URL 結構字元)
encodeURIComponent("https://例.com/path?q=hello world")
// 'https%3A%2F%2F%E4%BE%8B.com%2Fpath%3Fq%3Dhello%20world'
// 全部 reserved 字元都編
encodeURI("中文")
// '%E4%B8%AD%E6%96%87'
規則:
- 編整段 URL →
encodeURI - 編 query string value / path segment →
encodeURIComponent - 大多數情境用
encodeURIComponent,因為要編&=等保留字
2.5 大小寫
%E4 vs %e4 語意等價,但 RFC 3986 推薦大寫 hex。不同 lib 預設不同:
- Python
urllib.parse.quote→ 大寫 - JS
encodeURIComponent→ 大寫 - 舊版 PHP → 小寫
server 端比對時最好統一 .upper() 或 .lower() 後比較。
3. 實戰範例
範例 1:Python encode / decode
from urllib.parse import quote, unquote, quote_plus, unquote_plus
# 標準(空白 → %20)
quote("hello world") # 'hello%20world'
quote("中文") # '%E4%B8%AD%E6%96%87'
# form encoded(空白 → +)
quote_plus("hello world") # 'hello+world'
# decode
unquote("hello%20world") # 'hello world'
unquote_plus("hello+world") # 'hello world'
unquote_plus("hello%20world") # 'hello world' 也能處理 %20
# 保留 / 的編碼
quote("path/to/file", safe="") # 'path%2Fto%2Ffile'
quote("path/to/file") # 'path/to/file' (預設 safe="/")
範例 2:建構 query string
from urllib.parse import urlencode
params = {
"q": "hello world",
"lang": "zh-TW",
"tags": ["python", "tutorial"],
}
qs = urlencode(params, doseq=True)
print(qs)
# q=hello+world&lang=zh-TW&tags=python&tags=tutorial
full_url = f"https://api.example.com/search?{qs}"
範例 3:JavaScript 處理 URL
const params = new URLSearchParams({
q: "hello world",
lang: "zh-TW",
});
params.append("tag", "python");
params.append("tag", "tutorial");
const url = `https://api.example.com/search?${params}`;
// https://api.example.com/search?q=hello+world&lang=zh-TW&tag=python&tag=tutorial
// 解析
const parsed = new URL(url);
parsed.searchParams.get("q"); // "hello world"
parsed.searchParams.getAll("tag"); // ["python", "tutorial"]
範例 4:bash curl 自動編碼
# --data-urlencode 自動編
curl --get https://api.example.com/search \
--data-urlencode "q=hello world" \
--data-urlencode "lang=zh-TW"
# 結果: https://api.example.com/search?q=hello%20world&lang=zh-TW
純編碼工具:
# Linux
echo -n "hello world" | jq -sRr @uri
# hello%20world
# macOS(Python 內建)
python3 -c "import urllib.parse; print(urllib.parse.quote(input()))"
範例 5:mailto: 連結
<a href="mailto:hello@example.com?subject=您好&body=這是一封測試信">寄信</a>
實際傳給瀏覽器要 encode:
<a href="mailto:hello@example.com?subject=%E6%82%A8%E5%A5%BD&body=%E9%80%99%E6%98%AF%E4%B8%80%E5%B0%81%E6%B8%AC%E8%A9%A6%E4%BF%A1">寄信</a>
JS 動態產:
const subject = "您好";
const body = "這是一封測試信";
const link = `mailto:hello@example.com?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
4. 進階技巧
4.1 punycode(IDN 國際化域名)
URL 編碼只處理 path / query,域名部分用 punycode(RFC 3492):
中文.tw → xn--fiq228c.tw
döda.com → xn--dda-sna.com
import idna
idna.encode("中文.tw").decode()
# 'xn--fiq228c.tw'
idna.decode("xn--fiq228c.tw")
# '中文.tw'
4.2 安全:防 SSRF 與 open redirect
接收 user 提交的 URL 要先驗證:
from urllib.parse import urlparse
def validate_url(url):
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError("Only HTTP/HTTPS allowed")
if parsed.hostname in ("localhost", "127.0.0.1", "169.254.169.254"):
raise ValueError("Forbidden host")
return url
169.254.169.254 是 AWS metadata endpoint,被 SSRF 取得會洩漏 IAM credentials。
4.3 防 double encoding
原始: hello world
編一次: hello%20world
編兩次: hello%2520world ← 錯!
解一次: hello world
常見 bug:URL 已 encoded 被誤判沒 encode 又 encode 一次。
def safe_encode(s):
# 用 try unquote 判斷是否已 encoded
if "%" in s and s == quote(unquote(s)):
return s # 已 encoded
return quote(s)
但這個 heuristic 不完美。最佳實踐:在 layer boundary 明確規範 encoded vs raw,並用 type system(TypeScript branded type)追蹤。
4.4 自訂 safe characters
# 保留 / 與 :
quote("path/to:file", safe="/:")
# 'path/to:file'
# 完全不保留
quote("path/to/file", safe="")
# 'path%2Fto%2Ffile'
API 設計時要明確 spec 哪些字元保留。
4.5 URL 正規化(normalize)
同樣語意的 URL 可能有不同表現:
https://EXAMPLE.com/path/?b=2&a=1
https://example.com:443/path?a=1&b=2
https://example.com/path
正規化規則:
- scheme / host 轉小寫
- 預設 port 移除(http:80, https:443)
- trailing slash 統一
- query parameters 排序
- fragment 視場景保留或移除
from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode
def normalize(url):
p = urlparse(url)
qs = urlencode(sorted(parse_qsl(p.query)))
netloc = p.netloc.lower()
return urlunparse((p.scheme.lower(), netloc, p.path, "", qs, ""))
cache key、ID 用要先正規化。
5. 常見錯誤與解法
錯誤 1:URL 中文 400 錯誤
症狀:https://api/?q=中文 失敗。
根因:未編碼,HTTP request line 不允許非 ASCII。
解法:用 encodeURIComponent / urllib.parse.quote。
錯誤 2:+ 被解成空白
症狀:query 傳 1+2=3,server 收到 1 2=3。
根因:server 用 form-decode(+ → 空白)。
解法:客戶端把 + 編成 %2B:encodeURIComponent("+") → %2B。
錯誤 3:path 中 / 被當分隔符
症狀:要傳 category/sub,server 解成兩個 path segment。
根因:/ 沒編碼。
解法:用 encodeURIComponent 或 quote(s, safe="")。
錯誤 4:double encoding
症狀:URL 變成 %2520%2520,解開後是 %20,再解才是空白。
根因:encode 兩次。
解法:明確 layer 設計,型別 + lint 防範。
錯誤 5:跨系統編碼不一致
症狀:Python encode 後 Java decode 失敗。 根因:UTF-8 vs Latin-1 vs BIG5 不同。 解法:所有 layer 明確指定 UTF-8。
quote("中", encoding="utf-8") # 預設就是 utf-8
6. 用 SuperTools 線上版完成相同任務
不想開 REPL?打開 SuperTools URL 編碼工具:
- Encode / Decode 即時雙欄:左輸入右輸出
%20vs+:toggle 切換 form encoded- 大小寫:選 hex 大寫或小寫
- 批次模式:每行獨立 encode
- 整段 URL vs query:選
encodeURI或encodeURIComponent等效 - Punycode 模式:處理 IDN 域名
工具 client-side,內容不上傳。
實際使用流程:
- 從錯誤 log 複製 URL
- 開 supertools.tw/encoding/url-encode 貼到左側
- 點「Decode」看明文
- 對照預期結果找出哪段編錯
立即使用 SuperTools URL 編碼工具:https://supertools.tw/encoding/url-encode
7. 其他語言實作
Python
from urllib.parse import quote, unquote
quote("hello world") # 'hello%20world'
unquote("hello%20world") # 'hello world'
JavaScript
encodeURIComponent("hello world"); // 'hello%20world'
decodeURIComponent("hello%20world"); // 'hello world'
Go
import "net/url"
url.QueryEscape("hello world") // "hello+world"
url.PathEscape("hello world") // "hello%20world"
url.QueryUnescape("hello+world")
Rust
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
let encoded = utf8_percent_encode("hello world", NON_ALPHANUMERIC).to_string();
Bash
python3 -c "import urllib.parse; print(urllib.parse.quote(input()))"
jq -sRr @uri <<< "hello world"
8. FAQ
Q1:query string 一定要 encode 嗎? 含特殊字元 / 中文 / 空白 → 一定要。純 ASCII 字母數字 → 不用。
Q2:path 跟 query 編碼規則一樣嗎?
不一樣。path 允許 /、query 允許 = &。
Q3:encodeURIComponent 跟 RFC 3986 完全一致嗎?
JS 的 encodeURIComponent 不編 !'()*,與 RFC 3986 略異。完全合規用:
function strictEncode(s) {
return encodeURIComponent(s).replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
}
Q4:cookie value 要 encode 嗎? cookie 規範與 URL 不同。建議 encode 避免特殊字元,但解碼時要對應。
Q5:base64 後還要 URL encode 嗎?
標準 base64 含 + /,放 URL 要再 encode 或改 URL safe base64(見 Base64 完整指南)。
9. 實戰案例研究
案例 1:跨國電商搜尋亂碼
某台灣電商網站接國際物流 API,user 搜尋「東京涉谷」回傳 500 錯誤。debug 過程:
- 前端用
fetch("/api/search?q=" + query)直接拼字串,沒 encode - URL 變成
?q=東京涉谷,中文 byte 直接放 query - 後端 nginx parse URL 時遇到非 ASCII byte,部分版本 reject 整個 request
- 修法:前端改用
URLSearchParams({q: query}).toString()自動 encode - URL 變成
?q=%E6%9D%B1%E4%BA%AC%E6%B6%89%E8%B0%B7,後端正常 decode 為 UTF-8 中文
教訓:永遠用 lib 拼 URL,不要字串相加。
案例 2:QR Code 含 + 號掃描後失效
行銷活動發 QR Code 包含 https://example.com/promo?code=A+B+C,user 掃描後 server log 看到 code=A B C(空白)。
根因:+ 在 query 中被當空白。
解法:
- 短期:把
+編成%2B:https://example.com/promo?code=A%2BB%2BC - 長期:API 改用 path parameter 而非 query:
https://example.com/promo/A+B+C(path 中+不被當空白)
案例 3:log analysis 雙重 decode
某公司用 ELK stack 收集 nginx log。發現某 URL 在 Kibana 顯示為 hello world(已 decode),但實際請求是 hello%2520world。
追查:
- user 端 encode 一次:
hello world→hello%20world - CDN 中間 proxy encode 一次:
hello%20world→hello%2520world - nginx 收到後 log 寫的是
hello%2520world - Logstash filter 設了
urldecode,把 log 解一次 →hello%20world - Kibana 渲染又 decode 一次 →
hello world
實際 user 請求其實已被 double encode,但 log pipeline 連 decode 兩次掩蓋問題。修法:CDN 設定不要重 encode 已 encoded URL。
10. 結論 + 延伸閱讀
URL 編碼是 Web 開發的隱形基石。掌握「何時 encode」「+ vs %20」「encodeURI vs encodeURIComponent」三個關鍵後,多數編碼 bug 可以避免。下次面對亂碼 URL 時,先到 SuperTools 線上工具一鍵 decode 看明文,10 秒判斷哪層 encode 出錯。記住三條準則:用 lib 不要字串拼接、明確 layer boundary 防 double encode、所有 byte 統一 UTF-8 編碼。
延伸閱讀: