通过 AJAX 提交 Unicode 字符
当网站只支持 Latin-1 时,如何让 AJAX 像真正的浏览器一样发布内容?
引言
本文展示了一种非常具体的技术,用于通过 AJAX 以与网络浏览器相同的方式提交 Unicode 文本。如果你认为这已经是这种情况了,那么你需要阅读本文。
本文不会解释标准的 AJAX 用法,因为你可以在任何地方找到这些信息。
背景
我经常访问一个旧的网络论坛,它的页面字符集是“iso-8859-1”,也称为 Latin-1。它允许世界各地的人们使用他们想要的任何语言发布消息,但他们的 HTML 表单是纯粹的原生表单。有一个文本框和一个提交按钮。其他一切都取决于你。
它确实接受某些 HTML 代码子集,但你必须手动输入这些代码,而且错误会带来很大的麻烦。这对网站有利吗?肯定不是,但滥用很少发生,而且几乎总是由于错误造成的。用户总体上都是正派的人。
网站所有者对改变现状不感兴趣,所以我决定通过 Opera 的 UserJavaScript 和 Firefox 的 Greasemonkey 插件自行解决。简而言之,我编写了自己的代码来获取用户提交的内容并将其发布到网站,但我的 JavaScript 表单包含许多小的格式化按钮和安全检查。
此外,它通过 AJAX 提交,这样我就不必在每次评论后重新加载页面。当你的论坛有 400 多个带有图片和嵌入视频的帖子时,你真的不想在每次“伙计,这张照片真有趣”的评论后重新加载。
经过一番周折,我在两个浏览器中都使其运行得相当流畅。直到有一天,我尝试发布一些日文文本。有人知道“乱码”是什么意思吗?就是乱码字符。但为什么呢?我知道我可以在常规浏览器表单中发布完全相同的文本,并且它会完美显示,那么为什么 AJAX 会被搞砸呢?我之前看到的所有网络参考资料都说 AJAX 的工作方式与浏览器相同!
Using the Code
我将这段代码写在一个用户 JavaScript 文件中,它是纯 JavaScript,没有混合 HTML 或 CSS 或其他语言(除了嵌入在 JavaScript 中的),并且它以“*.js”结尾。你当然可以将代码用于任何你认为合适的地方,但只有我的特殊情况才让我发现问题确实存在,以及后来如何解决它。
我们将假设一个极其简单的 HTML 表单,如下所示
<form method="post" action="http://www.site.com/submit/path">
<textarea id="userTxt"></textarea>
<input type="submit" value="Submit">
</form>
正如我所说,非常简单。这与该网络论坛使用的内容大致相同。如果你在 Opera/Firefox/IE/Safari/任何浏览器中输入日文文本(例如,“ハロー、ワールド!”),你的评论将被提交并在页面重新加载时完全按照你输入的方式返回。
现在我们来编写一个 JavaScript 函数,通过 AJAX 提交相同的表单。这里有很多超出范围的细节,所以它们将被略过或省略。首先,假设表单已更改为调用 JavaScript 而不是直接提交
<form method="post">
<textarea id="userTxt"></textarea>
<input type="submit" value="Submit" onClick="sendByAJAX(); return false;">
</form>
我不会费心编写一个完美的、跨浏览器兼容的 AJAX 函数。就假装 Internet Explorer 在这部分遵循标准,或者在你的空闲时间自行调整。:-)
function sendByAJAX() {
// get the user text and make it safe for HTTP transmission
var userTxt = encodeURIComponent( document.getElementById('userTxt').value );
// create the AJAX object
var xmlhttp = new XMLHttpRequest();
// assume successful response -- do NOT actually make this assumption in real code
xmlhttp.onreadystatechange = function() {
if (xmlhttp.readyState==4 && xmlhttp.status>=200 && xmlhttp.status<300) {
// You'll probably want to do something more meaningful than an alert dialog
alert('POST Reply returned: status=[' + xmlhttp.status +
' ' + xmlhttp.statusText + ']\n\nPage data:\n' + xmlhttp.responseText);
}
}
xmlhttp.open('POST', 'http://www.site.com/submit/path');
// here we are overriding the default AJAX type,
// which is UTF-8 -- this probably seems like a stupid thing to do
xmlhttp.setRequestHeader('Content-type',
'application/x-www-form-urlencoded; charset=ISO-8859-1;');
xmlhttp.setRequestHeader('User-agent' , 'Mozilla/4.0 (compatible) Naruki');
xmlhttp.send(userTxt);
}
当您添加日文文本、俄语脚本、特殊数学符号等时,上述代码将会失败。嗯,从技术上讲,它会成功,并且不会抛出任何错误,但您会从服务器收到乱码字符。
原因是 Unicode。事实上,如果您强制浏览器将页面编码更改为 Unicode,您的字符现在将变得可读。它们根本不是真正的乱码字符,而是网站指定的页面编码与用户提供的文本冲突,您对此无能为力……哦,等等,有办法。
我知道有些不对劲,因为浏览器本身可以发布完全相同的文本,并且它能够正确返回,而无需通过浏览器设置强制使用新的编码。这个技巧是谷歌无法告诉我的,但一位非常有用的 Opera 员工可以。
当浏览器在要发布的文本中遇到 Unicode 字符时,它会悄悄地将它们转换为 HTML 实体。
你可能知道,Unicode 标准可以容纳 1,114,112 个字符,这几乎足以涵盖我们人类所需的一切。前 65,535 个字符与 HTML 数字实体有一对一的映射。从技术上讲,所有字符都有,但有一个陷阱,所以暂时忽略它。
那对我意味着什么?嗯,假设你非常渴望在你的帖子中包含一些时髦的数学字符。你在 Unicode 书籍中查找它们的数字,但你找不到与之匹配的 HTML 命名实体。比如说 Unicode 字符 0x24EA。你如何发布它?嗯,只需输入数字实体引用 `⓪`,你就搞定了。
由于 HTML 实体中的字符都是标准的 Latin-1 字符,因此传输该文本不需要特殊的编码。但是,如果我使用我的 IME(输入法编辑器)输入日文文本,它将不会生成 HTML 实体。它将直接将 Unicode 字符放入表单中。
浏览器知道如何预解析,所以让我们教 JavaScript 同样的事情。我们需要添加一个新函数,然后更改我们读取 `userTxt` 字段的行。
// get the user text and make it safe for HTTP transmission
var userTxt = encodeURIComponent
( uni2ent( document.getElementById('userTxt').value ) );
...
function uni2ent1stTry(snippet) {
var uSnip = '';
for (var c=0, val; val = snippet.charCodeAt(c); c++) {
if (val < 256) {
uSnip += snippet.charAt(c);
}
else {
uSnip += "&#" + val + ";"
}
}
return uSnip;
}
函数 `uni2ent()` [注意代码示例中的第一次尝试 - 尚未准备好] 使用 JavaScript 内置的 `string` 函数 `charCodeAt()` 逐个字符地解析文本。这给出了该字符的 Unicode 值。
如果该值低于 256,则可以安全地按原样使用原始字符,因为 Unicode 和 Latin-1 在这一点上使用相同的集合。如果该值更高,则需要将其转换为 HTML 数字实体。实体可以写成十进制 (`叶`) 或十六进制 (`叶`)。
JavaScript 本身使用的是 Unicode,而不是 Latin-1,在处理互操作性问题时,您应该注意这一点。
现在来说说陷阱!这个函数几乎是完美的,而且大多数人永远不会遇到问题。但它确实存在,它被称为代理对。
还记得我说过前 65535 个字符与 HTML 数字实体有一对一的映射吗?用十六进制表示,那是 0xFFFF,占用两个字节。一旦超过这个范围,你需要额外的两个字节(取决于特定的 Unicode 编码方案)来组成一个字符。JavaScript 和大多数 Unicode 程序通常使用 UTF-16 变体,所以它们需要这些额外的字节来表示更高编号的字符。
关于这个问题有很多网络讨论,但在你不知道“代理对”这个神奇短语之前很难找到。本质上,当一个 Unicode 字符必须表示为代理对时,你使用一个巧妙的小公式将 Unicode 数字转换为两个 UTF-16 数字。这很复杂,你需要花一段时间思考它才能开始理解。
我使用了两个主要参考资料,它们帮助我最终解决了这个难题。第一个是 维基百科的 UTF-16 编码过程示例。这与我想要的方向相反,所以我将其反向工程为以下内容:
function uni2ent2ndTry(srcTxt) {
var entTxt = '';
var c, hi, lo;
var len = 0;
for (var i=0, code; code=srcTxt.charCodeAt(i); i++) {
// need to convert to HTML entity
if (code > 255) {
// values in this range are surrogate pairs
if (0xD800 <= code && code <= 0xDBFF) {
hi = code;
lo = srcTxt.charCodeAt(i+1);
lo &= 0x03FF;
hi &= 0x03FF;
hi = hi << 10;
code = (lo + hi) + 0x10000;
}
// wrap it up as a Hex entity
c = "&#x" + code.toString(16).toUpperCase() + ";";
}
// smaller values can be used raw
else {
c = srcTxt.charAt(i);
}
entTxt += c;
}
return entTxt;
}
不要太喜欢那个函数。它没有错误检查,它假设输入是完美的,而且位移位仍然让我感到不安。
后来,我发现 Mozilla 发布了一个有趣的 关于如何从代理对中获取完整 Unicode 字符的示例,这正是我需要的!稍作修改,然后……
function uni2ent(srcTxt) {
var entTxt = '';
var c, hi, lo;
var len = 0;
for (var i=0, code; code=srcTxt.charCodeAt(i); i++) {
var rawChar = srcTxt.charAt(i);
// needs to be an HTML entity
if (code > 255) {
// normally we encounter the High surrogate first
if (0xD800 <= code && code <= 0xDBFF) {
hi = code;
lo = srcTxt.charCodeAt(i+1);
// the next line will bend your mind a bit
code = ((hi - 0xD800) * 0x400) + (lo - 0xDC00) + 0x10000;
i++; // we already got low surrogate, so don't grab it again
}
// what happens if we get the low surrogate first?
else if (0xDC00 <= code && code <= 0xDFFF) {
hi = srcTxt.charCodeAt(i-1);
lo = code;
code = ((hi - 0xD800) * 0x400) + (lo - 0xDC00) + 0x10000;
}
// wrap it up as Hex entity
c = "" + code.toString(16).toUpperCase() + ";";
}
else {
c = rawChar;
}
entTxt += c;
len++;
}
return entTxt;
}
这就是最终版本!你真的应该努力理解它,因为我希望别人和我一样痛苦。但如果你有任何问题,别找我。我一无所知。
如果你仔细查看 Mozilla 的示例,你会发现我放弃了错误检查,因为我是一个糟糕的程序员。照我说的做,不要照我做的做。
关注点
这是我的第一篇文章,所以我肯定会进行很多编辑(并收到很多错误报告)。请大家多多包涵。
我还没有做过太多正式测试,所以如果你发现任何严重的错误或不准确的陈述,请告诉我。谢谢。
历史
- 2009 年 3 月 26 日:首次发布