JS 数据类型的判断 & 深浅拷贝

141 阅读13分钟

JavaScript 的数据类型分为两大类:

  1. 原始类型 (Primitive Types) :

    • string
    • number (包括 NaN, Infinity, -Infinity)
    • boolean
    • null
    • undefined
    • symbol (ES6+)
    • bigint (ES2020+)
  2. 对象类型 (Object Type) :

    • object (这包括普通对象 {}、数组 []、函数 function(){}、日期 Date、正则表达式 RegExp、错误 Error 等等)

以下是常用的判断方法及其优缺点:

1. typeof 操作符

这是最基础的类型判断方式,但有其局限性。

返回值:

  • "string"
  • "number"
  • "boolean"
  • "undefined"
  • "symbol"
  • "bigint"
  • "object" (用于 null、Array、Date、RegExp、普通对象等)
  • "function" (用于函数)

优点:

  • 简单易用。
  • 对于大多数原始类型(除了 null)和 function 类型判断准确。

缺点:

  • typeof null 返回 "object" :这是一个历史遗留的 bug,无法区分 null 和其他对象。
  • 无法区分具体的对象类型(如 Array, Date, RegExp, 普通对象都返回 "object")。

示例:

console.log(typeof "hello");      // "string"
console.log(typeof 123);          // "number"
console.log(typeof NaN);          // "number" (注意!)
console.log(typeof true);         // "boolean"
console.log(typeof undefined);    // "undefined"
console.log(typeof Symbol("id")); // "symbol"
console.log(typeof 123n);         // "bigint"
console.log(typeof function() {});// "function"

console.log(typeof null);         // "object" (!!!)
console.log(typeof {});           // "object"
console.log(typeof []);           // "object"
console.log(typeof new Date());   // "object"
console.log(typeof /regex/);      // "object" (在一些老旧浏览器可能返回 "function")
    

2. instanceof 操作符

instanceof 用于检查一个对象是否是某个构造函数的实例,它会检查对象的原型链。

优点:

  • 可以区分具体的对象类型(如 Array, Date, RegExp)。
  • 可以判断自定义类的实例。

缺点:

  • 不能用于判断原始类型(它会返回 false,除非是使用构造函数创建的包装对象,如 new String("abc"),但这不推荐)。
  • 对 null 和 undefined 无效(会报错或返回 false)。
  • 在跨窗口/iframe 环境下可能失效:如果对象是在一个 iframe 中创建的,它的原型链与当前窗口的原型链不同,instanceof Array 可能会返回 false,即使它确实是个数组。

示例:

const arr = [];
const date = new Date();
const obj = {};
function Person() {}
const person = new Person();

console.log(arr instanceof Array);    // true
console.log(date instanceof Date);    // true
console.log(obj instanceof Object);   // true
console.log(arr instanceof Object);   // true (数组也是对象)
console.log(date instanceof Object);  // true (日期也是对象)
console.log(person instanceof Person); // true
console.log(person instanceof Object); // true (自定义对象也是对象)

console.log("hello" instanceof String); // false (原始类型)
console.log(new String("hello") instanceof String); // true (包装对象)
console.log(123 instanceof Number);    // false (原始类型)
// console.log(null instanceof Object); // Uncaught TypeError (在某些环境下可能不报错但返回 false)
// console.log(undefined instanceof Object); // Uncaught TypeError (同上)
    

3. Object.prototype.toString.call()

这是最推荐、最通用、最准确的判断方法,尤其是在需要区分各种内置对象类型时。它利用了 Object.prototype.toString 方法,该方法能返回一个表示对象类型的内部 [[Class]] 属性的字符串。

返回值格式: "[object Type]",其中 Type 是具体的类型名(如 String, Number, Boolean, Null, Undefined, Symbol, BigInt, Object, Array, Function, Date, RegExp, Error 等)。

优点:

  • 非常准确:可以精确判断所有内置类型,包括 null 和 undefined。
  • 不受跨窗口/iframe 问题影响
  • 适用于原始类型和对象类型。

缺点:

  • 写法稍微复杂一些。
  • 对于自定义类的实例,通常返回 "[object Object]",无法直接判断出自定义的类名(此时 instanceof 更合适)。

示例:

function getType(value) {
  // 获取 [object Type] 字符串,并提取 Type 部分
  return Object.prototype.toString.call(value).slice(8, -1);
}

console.log(getType("hello"));      // "String"
console.log(getType(123));          // "Number"
console.log(getType(NaN));          // "Number"
console.log(getType(true));         // "Boolean"
console.log(getType(undefined));    // "Undefined"
console.log(getType(null));         // "Null" (!!!)
console.log(getType(Symbol("id"))); // "Symbol"
console.log(getType(123n));         // "BigInt"
console.log(getType(function() {})); // "Function"
console.log(getType({}));           // "Object"
console.log(getType([]));           // "Array"
console.log(getType(new Date()));   // "Date"
console.log(getType(/regex/));      // "RegExp"
console.log(getType(new Error()));  // "Error"
console.log(getType(new Map()));    // "Map"
console.log(getType(new Set()));    // "Set"

class MyClass {}
const myInstance = new MyClass();
console.log(getType(myInstance));   // "Object" (无法识别自定义类)
    

4. 特定类型的检查方法

对于某些常用类型,JavaScript 提供了更直接、更语义化的检查方法。

  • Array.isArray() : 判断数组的最佳方法。它不受 instanceof 的跨窗口限制。

    console.log(Array.isArray([]));        // true
    console.log(Array.isArray({}));        // false
    console.log(Array.isArray("hello"));   // false
    console.log(Array.isArray(null));      // false
        
    
  • Number.isNaN() : 判断一个值是否为 NaN 的最佳方法。它不会像全局 isNaN() 那样进行类型转换。

    console.log(Number.isNaN(NaN));        // true
    console.log(Number.isNaN(123));        // false
    console.log(Number.isNaN("hello"));    // false (不会转换)
    
    console.log(isNaN(NaN));        // true
    console.log(isNaN("hello"));    // true (会先尝试将 "hello" 转为 Number,得到 NaN)
        
    
  • 直接比较 === null 和 === undefined: 判断 null 和 undefined 最简单、最直接的方法。

    let x = null;
    let y;
    console.log(x === null);         // true
    console.log(y === undefined);    // true
    console.log(x === undefined);    // false
    console.log(y === null);         // false
        
    

总结与推荐

类型推荐方法备选/说明
stringtypeof value === 'string'getType(value) === 'String'
numbertypeof value === 'number'getType(value) === 'Number' (包含 NaN, Infinity)
NaNNumber.isNaN(value)value !== value (只有 NaN 不等于自身)
booleantypeof value === 'boolean'getType(value) === 'Boolean'
undefinedvalue === undefinedtypeof value === 'undefined', getType(value) === 'Undefined'
nullvalue === nullgetType(value) === 'Null' (注意 typeof null 是 'object')
symboltypeof value === 'symbol'getType(value) === 'Symbol'
biginttypeof value === 'bigint'getType(value) === 'BigInt'
functiontypeof value === 'function'getType(value) === 'Function', value instanceof Function
ArrayArray.isArray(value)getType(value) === 'Array', value instanceof Array (注意跨窗口问题)
普通对象 ({})getType(value) === 'Object'typeof value === 'object' && value !== null && !Array.isArray(value) 等组合判断
Datevalue instanceof Date && !isNaN(value)getType(value) === 'Date' (注意 new Date('invalid') 也是 Date 对象)
RegExpvalue instanceof RegExpgetType(value) === 'RegExp'
Errorvalue instanceof ErrorgetType(value) === 'Error'
其他内置对象 (Map, Set)value instanceof Map / SetgetType(value) === 'Map' / 'Set'
自定义类实例value instanceof MyClass-
通用/精确判断Object.prototype.toString.call(value)(封装成 getType 函数使用)

总原则:

  1. 优先使用语义化的专用方法:Array.isArray(), Number.isNaN(), value === null, value === undefined。
  2. 判断原始类型和 function:typeof 通常足够且方便(但要记住 typeof null === 'object')。
  3. 判断内置对象类型(Array, Date, RegExp 等)且需要跨窗口兼容:Object.prototype.toString.call() 是最可靠的选择。
  4. 判断某个对象是否是特定(自定义)类的实例:instanceof 是最佳选择。
  5. 需要一个能处理所有类型的通用函数:基于 Object.prototype.toString.call() 实现。

深浅拷贝

好的,我们来详细探讨 JavaScript 中的浅拷贝(Shallow Copy)和深拷贝(Deep Copy)。

理解这两者的区别对于避免意外修改数据至关重要,尤其是在处理对象和数组时。


核心概念

JavaScript 的数据类型分为原始类型和对象类型。

  • 原始类型 (Primitives) : string, number, boolean, null, undefined, symbol, bigint. 赋值操作是按值传递 (pass by value) ,拷贝的是值的副本。
  • 对象类型 (Objects) : object (包括普通对象 {}、数组 []、函数 function(){}、Date, RegExp 等)。赋值操作是按引用传递 (pass by reference) ,拷贝的是指向内存地址的指针。

拷贝操作的目标是创建一个新的对象或数组,而不是仅仅复制引用。


浅拷贝 (Shallow Copy)

定义:

浅拷贝创建一个新对象或数组,并将原始对象/数组的顶层属性/元素复制到新对象/数组中。

  • 如果属性值是原始类型,则拷贝这个
  • 如果属性值是对象类型(如嵌套的对象或数组),则拷贝这个引用(内存地址)

结果:

新对象/数组的顶层是独立的,但其内部嵌套的对象/数组仍然与原始对象/数组共享同一份数据。修改其中一个的嵌套对象/数组会影响到另一个。

实现方法:

  1. Object.assign(target, ...sources) :

    • 用于拷贝对象的可枚举自有属性。
    • target 是目标对象,sources 是一个或多个源对象。
    • 通常用法:const shallowCopy = Object.assign({}, originalObject);
  2. 展开语法 (Spread Syntax) ... :

    • ES6+ 的现代语法,更简洁。
    • 对象:const shallowCopy = { ...originalObject };
    • 数组:const shallowCopy = [ ...originalArray ];
  3. Array.prototype.slice() :

    • 用于数组。slice() 不带参数时会返回原数组的浅拷贝。
    • const shallowCopy = originalArray.slice();
  4. Array.prototype.concat() :

    • 用于数组。concat() 不带参数或与空数组合并时返回原数组的浅拷贝。
    • const shallowCopy = originalArray.concat();
    • const shallowCopy = [].concat(originalArray);
  5. Array.from() :

    • 用于类数组对象或可迭代对象(包括数组)创建新数组。
    • const shallowCopy = Array.from(originalArray);

示例:

      const original = {
  a: 1,
  b: 'hello',
  c: {
    d: 2
  },
  e: [10, 20]
};

// 使用展开语法进行浅拷贝
const shallowCopy = { ...original };

console.log(original === shallowCopy); // false (顶层是新对象)
console.log(original.c === shallowCopy.c); // true (嵌套对象引用相同)
console.log(original.e === shallowCopy.e); // true (嵌套数组引用相同)

// 修改浅拷贝的顶层原始类型属性
shallowCopy.a = 100;
console.log(original.a); // 1 (原始对象不受影响)

// 修改浅拷贝的嵌套对象属性
shallowCopy.c.d = 200;
console.log(original.c.d); // 200 (原始对象受到影响!)

// 修改浅拷贝的嵌套数组元素
shallowCopy.e.push(30);
console.log(original.e); // [10, 20, 30] (原始对象受到影响!)
    

适用场景:

  • 当对象/数组只有一层结构,或者你明确知道不需要独立的嵌套结构时。
  • 在某些状态管理(如 React/Vue)中,只修改顶层属性以创建新状态对象,触发更新。

深拷贝 (Deep Copy)

定义:

深拷贝创建一个完全独立的新对象或数组。它不仅复制顶层属性/元素,还会递归地复制所有嵌套的对象和数组,确保新对象/数组及其所有内部结构都与原始对象/数组完全分离,不共享任何引用(除了像函数这种通常不拷贝或共享也可以的情况)。

结果:

修改新对象/数组(包括其嵌套结构)不会影响原始对象/数组,反之亦然。

实现方法:

  1. JSON.parse(JSON.stringify(object)) :

    • 原理: 将对象序列化成 JSON 字符串,然后再将字符串解析回新的 JavaScript 对象。

    • 优点: 实现简单,浏览器原生支持。

    • 缺点 (非常重要):

      • 会忽略 undefined、Symbol 属性。
      • 不能序列化函数 (function),会直接忽略。
      • 不能正确处理 Date 对象(会转换成 ISO 格式的字符串)。
      • 不能处理 RegExp、Error 对象(会转换成空对象 {})。
      • 不能处理 Map, Set 等 ES6+ 的数据结构。
      • 不能处理循环引用(对象内部属性直接或间接引用自身),会抛出 TypeError。
      • 会丢失对象的原型链。
      • 只拷贝对象自身的可枚举属性。
    • 示例:

        const original = {
        a: 1,
        b: new Date(),
        c: function() { console.log('hello'); },
        d: undefined,
        e: Symbol('id'),
        f: /regex/i,
        g: new Map([['key', 'value']]),
        h: { nested: 1 }
      };
      // let circular = { x: 1 };
      // circular.y = circular; // 循环引用
      
      try {
        const deepCopyJSON = JSON.parse(JSON.stringify(original));
        console.log(deepCopyJSON);
        // 输出: { a: 1, b: "2023-10-27T...", f: {}, h: { nested: 1 }, g: {} }
        // 丢失了 c, d, e, g被转为空对象, f被转为空对象, b变成字符串
      
        console.log(original.h === deepCopyJSON.h); // false (嵌套对象是独立的)
      
        deepCopyJSON.h.nested = 2;
        console.log(original.h.nested); // 1 (原始对象不受影响)
      
        // const deepCopyCircular = JSON.parse(JSON.stringify(circular)); // 这里会报错 TypeError
      } catch (error) {
        console.error("JSON deep copy failed:", error);
      }
          
      
  2. structuredClone() (现代方法) :

    • 原理: 使用 HTML 规范定义的“结构化克隆算法”,与 postMessage, IndexedDB, History API 等使用的算法相同。

    • 优点:

      • 浏览器和 Node.js v17+ 内置 API,无需库。
      • 支持多种 JavaScript 类型,包括 Date, RegExp, Map, Set, ArrayBuffer, Blob, File, ImageData 等等。
      • 支持循环引用
      • 比 JSON.parse(JSON.stringify()) 更强大、更可靠。
      • 保留原型链(部分情况)。
    • 缺点:

      • 不能克隆函数 (function),会抛出 DataCloneError。
      • 不能克隆 DOM 节点,会抛出 DataCloneError。
      • 不能克隆 Error 对象。
      • 不保留属性描述符(writable, enumerable, configurable)、getter 和 setter。
      • 相对较新,需要注意环境兼容性(查看 MDN)。
    • 示例:

        const original = {
        a: 1,
        b: new Date(),
        // c: function() { console.log('hello'); }, // 会报错
        d: undefined, // structuredClone 可以处理 undefined
        e: Symbol('id'), // structuredClone 可以处理 Symbol
        f: /regex/i,
        g: new Map([['key', 'value']]),
        h: { nested: 1 }
      };
      let circular = { x: 1 };
      circular.y = circular; // 循环引用
      original.i = circular;
      
      try {
         const deepCopyStructured = structuredClone(original);
         console.log(deepCopyStructured);
         // 输出类似: { a: 1, b: Date{...}, d: undefined, e: Symbol(id), f: /regex/i, g: Map(1){'key' => 'value'}, h: { nested: 1 }, i: { x: 1, y: [Circular] } }
      
         console.log(original.h === deepCopyStructured.h); // false
         console.log(original.g === deepCopyStructured.g); // false
         console.log(original.i === deepCopyStructured.i); // false
         console.log(deepCopyStructured.i.y === deepCopyStructured.i); // true (循环引用被正确复制)
      
         deepCopyStructured.h.nested = 2;
         console.log(original.h.nested); // 1 (原始对象不受影响)
      
         deepCopyStructured.g.set('key', 'newValue');
         console.log(original.g.get('key')); // 'value' (原始对象不受影响)
      
      } catch (error) {
          console.error("structuredClone failed:", error); // 如果包含函数会在这里捕获
      }
          
      
  3. 递归手动实现:

    • 原理: 编写一个递归函数,遍历对象/数组的每个属性/元素。如果是原始类型,直接返回值;如果是对象/数组,创建一个新的空对象/数组,并递归调用深拷贝函数来复制其内部属性/元素。

    • 优点: 完全可控,可以根据需要处理特定类型(如函数、DOM 节点等),可以处理循环引用(需要使用 Map 或 WeakMap 来记录已拷贝的对象,防止无限递归)。

    • 缺点: 实现复杂,容易出错,需要考虑各种边界情况(null、原型链、特殊对象类型、循环引用等)。

    • 简化示例 (未处理循环引用和所有类型):

        function simpleDeepClone(obj) {
        if (typeof obj !== 'object' || obj === null) {
          return obj; // 基本类型或 null 直接返回
        }
      
        let clone;
        if (Array.isArray(obj)) {
          clone = [];
        } else {
          clone = {};
        }
      
        for (const key in obj) {
          // 只拷贝对象自身的属性
          if (Object.prototype.hasOwnProperty.call(obj, key)) {
            clone[key] = simpleDeepClone(obj[key]); // 递归调用
          }
        }
      
        return clone;
      }
      
      const original = { a: 1, b: { c: 2 } };
      const deepCopyManual = simpleDeepClone(original);
      console.log(original.b === deepCopyManual.b); // false
      deepCopyManual.b.c = 3;
      console.log(original.b.c); // 2
          
      

      注意: 这是一个非常基础的实现,生产环境需要更完善的版本来处理循环引用、Date、RegExp、Map、Set 等。

  4. 使用第三方库 (如 Lodash) :

    • 原理: 这些库提供了经过充分测试、功能完善的深拷贝函数。

    • 优点: 功能强大,稳定可靠,处理各种边缘情况,通常性能较好。

    • 缺点: 需要引入额外的库依赖。

    • 示例 (Lodash):

      // 需要先安装 lodash: npm install lodash 或 yarn add lodash
      // 或者在 HTML 中引入 lodash CDN
      // import _ from 'lodash'; // 在模块环境中使用
      
      const original = {
          a: 1,
          b: new Date(),
          c: function() { console.log('hello'); },
          d: undefined,
          e: Symbol('id'),
          f: /regex/i,
          g: new Map([['key', 'value']]),
          h: { nested: 1 }
      };
      let circular = { x: 1 };
      circular.y = circular;
      original.i = circular;
      
      const deepCopyLodash = _.cloneDeep(original);
      
      console.log(deepCopyLodash); // Lodash 会正确处理大部分类型和循环引用
      console.log(original.h === deepCopyLodash.h); // false
      console.log(original.g === deepCopyLodash.g); // false
      console.log(original.i === deepCopyLodash.i); // false
      console.log(deepCopyLodash.i.y === deepCopyLodash.i); // true
      
      // Lodash 默认会复制函数引用,而不是创建新函数
      console.log(original.c === deepCopyLodash.c); // true
          
      

总结与选择

特性浅拷贝深拷贝 (JSON)深拷贝 (structuredClone)深拷贝 (Lodash _.cloneDeep)深拷贝 (手动递归)
目的复制顶层,共享嵌套引用创建完全独立的副本 (有限类型)创建完全独立的副本 (多数类型)创建完全独立的副本 (类型广泛)创建完全独立的副本 (自定义)
嵌套对象/数组共享引用独立副本独立副本独立副本独立副本 (需正确实现)
性能较快 (但序列化/反序列化有开销)较快 (原生优化)可能较慢 (功能全面)取决于实现复杂度
函数复制引用丢失报错 (DataCloneError)复制引用 (默认)可自定义处理
undefined复制值丢失保留保留保留 (需正确实现)
Symbol复制引用丢失保留 (作为 key 或 value)保留可自定义处理
Date复制引用转为字符串正确克隆正确克隆正确克隆 (需正确实现)
RegExp复制引用转为 {}正确克隆正确克隆正确克隆 (需正确实现)
Map/Set复制引用转为 {}正确克隆正确克隆正确克隆 (需正确实现)
循环引用复制引用 (可能导致问题)报错 (TypeError)支持支持支持 (需正确实现)
原型链丢失 (assign/spread 创建新对象)丢失部分保留保留可自定义处理
DOM 节点复制引用报错报错 (DataCloneError)通常不处理或报错可自定义处理 (复杂)
实现复杂度低 (使用库)
依赖无 (需环境支持)第三方库

选择建议:

  • 需要完全独立、无副作用的副本,且数据结构复杂或包含特殊类型 (Date, RegExp, Map, Set, 循环引用)?

    • 首选 structuredClone() (如果环境支持且不需克隆函数/DOM节点)。
    • 其次使用 Lodash _.cloneDeep() (如果可以接受引入库)。
    • 避免使用 JSON.parse(JSON.stringify()),除非你非常确定其限制对你的数据没有影响。
    • 仅在有特殊需求且其他方法不满足时考虑手动实现。
  • 只需要复制对象/数组顶层,或者嵌套部分可以共享?

    • 使用浅拷贝方法 (Object.assign, ... 展开语法, slice, concat, Array.from)。这通常更快、更简单。