接口里的 C++ 到了服务端变成 C 加两个空格,中文调用 btoa() 直接报错,页面上的 <div> 转义后又显示成了 <div>。
这些问题看起来都和“特殊字符”有关,处理方式却不一样。URL 编码负责 URL 中的数据表达,Base64 把字节转换成文本,HTML 实体则用于 HTML 中的字符表示。用错地方,多调用一次编码函数也解决不了问题。
下面用 JavaScript 拆开看,并把三个在线工具放在对应示例中,方便对照输入和输出。
一、先判断数据要交给谁
写代码前,先看下一步由什么解析器处理这段内容。
| 使用场景 | 应采用的方式 | 示例 |
|---|---|---|
| 把搜索词放进查询参数 | URL 参数序列化 | C++ → C%2B%2B |
| 在文本字段中传输字节数据 | Base64 | UTF-8 文本 你好 → 5L2g5aW9 |
| 在 HTML 源码中显示标签文本 | HTML 实体转义 | <div> → <div> |
这三种处理都不提供保密性。尤其是 Base64,字符串变得不直观,并不代表内容被加密了。
二、URL 编码:参数值里的 & 不能再当分隔符
假设搜索框输入:
C++ & 中文
直接拼接查询字符串:
const keyword = "C++ & 中文";
const query = "q=" + keyword;
问题出在字符含义上:& 会参与参数分隔,+ 在表单风格的查询参数解析中会被当成空格。
构造查询参数时,可以直接使用 URLSearchParams:
const keyword = "C++ & 中文";
const params = new URLSearchParams({
q: keyword });
console.log(params.toString());
// q=C%2B%2B+%26+%E4%B8%AD%E6%96%87
console.log(params.get("q"));
// C++ & 中文
如果只需要编码一个组件,也可以使用 encodeURIComponent():
console.log(encodeURIComponent("C++ & 中文"));
// C%2B%2B%20%26%20%E4%B8%AD%E6%96%87
两段输出里的空格分别变成了 + 和 %20。这是编码规则的差异:URLSearchParams 使用表单风格的序列化规则。解析查询字符串时,它也会把 + 还原为空格。具体行为可查阅 MDN 的 URLSearchParams 文档。
加号为什么会丢失
下面两种写法接收的数据含义不同:
// 传入已经拼好的查询字符串,+ 会按空格解析
const parsed = new URLSearchParams("q=C++");
console.log(JSON.stringify(parsed.get("q")));
// "C "
// 传入原始参数值,由 API 完成编码
const built = new URLSearchParams({
q: "C++" });
console.log(built.toString());
// q=C%2B%2B
因此,已有原始值时,直接调用 set()、append(),或者使用对象构造参数即可。
重复编码会把百分号也编码掉
const once = encodeURIComponent("a b");
const twice = encodeURIComponent(once);
console.log(once); // a%20b
console.log(twice); // a%2520b
%25 是百分号的编码结果。如果服务端只解码一次,得到的就是 a%20b,而不是 a b。
使用 URLSearchParams 时也一样,不要先手动编码,再把结果当原始值传进去。接收端如果已经由框架解析了查询参数,也不要无条件再调用一次 decodeURIComponent()。
可以把本文的输入放进 ToolExo URL 编码与解码工具,对照空格、加号、百分号和中文的变化。比较结果时记得确认编码模式;不同 API 对部分标点的保留规则也可能不同。
三、Base64:先确定字节,再讨论编码
Base64 处理的是字节。对于中文文本,需要先约定字符编码,通常使用 UTF-8。
它每次把 3 个字节的 24 位数据,分成 4 组,每组 6 位,再映射成字符。标准 Base64 使用字母、数字、+ 和 /,末尾可能出现填充符 =。
包含填充、没有换行时,输出长度为:
4 × ceil(输入字节数 / 3)
所以 Base64 通常会增加约三分之一的体积,短数据受填充影响更明显。标准定义见 RFC 4648。
为什么 btoa("你好") 会报错
浏览器的 btoa() 接收的是表示字节的字符串,每个字符的值必须在 0 到 255 之间。直接传中文不满足这个条件。
下面是适合小段文本的 UTF-8 编解码实现:
function encodeBase64Text(text) {
const bytes = new TextEncoder().encode(text);
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
function decodeBase64Text(encoded) {
const binary = atob(encoded);
const bytes = Uint8Array.from(
binary,
char => char.charCodeAt(0)
);
return new TextDecoder("utf-8", {
fatal: true
}).decode(bytes);
}
const encoded = encodeBase64Text("你好");
console.log(encoded);
// 5L2g5aW9
console.log(decodeBase64Text(encoded));
// 你好
这里分了两步:UTF-8 文本转换为字节,再把字节编码为 Base64。解码时按相反顺序处理。btoa() 对输入的限制及 Unicode 处理方式,见 MDN 的 btoa 文档。
fatal: true 会让无效 UTF-8 字节触发异常。这样可以发现“解码成功,但结果根本不是文本”的情况。若原始内容是图片或压缩包,应按二进制处理,不要强行转换成字符串。实际接收外部输入时,也应捕获 Base64 格式错误和文本解码错误。
标准 Base64 和 Base64URL 别混用
Base64URL 把标准 Base64 中的两个字符替换掉:
+ → -
/ → _
是否保留末尾的 =,由使用它的协议约定,不能只凭字符串长相判断。
这也解释了一个联调问题:把标准 Base64 直接拼进查询字符串,其中的 + 可能被解析成空格。可以使用 URLSearchParams 编码参数;如果接口明确要求 Base64URL,则按接口约定生成。两者的定义同样可查阅 RFC 4648 第 5 节。
需要手动核对时,可以使用 ToolExo Base64 编码与解码工具,用 你好 检查 UTF-8 往返结果,再对照标准 Base64 与 URL-safe 模式。
四、HTML 实体:让标签作为文本显示
技术文档里经常需要显示这样的代码:
<div>Hello & goodbye</div>
如果把它作为 HTML 解析,浏览器会把 <div> 当成元素。要在 HTML 源码中表达这段文字,可以转义成:
<div>Hello & goodbye</div>
一个基础实现如下:
function escapeHtml(text) {
const entities = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'"
};
return text.replace(/[&<>"']/g, char => entities[char]);
}
console.log(escapeHtml('<div class="box">A & B</div>'));
// <div class="box">A & B</div>
使用一次替换,可以避免后续替换把刚生成的 < 又变成 &lt;。
这段函数用于普通 HTML 文本、带引号的普通属性值等场景,不能直接套到 JavaScript、CSS 或事件属性里。URL 属性还需要检查协议和业务允许的地址范围。输出位置不同,防护方式也不同,见 OWASP 的 XSS 防护指南。
只是显示文字,就直接用 textContent
在浏览器里,如果目标只是展示一段字符串,通常不用手写转义:
const code = document.createElement("pre");
code.textContent = '<div class="box">A & B</div>';
document.body.appendChild(code);
这里传入原始文本即可。如果先调用 escapeHtml(),再赋给 textContent,页面会把 < 等字符原样显示出来。
这也是“明明转义了,页面却显示不对”的常见原因:渲染方式已经按文本处理,业务代码又提前转义了一次。
解码结果不能直接当作可信 HTML
把 < 还原成 < 只是字符转换。随后若把结果交给 innerHTML,它又会进入 HTML 解析流程。
纯文本展示继续使用 textContent;确实需要富文本时,应使用适合业务的 HTML 清理方案。实体编码不能替代富文本清理。
可以通过 ToolExo HTML 实体编码与解码工具 对照标签、引号和 & 的转换结果。调试时要分清:你需要的是字符的解码结果,还是浏览器解析后的页面效果。
五、把三个知识点放进一次联调
假设接口要求把一段 UTF-8 文本编码为标准 Base64,再放进查询参数。发送端可以这样写,复用前面的函数:
const original = "你好,C++ & HTML";
const payload = encodeBase64Text(original);
const params = new URLSearchParams({
payload });
const query = params.toString();
// 模拟接收端解析
const received = new URLSearchParams(query).get("payload");
if (received === null) {
throw new Error("缺少 payload 参数");
}
const restored = decodeBase64Text(received);
console.log(restored === original);
// true
如果还需要把结果显示到页面:
const output = document.createElement("pre");
output.textContent = restored;
document.body.appendChild(output);
这里的处理顺序由接口和输出位置决定:
原始文本
→ UTF-8 字节
→ Base64
→ 查询参数序列化
→ 查询参数解析
→ Base64 解码
→ UTF-8 文本
→ textContent 显示
这个例子以接口明确要求 Base64 为前提。普通搜索词直接交给 URLSearchParams 就够了,无须额外包一层。
排查乱码或字符丢失时,可以把每一步的输入和输出记录下来:发送前是什么,参数解析后是什么,解码后又是什么。定位到字符第一次变化的位置,通常就能找到多做或少做的那一次转换。