03 - 对象与数组

前置:函数与作用域。JS 的对象是”键值对的无序集合”,数组是特殊的对象——没有 Java 那种类与集合框架的严格分层,一切数据结构几乎都靠这两样搭出来。


1. 对象字面量

1.1 创建与属性访问

// 对象字面量:最常用的创建方式
const student = {
  name: "张三",            // 键可以不加引号(合法标识符时)
  "home address": "北京",   // 含空格等特殊字符必须加引号
  age: 20,
  scores: [92, 85, 78],     // 值可以是任何类型,包括另一个对象
  greet() {                 // 方法简写(ES6),等价于 greet: function() {}
    return `我是 ${this.name}`;
  },
};
 
// 点访问:键是合法标识符时使用
console.log(student.name);       // "张三"
console.log(student.greet());    // "我是 张三"
 
// 方括号访问:键含特殊字符 / 是变量 / 是数字时必须用
console.log(student["home address"]); // "北京"
const key = "age";
console.log(student[key]);            // 20 —— 点访问做不到动态键

点 vs 方括号的选择规则一句话:静态已知键用点,动态拼接或非法标识符用方括号。类比 Java:JS 对象更像 Map<String, Object> 而不是某个具体类的实例——属性本质就是字符串键。

1.2 增删改与遍历

// 增:直接赋值即可,无需预先声明字段
student.gender = "男";
student["phone"] = "13800000000";
 
// 改
student.age = 21;
 
// 删:delete 操作符
delete student.gender;
 
// 遍历方式一:for...in(包含继承来的可枚举键,需配合 hasOwnProperty 过滤)
for (const key in student) {
  if (Object.prototype.hasOwnProperty.call(student, key)) {
    console.log(key, student[key]);
  }
}
 
// 遍历方式二(推荐):Object.keys / values / entries 返回真数组
Object.keys(student);     // ["name", "home address", "age", "scores", "greet", "phone"]
Object.values(student);
Object.entries(student);  // [["name","张三"], ["age",21], ...]
 
// entries 配合 for...of + 解构是最清晰的遍历姿势
for (const [key, value] of Object.entries(student)) {
  console.log(`${key}: ${value}`);
}

判断属性是否存在用 "age" in studentstudent.age !== undefined(后者会把值为 undefined 的属性误判为不存在)。


2. this 在对象方法中

对象方法里的 this 指向调用时的点号前面那个对象

const counter = {
  count: 0,
  increment() {
    this.count++;        // this === counter
  },
};
 
counter.increment();
console.log(counter.count); // 1

但一旦方法被”抽离”出来单独调用,this 就丢失了(上一章的规则 1):

const inc = counter.increment; // 只是拿到函数引用
inc();                          // this 变成 window/undefined,count 变到全局去了

典型翻车场景是把方法作为回调传递:

document.querySelector("button").addEventListener("click", counter.increment);
// 点击后 this 是 button 而不是 counter!
 
// 解法一:包一层箭头函数(推荐)
button.addEventListener("click", () => counter.increment());
// 解法二:bind 固定 this
button.addEventListener("click", counter.increment.bind(counter));

3. 解构赋值

3.1 对象解构

const config = {
  host: "localhost",
  port: 8080,
  options: { retry: true },
};
 
// 从对象中按名提取
const { host, port } = config;
 
// 重命名
const { host: hostname } = config;      // 变量叫 hostname
 
// 默认值:属性不存在时生效
const { timeout = 5000 } = config;      // timeout = 5000
 
// 嵌套解构
const { options: { retry } } = config;  // retry = true

最常见的用途是接收函数返回的多个值函数参数解构

function connect({ host, port, timeout = 3000 }) {
  console.log(`连接 ${host}:${port},超时 ${timeout}ms`);
}
connect({ host: "example.com", port: 443 }); // 调用处一目了然,还自带命名参数效果

对比 Java:Java 直到最近才用 record 类模拟这种体验;JS 的参数解构让每个函数天然支持”命名参数”,弥补了无重载带来的可读性问题。

3.2 数组解构

const rgb = [255, 128, 0];
 
const [r, g, b] = rgb;
const [first, , third] = [1, 2, 3];      // 跳位
const [head, ...rest] = [1, 2, 3, 4];    // head=1, rest=[2,3,4]
 
// 经典技巧:交换变量(无需临时变量)
let a = 1, b = 2;
[a, b] = [b, a];

4. 展开运算符与浅拷贝陷阱

// 数组展开
const arr1 = [1, 2, 3];
const arr2 = [...arr1, 4, 5];        // 合并
Math.max(...arr1);                    // 展开传参,得 3
 
// 对象展开:后面的同名键覆盖前面的
const defaults = { theme: "light", lang: "zh" };
const userPref = { lang: "en" };
const merged = { ...defaults, ...userPref }; // { theme:"light", lang:"en" }
 
// 浅拷贝
const copy = { ...defaults };

浅拷贝陷阱:展开只复制第一层,嵌套对象仍然共享引用——这与 Java 的 clone 浅拷贝、Arrays.copyOf 行为完全一致:

const original = { info: { age: 20 }, tags: ["a"] };
const shallow = { ...original };
 
shallow.info.age = 99;
console.log(original.info.age); // 99 !嵌套对象被改动了
 
// 深拷贝方案一:structuredClone(现代浏览器原生支持)
const deep1 = structuredClone(original);
deep1.info.age = 1;
console.log(original.info.age); // 99,不受影响
 
// 深拷贝方案二:JSON 序列化(有局限:丢函数/undefined/Symbol,Date 变字符串)
const deep2 = JSON.parse(JSON.stringify(original));

记忆模型:JS 的引用类型变量存的都是”地址”,... 只复制地址本身一层;想彻底隔离就用 structuredClone


5. 数组高阶方法:函数式思维

以下方法是现代 JS 数据处理的基石,思想与 Java Stream 完全同源——都是不修改原数据,通过函数变换产出新结果。对照阅读 Stream 与函数式 收获加倍。

先准备一份公共数据:

const students = [
  { name: "张三", score: 92, cls: "A" },
  { name: "李四", score: 58, cls: "B" },
  { name: "王五", score: 76, cls: "A" },
  { name: "赵六", score: 88, cls: "B" },
];

5.1 map:一对一变换

const names = students.map(s => s.name);
// ["张三", "李四", "王五", "赵六"]
 
const withGrade = students.map(s => ({
  ...s,
  grade: s.score >= 60 ? "及格" : "不及格",
}));
// 每个元素都变成新对象,原数组不变

对应 Java:students.stream().map(s -> s.getName()).toList()

5.2 filter:过滤

const passed = students.filter(s => s.score >= 60);
// 3 个元素的新数组,李四被淘汰

对应 Java:.filter(s -> s.getScore() >= 60)。链式组合与 Stream 一模一样:

const aClsHonors = students
  .filter(s => s.cls === "A")
  .filter(s => s.score >= 90)
  .map(s => s.name);
// ["张三"]

5.3 reduce:聚合归并

// reduce(累积函数, 初始值)
const total = students.reduce((acc, s) => acc + s.score, 0);
// 314
 
// 进阶:分组(reduce 最能体现威力的用法)
const byCls = students.reduce((groups, s) => {
  (groups[s.cls] ??= []).push(s);
  return groups;
}, {});
// { A: [{张三}, {王五}], B: [{李四}, {赵六}] }

对应 Java:.reduce(0, Integer::sum) 或 Collectors.groupingBy。(a ??= b) 是”空值合并后赋值”简写,见 ES6+ 特性

5.4 find / findIndex / some / every

students.find(s => s.name === "李四");    // 第一个匹配的元素或 undefined
students.findIndex(s => s.score < 60);    // 第一个匹配的下标或 -1
students.some(s => s.cls === "C");        // false:是否存在至少一个满足
students.every(s => s.score >= 60);       // false:是否全部满足

对照 Java:findFirst()anyMatch()allMatch()。注意 JS 版本短路求值同样成立——some 找到一个就停,every 遇到一个不满足就停。

方法输出Java 对应是否修改原数组
map新数组(同长度)map
filter子集新数组filter
reduce任意聚合值reduce/collect
find元素或 undefinedfindFirst
some/everybooleananyMatch/allMatch

5.5 sort 的坑:默认按字符串排序

const nums = [10, 1, 5, 100];
nums.sort();
console.log(nums); // [1, 10, 100, 5] !默认转字符串按字典序排
 
// 数字排序必须传入比较器(与 Java Comparator.compare 同构)
nums.sort((a, b) => a - b); // 升序:[1, 5, 10, 100]
nums.sort((a, b) => b - a); // 降序
 
// 更隐蔽的坑:sort 会原地修改原数组!
const original = [3, 1, 2];
const sorted = [...original].sort((a, b) => a - b); // 先拷贝再排序
// ES2023 提供了不改原数组的 toSorted()
const sorted2 = original.toSorted((a, b) => a - b);

比较器返回负数/零/正数决定前后顺序,这条规则和 Java 一致,可以直接迁移经验。同理注意会修改原数组的还有 reverse/splice/push/pop/shift/unshift,不想动原数组就先展开拷贝或用 toReversed/toSpliced


6. class 语法

ES6 引入的 class 让 JS 终于有了和 Java 接近的面向对象书写形式。底层实现仍是原型链(prototype),但日常开发可以把它当作语法糖使用,原型机制深入见后续章节。

class Animal {
  // 构造器与实例字段
  constructor(name, sound) {
    this.name = name;
    this.sound = sound;
  }
 
  speak() {
    return `${this.name} 说 ${this.sound}`;
  }
 
  // getter / setter
  get title() {
    return `[${this.name}]`;
  }
  set nickname(value) {
    this._nick = value.toUpperCase();
  }
}
 
class Dog extends Animal {
  constructor(name) {
    super(name, "汪");          // 必须先调用 super 再用 this
    this.legs = 4;
  }
 
  // 重写父类方法(无需 override 注解,直接覆盖)
  speak() {
    const base = super.speak();
    return `${base},还会摇尾巴`;
  }
}
 
const dog = new Dog("旺财");
console.log(dog.speak());   // "旺财 说 汪,还会摇尾巴"
console.log(dog.title);     // "[旺财]" —— getter 当属性用
dog.nickname = "da huang";
console.log(dog._nick);     // "DA HUANG"

与 Java 的显著差异速记:无访问修饰符(私有字段要用 #field 语法)、成员之间不用逗号、方法不能重载、typeof 一个实例永远是 "object",判断类型用 instanceof


7. JSON 一句带过

JSON 是 JS 对象字面量的序列化子集,前端与后端交换数据的事实标准:JSON.stringify(obj) 把对象变字符串,JSON.parse(str) 反向解析。详细规范、常见坑(日期、循环引用、注释不支持)见 JSON 基础


8. 本章小结

  • 对象是无类型键值容器:静态键用点访问,动态键用方括号;增删改直接赋值/delete。
  • 遍历首选 Object.entries 配合 for...of
  • 方法中的 this 指向调用者,方法被抽离当回调时会丢失 this,用箭头函数包裹或 bind 补救。
  • 解构让”多返回值”和”命名参数”成为可能;展开运算符只做浅拷贝,深拷贝用 structuredClone。
  • 高阶方法 map/filter/reduce/some/every 就是轻量版 Stream,全部不改原数组;sort 默认字典序且原地修改,数字排序必传 (a,b)=>a-b
  • class 提供 constructor/extends/getter/setter 的熟悉外观,本质仍是原型。

下一章让这些数据”长”在页面上:DOM 操作