camelCase 和 snake_case 怎么互转?分词的坑
上周帮一个前端联调接口。后端同学返回的字段是 user_id、created_at 这种 snake_case,前端项目用 camelCase,他在 axios 响应拦截器里写了个转换函数。本地测了几个字段都对,上线第二天测试就提 bug:有个 parse_url 字段,到前端变成了 parseUrl,再往后业务方又要求它转回 snake,结果写回去变成了 parse_u_r_l,后端认不出来,数据落库丢了字段。
他拿着这段代码来找我,函数一共十行,核心是正则把下划线干掉、再把下划线后面的字母大写。逻辑没毛病,错的是他没意识到:命名格式互转的真正难点不在「加下划线还是改大小写」,而在「单词边界到底从哪到哪」。
五种格式,本质都是怎么标记边界
先把常见的几种命名格式摆出来:
camelCase(小驼峰):userName。第一个单词小写,后面的单词首字母大写。PascalCase(大驼峰):UserName。每个单词首字母都大写。snake_case(下划线):user_name。单词之间用_连接,全小写。kebab-case(连字符):user-name。单词之间用-连接,URL 里常见。CONSTANT_CASE(全大写下划线):USER_NAME。常量、枚举、环境变量用得多。
把这五种摆在一起,你会发现一件事:它们之间的差异,本质就是「怎么标记单词边界」。
snake、kebab、CONSTANT 这三种是显式边界,用 _ 或 - 这种分隔符把单词隔开,一眼就能看出来哪到哪。camel 和 Pascal 是隐式边界,靠大小写的跳变来暗示「这里有个新单词」,没留任何可见符号。
显式边界互转很简单,把 _ 换成 -、再统一大小写就行。真正麻烦的是从 camel / Pascal 反推边界,你得靠大小写的变化去猜原本的分割点在哪。
转换的核心是分词
把任何一种格式转成另一种,正确做法都是先「分词」、再「拼接」。先把原字符串切成一个单词数组 ['user', 'name'],再按目标格式拼起来。分词分错了,怎么拼都白搭。
显式分隔符的格式分词直接 split 就行:
'user_name'.split('_') // ['user', 'name']
'user-name'.split('-') // ['user', 'name']
'USER_NAME'.toLowerCase().split('_') // ['user', 'name']
camel / Pascal 就要靠大小写跳变来切。最朴素的做法是用正则在大写字母前面插一个分隔符再 split:
'userName'.replace(/([A-Z])/g, '_$1').toLowerCase().split('_')
// ['user', 'name']
'UserName'.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '').split('_')
// ['user', 'name']
这种写法对 userName、createdTime 这种「一个单词完全小写、下一个单词首字母大写」的标准驼峰是好用的。坑从下一节开始。
连续大写缩写会把大小写切分拆崩
来看两个真实翻车现场。
翻车一:HTMLElement 被拆成 h_t_m_l_element。
用上面的朴素正则处理 HTMLElement:
输入:HTMLElement
朴素切分:['', 'h', 't', 'm', 'l', 'element']
转 snake_case:h_t_m_l_element
H、T、M、L 每一个都被当成独立单词,于是 HTML 这个缩写被拆成了四个单字母词。业务上你大概率希望它整体被识别为 html,最后结果是 html_element。
翻车二:parseURL 被拆成 parse_u_r_l 或 parse_urll。
输入:parseURL
朴素切分:['parse', 'u', 'r', 'l']
转 snake_case:parse_u_r_l
期望的结果是 parse_url,URL 这个缩写应当整体作为最后一个词。
这两个例子暴露的是同一个问题:遇到连续大写字母(缩写)时,朴素的大小写切分会把缩写的每个字母当成一个独立词。
要正确处理,分词规则得升级。一个相对靠谱的策略是:连续大写字母整体视为一个词,但如果这串大写后面还跟着小写字母,把最后一个大写字母留给下一个词(因为它其实是下一个单词的首字母)。
伪代码大概是这样:
parseURL → ['parse', 'URL']
HTMLElement → ['HTML', 'Element']
myHTTPServer → ['my', 'HTTP', 'Server']
写成正则可以这么匹配:
// 大写连串(可能带后面小写),或 单大写+小写串
'parseURL'.match(/[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+/g)
// ['parse', 'URL']
'HTMLElement'.match(/[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+/g)
// ['HTML', 'Element']
关键是 [A-Z]+(?=[A-Z][a-z]) 这一段:它匹配「一串大写字母,但要求后面紧跟一个大写+小写」。也就是说,当一串大写后面紧跟着下一个单词的开头时,它会把最后一颗大写字母让给下一个词。靠这个前瞻,parseURL 才能正确地把 URL 留成一整块。
含数字的标识符是另一个灰色地带
user2name、parse3Args、base64Encode 这种带数字的标识符,分词规则更模糊。数字算不算独立词?user2name 到底是 ['user', '2', 'name'] 还是 ['user2', 'name'] 还是 ['user', '2name']?
不同工具答案不同。Lodash 的 camelCase 把数字和前面的字母算一起;很多 IDE 的重命名功能会把数字当边界;业务侧的实际语义可能两种都不对(base64Encode 里的 64 显然不该单独成词,parse3Args 里的 3 又像是个独立 token)。没有银弹,看你团队怎么约定。如果你接的接口字段里数字密集,最好在转换函数里加一条专门规则。
对已是目标格式的字符串,别二次转换
还有一个隐性陷阱。你拿到的字符串可能已经是目标格式了,比如后端返回的 user_id 已经是 snake_case,你的转换函数再跑一遍,理论上输入输出一致,但要是函数对连续下划线或大小写处理不当,可能把 user_id 变成 user__id 或 userId。规范做法是先做格式检测,已是目标格式就跳过,别做幂等性不保证的重复处理。
实操:丢到工具里看一眼
自己写转换函数之前,可以先把几个真实字段贴到 UPTools 大小写转换工具 里跑一遍。它支持 camelCase、PascalCase、snake_case、kebab-case、CONSTANT_CASE 之间的互转,分词规则对 HTMLElement、parseURL 这类缩写做了专门处理,你能直接看到转换结果符不符合预期,再决定要不要在自己代码里复刻这个逻辑。本地运行,不传任何数据。
三句话总结
命名格式互转的难点在分词:snake / kebab 靠分隔符、camel / Pascal 靠大小写跳变,后者靠猜。连续大写缩写(HTMLElement、parseURL)会让朴素的大小写切分拆崩,得靠前瞻把缩写整体识别为一个词。含数字的标识符(user2name、base64Encode)是灰色地带,没有标准答案,看团队约定。
相关文章