驼峰和下划线命名怎样互转?怎样深度转换对象的 key?
下划线转驼峰用正则匹配“下划线加一个字母”,在替换函数里把字母转成大写;驼峰转下划线匹配大写字母,替换成下划线加小写。深度转换对象 key 时递归处理:数组逐项转换,普通对象逐个转换 key 再递归处理值,Date 这类特殊对象和原始值原样返回。
简答
后端接口返回
{ user_name: '小明' },前端代码习惯写userName,所以请求回来要把 key 全转成驼峰,提交时再转回下划线。单个字符串的转换靠正则替换。
user_name转驼峰,匹配“下划线后面跟一个字母”,把它换成这个字母的大写;userName转下划线,匹配每个大写字母,换成“下划线加小写字母”。replace的第二个参数可以是函数,匹配到的内容会传进来,返回值就是替换结果。对象的 key 要深度转换,因为接口数据常常是对象里套数组、数组里套对象。递归处理:数组就逐项转换,普通对象就转换每个 key,再递归处理它的值,其他值原样返回。
要注意“普通对象”的判断。
typeof对Date、null都返回'object',如果当成普通对象去遍历 key,Date会变成空对象。所以要用原型判断,只处理原型是Object.prototype或null的对象。另外连续大写字母,比如userID,简单正则会转成user_i_d,需要的话要单独处理。
字符串互转
下面两个函数都用 replace 加替换函数,g 标志表示替换所有匹配。
const toCamel = (text) => text.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
const toSnake = (text) => text.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
function isPlainObject(value) {
if (value === null || typeof value !== 'object') return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function convertKeys(value, convert) {
if (Array.isArray(value)) return value.map((item) => convertKeys(item, convert));
if (!isPlainObject(value)) return value;
const result = {};
for (const [key, child] of Object.entries(value)) {
result[convert(key)] = convertKeys(child, convert);
}
return result;
}
console.log(toCamel('user_first_name')); // userFirstName
console.log(toSnake('userFirstName')); // user_first_name
console.log(toSnake('userID')); // user_i_d
const response = {
user_id: 1,
order_list: [{ order_no: 'A1', created_at: new Date(0) }],
};
const data = convertKeys(response, toCamel);
console.log(Object.keys(data).join(',')); // userId,orderList
console.log(Object.keys(data.orderList[0]).join(',')); // orderNo,createdAt
console.log(data.orderList[0].createdAt instanceof Date); // true
toCamel 的替换函数为什么写成 (_, letter) => ...?替换函数的第一个参数是整个匹配到的内容,比如 '_f',后面依次是各个捕获组。正则里用括号捕获了下划线后面的字母,所以第二个参数 letter 就是 'f',转成大写返回,下划线就跟着被替换掉了。
toSnake('userID') 得到 user_i_d,因为正则把 I 和 D 当成两个单独的大写字母。lodash 的 _.snakeCase('userID') 会得到 user_id,它先按单词拆分,把连续的大写字母当成一个词。如果接口里有这类缩写,可以改成先匹配连续大写字母,或者直接用 lodash。
为什么要判断普通对象
created_at 的值是一个 Date。如果判断条件只写 typeof value === 'object',Date 也会进入遍历分支。Object.entries(new Date()) 是空数组,结果就变成了 {},日期信息全丢了。
所以 isPlainObject 看的是原型:字面量 {} 创建的对象,原型是 Object.prototype;Object.create(null) 创建的对象,原型是 null。这两种才逐个转换 key,Date、Map、类的实例都原样保留。最后一行输出 true,说明转换后它还是 Date。
常见追问
会不会有循环引用导致无限递归?
接口返回的 JSON 不会有循环引用,因为 JSON 本身表示不了。如果要转换的是任意对象,就要像深拷贝那样用
WeakMap记录已经处理过的对象,见《怎样手写深拷贝?和 structuredClone 有什么区别?》。
转换后两个 key 撞在一起怎么办?
比如同时有
user_name和userName,转成驼峰后都是userName,后处理的会覆盖先处理的。这种数据说明接口命名本身不一致,应该和后端约定好一种风格,而不是靠转换函数兜底。