关于编码的那些事 - URL 编码
背景
Web 项目中经常会遇到处理 URL 中 Query 的情况,来看下下面问题你有疑惑吗?
- 项目中发现会用到
qs
、query-string
、URLSearchParams
、甚至querystring
几种不同的库,其到底差异在哪里,我该用哪个? - 在 query 中 key=a&key=b 这种情况 key 取值是什么?和 key[]=a&key[]=b 有区别嘛?
- 在 query 中会有结构如 %HH 的数据,为什么是这样形式的?我们为什么要使用 encodeURIComponent 进行编码?和过时的 escape 又有何区别?
Content-type
中x-www-form-urlencoded
的取值,是怎么一回事?
于是梳理一下关于 URL Query 的相关知识点,用来去伪解惑。
URL QueryString
首先介绍下 Query String 的基本概念,这是一切问题的开始。下面是 wiki 的描述:
A query string is a part of a uniform resource locator (URL) that assigns values to specified parameters.
通常的理解就是 URL 中问号(?)后面的部分,其设计最初是用做 HTML form 表单提交时的传参。
基本结构
下面我们看下 query 的基本结构 field1=value1&field2=value2&field3=value3...
包含了如下标准:
- Query String 由一组键值对(
field-value
)组成; - 每组键值对的
field
和value
用=
分割; - 每组数据用
&
分割;
补充个冷知识:除了使用&
分割每对数据外,W3C 曾在 1999 年建议所有 Web 服务器同时支持分号;
分割符:
We recommend that HTTP server implementors, and in particular, CGI implementors support the use of ";" in place of "&" to save authors the trouble of escaping "&" characters in this manner.
但在 2014 年以来,就只建议使用 &
作为分隔符了。也就目前我们用到的方式。
- 允许多个
value
被关联到同一个field
上,但field
如何取值,其实并无明确的处理标准。
例如:field=a&field=b
时,field 的值应该是 a、b、['a', 'b']、'a, b'
并无任何权威解释。
关于处理标准这点实在令人出乎意料。通常这类情况会按照数组的方式处理,即 field 值为 ['a', 'b']
,但这仅是不同的框架的决定了如何实现而已。
关于这个问题可以前往 stackoverflow 上查看。
数据编码
前面定义好了整体结构,接下来我们看下数据是如何在 query 中传输的。
由于某些字符集(如中文)和在 URL 中有特殊含义的字符(如 空格、%、&、=、?、# 等)无法直接在 Query String 中使用,因此使用了一种叫做「百分号编码 Percent-encoding」的方式先将这类特殊字符进行编码后,再进行传输。
其基本结构就是 % + 2 个 16 进制数字(一个 Byte 的内容),范围 %00 - %FF。
具体规则如下:
- 对保留字符进行编码,具体对应如下:
! | # | $ | & | ' | ( | ) | * | + | , | / | : | ; | = | ? | @ | [ | ] |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
%21 | %23 | %24 | %26 | %27 | %28 | %29 | %2A | %2B | %2C | %2F | %3A | %3B | %3D | %3F | %40 | %5B | %5D |
其对应的就是这些字符的 ASCII 编码的 16 进制格式;
- 如下非保留字符不进行编码,包含:
[A-Z]
、[a-z]
、[0-9]
、-
、_
、.
、~
; %
百分号编码为%25
;- 空格编码为
+
或%20
; - 其余字符数据使用某种编码方式转换为字节流,再用百分号编码
%HH
方式表示。 这里需要注意的是,由于早期规范中未明确应使用何种编码,所以会导致如果不明确说明使用何种编码,数据的解析会有歧义。因此在 2005 年发布的 RFC 3986 建议是先转成 UTF-8 编码,再对每个字节进行%HH
的编码。
注意,如果使用 from 表单 action 方式时,具体编码会根据 meta 头的 charset 的选择。
当然上述只是标准,实践中 JavaScript 内置了使用 UTF-8 编码的 encodeURI
/encodeURIComponent
函数,大大简化的编码过程。
关于指定编码,这里有个有趣的事情:
在使用百度时,你会发现 URL 中有个 ie 参数,其实含义就是 Input Encoding(对,不是 IE 浏览器),目的就是指定关键词 wd 的编码格式。曾默认是 GB2312(因为当时很多网站还使用 GB2312 编码),当然现在已经默认成 UTF-8 。(不过百度结果里依然有不少文章还在说 ie 的默认值是 GB2312 😂)
可以用 www.baidu.com/s?wd=%E4%B8… 和 www.baidu.com/s?wd=%E4%B8… 来感受下他们的差异吧~
编码实践
这一节我们挑重点地对比下各类 Query String 的函数库,了解老虎老鼠的差异,避免开发时傻傻分不清楚。
以下仅对常用 API 的部分用法做演示,更多用法可自行查找。
瑞士军刀 qs
A querystring parsing and stringifying library with some added security.
官方介绍很简单:一个增加了安全性的 Query String 解析和序列化的函数库。
.parse(string, [options])
- 对于简单 query,可以进行常规的转换,同时会对
field
和value
进行decode
解码。
qs.parse('a=c&b%201=d%26e');
// { a: 'c', 'b 1': 'd&e' }
注意 qs
不会忽略头部的 ?
,需要自行去掉,否则会当做 field
的一部分,例如:qs.parse('?a=b')
会解析为 { '?a': 'b' }
。
- 支持 query 中的嵌套对象。
qs.parse('foo[bar]=baz');
// { foo: { bar: 'baz' } }
但默认子元素最多嵌套 5 层,需要通过 parse(string, [options])
的 opinion.depth
来修改。
// defalut
qs.parse('a[b][c][d][e][f][g][h][i]=j');
// {a: {b: {c: {d: {e: {f: {'[g][h][i]': 'j'}}}}}}}
// set depth
qs.parse('a[b][c][d][e][f][g][h][i]=j', { depth: 1 });
// { a: { b: { '[c][d][e][f][g][h][i]': 'j' } } }
- 支持自定义除
&
以外的分隔符。
var delimited = qs.parse('a=b;c=d', { delimiter: ';' });
// { a: 'b', c: 'd' }
这点符合 W3C 对;
支持的建议,但大部分情况应该不会用到。
- 支持各种
array
的解析,虽然官方文档写了[]
作为数组标识,但实际上不使用[]
依然可以解析。
var withArray = qs.parse('a[]=b&a[]=c');
// { a: ['b', 'c'] }
var withArray = qs.parse('a=b&a=c');
// { a: ['b', 'c'] }
同时也支持为数组指定索引顺序。
var withIndexes = qs.parse('a[1]=c&a[0]=b');
// { a: ['b', 'c'] };
并行支持 allowSparse
获取抽稀形式的数组。
var sparseArray = qs.parse('a[1]=2&a[3]=5', { allowSparse: true });
// { a: [, '2', , '5'] };
但默认指定的 index 最大值为 20,如果超过最大值,则按照 object
形式解析。使用 arrayLimit
控制最大值。
var withMaxIndex = qs.parse('a[100]=b');
// { a: { '100': 'b' } }
var withArrayLimit = qs.parse('a[1]=b', { arrayLimit: 0 });
// { a: { '1': 'b' } }
.stringify(object, [options])
这里主要介绍下 array
类型的编码。qs
默认会对 field
和 value
都进行编码,同时会使用[]
作为数据的标识(且默认对[]
进行百分号编码),需指定 encodeValuesOnly: true
才仅对 value
编码。
// defalut
qs.stringify({key: ['a', 'b']});
// key%5B0%5D=a&key%5B1%5D=b
//
qs.stringify({key: ['a', 'b']}, { encodeValuesOnly: true });
// key[0]=a&key[1]=b
去掉[]
标识,可使用 { indices: false }
。
qs.stringify({key: ['a', 'b']}, { indices: false });
// key=a&key=b
支持配置 charset
默认使用 UTF-8,内置了 ISO-8859-1 模式,也可以支持 encoder 扩展。
而接下来的库仅支持 UTF-8 的编码方式。
简洁专注 query-string
Parse and stringify URL query strings
For browser usage, this package targets the latest version of Chrome, Firefox, and Safari.
官方名字看起来,依旧是处理 Query String 的。
另外,官方还送上友(wei)情(xian)提示,各位同学不要看走眼。
Not
npm install querystring
!!!!!
.parse(string, [options])
- 基本的解析和
qs
一样,会对 field 和 value 进行decode
。
不过,头部的?
和#
的部分将被忽略,因此可以直接将 location.search
和 location.hash
传入。
queryString.parse('a=c&b%201=d%26e');
// { a: 'c', 'b 1': 'd&e' }
- 不支持嵌套,官方建议可以使用
JSON
序列化的方式传值。
This module intentionally doesn't support nesting as it's not spec'd and varies between implementations, which causes a lot of edge cases.
You're much better off just converting the object to a JSON string:
- query 中
array
的解析,默认不支持[]
形式,需要指定{ arrayFormat: 'bracket' }
开启。
queryString.parse('key=a&key=b');
// { key: ['a', 'b'] };
queryString.parse('key[]=a&key[]=b');
// { 'key[]': ['a', 'b'] };
queryString.parse('key[]=a&key[]=b', { arrayFormat: 'bracket' });
// { key: ['a', 'b'] };
当然 query-string
也支持索引的方式标记的数组,{arrayFormat: 'index'}
。
queryString.parse('foo[0]=1&foo[1]=2&foo[3]=3', {arrayFormat: 'index'});
{foo: ['1', '2', '3']}
.stringify(object, [options])
依然重点介绍 array
类型的编码,默认不使用[]
标识。
queryString.stringify({key: ['a', 'b']});
// key=a&key=b
需要[]
的话,使用 {arrayFormat: 'bracket'}
开启,默认[]
也不会被 encode
。
queryString.stringify({key: ['a', 'b']}, {arrayFormat: 'bracket'});
// key[]=a&key[]=b
这点和 qs
是相反的,需要特别注意!
历史产物 querystring
NodeJS 中解析 query 的模块。
NodeJS 14.x 中明确标记为 Legacy,官方推荐 URLSearchaParms
代替。
The querystring API is considered Legacy. New code should use the URLSearchParams API instead.
但在 15.x 以及以后的版本又改为 Stable,但指出这是非标准 API。
querystring
is more performant than `` but is not a standardized API. Use<URLSearchParams>
when performance is not critical or when compatibility with browser code is desirable.
功能类似 query-string
,不支持嵌套对象的解析,这里不再赘述。
血统纯正 URL / URLSearchParams
URL
和 URLSearchParams
是 URL API 规范 中的两个标准的接口。其提供了访问、操作 URL 的 API。
其中,URL
定义了像域名、主机和 IP 地址等概念,URLSearchParams
定义了一些常用的方法来处理 Query String。我们重点介绍下后者。
URLSearchParams
两种方式创建 URLSearchParams
对象,URLSearchParams
构造函数会忽略 search
中的?
。
// 1. 通过 URL
const url = new URL('https://abc.com/path/v1?key=a&key=b%26c');
const search1 = url.searchParams;
// 2. 直接构造
const search2 = new URLSearchParams(location.search);
.get(name)
该方法获取的值会被自动 decode
,如果 name 不存在返回 null
,如果 value
不存在返回空字符串。
const search = new URLSearchParams('key=b%26c&key2');
search.get('key'); // b&c
search.get('key2'); // ''
search.get('key3'); // null
.getAll(name)
需要特别注意,如果有多个相同的 name,get()
只能获取第一个值。获取全部需要使用 getAll()
,该函数返回数组(即便只有一个 value
)。
const search = new URLSearchParams('key=a&key=b');
search.get('key'); // a
search.getAll('key'); // ['a', 'b']
.set(name, string) / .append(name, string)
向 URLSearchParams
中添加数据,set()
会覆盖原有值。如果需要添加重复的 name,需要使用 append()
。
set()
和 append()
仅支持 string
类型的 value。同时 field 和 value 都会被 encode
,无需额外处理。
const search = new URLSearchParams();
search.append('key', 'a');
search.append('key', 'b');
search.toString(); // key=a&key=b
.keys()
返回一个 IterableIterator
迭代器,可以使用for...of
遍历。需要注意,重复的 key
会出现多次
const search = new URLSearchParams('key=a&key=b');
for (const key of search.keys()) {
console.log(key);
}
// key
// key
.toString()
获取的 Query String,会被自动 encode
处理。空格转成+
。对于重复 field,使用了 field=v1&field=v2
的方式。
const search = new URLSearchParams();
search.set('key', '?&=')
search.set('key2', 'a b');
search.toString(); // key=%3F%26%3D&&key2=a+b
兼容
关于兼容,目前浏览器占比基本上没有问题。实际开发中遇到 iOS10 以下不兼容的情况,使用 polyfill 即可。
总结对比
从上面的总结来看,我们发现 qs
和 query-string / URLSearchParams
最大的差异在于对于多层嵌套对象(Nested object)的支持与否。
qs
被设计用于解析x-www-form-urlencoded
数据,拥有强大的序列化能力,可以处理复杂的类 JSON数据。query-string
和URLSearchParams
则使用简单的序列化算法,适合常规的 Web 端数据传输,处理平面数据结构。- 对于平面数据,以上效果是一样的。
而当使用复杂的 JSON 数据结构时,我们通常会使用JSON.stringify()
方法先将数据进行序列化(也称字符串化),将复杂数据转换成基本的字符串数据后,再进行传输。
- 另外如果有特殊编码需求,除
qs
外都仅支持 UTF-8 的编码。
因此通常情况下:
- 在 Web 项目中解析 GET 形式的 query,使用
URLSearchParams
就足够了(可代替query-string
); - 而在 NodeJS 项目中,除了解析 GET query 外,还要解析 POST body 中的数据,因此使用
qs
可以获得更好的兼容性。同时不少框架也依然使用了querystring
这个原生 API。
expressjs
的body-parser
中,用户可以自行选择使用qs
还是querystring
;
koajs
的koa-body
和bodyparser
所依赖的co-body
,都选择了qs
。
当然了解了他们差异后,选择哪种方式就要根据你的实际情况而定了。
延伸话题
整理资料过程中,引申出更多有趣的问题,也稍作整理。
空格编码问题
还记得前面提到的编码规则里,空格的编码可以是
+
或者%20
,这里描述的就很模糊。
函数对比
我们先来看下上面不同 API 是如何处理的?
对+
和%20
的识别都没问题(毕竟兼容还是能做到的),但是转换空格URLSearchParams
就有不同的逻辑了。至于为什么会有两种编码结果?
这里要特别说明的是URLSearchParams
采用了application/x-www-form-urlencoded
编码模式,而这个编码采用了一个非常早期(RFC 1738)的通用百分号编码方法——就是将空格转换为
+
。至于为什么会采用这种方式,我猜想是因为要考虑到历史兼容问题——生成的 URL 需要被那些旧的仅支持+
的程序识别。
当然+
已经不推荐了,在 RFC 3986 中已推荐使用%20
。
特别说明
这里特别说明下 decodeURIComponent
,是无法解析+
为空格的,因此实际业务中,如果无法保证传入空格的编码方式,还是使用
URLSearchParams
或者query-string
来解析数据吧。
或者做一个简单的兼容处理:
function decodeQueryParam(p) {
return decodeURIComponent(p.replace(/+/g, " "));
}
decodeQueryParam("search+query%20%28correct%29");
// 'search query (correct)'
扩展参考
URLSearchParams
中 +
的问题,具体细节可参考 whatwg 的描述:
As a
URLSearchParams
object uses theapplication/x-www-form-urlencoded
format underneath there are some difference with how it encodes certain code points compared to aURL
object (includinghref
andsearch
). This can be especially surprising when usingsearchParams
to operate on a URL’s query.
URLSearchParams
objects will percent-encode anything in theapplication/x-www-form-urlencoded percent-encode set
, and will encode U+0020 SPACE as U+002B (+).
以及 whatwg 中关于 application/x-www-form-urlencoded
的描述:
Control names and values are escaped. Space characters are replaced by '+', and then reserved characters are escaped as described in [RFC1738], section 2.2: Non-alphanumeric characters are replaced by
%HH
, a percent sign and two hexadecimal digits representing the ASCII code of the character. Line breaks are represented as "CR LF" pairs (i.e.,%0D%0A
).
Content-type 中的 x-www-form-urlencoded
当我们在HTTP
中使用 MIME
类型为x-www-form-urlencoded
格式提交数据时,所使用的就是前文所介绍的编码方式。
只是如果发送的是 GET 请求,数据会拼接在 Query 中;而发送 POST 请求则会将数据放置在消息体(body)中,通过Header
中的Content-Type
来指定 MIME
类型。
当然并不是所有的数据都适合使用 x-www-form-urlencoded
,通常有二进制数据时,urlencoded
使用百分号%HH
和UTF-8
的编码方式,会大大增加了数据的长度。为了节省传输数据的空间,会选择form-data
代替。
原生 from 表单的编码
除了上面提到的各类函数外,原生 html 的 form 表单在提交数据时,本身也是可以进行编码的。
<html>
<head>
<meta charset="UTF-8">
<!-- <meta charset="GBK">-->
</head>
<body>
<form action="/search" method="get" enctype="application/x-www-form-urlencoded">
<input type="text" name="name" required>
<input type="submit" value="提交">
</form>
</body>
</html>
当点击提交时, 表单内的 input 数据会进行百分号编码。但要注意的是编码的格式是按照 meta
中设定的 charset
进行的。例如当输入「中」时,UTF-8 是 name=%E4%B8%AD
,GBK 则是 name=%D6%D0
。空格则是
+
。
encodeURI(Component) 和 escape
前文提到过encodeURI(encodeURIComponent)
使用 UTF-8 编码,而 escape
是一个已经被废弃的非标准方式,其采用了 UTF-16 编码,同时在码点小于 255 的使用 %uXX 表示,码点大于 255 的使用 %uXXXX 的方式。
同时要注意当decodeURI(decodeURIComponent)
解析非法的 %HH 格式数据时(如不合规范的 UTF-8 数据、被截断的%HH 字符等),会包抛出URIError
异常。
try {
const a = decodeURIComponent("%E0%A4%A");
} catch (e) {
console.error(e);
}
// URIError: malformed URI sequence
因此如果无法保证数据的可用性,记得总是要 try...catch
一下比较保险。
或者更推荐使用类似safe-decode-uri-component
的三方库,来避免这类麻烦。
至于 UTF-8 的合法格式是什么样的,这就要涉及更多的编码知识了。
参考资料
- en.wikipedia.org/wiki/Query_…
- www.w3.org/TR/1999/REC…
- stackoverflow.com/questions/1…
- en.wikipedia.org/wiki/Percen…
- url.spec.whatwg.org/#interface-…
- datatracker.ietf.org/doc/html/rf…
- datatracker.ietf.org/doc/html/rf…
- www.w3.org/Internation…
- www.w3.org/TR/html4/in…
- stackoverflow.com/questions/1…
- www.baeldung.com/postman-for…
- stackoverflow.com/questions/2…