谈到中文姓名校验,大家是既熟悉又陌生,茫茫然中使用面向百度编程大法找到一个正则表达式,放到项目中。输入张三
,验证通过,完美!这就是我要的校验啦(😀)。
各位请跟C罗一起来看这个看似简单的问题,首先,下面给出一个目前项目中的场景,非常常见,基本满大街的项目可能都会遇到的。
姓名校验的核心代码如下
const validChineseName = (name) => {
return /^[\u3400-\u9fa5]$/g.test(name)
}
问题的转折点往往从但是
开始的,上面简洁的实现貌似并不完美,有一天,来了一位名字为𥖄(xian)
的用户,验证无法通过(😂)。咱们的故事由此开始讲起,整个故事中涉及到2个关键的知识点:
- Unicode及其编码算法的一些知识
- JS正则中对于字符相关的规则判断编写
先学习以下几个基本概念
Coded Character Set: 编码字符集,给字符表里的抽象字符编上一个数字,这些数字对跟字符集中的字符一一映射。Unicode字符集是一种编码字符集。
Character encoding form:,字符编码表,将
编码字符集
中的字符对应的码点转换成一定长度的二进制序列,便于计算机处理,此二进制序列与码点的映射关系称为字符编码表。我们经常提到的utf8、utf16都是指字符编码表的不同算法。
Code point:,码点,一个字符集一般 可以用一张或多张由多个行和多个列所构成的二位表来表示。二维表中的行和列的交叉点,称之为码点,码点拥有一个唯一的编号,称之为码点值或码点编号。
它们之间的关系可用下图来笼统地表达
经常会有同学跟我讨论的时候把
utf8
、utf16
跟unicode
混为一谈,结合上文的几个概念和示意图,不难发现,所谓的utf8
只是基于unicode
字符集及其码点的概念,提供一个码点寻址的算法,将其转换为计算机理解的二进制串,划一下重点。
Unicode对于互联网的巨大意义不言而喻,堪称信息互联的基石。Unicode流行起来之前,很多非英文字符国家会使用自己的一套玩法。比如GB2312,如果你没有安装相应的解码器,对不起,只能欣赏艺术感极强的乱码符号。
回到上面说到的生僻字校验的问题,任何一家尊重用户的企业,对于自己忠实的客户都不能将之拒之门外,哪怕这样的用户在巨大的群体中零星的存在。
初始时,考虑到以下2个问题
- 用户姓名的生僻字很难枚举,不确定边界
- 生僻字和emoji表情均是使用高、低代理区的方式表示
基于以上问题,考虑使用用户客诉后收集生僻字构建平台自有的特殊字符集
的方案。对于发生客诉后,强烈要求系统解决校验问题的客户,我们认为是忠诚度或者信任度较高的用户,构建此方案是利于我们留存这些用户,虽然体验不是那么完美,但至少能让此类用户有一个途径进一步触达产品的其他层面。
此方案的核心代码如下
const validChineseName = (n) => {
const excludedChars = ["𥖄","𤰉"];
const excludedCharsStr = excludedChars.join('');
const reg = new RegExp(`^([\u3400-\u9fa5${excludedCharsStr}]){2,15}$`, 'g');
return reg.test(n);
};
使用mocha
做一下单元测试,验证一下校验方法
// 功能示例代码
const validChineseName = (n) => {
const excludedChars = ["𥖄","𤰉"];
const excludedCharsStr = excludedChars.join('');
const reg = new RegExp(`^([\u3400-\u9fa5${excludedCharsStr}]){2,15}$`, 'g');
return reg.test(n);
};
module.exports = {
validChineseName
};
// 单元测试示例代码
const expect = require('chai').expect;
const mocha = require('mocha');
const validator = require('./validator');
describe('中文姓名校验', function() {
it('罗 应该是 false', function() {
expect(validator.validChineseName('罗')).to.be.equal(false);
});
});
describe('中文姓名校验', function() {
it('𥖄 应该是 false', function() {
expect(validator.validChineseName('𥖄')).to.be.equal(false);
});
});
describe('中文姓名校验', function() {
it('罗𥖄 应该是 true', function() {
expect(validator.validChineseName('罗𥖄')).to.be.equal(true);
});
});
describe('中文姓名校验', function() {
it('罗超 应该是 true', function() {
expect(validator.validChineseName('超')).to.be.equal(true);
});
});
第一轮单元测试的结果截图如下
第二个用例未通过单元测试,从上面的代码看出来,第二个只有一个生僻字𥖄
,理论上我们预期它的校验结果应该是false
,但实际这一个生僻字校验的结果居然是true
,something went wrong。
字符的正则校验,本质上可以理解为使用unicode
来做匹配,因此,通过线上unicode
与汉字的转算工具,查看𥖄
对应的unicode
为\ud855\udd84
。问题看来是出在高低代理对上。
为了验证我们的猜测,可以使用如下的正则来类比,本质上是一致的。
/[ab]{2,10}/g.test('ab')
// true
也就是说,在正则匹配的时候底层会把字符转换为unicode的utf-16的编码,然后进行匹配
。
定位到问题后,只需要把连续的生僻字的高低代理队结合起来再动态构造正则表达式,JavaScript
中字符串提供了一个方法charCodeAt
,对于我们处理这个问题是一个很重要的函数。
The charCodeAt() method returns an integer between 0 and 65535 representing the UTF-16 code unit at the given index.
The UTF-16 code unit matches the Unicode code point for code points that can be represented in a single UTF-16 code unit. If the Unicode code point cannot be represented in a single UTF-16 code unit (because its value is greater than 0x10000) then the code unit returned will be the first part of a surrogate pair for the code point. If you want the entire code point value, use codePointAt().
通过调用charCodeAt
可以获取对应utf16
编码,其中原则如下
- 可以用1个编码单元表示的时候直接用1个编码单元来表示字符的码点
- 如果1个编码单元无法表示的时候,使用2个编码单元,构成高低代理位的形式,通过一定的算法来表示字符的码点
可以用代码和相应的结果来直观感受
const aCharCode = 'a'.charCodeAt(0).toString(16).padStart(4, '0')
// aCharCode = \u0061
const hCode = '𥖄'.charCodeAt(0).toString(16).padStart(4, '0')
const lCode = '𥖄'.charCodeAt(1).toString(16).padStart(4, '0')
const code = `\\u${hCode}\\u${lCode}`
// code = \ud855\udd84
经过万般折腾后,调整后的校验示例代码如下
// 获取给定字符的utf-16编码
const getCharCode = (c) => {
const h = c.charCodeAt(0);
const l = c.charCodeAt(1);
let hStr = '', lStr = '';
if (h) {
let hCode = h.toString(16).padStart(4, '0');
hStr = `\\u${hCode}`;
}
if (l) {
let lCode = l.toString(16).padStart(4, '0');
lStr = `\\u${lCode}`;
}
const charCode = `${hStr}${lStr}`;
return charCode;
}
// 校验
const validChineseName = (n) => {
const excludedChars = ["𥖄","𤰉"];
const charCodeArr = excludedChars.map(c => {
return getCharCode(c)
});
const excludedCharsStr = charCodeArr.join(')|(');
const reg = new RegExp(`^([\u3400-\u9fa5]|(${excludedCharsStr})){2,15}`, 'g');
return reg.test(n);
};
运行单元测试,用例结果如下图所示
到此,一个前端兼容生僻字的方案有了初步的实现,方案不完美,还有以下问题需要同步考虑
- 需要确认后端校验规则同步修改
- 需要确认数据库(mysql)相应的字段是否为
utf8mb4
编码方式,mysql的utf8最多只支持3个字节,这个坑不多说,http://www.techug.com/post/in-mysql-never-use-utf8-use-utf8mb4.html参考这篇文章 - 其他下游业务需要对此字符支持
欢迎留言讨论 (by 前端cluo
)