前端知库

驼峰和下划线命名怎样互转?怎样深度转换对象的 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 标志表示替换所有匹配。

JavaScript
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,后处理的会覆盖先处理的。这种数据说明接口命名本身不一致,应该和后端约定好一种风格,而不是靠转换函数兜底。