最近在小红书上看到一个帖子:后端转前端发现诡异bug,他遇到了这样的诡异现象:

问题描述

  • 现象:浏览器 Network 面板显示接口返回的 JSON 数据中 createdBy 字段存在且有值
  • 问题:通过 axios 请求数据后,使用 console.log 打印 response.data,展开对象时该字段显示为 undefined
  • 环境:浏览器环境(Chrome DevTools),使用 axios 库,从 Vue 3 项目迁移部分代码

借着这个问题,评论区有一个 momo 大佬顺势提出了一道面试题:

请解释为什么在 JavaScript 中 console.log 有时打印的不是运行当下的值,而是对象的最终值。 特别是在 axios 请求场景中,为什么 console.log(response.data) 显示的内容会和浏览器 Network 面板里的原始响应数据不一致。 请结合对象引用、DevTools 延迟展开机制进行说明,并给出一个最小可复现的代码示例(包含 axios 或模拟异步修改对象的场景), 同时给出至少两种正确打印“当时快照值”的解决方案,并说明各自的适用场景。

贴主的这个问题的答案同时也是 momo 大佬这道面试题的答案,其本质涉及三个层面:

  1. JavaScript 对象的引用传递机制
  2. 浏览器 DevTools 的延迟展开实现
  3. Vue 3 响应式数据的访问方式

原因分析


浏览器 console.log 的延迟展开机制


核心机制

当在浏览器中执行 console.log(obj) 时,控制台的处理流程如下:

  1. 打印阶段:存储该对象的内存引用(而非值的快照)
  2. 展开阶段:用户点击控制台中的对象时,DevTools 读取该引用指向的当前值
  3. 显示结果:如果对象在打印后被修改,展开时看到的是修改后的值

这是浏览器为了性能优化而采取的设计:

  • 避免在打印时深度遍历和序列化大型对象
  • 节省内存,不需要存储对象的完整副本
  • 提升控制台响应速度

延迟展开的副作用

const data = { value: 1 };
console.log('打印时刻:', data);
data.value = 2;
// 此时在控制台展开 data,看到的是 { value: 2 }

上述代码中,虽然打印发生在修改之前,但由于存储的是引用,展开时读取的是修改后的值。

浏览器的提示机制

Chrome DevTools 会在某些情况下在控制台显示一个 标记,提示用户:"Value below was evaluated just now"(下面的值是刚刚计算的)。这说明展开时的值可能与打印时不同。

环境差异:浏览器 vs Node.js

不同 JavaScript 运行环境对 console.log 的实现不同:

环境 实现方式 特点
浏览器 存储对象引用,延迟展开 性能好,但可能显示"未来的值"
Node.js 立即序列化对象 显示打印时刻的准确值

在 Node.js 中执行相同代码:

const data = { value: 1 };
console.log('打印时刻:', data);  // 输出: 打印时刻: { value: 1 }
data.value = 2;
// Node.js 中输出的始终是 { value: 1 }

案例中的第二层原因:Vue 3 ref 访问问题

原始问题的完整原因是双重的:

  1. console 延迟展开:导致看到的是对象的最终状态
  2. Vue ref 访问错误:从 Vue 代码迁移时忘记添加 .value

Vue 3 的响应式数据访问:

import { ref } from 'vue';

const userIdRef = ref('user123');

// 错误:直接访问 ref 对象
const createdBy = userIdRef;  // 返回 RefImpl 对象
console.log(createdBy);       // 可能显示为 undefined 或 RefImpl

// 正确:通过 .value 访问实际值
const createdBy = userIdRef.value;  // 返回 'user123'

在 Vue 组件中,模板会自动解包 ref,但在纯 JavaScript 代码中必须手动添加 .value

技术原理


JavaScript 引用类型

JavaScript 的数据类型分为两类:

基本类型(值传递)

  • Number
  • String
  • Boolean
  • null
  • undefined
  • Symbol
  • BigInt

基本类型直接存储值,赋值时复制值:

let a = 1;
let b = a;
b = 2;
console.log(a);  // 1(不受 b 的修改影响)

引用类型(引用传递)

  • Object
  • Array
  • Function
  • Date
  • RegExp
  • Map
  • Set

引用类型存储内存地址,赋值时复制引用:

let obj1 = { value: 1 };
let obj2 = obj1;
obj2.value = 2;
console.log(obj1.value);  // 2(obj1 和 obj2 指向同一个对象)

DevTools 延迟展开的实现细节

浏览器控制台对对象的处理流程:

打印阶段:
console.log(obj)
    ↓
存储对象引用 + 堆栈信息
    ↓
显示对象的简要表示(如:{...})

展开阶段:
用户点击展开
    ↓
通过引用读取对象当前属性
    ↓
遍历并显示属性值

为什么不立即序列化?

假设有一个深层嵌套的大对象:

const largeObject = {
  level1: {
    level2: {
      level3: {
        // ... 数百个属性
        data: new Array(10000).fill({ /* 复杂对象 */ })
      }
    }
  }
};

console.log(largeObject);

如果立即序列化:

  • 需要深度遍历整个对象树
  • 消耗大量 CPU 和内存
  • 阻塞主线程,影响页面响应

延迟展开则只在用户需要时才读取,性能更优。

解决方案


方案 1:JSON 序列化深拷贝

console.log('快照:', JSON.parse(JSON.stringify(obj)));

原理

  1. JSON.stringify(obj) 将对象序列化为 JSON 字符串
  2. JSON.parse(...) 将字符串反序列化为新对象
  3. 新对象与原对象完全独立,不共享引用

优点

  • 完全隔离,打印的是真实的快照
  • 语法简单,容易理解

缺点

  • 无法序列化函数、Symbol、undefined
  • 循环引用会抛出错误
  • 无法处理特殊对象(Date、RegExp、Map、Set 等会被转换或丢失)

示例

const obj = {
  name: 'test',
  fn: () => {},      // 会被忽略
  undef: undefined,  // 会被忽略
  date: new Date(),  // 转换为字符串
  map: new Map()     // 转换为 {}
};

console.log('原对象:', obj);
console.log('JSON 快照:', JSON.parse(JSON.stringify(obj)));
// 输出: { name: 'test', date: '2024-01-01T00:00:00.000Z', map: {} }

适用场景:调试纯数据对象(POJO)

方案 2:structuredClone()

console.log('快照:', structuredClone(obj));

原理:使用结构化克隆算法,这是 HTML 标准定义的深拷贝算法。

优点

  • 支持更多数据类型:Map、Set、Date、RegExp、ArrayBuffer、TypedArray 等
  • 处理循环引用
  • 性能较好

缺点

  • 仍不支持函数、DOM 节点、Error 对象
  • 需要较新的浏览器版本

浏览器兼容性

  • Chrome 98+ (2022 年 2 月)
  • Firefox 94+ (2021 年 11 月)
  • Safari 15.4+ (2022 年 3 月)

示例

const obj = {
  name: 'test',
  date: new Date(),
  map: new Map([['key', 'value']]),
  set: new Set([1, 2, 3])
};

console.log('structuredClone:', structuredClone(obj));
// 完整保留 Date、Map、Set 的类型和值

适用场景:包含复杂数据结构(Map、Set、Date 等)的对象

方案 3:展开运算符(浅拷贝)

console.log('对象快照:', { ...obj });
console.log('数组快照:', [...arr]);

原理:创建新对象,复制第一层属性。

优点

  • 语法简洁
  • 性能最好
  • 支持所有类型

缺点

  • 仅浅拷贝,嵌套对象仍是引用

示例

const obj = {
  name: 'test',
  nested: { value: 1 }
};

const shallow = { ...obj };
obj.nested.value = 2;

console.log(shallow.nested.value);  // 2(nested 仍是引用)

适用场景:扁平结构的对象

方案 4:直接打印值或字符串化

// 方式 1:打印特定字段
console.log('createdBy:', obj.createdBy);

// 方式 2:JSON 字符串(格式化)
console.log('JSON 字符串:', JSON.stringify(obj, null, 2));

// 方式 3:模板字符串
console.log(`createdBy=${obj.createdBy}, status=${obj.status}`);

优点

  • 最直接,无额外开销
  • 基本类型不受引用影响
  • JSON 字符串易于复制和分享

适用场景

  • 只关注特定字段
  • 需要查看整体 JSON 结构
  • 需要复制数据用于测试

方案 5:console.table()

console.table(obj);
console.table([obj1, obj2, obj3]);

原理:以表格形式展示数据,内部会自动序列化当前值。

优点

  • 可视化效果好
  • 自动序列化,不受引用影响
  • 适合对比多个对象

示例

const users = [
  { id: 1, name: 'Alice', age: 25 },
  { id: 2, name: 'Bob', age: 30 },
  { id: 3, name: 'Charlie', age: 35 }
];

console.table(users);
// 输出一个表格,列为 id、name、age

适用场景:数组或对象列表的调试

方案 6:调试器断点

debugger;
console.log(obj);

或在 Chrome DevTools 中设置断点。

优点

  • 在断点处查看变量的准确状态
  • 可以逐步执行,观察变量变化
  • 可以使用 DevTools 的所有功能

适用场景:复杂的调试场景,需要逐步跟踪

方案对比

方案 深度拷贝 性能 类型支持 代码复杂度 适用场景
JSON 序列化 中等 纯数据(不支持函数、undefined、Symbol) 常规数据对象
structuredClone 中等 较广泛(支持 Map、Set、Date,不支持函数) 复杂数据结构
展开运算符 否(浅拷贝) 所有类型 扁平对象
直接打印值 - 最快 基本类型和字符串 查看特定字段
console.table 是(自动) 可序列化类型 列表/表格数据
断点调试 - - 所有类型 中等 复杂调试流程

推荐策略

  1. 默认选择JSON.parse(JSON.stringify(obj)) - 适用于 90% 的场景
  2. 复杂对象structuredClone(obj) - 包含 Map、Set、Date 等类型
  3. 简单查看:直接打印字段或使用 JSON.stringify(obj, null, 2)
  4. 列表数据console.table(arr)
  5. 复杂调试:使用断点调试器

相关知识点


Vue 响应式数据访问


Vue 3 的 ref

import { ref } from 'vue';

const count = ref(0);

// 错误:直接访问 ref
console.log(count);  // RefImpl { _value: 0, ... }

// 正确:通过 .value 访问
console.log(count.value);  // 0

// 修改值
count.value++;

Vue 3 的 reactive

import { reactive } from 'vue';

const state = reactive({ count: 0 });

// 正确:直接访问属性
console.log(state.count);  // 0

// 修改值
state.count++;

获取原始值

import { toRaw, reactive } from 'vue';

const state = reactive({ count: 0 });
const raw = toRaw(state);

// raw 是非响应式的原始对象
console.log(raw);

从 Vue 组件迁移代码的注意事项

在 Vue 模板中,ref 会自动解包:

<template>
  <!-- 自动解包,无需 .value -->
  <div>{{ count }}</div>
</template>

<script setup>
import { ref } from 'vue';

const count = ref(0);

// 在 JS 中必须使用 .value
function increment() {
  count.value++;  // 正确
  // count++;     // 错误
}
</script>

迁移到纯 JavaScript 时必须手动添加 .value

浏览器与 Node.js 的 console 实现差异

浏览器(Chrome)

const obj = { value: 1 };
console.log(obj);
obj.value = 2;
// 展开时显示 { value: 2 }

实现原理:

  • 存储对象引用
  • 延迟读取属性
  • 优化性能和内存

Node.js

const obj = { value: 1 };
console.log(obj);
obj.value = 2;
// 输出 { value: 1 }

实现原理:

  • 立即调用 util.inspect()
  • 序列化对象为字符串
  • 显示打印时刻的准确值

选择建议

  • 开发调试:使用浏览器 DevTools,功能更强大
  • 服务器日志:Node.js 环境,输出准确
  • CI/CD 测试:Node.js 环境,可靠性高

其他有用的 console 方法

console.dir()

显示对象的所有属性(包括不可枚举属性):

const obj = { a: 1 };
Object.defineProperty(obj, 'hidden', {
  value: 'secret',
  enumerable: false
});

console.log(obj);   // { a: 1 }
console.dir(obj);   // { a: 1, hidden: 'secret' }

console.assert()

断言调试,条件为 false 时打印错误:

const value = 5;
console.assert(value === 10, 'value 应该等于 10,但实际是', value);
// Assertion failed: value 应该等于 10,但实际是 5

console.trace()

打印当前的调用堆栈:

function foo() {
  function bar() {
    console.trace('调用堆栈');
  }
  bar();
}

foo();
// 输出完整的调用链:bar -> foo -> <anonymous>

console.time() / console.timeEnd()

测量代码执行时间:

console.time('数组处理');
const arr = new Array(1000000).fill(0).map((_, i) => i * 2);
console.timeEnd('数组处理');
// 数组处理: 23.456ms

console.group() / console.groupEnd()

分组显示日志:

console.group('用户信息');
console.log('姓名:', 'Alice');
console.log('年龄:', 25);
console.groupEnd();

总结


核心要点

  1. 引用陷阱console.log(obj) 在浏览器中存储的是对象引用,展开时读取的是当前值
  2. 环境差异:浏览器延迟展开(性能优化),Node.js 立即序列化(准确性优先)
  3. 解决方案:使用深拷贝(JSON 或 structuredClone)打印快照,或直接打印字段值
  4. Vue 特殊性:从 Vue 迁移代码时注意 ref 需要 .value 访问

调试最佳实践

  1. 默认方案

    console.log('快照:', JSON.parse(JSON.stringify(obj)));
    
  2. 复杂对象

    console.log('快照:', structuredClone(obj));
    
  3. 查看字段

    console.log('字段:', obj.field);
    
  4. 列表数据

    console.table(arrayOfObjects);
    
  5. 复杂场景:使用 DevTools 断点调试器

参考资料