JavaScript字符串超出BMP范围

41

BMP代表基本多文种平面,参见维基百科

根据《JavaScript权威指南》:

JavaScript在Unicode是16位字符集时构建的,因此JavaScript中的所有字符都是16位宽。

这让我相信JavaScript使用UCS-2(而非UTF-16!),只能处理U+FFFF及以下的字符。

进一步调查证实了这一点:

> String.fromCharCode(0x20001);
fromCharCode方法似乎只使用返回Unicode字符时的最低16位。尝试获取U+20001(CJK统一表意符号20001)实际返回的是U+0001。问题是:在JavaScript中是否有可能处理后BMP字符?2011-07-31: Unicode Support Shootout: The Good, The Bad, & the (mostly) Ugly 幻灯片12很好地涵盖了与此相关的问题:

1
如果使用UTF-16,那么您会期望字符超出基本多语言平面将使用代理对进行支持。为什么您会期望它接受32位字符? - Michael Aaron Safyan
非常感谢你,我从未想过这种方式。 - Delan Azabani
3
由于JavaScript没有类似于“char”的类型,而String.fromCharCode()返回一个字符串,因此我们可以合理地期望它返回包含构成字符的两个代码单元的字符串。我相信未来的JavaScript标准将添加String.fromCodePoint()以实现这一点。 - hippietrail
你的问题解释了为什么我在使用String.fromCharCode后会不断得到length === 1。 - Olga
2
你现在可以在 ES6 中执行 "\u{23222}" :D - Henry
6个回答

36

根据您所说的“支持”的含义而定。您可以使用代理项将非UCS-2字符放入JS字符串中,如果浏览器可以显示它们,它们将显示出来。

但是,JS字符串中的每个项目都是单独的UTF-16码单元。没有语言级别的支持来处理完整字符:所有标准字符串成员(lengthsplitslice等)都处理代码单元而不是字符,因此会很愉快地分割代理对或保留无效的代理序列。

如果您想要代理感知方法,恐怕您需要开始自己编写这些方法!例如:

String.prototype.getCodePointLength= function() {
    return this.length-this.split(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g).length+1;
};

String.fromCodePoint= function() {
    var chars= Array.prototype.slice.call(arguments);
    for (var i= chars.length; i-->0;) {
        var n = chars[i]-0x10000;
        if (n>=0)
            chars.splice(i, 1, 0xD800+(n>>10), 0xDC00+(n&0x3FF));
    }
    return String.fromCharCode.apply(null, chars);
};

非常感谢。这是一个很好、详细的答案。 - Delan Azabani
@bobince 那么,从技术上讲,JS使用UCS-2还是UTF-16?UCS-2不支持BMP之外的字符,但如果输入单个代理半身(例如'\uD834\uDD1E'表示U+1D11E),JavaScript确实支持。但这是否使它成为UTF-16呢? - Mathias Bynens
3
JavaScript 不支持 UTF-16 编码。它会给你一系列 16 位编码单元,可以让你随意存储内容。如果需要的话,你可以将代理项存储在其中,但你无法像处理字符一样特殊处理它们。关于是否应该将其描述为“使用”UCS-2或UTF-16,这是一个语义上的争议,没有一个确定性的答案。但无论 JS 中是否有语言级别的支持,浏览器的其他部分都支持代理项以进行界面渲染/交互,因此在 JS 字符串中包含它们是有意义的。 - bobince
3
@bobince 谢谢!我进一步研究了一下,并在这里写了我的发现:http://mathiasbynens.be/notes/javascript-encoding。欢迎提供反馈。 - Mathias Bynens
2
更新了fromCodePoint以匹配ECMAScript 6对正确Unicode支持的名称。现在它已经成为一个有效的polyfill。 - bobince
我改变了我的投票,因为这个答案现在已经过时了,它说“没有语言级别的支持来处理完整字符”。现在有一些语言级别的完整字符支持,比如 codePointAtfromCodePointArray.from()/ufor ... of... 操作符。也许还有其他的支持? - hippietrail

3

最近的JavaScript引擎有String.fromCodePoint方法。

const ideograph = String.fromCodePoint( 0x20001 ); // outside the BMP

此外还有一个代码点迭代器,可以获取代码点长度。
function countCodePoints( str )
{
    const i = str[Symbol.iterator]();
    let count = 0;
    while( !i.next().done ) ++count;
    return count;
}

console.log( ideograph.length ); // gives '2'
console.log( countCodePoints(ideograph) ); // '1'

3

我得出了与bobince相同的结论。如果你想处理包含BMP之外的unicode字符的字符串,就必须重新实现javascript的String方法。这是因为javascript将每个16位代码值作为一个字符计算。BMP之外的符号需要两个代码值来表示。这样你就会遇到一种情况,即某些符号计为两个字符,而有些只计为一个字符。

我已经重新实现了以下方法,以将每个unicode代码点视为单个字符:.length, .charCodeAt, .fromCharCode, .charAt, .indexOf, .lastIndexOf, .splice和.split。

你可以在jsfiddle上查看它:http://jsfiddle.net/Y89Du/

这是没有注释的代码。我测试过了,但仍可能存在错误。欢迎评论。

if (!String.prototype.ucLength) {
    String.prototype.ucLength = function() {
        // this solution was taken from 
        // https://dev59.com/tG865IYBdhLWcg3wlviq
        return this.length - this.split(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g).length + 1;
    };
}

if (!String.prototype.codePointAt) {
    String.prototype.codePointAt = function (ucPos) {
        if (isNaN(ucPos)){
            ucPos = 0;
        }
        var str = String(this);
        var codePoint = null;
        var pairFound = false;
        var ucIndex = -1;
        var i = 0;  
        while (i < str.length){
            ucIndex += 1;
            var code = str.charCodeAt(i);
            var next = str.charCodeAt(i + 1);
            pairFound = (0xD800 <= code && code <= 0xDBFF && 0xDC00 <= next && next <= 0xDFFF);
            if (ucIndex == ucPos){
                codePoint = pairFound ? ((code - 0xD800) * 0x400) + (next - 0xDC00) + 0x10000 : code;
                break;
            } else{
                i += pairFound ? 2 : 1;
            }
        }
        return codePoint;
    };
}

if (!String.fromCodePoint) {
    String.fromCodePoint = function () {
        var strChars = [], codePoint, offset, codeValues, i;
        for (i = 0; i < arguments.length; ++i) {
            codePoint = arguments[i];
            offset = codePoint - 0x10000;
            if (codePoint > 0xFFFF){
                codeValues = [0xD800 + (offset >> 10), 0xDC00 + (offset & 0x3FF)];
            } else{
                codeValues = [codePoint];
            }
            strChars.push(String.fromCharCode.apply(null, codeValues));
        }
        return strChars.join("");
    };
}

if (!String.prototype.ucCharAt) {
    String.prototype.ucCharAt = function (ucIndex) {
        var str = String(this);
        var codePoint = str.codePointAt(ucIndex);
        var ucChar = String.fromCodePoint(codePoint);
        return ucChar;
    };
}

if (!String.prototype.ucIndexOf) {
    String.prototype.ucIndexOf = function (searchStr, ucStart) {
        if (isNaN(ucStart)){
            ucStart = 0;
        }
        if (ucStart < 0){
            ucStart = 0;
        }
        var str = String(this);
        var strUCLength = str.ucLength();
        searchStr = String(searchStr);
        var ucSearchLength = searchStr.ucLength();
        var i = ucStart;
        while (i < strUCLength){
            var ucSlice = str.ucSlice(i,i+ucSearchLength);
            if (ucSlice == searchStr){
                return i;
            }
            i++;
        }
        return -1;
    };
}

if (!String.prototype.ucLastIndexOf) {
    String.prototype.ucLastIndexOf = function (searchStr, ucStart) {
        var str = String(this);
        var strUCLength = str.ucLength();
        if (isNaN(ucStart)){
            ucStart = strUCLength - 1;
        }
        if (ucStart >= strUCLength){
            ucStart = strUCLength - 1;
        }
        searchStr = String(searchStr);
        var ucSearchLength = searchStr.ucLength();
        var i = ucStart;
        while (i >= 0){
            var ucSlice = str.ucSlice(i,i+ucSearchLength);
            if (ucSlice == searchStr){
                return i;
            }
            i--;
        }
        return -1;
    };
}

if (!String.prototype.ucSlice) {
    String.prototype.ucSlice = function (ucStart, ucStop) {
        var str = String(this);
        var strUCLength = str.ucLength();
        if (isNaN(ucStart)){
            ucStart = 0;
        }
        if (ucStart < 0){
            ucStart = strUCLength + ucStart;
            if (ucStart < 0){ ucStart = 0;}
        }
        if (typeof(ucStop) == 'undefined'){
            ucStop = strUCLength - 1;
        }
        if (ucStop < 0){
            ucStop = strUCLength + ucStop;
            if (ucStop < 0){ ucStop = 0;}
        }
        var ucChars = [];
        var i = ucStart;
        while (i < ucStop){
            ucChars.push(str.ucCharAt(i));
            i++;
        }
        return ucChars.join("");
    };
}

if (!String.prototype.ucSplit) {
    String.prototype.ucSplit = function (delimeter, limit) {
        var str = String(this);
        var strUCLength = str.ucLength();
        var ucChars = [];
        if (delimeter == ''){
            for (var i = 0; i < strUCLength; i++){
                ucChars.push(str.ucCharAt(i));
            }
            ucChars = ucChars.slice(0, 0 + limit);
        } else{
            ucChars = str.split(delimeter, limit);
        }
        return ucChars;
    };
}

1
非常感谢您将其发布到公共领域。您,先生/女士,是一位绅士/学者。 - Ahmed Fasih
ucCharAt似乎有问题。 "".ucCharAt(0)返回正确的值,但将0更改为1则返回无意义的值。 将其更改为2会返回第二个(而不是第一个)字符。 因此,要到达最后一个字符,必须调用ucCharAt(8),它比字符串的ucLength更大。 - Ahmed Fasih

1

这个旧话题现在有了ES6的简单解决方案:

将字符分割成数组

简单版本

[..."⛔"] // ["", "", "", "⛔", "", "", ""]

然后将它们分开,您可以轻松处理它们以适应大多数常见情况。

信用:DownGoat

完整解决方案

为了克服评论中的特殊表情符号,可以搜索连接字符(UTF-16中的字符代码8205)并进行一些修改。以下是具体步骤:

let myStr = "‍‍‍"
let arr = [...myStr]

for (i = arr.length-1; i--; i>= 0) {
    if (arr[i].charCodeAt(0) == 8205) { // special combination character
        arr[i-1] += arr[i] + arr[i+1]; // combine them back to a single emoji 
        arr.splice(i, 2)
    }
}
console.log(arr.length) //3

目前尚未发现此方法无效的情况。如有,请留言。

总结

似乎JS使用8205字符代码将UCS-2字符表示为UTF-16组合。


1
不是那么简单:[..."‍‍‍"] -> ['', '‍', '', '‍', '', '‍', ''] - Gene S
存在非表情符号的问题 [..."ഹൈബ്രിഡ്"] -> ['ഹ', 'ൈ', 'ബ', '്', 'ര', 'ി', 'ഡ', '്'] - kiranvj
这适用于组合表情符号和其他组合字符 - https://dev59.com/lGAf5IYBdhLWcg3wizOu#71619350 - kiranvj

0

可以。尽管根据ECMAScript标准,在源文档中直接支持非BMP字符是可选的,但现代浏览器可以使用它们。自然地,必须正确声明文档编码,并且对于大多数实际目的,您需要使用UTF-8编码。此外,您需要一个可以处理UTF-8的编辑器和一些输入方法;例如,请参见我的Full Unicode Input工具。

使用适当的工具和设置,您可以编写var foo = ''

非BMP字符将被内部表示为代理对,因此每个非BMP字符在字符串长度中计为2。


0
使用for (c of this)指令,可以在包含非BMP字符的字符串上进行各种计算。例如,计算字符串长度和获取字符串的第n个字符:
String.prototype.magicLength = function()
{
    var c, k;
    k = 0;
    for (c of this) // iterate each char of this
    {
        k++;
    }
    return k;
}

String.prototype.magicCharAt = function(n)
{
    var c, k;
    k = 0;
    for (c of this) // iterate each char of this
    {
        if (k == n) return c + "";
        k++;
    }
    return "";
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接