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

正規化規則:

  1. scheme / host 轉小寫
  2. 預設 port 移除(http:80, https:443)
  3. trailing slash 統一
  4. query parameters 排序
  5. 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 即時雙欄:左輸入右輸出
  • %20 vs +:toggle 切換 form encoded
  • 大小寫:選 hex 大寫或小寫
  • 批次模式:每行獨立 encode
  • 整段 URL vs query:選 encodeURI 或 encodeURIComponent 等效
  • Punycode 模式:處理 IDN 域名

工具 client-side,內容不上傳。

實際使用流程:

  1. 從錯誤 log 複製 URL
  2. 開 supertools.tw/encoding/url-encode 貼到左側
  3. 點「Decode」看明文
  4. 對照預期結果找出哪段編錯

立即使用 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 過程:

  1. 前端用 fetch("/api/search?q=" + query) 直接拼字串,沒 encode
  2. URL 變成 ?q=東京涉谷,中文 byte 直接放 query
  3. 後端 nginx parse URL 時遇到非 ASCII byte,部分版本 reject 整個 request
  4. 修法:前端改用 URLSearchParams({q: query}).toString() 自動 encode
  5. 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 中被當空白。

解法:

  1. 短期:把 + 編成 %2B:https://example.com/promo?code=A%2BB%2BC
  2. 長期: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。

追查:

  1. user 端 encode 一次:hello world → hello%20world
  2. CDN 中間 proxy encode 一次:hello%20world → hello%2520world
  3. nginx 收到後 log 寫的是 hello%2520world
  4. Logstash filter 設了 urldecode,把 log 解一次 → hello%20world
  5. 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 編碼。

延伸閱讀: