Skip to content

ES6 回顾与ECMAScript 总结(Day4 1/4) ​

本页对应 Day04_ES6&Git,共 7 段视频,约 1 小时 37 分。

学习目标 ​

  • 能归纳 ES6 核心特性,说明 let/const、解构赋值、模板字符串、箭头函数、扩展运算符、class、迭代器/生成器及新增对象类型的关键用法与差异。
  • 能列举 JavaScript 的原始类型和对象类型,准确说明 7 种原始类型,并区分 Array、Object、Set、Map 等对象类型。
  • 能说明 JavaScript 中定义变量的 6 种方式,并检验 ES6 新增方式是否具备不能重复声明、不会提升、不作为全局对象属性、块级作用域四个特点。
  • 能使用 flat(Infinity)、toString+split、递归函数三种方式实现数组扁平化,并比较各方式的适用场景与局限。
  • 能使用扩展运算符、slice、concat、Array.from、Object.assign 实现数组或对象浅拷贝,并通过修改嵌套引用验证浅拷贝特征。
  • 能使用 JSON.parse(JSON.stringify()) 和递归函数实现数组或对象深拷贝,说明 JSON 方式丢失方法与特殊值的限制,并编写递归深拷贝函数处理嵌套数组和对象。

知识点 ​

先回顾:ES6 的知识地图 ​

这一节是 Day04 的总结课,转写稿里反复提到“量还是很大的”。为什么需要总结?因为前面零散学过的 let/const、解构赋值、模板字符串、数值与函数新增特性、数组与对象新增方法、class、新增运算符、迭代器与生成器,如果不串成地图,很容易“学过但不会用”。怎么想?按“定义变量—处理数据—组织对象—遍历数据”的主线回忆。怎么做?重点记住:let/const 的块级作用域、解构赋值左右两边写法、模板字符串可换行和嵌入表达式、箭头函数没有 this/arguments 且不能作为生成器、扩展运算符可拆分也可合并、class 本质仍是构造函数、for...of 专门遍历可迭代对象。易错点是把转写稿里的同音词写错:that 应为 let,cont 应为 const,“结构负值”应为“解构赋值”,“竞态方法”应为“静态方法”,“便力器”应为“迭代器”,“可辨利对象”应为“可迭代对象”。

数据类型与定义变量方式 ​

为什么需要单独总结?因为判断类型、拷贝、遍历都依赖它。怎么想?先分两大类:原始类型和对象类型。现在原始类型有 7 个:number、string、boolean、null、undefined、bigint、symbol;对象类型包括数组、函数、Object、Date,以及 ES6 新增的 Set、WeakSet、Map、WeakMap 等。定义变量方式转写稿总结为 6 种:var、function、let、const、class、import。其中 let、const、class、import 是 ES6 新增,具备四个特点:不能重复声明、不会提升、全局变量不作为全局对象属性、具有块级作用域。易错点:class 本质仍是构造函数,typeof class {} 仍是 function;import 涉及模块化,后续专题再讲。

数组扁平化:从 flat 到递归 ​

为什么需要?多维数组要转成一维数组。怎么想?转写稿给了三种:ES6 的 flat、join/split、递归。flat(Infinity) 最简单;join/split 会把元素转成字符串,只适合元素本来就是字符串的场景;递归最锻炼思维。怎么做?递归判断当前元素是否数组:是数组就继续递归,不是就加入新数组。

js
function flatArray(arr) {
  let newArr = [];
  for (let i = 0; i < arr.length; i++) {
    if (Array.isArray(arr[i])) {
      newArr = newArr.concat(flatArray(arr[i]));
    } else {
      newArr.push(arr[i]);
    }
  }
  return newArr;
}

const names = [100, 200, ['司马姥姥', '欧阳姥姥', '东方姥姥'], 400, ['A', 'B', 'C']];
console.log(flatArray(names));
console.log(names.flat(Infinity));

调试过程:转写稿里一开始用 const newArr,后面又 newArr = newArr.concat(...),会报错,所以必须改成 let。易错点:concat 不修改原数组,必须重新赋值;递归函数要返回新数组,不能只依赖外部变量。

浅拷贝:为什么只复制一层 ​

为什么需要?直接赋值只是两个变量指向同一地址,改一个会影响另一个。怎么想?数组浅拷贝可用扩展运算符、slice(0)、concat()、Array.from();对象浅拷贝可用 Object.assign({}, obj) 或 {...obj}。怎么做?

js
const arr1 = [100, 200, ['司马姥姥', '欧阳姥姥']];
const arr2 = [...arr1];
const arr3 = arr1.slice(0);
const arr4 = arr1.concat();
const arr5 = Array.from(arr1);

arr2[0] = 250;
console.log(arr1[0]); // 100,第一层断开
arr2[2][1] = '伊丽莎白姥姥';
console.log(arr1[2][1]); // 也改了,说明只是浅拷贝

易错点:浅拷贝只复制第一层,嵌套的数组/对象仍共享地址。转写稿中特别强调“改欧阳姥姥,原数组也改了”,这就是浅拷贝的边界。

深拷贝:JSON 与递归 ​

为什么需要?嵌套结构也要完全断开引用。怎么想?第一种是 JSON.parse(JSON.stringify(obj)),简单,但转写稿明确说“方法没了”,所以只适合没有方法的纯数据对象。第二种是递归:判断是对象还是数组,创建新的空对象/空数组,再逐个属性递归拷贝。

js
function getType(data) {
  return Object.prototype.toString.call(data).slice(8, -1);
}

function deepClone(obj) {
  let result;
  if (getType(obj) === 'Object') {
    result = {};
  } else if (getType(obj) === 'Array') {
    result = [];
  } else {
    return obj;
  }

  for (let key in obj) {
    result[key] = deepClone(obj[key]);
  }
  return result;
}

const user1 = {
  name: '高小个',
  parent: { name: '高启强' },
  children: ['欧阳姥姥'],
  getInfo() {}
};
const user2 = deepClone(user1);
user2.name = 'DW';
user2.parent.name = '高启墙';
user2.children[0] = '伊丽莎白姥姥';
console.log(user1); // 原对象不受影响

调试过程:转写稿中递归版第一次报 result is not defined,原因是 let result 写在 if 块里,块级作用域导致外面访问不到;应把 result 声明在判断外。另一个关键错误是只拷贝一层,嵌套数组没有继续递归,所以改 children 仍影响原对象;把赋值改成 result[key] = deepClone(obj[key]) 后才真正深拷贝。易错点:for...in 能同时遍历对象和数组;非对象/数组直接 return obj;深拷贝递归较绕,要尽量理解调用链。

本节笔记原样保留 ​

text
1. let 和 const
2. 解构赋值
3. 字符串新增特性
4. 数值新增特性
   二进制和八进制表示方式
   Number的静态属性方法
   Math对象的属性方法
   数字分隔符
5. 函数新增特性
   参数默认值
   rest 参数
   箭头函数
   标签模板
   参数尾逗号
6. 数组新增特性
   扩展运算符
   Array的静态属性方法
   Array实例的属性方法
7. 对象新增特性
   属性简写、方法简写,表达式作为属性名
   super 关键字
   扩展运算符
   Object 的静态属性方法
8. class 语法
   class 定义类、 constructor 构造器方法
   私有属性
   静态方法
   class 继承、重写父类方法、super
9. 新增运算符
10 新增对象类型
   Set、WeakSet、Map、WeakMap
11. 遍历器对象和可遍历对象
12. 生成器

本节笔记未附图片,下面保留原列表并展开讲解。

先建立全局地图:为什么回顾 ES6 ​

为什么需要:ES6 内容多,单点学会不等于能串起来。回顾的目的是把“变量、函数、数组、对象、类、集合、遍历”连成一张图。
怎么想:先按笔记 12 项过一遍,每项只回答三个问题:它解决什么问题?核心语法是什么?最容易错在哪里?
怎么做:把笔记当目录,遇到模糊点翻回原笔记,不要求一次背完细节,但要知道“有这个能力,用的时候会查”。
易错点:不要把 ES6 新语法和旧写法割裂。转写稿特别提醒:工作中老代码、新代码会混用,不能只认识新的、不认识旧的。

变量与解构:let、const、解构赋值 ​

为什么需要:var 有变量提升、重复声明、块级作用域不明显等问题;解构赋值是为了更简洁地给变量赋值。
怎么想:let 声明可变变量,const 声明常量;二者与 class、import 等 ES6 新增声明都具有“不能重复声明、不会提升、全局变量不作为全局对象属性、具备块级作用域”等特点。解构赋值分数组解构和对象解构,本质都是“按结构取出来再赋给变量”。
怎么做:数组解构把变量名写进数组结构,右边可以是数组或其他可迭代对象;对象解构把变量名写在属性值位置,属性名两边匹配,右边可以是对象。函数调用时实参传给形参也是赋值,所以也适用解构。
易错点:等号左边不是数组/对象字面量本身,而是解构语法;对象解构匹配的是属性名,不是变量书写顺序。

字符串、数值、函数的新增特性 ​

为什么需要:模板字符串解决拼接和换行麻烦;数值新特性提升可读性;函数新特性让参数处理和 this 场景更灵活。
怎么想:模板字符串用反引号,仍是字符串,但支持换行和 ${表达式}。数值部分记二进制/八进制表示、Number 静态属性方法、Math 属性方法、数字分隔符。函数部分记参数默认值、rest 参数、箭头函数、标签模板、参数尾逗号。
怎么做:rest 参数用来替代 arguments;箭头函数重点记四个特点:没有自己的 this、不能作为构造函数调用、没有 arguments、不能用作生成器函数。标签模板是函数名后紧跟模板字符串,会把模板字符串拆分。
易错点:箭头函数里的 this 沿作用域往上找,不是调用时绑定;箭头函数里写 yield 会报错;标签模板不要和普通函数调用混淆。

数组与对象:扩展运算符、简写、super ​

为什么需要:扩展运算符让“拆开”和“合并”更自然;对象简写减少重复代码;super 用于访问父级原型。
怎么想:数组扩展运算符既能把可迭代对象拆成逗号分隔的参数序列,也能把参数序列合并进数组,二者互为逆运算。对象新增属性简写、方法简写、表达式作为属性名、对象扩展运算符、Object 静态方法。
怎么做:属性简写要求变量名和属性名一致;方法简写省略 function 关键字;表达式作属性名写在方括号里。super 在对象简写方法中指向当前对象原型,在 class 中用于父类构造器或父类方法。
易错点:属性简写和方法简写不是一回事;super 只能在简写方法等特定位置使用,不能乱放。

class、新增运算符、新增对象类型 ​

为什么需要:class 让自定义构造函数的写法更清晰;新增运算符补足指数、可选链、空值判断、逻辑赋值等场景;Set、Map 等补足集合和键值结构。
怎么想:class 本质仍是构造函数,但只能用 new 调用,不能当普通函数调用;constructor 是构造器方法。Set 值不重复,常用于数组去重;Map 是升级版键值结构,键可以是任意类型。
怎么做:类中定义方法会自动放到实例原型上;继承用 extends,重写父类方法,重写构造器时用 super。新增运算符按“和加减等赋值运算符同一套路”理解。
易错点:class 定义的不是普通可调用函数;WeakSet、WeakMap 的弱引用特性不要和普通集合混为一谈。

迭代器、可迭代对象与生成器 ​

为什么需要:for...of、扩展运算符、解构、Array.from 等都需要统一的遍历机制。
怎么想:遍历器对象有 next(),每次取一个值并移动指针;可迭代对象部署了“遍历器接口”,调用后返回遍历器。数组、arguments、Set、Map 等都是可迭代对象。生成器函数是能产生遍历器的特殊函数。
怎么做:生成器函数写法是 function*,内部用 yield 配合 next() 分段执行;调用生成器不会立即执行函数体,只创建遍历器,调用 next() 才执行到下一个 yield。
易错点:生成器里的 return 虽然不影响常规返回值理解,但能结束遍历;不要把“调用生成器”和“执行生成器内部代码”混为一谈。

转写稿里的三个高频回顾:类型、扁平化、深浅拷贝 ​

为什么需要:这些是前面知识的综合应用,也是面试和作业常考点。
怎么想:数据类型分原始类型和对象类型,原始类型现在有 7 个:number、string、boolean、null、undefined、symbol、bigint。数组扁平化可用 flat(Infinity)、递归、join+split,但后者会把元素转成字符串。浅拷贝只复制一层,数组可用扩展运算符、slice、concat、Array.from,对象可用 Object.assign。深拷贝可用 JSON.parse(JSON.stringify(obj)),但方法会丢失,只适合纯数据对象;更完整的是递归。
怎么做:判断类型可用:

js
Object.prototype.toString.call(obj).slice(8, -1)

递归深拷贝的核心是:遇到对象创建新对象,遇到数组创建新数组,否则直接返回;遍历时写:

js
res[i] = deepClone(obj[i])

不能直接 res[i] = obj[i],否则只浅一层。转写稿调试时出现 res is not defined,原因是 let res 写在块级作用域里,外部不可见;应在遍历前声明,或使用外层可访问的变量。
易错点:typeof 区分不了数组和普通对象;JSON 深拷贝丢方法;递归中忘记继续递归,就会得到“看起来拷贝了、实际只拷一层”的浅拷贝。

(本节笔记未附图片,以下按转写稿整理关键代码。)

一、ES6 回顾:先建立“知识索引” ​

为什么需要:ES6 内容多,若不串联,容易学后面忘前面。
怎么想:按“变量→解构→字符串/数值/函数/数组/对象→class→运算符→迭代器→新增集合→生成器”的顺序回忆。
怎么做:let/const 具备不能重复声明、不提升、全局变量不挂到全局对象、块级作用域四个特点;解构赋值分数组和对象,本质是给变量赋值,函数传参也适用;模板字符串用反引号,可换行、可嵌入表达式;数值部分记住二进制/八进制、Number 与 Math 的静态方法、数字分隔符;函数部分重点记参数默认值、rest、箭头函数、标签模板、尾逗号;数组重点记扩展运算符、Array 静态方法和实例方法;对象重点记属性/方法简写、计算属性、super、Object 静态方法;class 本质仍是构造函数,有 constructor、原型、私有属性、静态方法、继承和 super;新增运算符包括指数、可选链、空值合并和三个逻辑赋值;迭代器对象靠 next() 下移指针,可迭代对象部署了迭代器接口,for...of 专门遍历可迭代对象;新增 Set/WeakSet/Map/WeakMap;生成器函数用 function*,配合 yield 和 next()。
易错点:箭头函数没有自己的 this、没有 arguments、不能 new、不能做生成器;class 定义的类不能当普通函数调用;super 在对象方法中指向原型,在 class 中指向父类构造方法。

二、数据类型与定义变量方式 ​

为什么需要:面试和排错都要求能准确说清“有哪些类型、变量怎么来”。
怎么想:先分原始类型和对象类型,再列定义变量的六种方式。
怎么做:原始类型有 7 个:number、string、boolean、null、undefined、bigint、symbol;对象类型包括数组、函数、Object、Date、RegExp、Set、WeakSet、Map、WeakMap 以及自定义类实例等。定义变量方式可总结为 var、function、let、const、class、import,其中后四种是 ES6 新增,具备前述四个特点。
易错点:typeof 不能准确区分数组和对象;class 名字本质也是变量,import 属于模块化专题,先知道它也算定义变量的方式即可。

三、数组扁平化:三种方案 ​

为什么需要:多维数组常要转成一维数组。
怎么想:最简单用 flat(Infinity);想练递归就封装一个只接收数组、直接返回新数组的函数;字符串拼接方案不完美。
怎么做:

js
function flatArray(arr) {
  let newArr = [];
  for (let i = 0; i < arr.length; i++) {
    if (Array.isArray(arr[i])) {
      newArr = newArr.concat(flatArray(arr[i]));
    } else {
      newArr.push(arr[i]);
    }
  }
  return newArr;
}
const names = [100, 200, ['司马姥姥', '欧阳姥姥'], 400, 500, ['A', 'B']];
console.log(flatArray(names));
console.log(names.flat(Infinity));

易错点:早期写法在函数外先建新数组、函数内只往里加,封闭性差;递归中若重新赋值 newArr,不能用 const;join().split() 会把元素都变成字符串,只适合原本就是字符串的固定场景。

四、浅拷贝:只复制一层 ​

为什么需要:直接赋值只是两个变量指向同一地址,改一个会影响另一个。
怎么想:扩展运算符、slice、concat、Array.from 都会产生新数组,但只复制第一层;对象常用 Object.assign({}, obj)。
怎么做:

js
const arr2 = [...arr1];
const arr3 = arr1.slice();
const arr4 = arr1.concat();
const arr5 = Array.from(arr1);
const obj2 = Object.assign({}, obj1);

易错点:嵌套的数组或对象仍是共享地址,所以叫浅拷贝;改 arr2[2][0] 仍可能改到原数组。

五、深拷贝:JSON 方案与递归方案 ​

为什么需要:嵌套结构也要完全独立。
怎么想:JSON 方案短,但会丢方法;递归方案判断类型:数组建 [],对象建 {},其他直接返回。
怎么做:

js
function getType(data) {
  return Object.prototype.toString.call(data).slice(8, -1);
}
function deepClone(obj) {
  let res;
  const type = getType(obj);
  if (type === 'Object') res = {};
  else if (type === 'Array') res = [];
  else return obj;
  for (let key in obj) {
    res[key] = deepClone(obj[key]);
  }
  return res;
}
const user02 = JSON.parse(JSON.stringify(user01));
const user03 = deepClone(user01);

易错点:JSON.parse(JSON.stringify()) 会丢方法,只适合纯数据对象;递归里 res 若在 if 块内声明,块级作用域会导致外面不可用,应在外层用 let res;Object.prototype.toString.call(data) 可区分 [object Array]、[object Object],再用 slice(8, -1) 截取类型名。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

1. 数组扁平化:递归 / toString+split / flat ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>数组扁平化三种方式</title>
</head>
<body>
<script>
  // 多维数组(元素里有数组,数组里还有数组)
  const names = [100, ['司马姥姥', '欧阳姥姥', '东方姥姥'], 400, 500, ['A', 'B', 'C', 'D']];

  /* ---------- 方式一:递归 ---------- */
  function flatArray(arr) {
    let newArr = [];                       // 每次递归都产生一个属于自己的新数组
    for (let i = 0; i < arr.length; i++) {
      if (Array.isArray(arr[i])) {         // 元素还是数组,继续往下钻
        newArr = newArr.concat(flatArray(arr[i]));
      } else {                             // 不是数组,直接收进来
        newArr.push(arr[i]);
      }
    }
    return newArr;                         // 返回值就是扁平化后的数组,不依赖外部变量
  }

  /* ---------- 方式二:toString + split ---------- */
  function flatByString(arr) {
    return arr.toString().split(',');      // 缺点:所有元素都会被转成字符串
  }

  /* ---------- 方式三:ES6 flat ---------- */
  function flatByES6(arr) {
    return arr.flat(Infinity);             // Infinity 表示不管多少层都摊平
  }

  console.log(flatArray(names));    // [100, '司马姥姥', '欧阳姥姥', '东方姥姥', 400, 500, 'A', 'B', 'C', 'D']
  console.log(flatByString(names)); // ['100', '司马姥姥', '欧阳姥姥', '东方姥姥', '400', '500', 'A', 'B', 'C', 'D']
  console.log(flatByES6(names));    // [100, '司马姥姥', '欧阳姥姥', '东方姥姥', 400, 500, 'A', 'B', 'C', 'D']
</script>
</body>
</html>

要点解释与易错点:

  • newArr 必须定义在函数内部。旧写法是在函数外面先建一个数组、递归里不停往里加,函数没有任何返回值,封装性很差;改成内部创建 + return 之后,传参进来就得到一个扁平化结果,对外完全封闭。
  • 递归那行 newArr = newArr.concat(flatArray(arr[i])) 是关键:concat 不修改原数组,所以必须把结果重新赋值回 newArr,漏写赋值就等于白算。
  • 递归的终止条件是“元素不是数组”,此时走 push 分支;Array.isArray 只认真正的数组,别用 typeof(数组的 typeof 是 'object')。
  • 方式二借用了“数组转字符串时元素会用逗号拼接”的特性,一行搞定,但所有元素都变成字符串,数字 100 会变成 '100',只适合元素本来就是字符串的场景。
  • 方式三最简单,但要注意 flat() 不传参默认只摊平一层,必须传 Infinity;同时它依赖 ES6 运行环境,兼容性要求高的项目要慎重。

2. 判断数据类型的 getType 函数 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>封装 getType 判断数据类型</title>
</head>
<body>
<script>
  /**
   * 判断数据的类型
   * @param {*} data 要判断类型的数据
   * @returns {String} 数据类型的字符串表示
   */
  function getType(data) {
    const typeStr = Object.prototype.toString.call(data); // "[object Array]"
    return typeStr.slice(8, -1).toLowerCase();            // 从第 8 位截到倒数第 1 位之前
  }

  const arr = [1, 2, 3, 4];
  const msg = 'hello';
  const fn = function () {};
  const num = 100;
  const obj = { name: '张三' };

  console.log(getType(arr)); // array
  console.log(getType(msg)); // string
  console.log(getType(fn));  // function
  console.log(getType(num)); // number
  console.log(getType(obj)); // object

  // 对比:typeof 和 instanceof 都区分不干净
  console.log(typeof arr);            // object —— 数组和对象分不开
  console.log(arr instanceof Object); // true  —— 数组也是 Object 的实例
</script>
</body>
</html>

要点解释与易错点:

  • 原理是“借方法”:Object.prototype.toString 是顶层原型上的方法,返回值形如 "[object Array]",call 把 this 换成要判断的数据,于是拿到真实类型。
  • 截取用 slice(8, -1):前面 "[object " 固定占 8 个字符(索引 0~7),后面还有一个 "]",所以终点写 -1(slice 含头不含尾,正好把 ] 去掉)。不要用固定长度去截,不同类型名字长度不一样。
  • 一定要用 Object.prototype.toString,直接 arr.toString() 会走数组自己原型上的 toString,得到的是 "1,2,3,4",完全不是类型信息。
  • toString 返回的是首字母大写的 Object、Array、Function,所以后面统一 .toLowerCase() 再做比较,否则 getType({}) === 'object' 会永远为 false。
  • typeof 只能区分原始类型,instanceof Object 对数组、函数、对象全都成立(它们的原型链上都挂着 Object.prototype),这正是要自己封装 getType 的原因。

3. 深拷贝:JSON 方式与递归方式 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>对象和数组的深拷贝</title>
</head>
<body>
<script>
  function getType(data) {
    return Object.prototype.toString.call(data).slice(8, -1).toLowerCase();
  }

  // 两层结构的原始数据,包含数组、嵌套对象和方法
  const user01 = {
    name: '大杯',
    age: 18,
    hobby: ['司马姥姥', '欧阳姥姥', '东方姥姥'],
    parent: {
      name: '高启强',
      age: 50
    },
    sayHi: function () {
      console.log('hi');
    },
    getInfo: function () {
      console.log('info');
    }
  };

  /* ---------- 方式一:JSON ---------- */
  const user02 = JSON.parse(JSON.stringify(user01));

  /* ---------- 方式二:递归 ---------- */
  function deepClone(obj) {
    let res;                                   // 声明在 if 外面,避免块级作用域问题
    if (getType(obj) === 'object') {
      res = {};                                // 是对象就造一个新对象
    } else if (getType(obj) === 'array') {
      res = [];                                // 是数组就造一个新数组
    } else {
      return obj;                              // 原始类型直接返回,递归的出口
    }

    for (const key in obj) {
      res[key] = deepClone(obj[key]);          // 每个成员都再判断一次,这才是“深”
    }
    return res;
  }

  const user03 = deepClone(user01);

  // 验证 JSON 方式:改数据不影响原对象,但方法丢了
  user02.name = 'D大杯';
  user02.hobby[1] = '伊丽莎白姥姥';
  user02.parent.name = '高气墙';
  console.log(user01.name);         // '大杯'      —— 没被改,说明是深拷贝
  console.log(user01.parent.name);  // '高启强'    —— 没被改
  console.log(typeof user02.sayHi); // 'undefined' —— 方法被丢掉了

  // 验证递归方式:数据和方法都完整保留,且互不影响
  user03.hobby[0] = '伊丽莎白姥姥';
  user03.parent.name = '高气墙';
  console.log(user01.hobby[0]);     // '司马姥姥'  —— 原对象没变
  console.log(user01.parent.name);  // '高启强'    —— 原对象没变
  console.log(typeof user03.sayHi); // 'function'  —— 方法还在
</script>
</body>
</html>

要点解释与易错点:

  • JSON.stringify 把对象变成 JSON 格式字符串,JSON.parse 再根据这串字符重建对象,新对象和原对象毫无引用关系,所以是深拷贝;但字符串里存不下函数,方法会全部丢失,只适合“纯数据对象”。另外 undefined、Symbol 等也会被丢弃。
  • 递归函数里 let res 必须写在 if 外面(或用 var)。写进 if 块里就是块级作用域,return res 那行会直接报 res is not defined,这是最容易踩的坑。
  • 递归的出口是 else { return obj; }:既不是对象也不是数组的原始值,原样返回即可,这一句同时保证了纯数据能被正常塞进新容器。
  • 关键的一行是 res[key] = deepClone(obj[key]),而不是 res[key] = obj[key]。如果直接赋值,数组和嵌套对象复制过去的仍然是地址,改新对象会连带改原对象,那就退化成浅拷贝了(转写稿里第一次跑出来就是这个结果)。
  • for...in 既能遍历对象属性名,也能遍历数组索引,所以一段循环同时兼容对象和数组;res 在分支里已经按类型建好了,索引/属性名直接就能对得上。
  • 浅拷贝的四种方式(扩展运算符、slice、concat、Array.from)都只复制一层,元素里再嵌数组或对象时依然是共享地址,这是判断“深”“浅”的唯一标准。

常见错误 ​

1. 用扩展运算符 / Object.assign / slice / concat 当深拷贝用

  • 现象:改拷贝体的 arr[2][1] 或 obj.parent.name,原数据跟着变。
  • 原因:这几种手段都只复制最外层容器,内层成员存的仍是同一份地址(引用),属于浅拷贝。
  • 修法:先明确"要几层"——只要一层就用浅拷贝;需要多层就上递归深拷贝(或 structuredClone),并且改完必须回头验证内层是否被污染。

2. JSON.parse(JSON.stringify(obj)) 当万能深拷贝

  • 现象:拷贝完方法不见了,undefined 属性消失,Date 变成字符串,NaN/Infinity 变成 null,遇到循环引用直接抛错。
  • 原因:JSON 只是一种纯数据的文本格式,函数、undefined、特殊数值都不在它的表达能力内。
  • 修法:只在"纯数据对象"(无方法、无特殊类型、无循环引用)场景使用;其余场景改用递归深拷贝,并在递归里显式处理函数、日期、循环引用。

3. 用 typeof 或 instanceof 区分数组和对象

  • 现象:typeof [] 得到 'object';[] instanceof Object 得到 true;自定义类型判断全乱。
  • 原因:typeof 只够区分原始类型;instanceof 判的是原型链,所有对象最终都挂在 Object.prototype 上,天然全成立。
  • 修法:封装统一的 getType,借 Object.prototype.toString.call(x) 取返回值再截取类型名;数组单独判断时用 Array.isArray。

4. 递归函数里变量声明位置写错,运行时报 xxx is not defined

  • 现象:if 里写了 const res = {},if 外面 for...in 给 res 赋值时直接报错;或者用 const 声明后又想重新赋值报"常量不可改"。
  • 原因:let/const 是块级作用域,出了大括号就失效;const 又不允许重新赋值。
  • 修法:把结果容器的声明提到函数体最顶部用 let(容器内部属性/元素可以改,不需要 const);判断分支只负责"创建",赋值统一放在分支之后。

5. 数组扁平化用 toString().split(',') 就收工

  • 现象:数字、布尔全变成了字符串;元素本身含逗号时结果直接错乱。
  • 原因:转字符串是"降级"操作,类型信息在拼接那一刻就丢了。
  • 修法:把它当作"仅当元素本来全是字符串"的取巧方案;通用场景用 flat(Infinity),兼容性受限时写递归,递归里用 concat 合并子结果。

6. 箭头函数当普通函数用

  • 现象:arguments 报未定义;new 调用报错;里面写 yield 报错;需要动态 this 的地方 this 指向外层。
  • 原因:箭头函数没有自己的 this、arguments、prototype,也不能做构造函数和生成器。
  • 修法:需要 this 随调用者变化、需要 arguments、需要 new、需要 yield 的场合,一律写普通函数;箭头函数只用于"不关心 this 的短回调"。

7. for...in 遍历直接用于深拷贝却不过滤原型属性

  • 现象:拷贝出来的对象莫名多出几个原数据里没有的键。
  • 原因:for...in 会沿着原型链枚举可枚举属性。
  • 修法:拷贝时加 Object.prototype.hasOwnProperty.call(obj, key) 过滤,或改用 Object.keys / for...of Object.entries。

练习 ​

练习 1(类型判断工具) 封装 getType(data),返回 'Object'、'Array'、'String'、'Number'、'Function'、'Null'、'Undefined' 等纯类型名(不含方括号、不含空格)。用数组、函数、数字、字符串、null 各测一次,并说明为什么不能用 instanceof 完成这件事。

练习 2(扁平化改造) 先写出"外部新建数组 + 无返回值"的递归扁平化版本,运行确认结果正确;再把它改造成"只传一个参数、直接 return 一个扁平化新数组"的封闭版本。写注释说明:为什么递归调用返回的数组要用 concat 拼接,以及"假设传入的已经是扁平数组"这个前提在推演中起什么作用。

练习 3(浅拷贝验证) 构造一个数组 [100, 200, ['司马姥姥','欧阳姥姥'], 400, ['A','B','C']],分别用扩展运算符、slice(0)、concat()、Array.from() 复制,逐个验证:(a) 改外层第一个元素,原数组是否变化;(b) 改内层数组元素,原数组是否变化。把结论写成一句话。

练习 4(深拷贝实现) 用递归实现 deepClone(obj),要求:能正确处理对象、数组、原始值;对数组用 []、对对象用 {} 创建新容器;对非对象非数组的值直接返回。用你上一题的嵌套数组和一个含 parent、含数组属性的对象各测一遍,验证内层修改不影响原数据。

练习 5(选型判断) 下列四个场景分别选哪种拷贝方式,各写一句理由: (1) 一个只有字符串和数字、无方法的配置对象; (2) 一个属性里带 Date、带 undefined 的对象; (3) 一个两层嵌套、需要独立修改的数组; (4) 一个含循环引用(自己指向自己)的对象。

原视频对照 ​

#视频时长
1ES6回顾总结20:41
2ES数据类型总结02:04
3ES定义变量的方式总结03:54
4实现数组扁平化总结21:09
5数组和对象的浅拷贝14:17
6利用json格式显示对象数组的深拷贝wmv08:29
7使用递归函数实现数组和对象的深拷贝26:47

小结 ​

  1. 复习的抓手是"目录"不是"细节":先按大条目回忆每一块讲了什么,再翻笔记补细节;记不住 API 名字不算问题,想不起"有这么个东西"才是问题。
  2. 判断静态还是实例,只看挂在哪:挂在构造函数/类本身上的就是静态,挂在 prototype 上的就是实例——这条准则可以套用到所有构造函数,不必逐个背。
  3. 凡涉及"拷贝",第一个问题永远是"几层":先问层数再选方案,选完必须写验证代码,不能靠"看起来一样"下结论。
  4. 递归题的通用套路:先写出口,再写递推,用"假设子问题已解决"推演。扁平化如此,深拷贝如此;绕不出来时不要硬想,先跑一遍最简单的一层数据,看变量怎么走。
  5. 好函数的标准是"封闭":只靠参数拿到输入,只靠 return 交出结果,不依赖函数外预先建好的变量。写出能跑的函数只是及格,能改造成封闭版本才算掌握。
  6. 取巧方案要能说出它的失效边界:toString().split、JSON 深拷贝这类方案不是"错",而是"有条件成立",讲不出边界就等于埋雷。
  7. 面试/笔试类问题按"一问多答"准备:同一功能能给出几种实现、各自优缺点、推荐哪种,比只写对一种更有价值。
  8. 新旧写法都要认识:当前代码库里老写法与新语法长期共存,只认新的会在阅读旧代码时卡住,只认旧的写不出可维护的新代码。