搞定 JavaScript!从基础到高级全梳理笔记(语法篇)


第一章: JavaScript 核心语法:构建坚实基础

摘要: 本章将是您 JavaScript 学习之旅的坚实起点。我们不会重复您已熟知的编程常识,而是直击 JavaScript 的核心特性与差异点,如果是完全从零基础,或是如果您对第一章还有看不懂的知识,请您去稍微的补充您的编程基础,但相信我这并不是很难的事情!您将了解到现代 JS 的语言构成与技术生态,深入探讨 letconst 为何能取代 var,并揭示 JS 独有的数据类型与“臭名昭著”的隐式类型转换机制。学完本章,您将对 JavaScript 的底层行为有更深刻的理解,为后续学习打下坚不可摧的基础。

注意: 阅读本笔记的重要前提是对于 JS 的最基础的语法要有一定的认知,包括运算符、函数、基本数据类型,只需要对于最基础的内容有所认知即可


在本章中,我们将聚焦于 JavaScript 的独特之处:

  1. 首先,我们将快速了解 JavaScript 的构成与标准,明确学习的重点。
  2. 接着,我们将深入 var 的历史遗留问题——变量提升,并理解为何 letconst 是现代 JS 的必然选择。
  3. 然后,我们将辨析 JS 特有的 数据类型,并剖析最关键的“陷阱”——隐式类型转换
  4. 最后,我们将跳过基础语法,仅探讨 JS 中 运算符与流程控制的特殊行为,例如 ===== 的本质区别,以及 for...infor...of 的正确用法。

1.1. 初识 JavaScript:语言构成与现代标准

技术背景: JavaScript(简称 JS)诞生于 1995 年,它与 Java 并无直接的血缘关系,是网景公司为了让浏览器处理动态交互而设计的脚本语言。发展至今,它已成为构建现代 Web 应用不可或缺的核心技术。

一份完整的 JavaScript 实现由三个不同部分组成:

  • 核心 (ECMAScript): 由 ECMA-262 规范定义,提供核心语言功能。我们通常所说的 ES5, ES6 (ECMAScript 2015), ES2025 等版本,就是指这个核心语法的标准。在 2025 年,ES6+ 已是行业开发标准
  • 文档对象模型 (DOM): 提供与网页内容(HTML 文档)交互的接口。
  • 浏览器对象模型 (BOM): 提供与浏览器窗口交互的接口。

解决方案: 在现代 Web 开发中,我们推荐将 JavaScript 代码分离到独立的 .js 文件中,并通过 <script> 标签引入,这是维护代码整洁和可复用性的最佳实践。

场景
任何规模项目,行业标准。

优势

  • 关注点分离:HTML、CSS、JS 互不干扰
  • 可维护:逻辑集中,易改易调
  • 性能好:浏览器可缓存 .js

scripts/main.js

1
console.log('Hello from an external file!');

index.html

1
<script src="scripts/main.js"></script>

场景
一次性、与页面高度耦合的临时脚本。

劣势

  • 代码混杂:破坏结构清晰度
  • 无法复用:其他页面无法共享
1
2
3
<script>
console.log('Hello from inside the HTML!');
</script>

注意:带 src<script> 标签内部若再写代码会被忽略。


1.2. 变量与常量:letconst 的时代与 var 的历史包袱

在上一节,我们了解了 JS 的基本构成。现在,我们来看编程的第一步:变量。您可能熟悉其他语言的变量声明,但在 JS 中,这是一个有“历史”的话题,也是理解其底层行为的关键。

痛点背景: 在 ES6 (2015) 之前,JavaScript 只有 var 一种声明变量的方式。var 存在一个与其他主流语言(如 Java, Python)截然不同的特性——变量提升 (Hoisting)。这会导致代码行为违反直觉,是许多早期 JS bug 的根源。

看下面的代码,在 Java 或 Python 中,这会直接抛出“变量未定义”的错误。但在 JavaScript 中…

1
2
3
4
5
6
7
function hoistingTest() {
console.log(myVar); // 在声明前使用变量
var myVar = "Hello, Prorise!";
console.log(myVar);
}

hoistingTest();

解决方案: 为了解决 变量提升 以及 var 带来的其他作用域问题,ES6 引入了 letconst。它们使用 块级作用域 (Block Scope),行为更符合预期,并且 let 声明的变量在声明前访问会直接报错,这被称为 暂时性死区 (Temporal Dead Zone, TDZ),从根本上杜绝了变量提升带来的问题。

1
2
3
4
5
6
7
8
9
10
11
function blockScopeTest() {
try {
console.log(myLet); // 在声明前访问,进入暂时性死区
} catch (e) {
console.error(e.message);
}
let myLet = "Hello, Modern JS!"; // 变量在这里才“出生”
console.log(myLet);
}

blockScopeTest();

2025 年最佳实践:

  • 停止使用 var
  • 默认使用 const 声明变量,这能确保变量不被重新赋值,增强代码的稳定性和可预测性。
  • 只在确定变量需要被重新赋值时,才使用 let

1.3. 数据类型:掌握程序的基石

JavaScript 的数据类型分为两大类:原始类型和引用类型。这个划分对其内存分配和变量赋值行为有着深远影响,本小节我们仅作为了解,在后续的章节我们会对于每一个数据类型进行深入剖析

类型:原始类型
特点:不可变文本序列

1
2
const str = 'Hello';
console.log(typeof str); // string

类型:原始类型
特点:所有数字(含整数、浮点、NaN、Infinity)

1
2
const n = 42;
console.log(typeof n); // number

类型:原始类型
特点truefalse

1
2
const b = true;
console.log(typeof b); // boolean

类型:原始类型
特点:表示“空对象引用”,历史原因导致 typeof null === 'object'

1
2
const val = null;
console.log(val); // null

类型:原始类型
特点:声明但未赋值的默认结果

1
2
let u;
console.log(u); // undefined

类型:原始类型 (ES6)
特点:唯一、不可变标识符

1
2
const sym = Symbol('id');
console.log(typeof sym); // symbol

类型:原始类型 (ES2020)
特点:任意精度整数

1
2
const big = 9007199254740993n;
console.log(typeof big); // bigint

类型:引用类型
特点:键值对集合,属性可变

1
2
const obj = { name: 'Prorise' };
console.log(typeof obj); // object

类型:引用类型(特殊 Object)
特点:有序数据集合

1
2
const arr = [1, 2, 3];
console.log(Array.isArray(arr)); // true

类型:引用类型(特殊 Object,可调用)
特点:可执行代码块,一等公民

1
2
const fn = () => console.log('hi');
console.log(typeof fn); // function

类型:引用类型
特点:Date、RegExp(正则表达式)、Map、Set 等

1
2
const date = new Date();
console.log(date instanceof Date); // true

1.4. 深度解析:类型转换与隐式强制的“陷阱”

这是 JavaScript 与 Java、Python 等语言最大的区别之一,也是面试中的高频考点。JavaScript 是一门 弱类型 语言,在运算时会自动进行类型转换,这个过程被称为 隐式强制类型转换 。虽然这提供了灵活性,但也埋下了很多“陷阱”。

痛点背景: 观察以下代码,其结果在强类型语言中是不可想象的,但在 JS 中却真实发生。

1
2
3
4
5
console.log(`'5' - 1 = ${'5' - 1}`);
console.log(`'5' + 1 = ${'5' + 1}`);
console.log(`true + 1 = ${true + 1}`);
console.log(`' ' == 0 is ${' ' == 0}`);
console.log(`null == undefined is ${null == undefined}`);

一、== vs ===:核心区别

  • === 严格相等:不做任何类型转换,只要类型或值不同就返回 false
  • == 宽松相等:先按隐式规则把两边转成同一类型,再比较值。规则复杂、结果难预测,99 % 的场景请用 ===
1
2
'1' === 1   // false  (类型不同)
'1' == 1 // true (字符串 '1' 被转成数字 1)

二、算术 / 拼接运算符的隐式转换

  • 加法 +:只要有一个操作数是字符串,就执行 字符串拼接
  • 其余 -*/%:两边都尽量 转成数字 再运算。
1
2
3
'10' + 5   // "105"
'10' - 5 // 5
'6' * '2' // 12

三、实战建议

  1. 比较时始终 优先使用 ===
  2. 运算或比较前 主动显式转换,避免依赖隐式规则。
  3. 判断“空”时,用 value == null 可一次性覆盖 nullundefined,这是少数 ===== 更简洁且不易出错的场景。

1.5. 运算符与流程控制中的 JS 特性

您已经熟悉了大多数运算符和流程控制结构。这里我们仅聚焦于 JavaScript 中行为独特且常用的部分。在 Java 或 Python 中,逻辑运算符 &&|| 通常返回布尔值。但在 JavaScript 中,它们返回的是决定了整个表达式结果的那个 操作数的值,这种特性被称为 短路求值

  • expr1 && expr2: 如果 expr1 能被转换为 false 值,则返回 expr1 的值,否则返回 expr2 的值。
  • expr1 || expr2: 如果 expr1 能被转换为 true 值,则返回 expr1 的值,否则返回 expr2 的值。

实战场景: 这种行为常被用于设置函数参数的默认值或执行条件代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 场景 1: 设置默认值
function greet(name) {
const finalName = name || "Guest";
console.log(`Hello, ${finalName}!`);
}

greet("Prorise");
greet(); // name is undefined (false), 所以会打印 Guest

// 场景 2: 条件函数执行
let userIsAdmin = true;
// 如果 userIsAdmin 为 true,则执行后面的函数
userIsAdmin && console.log("Admin panel functions loaded.");

JavaScript 提供了两种独特的 for 循环变体来遍历数据结构,它们的用途完全不同。

  • for...in: 遍历对象的可枚举属性名 (key)。它主要用于遍历普通对象。不推荐用它来遍历数组,因为它会遍历到数组原型链上的属性,且顺序无法保证。
  • for...of (ES6 新增): 遍历可迭代 (Iterable) 对象的值 (value)。这是遍历数组、字符串、Map、Set 等数据结构的 推荐方式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const user = {
name: "Prorise",
role: "Admin"
};

const permissions = ["read", "write", "execute"];

// 使用 for...in 遍历对象属性
console.log("--- for...in (Object) ---");
for (const key in user) {
console.log(`${key}: ${user[key]}`);
}

// 使用 for...of 遍历数组元素
console.log("\n--- for...of (Array) ---");
for (const permission of permissions) {
console.log(permission);
}

1.6. 本章核心速查与高频面试题

核心速查总结

分类关键项核心描述
变量声明let, const(推荐) 块级作用域,无变量提升,存在暂时性死区。
var(废弃) 函数作用域,存在变量提升,易导致意外行为。
核心区别原始类型 vs 引用类型赋值时,前者复制值,后者复制引用(内存地址)。
相等比较=== (严格相等)(推荐) 不进行类型转换,同时比较类型和值。
== (宽松相等)(慎用) 会进行隐式类型转换,规则复杂,易出错。
类型转换Falsy 值false, 0, "", null, undefined, NaN。这 6 个值转为布尔为 false
循环遍历for...of(推荐) 用于遍历数组、字符串等可迭代对象的值。
for...in用于遍历对象的键(属性名)。

第二章: 函数与作用域:代码组织与执行上下文

摘要: 在第一章,我们掌握了 JavaScript 的基本构件。现在,我们将进入一个更核心的领域:函数与作用域。函数是组织可复用代码的基石,而作用域则是控制这些代码中变量访问权限的规则体系。本章将深入探讨函数声明与表达式的区别、闭包的强大威力、this 关键字的精髓,以及如何通过 call, apply, bind 精准控制函数的执行上下文。理解这些概念,是您从“会写代码”迈向“写好代码”的关键一步。

在本章中,我们将层层递进,揭开函数与作用域的神秘面纱:

  1. 首先,我们将从 函数基础 出发,辨析两种核心的函数定义方式及其差异。
  2. 接着,我们将深入 作用域与闭包,理解 JavaScript 是如何管理变量以及函数为何能“记住”其创建时的环境。
  3. 然后,我们将攻克 JS 中最重要也最易混淆的概念之一 —— this 关键字,掌握其指向的四大核心规则。
  4. 紧接着,我们将学习 call, apply, bind 这三个强大的工具,学会如何随心所欲地改变 this 的指向。
  5. 最后,我们将探讨几种 特殊的函数形式,尤其是改变了 this 规则的箭头函数。

2.1. 函数基础:代码复用的核心

您已经了解函数的基本概念,但 JavaScript 中定义函数的方式存在一个不易察觉的但至关重要的区别:函数声明与函数表达式。

定义方式
使用 function 关键字开头,后跟函数名。

1
2
3
4
5
console.log(add(5, 10)); // 15

function add(a, b) {
return a + b;
}

核心特性

  • 提升:整个函数定义在代码执行前被提升到作用域顶部,可在声明前调用。
  • 适用场景:全局/模块级工具函数、无需动态生成时首选。

定义方式
创建匿名函数并赋值给变量(常用 const/let)。

1
2
3
4
5
6
7
8
9
10
11
try {
console.log(subtract(10, 5)); // 报错!
} catch (e) {
console.error(e.message); // Cannot access 'subtract' before initialization
}

const subtract = function (a, b) {
return a - b;
};

console.log(subtract(10, 5)); // 5

核心特性

  • 变量声明提升,赋值不提升:调用发生在赋值前会触发暂时性死区 (TDZ)。
  • 适用场景:回调、按需动态定义、需要闭包或箭头函数时更灵活。

函数的参数

JavaScript 在函数参数处理上非常灵活,ES6 更是引入了便捷的默认值和 Rest 参数。

  • 参数默认值: 为参数提供默认值,当调用函数时未传递该参数或传递了 undefined 时,该默认值会被使用。

    1
    2
    3
    4
    5
    function greet(name = "Guest", message = "Welcome") {
    console.log(`${message}, ${name}!`);
    }
    greet("Prorise"); // Welcome, Prorise!
    greet(); // Welcome, Guest!
  • Rest 参数 (Rest Parameters): 使用 ... 语法,可以将一个不定数量的参数表示为一个数组。这在需要处理可变数量参数的场景中非常有用。

    1
    2
    3
    4
    5
    6
    function sum(...numbers) {
    // 'numbers' 是一个包含了所有传入参数的真实数组
    return numbers.reduce((total, current) => total + current, 0);
    }
    console.log(sum(1, 2, 3)); // 6
    console.log(sum(10, 20, 30, 40)); // 100

    Rest 参数必须是函数参数列表的最后一个参数。


2.2. 作用域与闭包:理解变量的生命周期与“记忆”

承上启下: 我们已经知道如何创建函数,但函数内部的变量是如何被访问和管理的?这就引出了 JavaScript 中两个最核心的概念:作用域和闭包。

作用域 (Scope)

作用域 是指在程序中定义变量的区域,它决定了变量的可见性和生命周期。在现代 JavaScript 中,主要有三种作用域:
  1. 全局作用域 (Global Scope): 在所有函数和代码块之外定义的变量,拥有全局作用域,在代码的任何地方都可以访问。
  2. 函数作用域 (Function Scope): 在函数内部定义的变量,只能在该函数内部访问。
  3. 块级作用域 (Block Scope): (ES6 新增) 在 {} 代码块(如 if, for, while 语句)内由 letconst 声明的变量,只在该代码块内部有效。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1. 全局作用域 (Global Scope)
const globalVar = "我在全局作用域";

console.log(globalVar); // -> "我在全局作用域"

function scopeTest() {
// 2. 函数作用域 (Function Scope)
var functionVar = "我仅在函数作用域内可见";

console.log(globalVar); // -> 可以访问全局变量
console.log(functionVar); // -> "我仅在函数作用域内可见"

if (true) {
// 3. 块级作用域 (Block Scope)
let blockVar = "我仅在这个 if 块内可见";
console.log(blockVar); // -> "我仅在这个 if 块内可见"
}
}

闭包 (Closure)

痛点背景: 按照作用域规则,一个函数执行完毕后,其内部的局部变量应该被销毁和回收。但如果我们希望一个函数能“记住”它创建时所在的环境,即使它在其他地方被执行,应该怎么办?

1
2
3
4
5
6
7
function createGreeter() {
const name = "Prorise";
// 此处应该返回一个打招呼的动作,但如果直接返回字符串,name 变量就丢失了
return `Hello, ${name}`; // 这样无法实现“稍后”打招呼
}
const greeting = createGreeter();
console.log(greeting); // 只能立即得到结果

解决方案: 闭包 就是解决这个问题的答案。闭包能使函数执行完后,其内部局部变量因被引用而不被销毁,从而记住创建时的环境。比如在一个函数内部返回另一个函数,内部函数就形成了闭包,可访问外部函数的局部变量。在 JavaScript 中,当一个函数返回另一个函数时,就创建了一个闭包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function createGreeter() {
const name = "Prorise";
// 返回一个函数,这个函数“记住”了它被创建时的环境,包括变量 name
return function () {
console.log(`Hello, ${name}`);
};
}

// greeter 变量现在持有了内部返回的那个函数
const greeter = createGreeter();

// 即使 createGreeter 已经执行完毕,greeter 函数依然可以访问到 name 变量
greeter();
greeter();

闭包的核心价值:

  1. 数据封装与私有变量: 闭包可以创建出只能通过特定函数访问的“私有”变量,是实现模块化和封装的基础。
  2. 状态保持: 让函数能够跨多次调用保持状态,例如计数器、缓存等。

2.3. this 关键字:解密上下文的指向

this 是 JavaScript 中最令人困惑的概念之一,但也是最重要的。与许多其他语言不同,JavaScript 中 this 的值并不取决于它在哪里被定义,而是取决于它在何处、以及如何被调用this 指向的是函数的 执行上下文

掌握 this 的关键在于理解以下四种绑定规则:

1. 默认绑定

当函数作为独立函数直接调用时(没有通过对象调用),this 会被绑定到全局对象。在浏览器中是 window,在严格模式 ('use strict') 下是 undefined

1
2
3
4
5
function showThis() {
console.log(this);
}

showThis(); // 在非严格模式下,输出 window 对象

2. 隐式绑定

当函数作为对象的一个方法被调用时,this 会被绑定到调用该方法的那个对象。

1
2
3
4
5
6
7
8
9
const user = {
name: "Prorise",
greet: function() {
// this 指向调用 greet 方法的 user 对象
console.log(`Hello, my name is ${this.name}`);
}
};

user.greet(); // greet 是通过 user 对象调用的

陷阱: 如果将方法赋给另一个变量再调用,隐式绑定会丢失,退化为默认绑定。

1
2
3
4
5
6
7
8
const user = {
name: "Prorise",
greet: function () {
console.log(this.name);
}
};
const standaloneGreet = user.greet;
standaloneGreet(); // 输出 undefined (非严格模式下 this.name 是 window.name)

3. new 绑定

当函数通过 new 关键字调用(作为构造函数)时,this 会被绑定到一个新创建的空对象上。

1
2
3
4
5
6
7
8
function User(name) {
// this 被绑定到一个新对象 {}
this.name = name;
// 构造函数隐式返回 this
}

const userInstance = new User("Prorise");
console.log(userInstance.name);

4. 显式绑定

我们可以通过 call(), apply(), 或 bind() 方法,强制指定函数执行时的 this 值。我们将在下一节详细探讨。


2.4. 核心原理:callapplybind 的应用

上一节我们提到,隐式绑定可能会丢失,导致 this 指向非预期的对象。为了解决这个问题,JavaScript 提供了三种方法来显式地、强制地设置函数的 this 上下文。

作用
立即调用函数,并把 this 绑定到指定对象;其余参数 按逗号逐个传递

记忆法
callC omma(逗号分隔参数)

1
2
3
4
5
6
7
const person = { name: "Prorise" };

function introduce(city, country) {
console.log(`I am ${this.name}, from ${city}, ${country}.`);
}

introduce.call(person, "Guangzhou", "China"); // I am Prorise, from Guangzhou, China.

作用
call 相同,但参数以 数组(或类数组) 一次性传入。

记忆法
applyA rray(数组传参)

1
2
3
4
5
6
7
8
const person = { name: "Prorise" };
const location = ["Shenzhen", "China"];

function introduce(city, country) {
console.log(`I am ${this.name}, from ${city}, ${country}.`);
}

introduce.apply(person, location); // I am Prorise, from Shenzhen, China.

作用
不立即调用,而是返回一个 永久绑定 this 的新函数;可预设部分参数(柯里化)。

记忆法
bindB ind and return(绑定并返回新函数)

1
2
3
4
5
6
7
8
9
10
const person = { name: "Prorise" };

function introduce(city, country) {
console.log(`I am ${this.name}, from ${city}, ${country}.`);
}

const boundIntroduce = introduce.bind(person, "Beijing");

// 稍后调用
boundIntroduce("China"); // I am Prorise, from Beijing, China.

2.5. 特殊函数:IIFE, 箭头函数

除了常规函数,JavaScript 还有一些特殊的函数形式,它们在特定场景下非常有用。

IIFE (立即执行函数表达式)

IIFE (Immediately Invoked Function Expression) 是一个在定义时就立即执行的函数表达式。

痛点背景: 在 ES6 出现块级作用域之前,为了避免在 for 循环等场景中创建的变量污染全局作用域,开发者们发明了 IIFE 来创建一个临时的函数作用域。

1
2
3
4
(function() {
var message = "函数内部作用域";
console.log(message);
})();

虽然 letconst 的块级作用域让 IIFE 用于创建作用域的需求大大降低,但它在一些需要初始化且只执行一次的模块化代码中仍有应用。

箭头函数

ES6 引入的箭头函数提供了一种更简洁的函数写法,但它最重要的特性是 它没有自己的 this 绑定

核心特性: 箭头函数会捕获其定义时所在上下文(作用域)的 this 值,并将其作为自己的 this。这彻底解决了传统函数在回调中 this 指向丢失的问题。

痛点背景: 看一个传统的回调 this 丢失问题。

1
2
3
4
5
6
7
8
9
10
11
const team = {
name: "Prorise Devs",
members: ["Alice", "Bob"],
showMembers: function() {
this.members.forEach(function(member) {
// 这里的 this 不再指向 team 对象,而是 window (默认绑定)
console.log(`${member} is on team ${this.name}`);
});
}
};
team.showMembers();

解决方案: 使用箭头函数,this 会被自动绑定到 showMembers 方法的 this,也就是 team 对象。

1
2
3
4
5
6
7
8
9
10
11
const team = {
name: "Prorise Devs",
members: ["Alice", "Bob"],
showMembers: function() {
this.members.forEach(member => {
// 箭头函数没有自己的 this,它会“继承”外层 showMembers 的 this
console.log(`${member} is on team ${this.name}`);
});
}
};
team.showMembers();

2.6. 本章核心速查

核心速查总结

分类关键项核心描述
函数定义函数声明function name(){},存在函数提升,可在声明前调用。
函数表达式const name = function(){},不提升,更灵活。
核心概念闭包 (Closure)函数能“记住”并访问其定义时的作用域,用于封装和状态保持。
this 绑定隐式绑定obj.method()this 指向 obj
显式绑定使用 call, apply, bind 强制指定 this
箭头函数没有自己的 this,继承外层作用域的 this
this 控制call() / apply()立即执行 函数,区别在于参数是 逗号分隔 还是 数组
bind()返回一个新函数,其 this 被永久绑定。

第三章:内置对象与数据结构:从 API 到内部原理

摘要: 在掌握了语言的核心语法与函数之后,我们进入了数据处理的领域。本章将深入剖析 JavaScript 中最核心的内置对象与数据结构。我们将超越简单的 API 调用,探讨 Array 在 V8 引擎中的内部表示与性能特征,理解 String 的不可变性原理,并从时间复杂度的角度深度对比 ObjectMapSet 的适用场景。学完本章,您将能够基于底层原理,为不同的业务需求选择最高效的数据结构,并写出更健壮、性能更优的代码。


在本章中,我们将深入探索数据的组织与处理:

  1. 首先,我们将从 Array 开始,不仅学习其丰富的 API,更要理解其作为动态集合的性能考量与函数式编程的应用。
  2. 接着,我们将探讨 String,重点是理解其“不可变性”这一核心原理及其对程序性能的深远影响。
  3. 然后,我们将对 ObjectMapSet 进行深度对比,分析它们作为键值对集合的优劣与底层差异。
  4. 最后,我们将快速浏览 JSONMathDate 等实用工具对象,掌握它们在实际开发中的最佳实践。

3.1. 数组 (Array):动态集合与性能考量

在几乎所有的编程语言中,数组都是一种基础且重要的数据结构。然而,JavaScript 的 Array 与 C++/Java 等语言中的传统数组在底层实现上存在着根本性的区别。

3.1.1. 数组的本质:动态对象而非连续内存

核心原理:传统数组(如 C++ 中的 int arr[10])是一段 连续的、固定大小的内存空间,访问元素(arr[i])可以通过简单的指针运算实现,速度极快 (O(1))。

而 JavaScript 的 Array 本质上是一种特殊的对象,其键是整数索引,但它 并不保证内存的连续性。这赋予了它极大的灵活性(如动态增删、存储不同类型元素),但也带来了性能上的复杂性。

为了优化性能,V8 等现代 JavaScript 引擎内部会将数组分为两种主要模式:

  • 密集数组: 当数组元素是连续的时(例如 [1, 2, 3]),引擎会为其分配连续的内存,实现类似传统数组的快速访问。这是性能最优的模式。
  • 稀疏数组: 当数组存在空位时(例如 const a = [1, , 3]; a[1000] = 5;),引擎会将其降级为更慢的哈希表/字典结构来存储。对稀疏数组的操作会比密集数组慢得多。

3.1.2. 基础操作 API:用法与副作用分析

在对数组进行操作时,一个至关重要的考量是该操作是否会修改原数组。这种修改被称为 副作用可变性

产生副作用(修改原数组)的常用方法:

  • push(): 在数组末尾添加一个或多个元素,返回新的长度。
  • pop(): 删除并返回数组的最后一个元素。
  • shift(): 删除并返回数组的第一个元素。
  • unshift(): 在数组开头添加一个或多个元素,返回新的长度。
  • splice(start, deleteCount, ...items): 万能方法,可以从 start 索引开始,删除 deleteCount 个元素,并插入 items。它返回被删除元素的数组。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const numbers = [10, 20, 30, 40];

// push - 往后方推入数组
console.log("--- push ---");

numbers.push(50)
console.log('更新后的数组:', numbers); // [10, 20, 30, 40, 50]

// pop - 弹出数组最后一个元素
console.log("--- pop ---");
const last = numbers.pop();
console.log('弹出的元素为:', last); // 50
console.log('弹出后的数组为:', numbers); // [10, 20, 30, 40]

// shift - 弹出数组第一个元素
console.log("--- shift ---");
const first = numbers.shift();
console.log('弹出的元素为:', first); // 10
console.log('弹出后的数组为:', numbers); // [20, 30, 40]

// unshift - 往前方推入数组
console.log("--- unshift ---");
numbers.unshift(5);
console.log('更新后的数组:', numbers); // [5, 20, 30, 40]

// splice(start, deleteCount, items) - 可以从 `start` 索引开始,删除 `deleteCount` 个元素,并插入 `items`。
// [5,20,30,40]
console.log("--- splice ---");
const deleted = numbers.splice(1, 2); // 从索引 1 开始删除 2 个元素
console.log('删除的元素为:', deleted); // [20, 30]
console.log('更新后的数组为:', numbers); // [ 5, 40 ]

不产生副作用(返回新数组)的常用方法:

  • slice(start, end): 提取从 startend(不含 end)的元素,返回一个 新数组,原数组不变。
  • concat(...items): 连接多个数组或值,返回一个 新数组
1
2
3
4
5
6
7
8
9
10
11
12
13
const original = [1,2,3,4]
const sliced = original.slice(1,3) // 相当于提取 1 和 2
console.log("---slice---");

console.log('Sliced:', sliced); // [2, 3]
console.log('Original:', original); // [1, 2, 3, 4] (原数组未被修改)

const original_2 = [5,6,7,8]
const concat_arry = original.concat(original_2)
console.log("---concat---");
console.log('Concat:', concat_arry); // [1, 2, 3, 4, 5, 6, 7, 8]
console.log('Original:', original); // [1, 2, 3, 4] (原数组未被修改)

在现代 JavaScript 开发,特别是函数式编程和 React 等框架中,强烈推荐优先使用不产生副作用的方法,以保证数据的可预测性和程序的稳定性。

3.1.3. 高阶函数与函数式编程范式

核心思想: 所谓高阶函数,就是指可以接受函数作为参数,或者将函数作为返回值的函数。map, filter, reduce 是数组最重要的三个高阶函数,它们完美体现了 不变性 原则——即从不修改原数组,而是返回一个全新的、经过处理的数组。

map(callback)

作用: 遍历数组,对每个元素执行 callback 函数,并将每次执行的 返回值 收集起来,组成一个 新的数组 返回。

业务场景: 你有一个商品价格列表,需要生成一个包含税后价格的新列表。

1
2
3
4
5
6
7
8
9
const prices = [100, 200, 350];
const TAX_RATE = 1.1;

// 使用 map 生成税后价格数组
// 在这里我们相当于 map 中进行了一个箭头函数,他会对于数组的每一个元素进行一次函数操作
const pricesWithTax = prices.map(price => price * TAX_RATE);

console.log('Prices with tax:', pricesWithTax);
console.log('Original prices:', prices); // 原数组不受影响

filter(callback)

作用: 遍历数组,对每个元素执行 callback 函数。如果 callback 返回 true,则保留该元素,否则丢弃。最后将所有保留的元素组成一个 新的数组 返回。

业务场景: 你有一个用户列表,需要筛选出所有已成年的用户。

1
2
3
4
5
6
7
8
const users = [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 17 },
{ name: 'Cathy', age: 30 }
];

const adults = users.filter(user => user.age > 18);
console.log('Adult users:', adults);

reduce(callback, initialValue)

作用: 数组的“聚合器”。它接收一个回调函数和一个可选的初始值。它会遍历数组,将上一次回调的返回值(accumulator)和当前元素(currentValue)传入下一次回调,最终将数组“减少”为一个单一的值。

业务场景: 计算购物车中所有商品的总价。

1
2
3
4
5
6
7
8
9
10
11
12
const cart = [
{ product: 'Laptop', price: 1200 },
{ product: 'Mouse', price: 50 },
{ product: 'Keyboard', price: 100 }
];

// 0 是 accumulator 的初始值
const totalPrice = cart.reduce((accumulator, currentItem) => {
return accumulator + currentItem.price;
}, 0);

console.log('Total price:', totalPrice); // 1350

3.1.4. 排序与搜索的陷阱

sort([compareFunction])

sort() 方法会 就地 对数组元素进行排序(即修改原数组)。它最大的陷阱在于其默认行为。

核心陷阱: 如果不提供 条件sort() 会将所有元素转换为 字符串,然后按 UTF-16 编码顺序进行排序。

1
2
3
4
const numbers = [1, 10, 2, 21, 5];
numbers.sort(); // 默认排序

console.log(numbers);

看到结果 [ 1, 10, 2, 21, 5 ] 了吗?因为 “10” 在字符串比较中排在 “2” 的前面。对数字数组排序时,必须提供比较函数。

解决方案: 提供一个比较函数 (a, b)

  • 如果返回 a - b,则为升序排序。
  • 如果返回 b - a,则为降序排序。
1
2
3
4
const numbers = [1, 10, 2, 21, 5];
// 提供比较函数以实现正确的数字升序排序
numbers.sort((a, b) => a - b);
console.log(numbers);

find() vs indexOf()

  • indexOf(value): 返回指定 value 在数组中首次出现的 索引,如果不存在则返回 -1。它只能用于查找原始类型值。
  • find(callback): 返回数组中满足 callback 函数的 第一个元素的值,如果不存在则返回 undefined。它非常适合用来查找对象数组中的特定对象。
1
2
3
4
5
6
7
8
9
10
11
const users = [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 17 },
{ name: 'Cathy', age: 30 }
];

const indexOfUser = users.findIndex(user => user.name == "Bob")
console.log(indexOfUser); // 1

const findUser = users.find(user => user.name == "Bob")
console.log(findUser); // { name: 'Bob', age: 17 }

3.1.5. 迭代协议:for...of 背后的原理

核心原理: for...of 循环之所以能遍历数组、字符串、Map、Set 等,是因为这些对象都遵守了 Iterable 协议

一个对象要成为“可迭代”的,它必须实现一个 [Symbol.iterator] 方法。这个方法返回一个 迭代器 (Iterator) 对象,该对象有一个 next() 方法。每次调用 next(),它会返回一个形如 { value: ..., done: boolean } 的对象,直到遍历结束 done 变为 true

for...of 循环就是这个过程的语法糖。理解这一点有助于你明白为什么 for...of 不能用于遍历普通对象(因为它们默认不是可迭代的)。

1
2
3
4
5
6
const arr = ['a', 'b'];
const iterator = arr[Symbol.iterator]();

console.log(iterator.next()); // { value: 'a', done: false }
console.log(iterator.next()); // { value: 'b', done: false }
console.log(iterator.next()); // { value: undefined, done: true }

3.2. 字符串 (String):不可变性与高效处理

3.2.1. 字符串的不可变性原理

核心原理: 与数组不同,JavaScript 中的字符串是 不可变的。这意味着一旦一个字符串被创建,它的内容就不能被改变。所有看起来像在修改字符串的方法(如 replace(), toUpperCase()),实际上都是在 返回一个全新的字符串,而原始字符串保持不变。

1
2
3
4
5
6
7
8
9
let str = "hello";
let upperStr = str.toUpperCase();

console.log('upperStr:', upperStr); // "HELLO"
console.log('original str:', str); // "hello" (原字符串未变)

// 试图修改字符串的某个字符是无效的
str[0] = 'H';
console.log('After modification attempt:', str); // "hello"

性能影响: 字符串的不可变性意味着在循环中用 ++= 来拼接大量字符串时,性能会很差。因为每次拼接都会创建一个新的中间字符串,并可能引发垃圾回收。

痛点场景:

1
2
3
4
5
let longString = "";
// 性能不佳!会创建大量中间字符串
for (let i = 0; i < 10000; i++) {
longString += "text";
}

解决方案: 在需要拼接大量字符串时,更高效的做法是先将各部分放入一个数组,最后用 join('') 方法一次性合并。

1
2
3
4
5
const parts = [];
for (let i = 0; i < 10000; i++) {
parts.push("text");
}
const longString = parts.join(''); // 高效得多

3.2.2. 现代字符串操作

模板字符串 (Template Literals)

ES6 引入的模板字符串(使用反引号 `)是处理动态文本的最佳方式,它支持内嵌表达式 ${...} 和多行文本,完全取代了传统的 + 拼接方式。

1
2
3
4
5
6
7
const user = { name: 'Prorise', plan: 'Premium' };
const welcomeMessage = `
Hello ${user.name},
Welcome to our service.
Your current plan is: ${user.plan}.
`;
console.log(welcomeMessage);

常用 API

很多时候,我们不需要记忆所有的 API,API 永远都只是一个方法,想要什么值就往里传,在学习过很多编程语言之后我相信您就能理解,再多的方法都不如一个场景使用到的思路来的实在。

方法/属性说明返回值
length属性,获取字符串的长度。Number
toLowerCase() / toUpperCase()转换为全小写或全大写。常用于不区分大小写的比较。String
trim()移除字符串两端的空白字符(空格、制表符、换行符等)。String
includes(substring)检查是否包含子字符串。Boolean
startsWith(str) / endsWith(str)检查字符串是否以指定子字符串开头或结尾。Boolean
indexOf(substring)查找子字符串首次出现的位置索引,如果不存在则返回 -1。Number
slice(startIndex, endIndex)提取子字符串,返回一个新字符串。String
split(separator)按分隔符将字符串分割成数组。Array
replace(searchValue, newValue)替换 第一个 匹配的子字符串或模式。String
replaceAll(searchValue, newValue)替换 所有 匹配的子字符串或模式。String
padStart(len, str) / padEnd(len, str)用指定字符串在开头或末尾进行填充,直到达到目标长度。常用于格式化数字(如补零)。String
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 字符串常用方法演示
let text = "Hello, JavaScript world! ";
let paddedText = " some text ";

// length - 获取字符串长度 (属性)
console.log(text.length); // 25 (注意末尾的空格)

// toLowerCase() / toUpperCase() - 大小写转换
console.log(text.toLowerCase()); // "hello, javascript world! "
console.log(text.toUpperCase()); // "HELLO, JAVASCRIPT WORLD! "

// trim() - 移除两端空白
console.log(paddedText.trim()); // "some text"

// includes() - 检查是否包含子字符串
console.log(text.includes("JavaScript")); // true
console.log(text.includes("Python")); // false

// startsWith() / endsWith() - 检查开头或结尾
console.log(text.startsWith("Hello")); // true
console.log(text.trim().endsWith("!")); // true (先移除末尾空格再判断)

// indexOf() - 查找索引
console.log(text.indexOf("Java")); // 7
console.log(text.indexOf("Python")); // -1

// slice() - 提取子字符串
console.log(text.slice(0, 5)); // "Hello"
console.log(text.slice(7, 17)); // "JavaScript"

// split() - 按分隔符分割成数组
console.log(text.trim().split(" ")); // ["Hello,", "JavaScript", "world!"]
console.log(text.split(",")); // ["Hello", " JavaScript world! "]

// replace() - 替换第一个匹配项
console.log(text.replace("JavaScript", "Node.js")); // "Hello, Node.js world! "

// replaceAll() - 替换所有匹配项
let repeated = "apple, apple, apple";
console.log(repeated.replaceAll("apple", "orange")); // "orange, orange, orange"

// padStart() - 头部填充
let month = "5";
console.log(month.padStart(2, "0")); // "05" (常用于日期格式化)

3.2.3. 正则表达式 (RegExp) 入门

业务场景: 假设你需要验证用户输入的邮箱地址是否合法,或者从一段文本中提取所有电话号码。用 if/else 等传统方法处理会非常繁琐且极易出错。这正是正则表达式的用武之地。

重要提示: 正则表达式是一种独立于任何编程语言的、强大的 文本模式匹配工具。它有自己的一套语法规则。本节的重点不是深入讲解正则表达式本身的每一个规则,而是 介绍如何在 JavaScript 中创建和使用它。如果您对元字符不熟悉,可以查阅 MDN 或其他专业教程进行学习,当然,现在 Ai 对于正则表达式的编写已经比人类强大得多得多了,我强烈建议如果有需求,可以直接找 AI 写正则表达式,完全没有问题

在 JavaScript 中创建正则表达式

  1. 字面量创建 (推荐): 语法为 /pattern/flags,在脚本加载时编译,性能更高。

    1
    const regex = /[a-z]+/g; // 匹配所有小写字母组合
  2. 构造函数创建: 语法为 new RegExp("pattern", "flags"),在运行时编译,适用于模式是动态的(例如,来自用户输入)场景。

    1
    2
    let userInput = "abc";
    const dynamicRegex = new RegExp(userInput, "i"); // 根据用户输入动态创建,忽略大小写

核心概念速览

分类符号说明
常用元字符\d匹配一个数字 (等同于 [0-9])
\w匹配字母、数字、下划线 (等同于 [A-Za-z0-9_])
\s匹配一个空白字符(空格、制表符、换行符等)
.匹配除换行符外的任意单个字符
量词+匹配前面的表达式 一个或多个
*匹配前面的表达式 零个或多个
?匹配前面的表达式 零个或一个
边界^匹配输入的 开头
$匹配输入的 结尾
字符集[]匹配方括号内的任意一个字符,如 [aeiou]
修饰符 (Flags)g全局 (Global) 匹配,查找所有匹配项而非第一个
i忽略大小写 (Ignore case) 匹配
m多行 (Multiline) 匹配,使 ^$ 匹配行的开头/结尾

在 JavaScript 中的实战方法

  • regexp.test(string): 验证。检查字符串是否匹配模式,返回 truefalse。这是最常用的验证方法。
  • string.match(regexp): 提取。返回一个包含匹配结果的数组。如果正则有 g 标志,返回所有匹配的子串;否则只返回第一个匹配项及其捕获组的详细信息。
  • string.replace(regexp, newValue): 替换。查找匹配项并用新值替换。结合 g 标志可以实现全局替换。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// --- 1. 使用 .test() 进行验证 ---
// 邮箱正则表达式:简单的邮箱格式检查
// 匹配:任意字符 + @ + 任意字符 + . + 任意字符
const emailRegex = /.+@.+\..+/;
const email1 = "test@prorise.com";
const email2 = "invalid-email@";

console.log(`是否 '${email1}' 有效? ${emailRegex.test(email1)}`); // 是否 'test@prorise.com' 有效? true
console.log(`是否 '${email2}' 有效? ${emailRegex.test(email2)}`); // 是否 'invalid-email@' 有效? false

// --- 2. 使用 .match() 进行提取 ---
const text = "My numbers are 123 and 456, not 789.";
const numberRegex = /\d+/g; // \d+ 匹配一个或多个数字, g 表示全局查找

console.log(text.match(numberRegex)); // ["123", "456", "789"]

// --- 3. 使用 .replace() 进行替换 ---
const message = "JavaScript is great. I love JavaScript!";
const jsRegex = /javascript/ig; // i 表示忽略大小写, g 表示全局
// 将所有 "JavaScript" (不区分大小写) 替换为 "JS"
console.log(message.replace(jsRegex, "JS")); // "JS is great. I love JS!"

3.3. 键值对集合:Object, Map, Set 的深度对比

在 JavaScript 中,存储键值对数据最常用的方式是 Object。但 ES6 引入的 MapSet 提供了更专业、更高效的解决方案。

3.3.1. 作为字典的 Object

优势: 语法简洁,易于创建和访问。
原生缺陷:

  1. 键类型限制: 对象的键只能是 StringSymbol 类型。任何非字符串的键都会被隐式转换为字符串。
  2. 原型链污染风险: 如果不小心使用了 __proto__ 等内置属性名作为键,可能会覆盖原型链上的方法,导致意外行为或安全漏洞。
  3. 迭代不便: 遍历对象的属性需要 for...inObject.keys(),不如 MapSet 的直接迭代方便。

3.3.2. Map:为“字典”场景而生的数据结构

Map 是一个真正的哈希表,它的设计完全是为了高效地存储和检索键值对。

与 Object 的核心对比:

特性MapObject
键的类型任意类型 (包括对象、函数)String 或 Symbol
键的顺序按插入顺序历史上无序,现代引擎大多实现有序,但不保证
大小获取.size 属性 (O(1))Object.keys().length (O(n))
迭代直接可迭代 (for...of, forEach)需要辅助方法 (Object.keys, values, entries)
性能在频繁增删键值对的场景下,通常 更优在仅有少量固定属性时性能极佳
原型无原型链,不会与内置属性冲突有原型链,可能存在键名冲突

业务场景: 当你需要一个键不是字符串的字典,或者需要频繁地对集合进行增删操作时,Map 是不二之选。

1
2
3
4
5
6
7
8
9
10
const user1 = { id: 1 };
const user2 = { id: 2 };

const roles = new Map();
// 使用对象作为键
roles.set(user1, 'Admin');
roles.set(user2, 'Editor');

console.log('Role of user1:', roles.get(user1));
console.log('Map size:', roles.size);

3.3.3. Set:唯一值的数学集合

Set 对象允许你存储任何类型的 唯一值,无论是原始值或者是对象引用。

与 Array 的核心对比:

业务场景: 数组去重、检查一个值是否存在于一个大集合中。

场景SetArray
保证唯一性天生如此需要手动去重(如 filter + indexOf
成员检查set.has(value) (O(1))array.includes(value) (O(n))

性能差异是关键。检查一个元素是否存在于一个有 100 万个元素的 Set 中,速度几乎和检查 10 个元素一样快。而对于数组,则需要遍历整个数组,速度会慢 10 万倍。

1
2
3
4
5
6
7
8
9
10
11
const numbers = [1, 2, 2, 3, 4, 4, 5];

// 1. 数组去重
const uniqueNumbers = new Set(numbers);
console.log('Unique numbers:', uniqueNumbers);
// 可轻松转回数组 通过...解构赋值语法将他拆散变为数组
console.log('Back to array:', [...uniqueNumbers]);

// 2. 成员检查
console.log('Does Set have 3?', uniqueNumbers.has(3)); // true
console.log('Does Set have 6?', uniqueNumbers.has(6)); // false

3.4. 其他实用内置对象

3.4.1. JSON:通用数据交换格式

JSON (JavaScript Object Notation) 是一种轻量级的数据交换格式,已成为 Web API 通信的事实标准。

  • JSON.stringify(JavaScript值[, 转换函数, 缩进]): 将 JavaScript 的值(如对象、数组)转换为 JSON 字符串

    • JavaScript值: 必需,要被转换成 JSON 字符串的对象、数组或其他值。
    • 转换函数: 可选,一个函数,用于在序列化过程中改变值的行为。
    • 缩进: 可选,用于美化输出的 JSON 字符串,可以是数字(代表空格数)或字符串(如 '\t')。
  • JSON.parse(JSON字符串[, 还原函数]): 将一个 JSON 字符串 解析为对应的 JavaScript 值。

    • JSON字符串: 必需,一个符合 JSON 格式规范的字符串。
    • 还原函数: 可选,一个函数,用于在解析后对生成的值进行转换。

核心规范与安全:

  • JSON 格式比 JS 对象字面量更严格:键必须是双引号包裹的字符串。
  • JSON 不能表示 undefined, Symbol, 函数等。
  • 在解析来自不受信任来源的 JSON 字符串时要小心,JSON.parse 本身是安全的,但解析后的数据可能被恶意利用。reviver 参数可用于在解析过程中对数据进行处理和校验。
1
2
3
4
5
6
const user = { name: "Prorise", id: 1, joined: new Date() };
const jsonString = JSON.stringify(user, null, 2); // 第三个参数用于格式化输出
console.log(jsonString);

const parsedUser = JSON.parse(jsonString);
console.log(parsedUser.name);

3.4.2. Math 与 Date

  • Math: 一个静态对象,提供了常用的数学常数和函数,如 Math.random() (生成 0-1 之间的随机数), Math.max(), Math.floor() 等。
  • Date: 用于处理日期和时间。创建一个 new Date() 实例来表示特定时间点。

实践建议: Date 对象的 API 在处理时区等复杂场景时显得笨拙且易出错。在生产环境中,强烈推荐使用成熟的第三方库,如 date-fnsDay.js,它们提供了更友好、更可靠的 API,同样的 Math 也是一样,对于这些对象的 Api 我们无需细扣,用到什么我们查什么即可


3.5. 本章核心原理速查与高频面试题

核心原理速查

概念核心原理关键影响
JS 数组本质是特殊对象,非连续内存动态灵活,但需警惕稀疏数组带来的性能下降。
副作用/不变性操作是否修改原数据不可变操作更利于代码的可预测性和调试。
高阶函数接受或返回函数的函数map, filter, reduce 是函数式编程的核心,遵循不变性。
字符串不可变性字符串一旦创建无法更改频繁拼接字符串时,应使用 Array.join() 以避免性能问题。
Map vs ObjectMap 键类型任意,性能更优在非字符串键或频繁增删的字典场景,应使用 Map。
Set vs ArraySet 保证唯一性,has 检查在去重和成员检查场景,Set 性能远超 Array。

3.6 课后小结

面试官深度追问
2025-08-27

假设有一个包含上千个用户对象的数组,我需要频繁地检查某个特定用户(通过其对象引用)是否存在于这个数组中。你会选择什么数据结构,为什么?

我会选择 Set 而不是 Array

具体解释一下。

原因是时间复杂度。如果使用数组,每次检查都需要调用 array.includes(userObject),这是一个 O(n) 操作,意味着在最坏的情况下,它需要遍历整个数组来找到该用户。对于上千个元素,这会越来越慢。

Set 内部使用类似哈希表的结构来存储数据。检查一个成员是否存在,使用 set.has(userObject),其平均时间复杂度是 O(1),几乎是瞬时的,不受集合大小的影响。因此,对于频繁的成员检查场景,Set 的性能优势是压倒性的。

很好。看来你对于他们两的理解的还不错


第四章:对象、原型与继承

摘要: 在前几章中,我们将对象作为一种数据结构来使用。本章,我们将深入其底层,探索 JavaScript 作为一门面向对象语言的真正基石:原型 (Prototype)。我们将从对象属性的内部特性 (Property Descriptor) 讲起,理解构造函数模式的原理与局限,并最终揭示 原型链 是如何实现继承这一核心概念的。我们还将回顾从古至今各种继承模式的演进,最终落脚于 ES5 时代最优雅的实现方案。学完本章,您将对 JavaScript 的对象系统和继承机制有体系化的、深入的理解。


在本章中,我们将沿着一条从微观到宏观的路径,彻底解构 JavaScript 的对象世界:

  1. 首先,我们将深入一个 对象的内部,了解其属性的底层特性。
  2. 接着,我们将探讨 构造函数 这一批量创建对象的模式及其内存效率问题。
  3. 然后,我们将引出解决方案——原型与原型链,这是理解 JS 继承机制的唯一钥匙。
  4. 在此基础上,我们将回顾并推演 JS 中 经典继承模式的演进历史,理解每种模式解决的问题与引入的新问题。
  5. 最后,我们将学习现代 JS 提供的、用于操作对象和原型的 一系列工具方法

4.1. 对象基础回顾:属性的内部特性

我们通常通过点或方括号来为对象赋值,但这只是表象。在 JavaScript 引擎内部,每个属性都拥有一组 内部特性,它们描述了属性的行为。这些特性被封装在一个名为 属性描述符 的对象中。

属性分为两类:数据属性访问器属性

概念
最常见的属性,直接存储一个值,由 4 个描述符组成:

  • value 默认值
  • writable 是否可改
  • enumerable 是否可枚举
  • configurable 是否可删除/改特性

痛点示例
普通赋值无法做到只读:

1
2
3
4
const user = {};
user.id = 1;
user.id = 2; // 被成功改写
console.log(user.id); // 2

解决方案
使用 Object.defineProperty 设为只读:

1
2
3
4
5
6
7
8
9
10
11
12
const user = {}

Object.defineProperty(user, 'id', {
value: 1,
writable: false,
enumerable: true,
configurable: false
});

console.log('Initial ID:', user.id); // 1
user.id = 2; // 严格模式抛出 TypeError;非严格模式静默失败
console.log('Final ID:', user.id); // 1

概念
不直接存值,而是通过 get / set 函数拦截 读取 / 写入,同样拥有 enumerableconfigurable

痛点示例
需要 fullName 实时反映 firstName + lastName

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const user = {
firstName: 'Prorise',
lastName: 'Blog'
};

Object.defineProperty(user, 'fullName', {
enumerable: true,
configurable: true,
get() {
return `${this.firstName} ${this.lastName}`;
},
set(value) {
const [first, last] = value.split(' ');
this.firstName = first;
this.lastName = last;
}
});

console.log(user.fullName); // "Prorise Blog"

user.lastName = 'Docs';
console.log(user.fullName); // "Prorise Docs"

user.fullName = 'Awesome User';
console.log(user.firstName); // "Awesome"
console.log(user.lastName); // "User"

4.2. 构造函数模式

重要信息: 这本身是面向对象编程中的知识点,但是为了了解原型链,我们必须要了解构造函数

痛点背景: 如果我们需要创建多个结构相同的对象(例如多个用户),使用对象字面量会导致大量重复代码。

1
2
3
const user1 = { name: 'Alice', age: 25 };
const user2 = { name: 'Bob', age: 30 };
// ... 代码非常冗余

解决方案: 使用 构造函数 (Constructor) 模式。构造函数本质上是一个普通的函数,但按照惯例,我们用 new 关键字来调用它,用于“构造”一个新的对象实例。

工作原理: 当你使用 new 关键字调用一个函数时,会自动发生以下四件事:

  1. 在内存中创建一个新的空对象。
  2. 将这个新对象的内部 [[Prototype]] 属性(在代码中通过 __proto__ 访问)指向构造函数的 prototype 属性。
  3. 将构造函数内部的 this 指向这个新对象。
  4. 执行构造函数内部的代码(为新对象添加属性)。
  5. 如果构造函数没有显式返回其他对象,则隐式地返回这个新创建的对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 构造函数,首字母通常大写
function User(name, age) {
// this 指向新创建的实例
this.name = name;
this.age = age;
this.greet = function() {
console.log(`Hello, I am ${this.name}.`);
};
}

const user1 = new User('Alice', 25);
const user2 = new User('Bob', 30);

user1.greet();
user2.greet();

局限性: 构造函数模式虽然解决了代码复用问题,但引入了一个新的 性能问题。每个由 User 构造函数创建的实例,都在内存中拥有一个 全新的、独立的 greet 方法副本。如果有一万个用户实例,就会有一万个功能完全相同的 greet 函数,这造成了极大的内存浪费。

1
console.log(user1.greet === user2.greet); // false,它们是两个不同的函数

4.3. 原型 (prototype) 与原型链 (__proto__)

承上启下: 为了解决构造函数模式中方法无法共享的问题,JavaScript 引入了其继承模型的核心——原型 (Prototype)

核心原理: 在 JavaScript 中,每个函数 在创建时都会自动获得一个 prototype 属性。这个 prototype 属性的值是一个对象,我们称之为 原型对象。这个原型对象的作用就是作为一个公共的存储空间,存放所有需要被该构造函数创建的实例所 共享 的属性和方法。

解决方案: 我们可以将需要共享的方法(如 greet)从构造函数内部移到其 prototype 对象上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function User(name, age) {
this.name = name;
this.age = age;
}

// 将 greet 方法定义在 User 的原型对象上
User.prototype.greet = function() {
console.log(`Hello, I am ${this.name}.`);
};

const user1 = new User('Alice', 25);
const user2 = new User('Bob', 30);

user1.greet();
user2.greet();

// 验证方法是否共享
console.log(user1.greet === user2.greet); // true!

原型链 (Prototype Chain)

那么,user1 实例本身并没有 greet 方法,它是如何调用到 User.prototype.greet 的呢?答案就是 原型链

  1. 每个由构造函数创建的实例对象,都有一个内部属性 [[Prototype]](在代码中通常可以通过非标准的 __proto__ 访问),这个属性指向其构造函数的 prototype 对象。
    user1.__proto__ === User.prototype // true

  2. 当你试图访问一个对象的属性(如 user1.greet)时,JavaScript 引擎会首先在 对象自身 查找该属性。

  3. 如果找不到,引擎就会通过 __proto__ 链接,去其 原型对象 (User.prototype) 上查找。

  4. 如果原型对象上还找不到,而原型对象本身也是一个对象,它也有自己的 __proto__,引擎会继续沿着这条链向上查找,直到找到该属性或到达原型链的顶端 (Object.prototype__proto__ 指向 null)。

这条由 __proto__ 串联起来的对象链条,就是 原型链


4.4. 继承模式的演进

基于原型链,JavaScript 开发者在历史上探索出了多种继承模式,每种模式都试图解决前一种模式的缺陷。正是因为这些方式的不断迭代,后来各大编程语言才推出了 Class 关键字以及 Super 关键字,了解这些演进可以让你对面向对象有更深刻的认知

1. 原型链继承

实现: 将子类的原型直接赋值为父类的一个实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function Parent(name) { 
this.parentProp = 'Parent';
this.sharedArray = []; // 引用类型属性
this.name = name || 'default';
}
Parent.prototype.getParentProp = function() { return this.parentProp; };

function Child() { this.childProp = 'Child'; }
Child.prototype = new Parent(); // 核心

const child1 = new Child();
const child2 = new Child();

// 缺点 1:父类实例的引用类型属性被所有子类实例共享
child1.sharedArray.push('item1');
console.log(child1.sharedArray); // ['item1']
console.log(child2.sharedArray); // ['item1'] - 被意外修改了!

// 缺点 2:无法向父类构造函数传递参数
console.log(child1.name); // 'default' - 无法传递自定义 name 参数
console.log(child1.getParentProp()); // 'Parent'
  • 优点: 简单,直观地利用了原型链。
  • 缺点:
    1. 父类实例的属性(parentProp)变成了子类原型的一部分,会被所有子类实例共享。如果这个属性是引用类型(如数组),一个实例的修改会影响所有其他实例。
    2. 创建子类实例时,无法向父类构造函数传递参数。

2. 构造函数窃取

实现: 在子类构造函数中,使用 .call().apply() 调用父类构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Parent(name) { this.name = name; this.colors = ['red']; }
Parent.prototype.sayHello = function () { return `Hello, I'm ${this.name}`; }; // 添加原型方法

function Child(name) {
Parent.call(this, name); // 核心
}

const child1 = new Child('c1');
const parent = new Parent();
child1.colors.push('blue');


console.log(child1.name, child1.colors); // c1, ['red', 'blue']
console.log(parent.colors); // 优点:他的属性没有被影响 [ 'red' ]
console.log(child1.sayHello); // undefined - 缺点:无法访问父类原型方法
  • 优点: 解决了原型链继承的两个主要缺点:可以向父类传参,且父类实例属性不会被共享。
  • 缺点: 父类原型上的方法没有被继承。子类实例无法访问 Parent.prototype 上的方法。

3. 组合继承

实现: 结合以上两种模式,用构造函数窃取继承实例属性,用原型链继承原型方法。

1
2
3
4
5
6
7
8
9
10
11
12
function Parent(name) { this.name = name; }
Parent.prototype.sayName = function() { console.log(this.name); };

function Child(name, age) {
Parent.call(this, name); // 第一次调用 Parent
this.age = age;
}
Child.prototype = new Parent(); // 第二次调用 Parent
Child.prototype.constructor = Child; // 修复 constructor 指向

const child = new Child('c1', 10);
child.sayName(); // 'c1'
  • 优点: 解决了之前两种模式的所有问题,是 JavaScript 中最常用的继承模式之一。
  • 缺点: 父类构造函数被调用了两次:一次在 Parent.call,一次在 new Parent()。这会创建一份多余的父类实例属性在子类原型上,造成了轻微的性能浪费,且还需要频繁的修复 constructor 指向

4. 寄生组合式继承

实现: 这是对组合继承的优化,被认为是 ES6 class 出现之前最理想的继承方案。它通过 Object.create() 来避免调用第二次父类构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Parent(name) { this.name = name; }
Parent.prototype.sayName = function() { console.log(this.name); };

function Child(name, age) {
Parent.call(this, name); // 继承属性
this.age = age;
}

// 核心优化:
// 1. 创建一个父类原型的副本
// 2. 增强这个副本
// 3. 将增强后的副本赋值给子类原型
const parentPrototype = Object.create(Parent.prototype);
parentPrototype.constructor = Child;
Child.prototype = parentPrototype;

const child = new Child('c1', 10);
child.sayName();
  • 优点: 高效。只调用一次父类构造函数,并且原型链保持完整,但这样的代码并没有办法称得上优雅

4.5. Object 的静态方法:操作对象的标准化工具箱

在上一节,我们历经了从“原型链”到“寄生组合式继承”的漫长探索。这个过程虽然揭示了 JavaScript 继承的本质,但也暴露了一个问题:开发者长期以来依赖一些非标准(如 __proto__)或较为繁琐的模式来操作原型。

为了解决这一问题,ES5 和 ES6 正式标准化了一套静态方法,它们如同一个官方提供的“瑞士军刀”,为我们提供了更安全、更可靠、更清晰的方式来与对象及其底层原型进行交互。

主题一:原型链的标准化操作

这些方法是直接针对我们刚刚探讨的原型继承问题的官方解决方案。

方法核心作用解决的问题
Object.create(proto)创建一个新对象,其原型指向 proto在实现继承时,需要一个干净继承原型、但又不执行父构造函数的中间对象。
Object.getPrototypeOf(obj)读取 一个对象的 [[Prototype]]长期依赖非标准的 __proto__ 属性来访问原型,存在兼容性风险。
Object.setPrototypeOf(obj, proto)写入 或修改一个已存在对象的 [[Prototype]]过去没有标准方法来动态修改一个对象的原型链。

这三个方法构成了现代 JavaScript 中直接操作对象 [[Prototype]](即对象原型)的标准 API。它们分别用官方、可靠的方式解决了原型继承、读取和写入这三个核心场景下的历史痛点,取代了过去非标准或有副作用的实现技巧。

尤其需要注意的是 Object.setPrototypeOf,在对象创建后修改其原型是一个对性能极不友好的操作,会破坏 JavaScript 引擎的内部优化,应在开发中极力避免使用。

主题二:属性的遍历与枚举

历史痛点: for...in 循环是遍历对象属性的传统方式,但它存在两个主要问题:1. 它会遍历到原型链上可枚举的属性;2. 必须配合 hasOwnProperty() 才能过滤出对象自身的属性,写法繁琐。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 痛点演示
function Parent() { this.parentProp = 'Parent'; }
const p = new Parent();

const obj = Object.create(p);
obj.ownProp = 'Own';

for (const key in obj) {
// 如果不加这个判断,'parentProp' 也会被打印出来
if (obj.hasOwnProperty(key)) {
console.log(key); // ownProp
}
}

解决方案: ES5/ES6 提供了一系列方法,用于精确地获取对象 自身的、可枚举的 属性,让遍历更安全、更直接。

  • Object.keys(obj): 返回一个由对象 自身可枚举属性名 组成的数组。
  • Object.values(obj): 返回一个由对象 自身可枚举属性值 组成的数组。
  • Object.entries(obj): 返回一个由对象 自身可枚举键值对 [key, value] 组成的二维数组。
1
2
3
4
5
6
const user = { name: 'Prorise', role: 'Admin' };
Object.defineProperty(user, 'id', { value: 1, enumerable: false }); // id 不可枚举

console.log(Object.keys(user));
console.log(Object.values(user));
console.log(Object.entries(user));

这些方法返回的是 真数组,可以无缝衔接使用 forEach, map, filter 等高阶数组方法,是现代 JavaScript 中遍历对象的首选方式。

主题三:对象的合并与克隆

历史痛点: 在 ES6 之前,要合并多个对象的属性,需要手动遍历源对象并逐一赋值给目标对象,代码冗长。

解决方案: Object.assign() 与现代的扩展运算符 ...

  • Object.assign(target, ...sources): 将一个或多个源(sources)对象中所有 可枚举的自有属性 复制到目标(target)对象。它会修改并返回 target 对象。这是一个 浅拷贝
  • 扩展运算符 ... (Spread Syntax): ES6 引入的语法糖,是目前 创建合并后新对象 的首选方式,语法更简洁,意图更清晰。

需求
不修改任何原始对象,而是生成一个全新的对象。

1
2
3
4
5
6
7
8
9
10
const defaults   = { theme: 'dark', version: '1.0' };
const userConfig = { version: '1.2', showAvatar: true };

// 扩展运算符(更简洁,社区首选)
const finalSpread = { ...defaults, ...userConfig };
console.log('Spread:', finalSpread);

// Object.assign(需传入空对象)
const finalAssign = Object.assign({}, defaults, userConfig);
console.log('Assign:', finalAssign);

二者结果相同,但 ... 可读性更高;Object.assign 必须第一个参数是空对象 {} 才能创建新副本。

需求
显式修改已存在的对象。

1
2
3
4
5
6
7
const user       = { name: 'Prorise' };
const permissions = { canEdit: true, canDelete: false };

// 直接在 user 上合并属性
Object.assign(user, permissions);

console.log('Mutated user:', user); // Mutated user: { name: 'Prorise', canEdit: true, canDelete: false }

扩展运算符无法原地修改。user = { ...user, ...permissions }; 会创建新对象并替换引用,而非修改原始对象。


4.6. 本章核心原理与课后小结

核心原理速查

概念核心原理关键点
属性描述符每个属性都有 value, writable, enumerable, configurable 等内部特性。Object.defineProperty() 是控制属性行为的底层工具。
构造函数通过 new 调用,用于批量创建实例。this 指向新实例,但方法不共享导致内存浪费。
原型 prototype函数的属性,一个对象,用于存放共享成员。解决了构造函数方法不共享的问题。
原型链 __proto__实例指向其构造函数原型的链接。属性查找沿着此链向上进行,是 JS 继承的根本。
继承方案寄生组合式继承(ES5 最佳实践) 通过 .call() 继承属性,通过 Object.create() 继承原型,避免了父构造函数的二次调用。

课后小结

面试官深度追问
2025-08-27

你能解释一下 __proto__prototype 的区别和联系吗?

当然。prototype 是函数特有的一个属性,它指向一个对象,即原型对象。这个原型对象的作用是让所有由该函数作为构造函数创建出来的实例,都能共享 prototype 对象上的属性和方法。

__proto__ 则是每个对象(包括函数)都有的一个内部属性,它指向该对象的原型。对于一个实例对象来说,它的 __proto__ 指向创建它的那个构造函数的 prototype

它们的联系就是,实例通过 __proto__ 这条链,找到了构造函数的 prototype,从而实现了属性和方法的继承。可以说,__proto__ 是原型链中实际的链接,而 prototype 是构造函数用来“播种”这条链的起点。

非常清晰。那么,Object.create(null) 创建出来的对象有什么特殊之处?

Object.create(null) 创建出来的是一个纯粹的空对象。它没有任何原型,即它的 __proto__null

这意味着它不继承自 Object.prototype,因此它身上没有任何内置的对象方法,比如 toString(), hasOwnProperty() 等。这让它成为一个非常干净的、用作数据字典(哈希表)的理想选择,可以完全避免与原型链上的属性发生意外冲突。


第五章:面向对象编程 (OOP):从语法到思想的结构化之道

摘要: 在第四章,我们深入探索了 JavaScript 继承的底层基石——原型链,并一步步推导出了 ES5 时代最优雅的“寄生组合式继承”模式。然而,那个过程是复杂且易错的。本章,我们将学习 ES6 带来的革命性变革:class 关键字。我们将揭示 class 如何作为一层优雅的“语法糖”,将我们之前复杂的原型操作,封装成一种对后端开发者(特别是 Java/Spring Boot 背景)极其友好的、结构化的编程范式。本章的核心并非学习一套全新的体系,而是理解这套新语法是如何让我们更高效、更安全地去运用我们已经掌握的原型继承原理。

在本章,我们将完成一次从底层原理到上层建筑的思维跨越:

  1. 首先,我们将探讨 OOP 思想为何在大型 JavaScript 项目中至关重要,直面“代码随地乱飞”的开发痛点。
  2. 接着,我们将逐一学习 class 的核心语法 (constructor, static, #private),并时刻与第四章的原型实现进行对比,理解其“语法糖”的本质。
  3. 然后,我们将聚焦于 extendssuper,看看它们是如何将复杂的“寄生组合式继承”和“构造函数窃取”简化为单个关键字的。
  4. 最后,我们将把 OOP 思想置于 现代前端的生态 中,探讨它在 Angular/NestJS 中的核心地位,以及如何将其分层思想与 React/Vue 的函数式范式进行完美融合。

5.1. OOP 思想在 JavaScript 中的价值

注意: 这一章节适合有面向对象基础的人观看,如果您看不懂,没关系的,在日后的开发中以及现在的前端生态,面向对象的思想在前端体现的会比较少,更多的是组合式,组件化思想

承上启下: 在我们深入 class 语法之前,必须先回答一个根本问题:在一个以函数为一等公民、原型继承如此灵活的语言里,我们为什么还需要看似“刻板”的面向对象编程?

痛点背景:对于习惯了 Java/Spring Boot 这类强结构化框架的开发者而言,初入前端,特别是现代 React/Vue 的函数式组件世界,往往会感到一种“无序感”。数据状态、业务逻辑、API 请求散落在不同的自定义 Hooks 或 Composable 函数中,缺乏一个清晰的、高内聚的组织单元。总会感受到的“代码随地乱飞”。

OOP 思想为解决这一问题提供了强大的武器库:

  • 结构化与封装: 这是 OOP 的核心价值。class 如同一张蓝图,它强制性地将数据(属性)和操作这些数据的行为(方法)紧密地捆绑在一个独立的、高内聚的单元里。当需要处理“用户”逻辑时,我们去找 User 类;需要处理“订单”逻辑,就去找 Order 类。这与在 Spring Boot 中寻找 UserServiceOrderController 的思路是完全一致的。

  • 继承: 允许创建子类来复用和扩展父类的功能,是构建可维护、可扩展代码库的基础。例如,一个 BaseApiController 可以封装通用的错误处理和请求逻辑,所有具体的业务 API 客户端只需继承它即可。

  • 抽象与清晰的契约: class 通过其公共方法向外暴露一个清晰的“契约”,隐藏内部复杂的实现细节。这极大地降低了系统模块间的耦合度,使得大型项目得以协同开发和长期维护。

JavaScript 的 class 语法,正是为了将这些宝贵的 OOP 思想,用一种更简洁、更标准、对开发者更友好的方式引入到语言中。它并没有改变 JavaScript 原型继承的本质,而是为其提供了一个更强大的上层建筑。


5.2. ES6 class:现代 OOP 的语法基石

现在,让我们来逐一解析 class 的语法,并看看它们分别对应着我们在第四章学到的哪些原型概念。

5.2.1. classconstructor:定义蓝图与实例化

class 提供了一个定义“类”的清晰结构。constructor 方法是类的默认构造函数,通过 new 命令创建对象实例时,会自动调用该方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class User {
// constructor 负责初始化实例属性
constructor(name, age) {
this.name = name;
this.age = age;
}

// 实例方法
greet() {
console.log(`Hello, I am ${this.name}.`);
}
}

const user1 = new User('Alice', 25);
user1.greet();

与原型的联系 (Aha Moment!):

  • class User {...} 的声明,本质上等同于我们第四章写的 function User(...) {} 构造函数。
  • constructor 方法就是那个构造函数本身。
  • greet() 这个实例方法,class 语法会自动地、正确地将其定义在 User.prototype,而不是在构造函数里为每个实例重复创建。这正是对第四章“构造函数性能缺陷”的完美优化。
1
2
3
4
5
6
7
8
9
10
11
12
// 上述 class 写法的 ES5 原型等价实现
function User(name, age) {
this.name = name;
this.age = age;
}
User.prototype.greet = function() {
console.log(`Hello, I am ${this.name}.`);
};

const user1 = new User('Bob', 30);
// 验证一下
console.log(user1.greet === (new User()).greet); // true, 方法是共享的

5.2.2. 实例成员与 static 成员

  • 实例成员: 定义在 constructor 内部(通过 this)或类体顶层的属性,以及普通的方法。它们属于每一个对象实例。
  • static 成员: 使用 static 关键字修饰的属性和方法。它们不属于任何实例,而是直接属于 类本身
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class User {
// 实例属性
name = 'Anonymous';

// 静态属性
static platform = 'Prorise Blog';

constructor(name) {
this.name = name;
}

// 静态方法
static getPlatform() {
return `This user is from ${this.platform}.`; // 静态方法中的 this 指向类本身
}
}

const user = new User('Cathy');
console.log(user.name); // 'Cathy'
// console.log(user.platform); // undefined,实例无法访问静态成员

console.log(User.platform); // 'Prorise Blog'
console.log(User.getPlatform()); // "This user is from Prorise Blog."

与原型的联系:

  • static 成员就是直接定义在 构造函数 这个对象上的属性和方法。它等同于:
    1
    2
    3
    function User() {}
    User.platform = 'Prorise Blog';
    User.getPlatform = function() { /*...*/ };

5.2.3. 私有成员 (#)

历史痛点: 在原型时代,JavaScript 没有真正的私有成员。开发者只能通过“下划线命名约定”(如 _privateVar)来“提示”这是一个私有成员,但它在外部依然可以被随意访问和修改,封装性形同虚设。

解决方案: ES2022 引入了 # 前缀,用于定义 真正意义上的私有属性和方法。它们只能在类的内部被访问,外部访问会直接抛出语法错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class User {
#email; // 必须在类体顶层先声明私有字段

constructor(name, email) {
this.name = name;
this.#email = email;
}

getEmail() {
return this.#getFormattedEmail(); // 内部可以访问
}

#getFormattedEmail() { // 私有方法
return `Email: ${this.#email}`;
}
}

const user = new User('David', 'david@example.com');
console.log(user.getEmail()); // "Email: david@example.com"

try {
console.log(user.#email); // 外部访问,将抛出错误
} catch (e) {
console.error(e.message);
}

5.3. 继承 (extends) 与 super 关键字

这是 class 语法糖最强大的地方,它将我们第四章推导出的复杂继承模式,简化为了两个关键字。

5.3.1. extends:建立类之间的层级关系

extends 关键字用于让一个类(子类)继承另一个类(父类)。这意味着子类将自动获得父类所有非私有的属性和方法。

基础代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 1. 定义一个父类
class Parent {
constructor(name) {
this.name = name;
}

greet() {
console.log(`Hello from the Parent, my name is ${this.name}.`);
}
}

// 2. 子类 Child 使用 extends 继承 Parent
class Child extends Parent {
// 即使子类是空的,它也继承了 Parent 的所有东西
}

// 3. 创建子类的实例
const childInstance = new Child('Prorise');

// 4. 调用继承自父类的方法
childInstance.greet();
console.log(childInstance.name);

与原型的联系 (The Ultimate Aha Moment!):

  • class Child extends Parent {} 这一行代码,背后为你做了两件大事,完美对应了“寄生组合式继承”模式:
    1. 它将 Child.prototype[[Prototype]] 设置为了 Parent.prototype (等同于 Object.create(Parent.prototype)),从而实现了原型方法的继承。
    2. 它还将 Child 构造函数自身的 [[Prototype]] 设置为了 Parent 构造函数,从而实现了静态成员的继承。

5.3.2. super():在子类构造函数中调用父类

核心规则: 如果子类有 constructor,则 必须 在其中调用 super(),并且必须在使用 this 关键字之前调用。

与原型的联系:

  • super() 在这里作为函数调用,等同于我们之前在子类构造函数中使用的 Parent.call(this, ...args)。它执行父类的构造函数,并将 this 绑定到当前子类的实例上,从而实现 实例属性的继承(即“构造函数窃取”)。

5.3.3. super.method():在子类中调用父类的方法

super 作为对象使用时,它指向父类的原型。这允许我们在子类中调用被覆盖的父类方法,以实现功能的扩展而非完全重写。

综合代码示例 (extends, super(), super.method()):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Vehicle {
constructor(name) {
this.name = name;
}
move() {
console.log(`${this.name} is moving.`);
}
}

class Car extends Vehicle {
constructor(name, brand) {
// 1. 使用 super() 调用父类构造函数,继承 name 属性
super(name); // 等同于 Vehicle.call(this, name)
this.brand = brand;
}

// 2. 子类重写 move 方法
move() {
// 3. 使用 super.move() 调用父类的同名方法
super.move(); // 等同于 Vehicle.prototype.move.call(this)
console.log(`It's a ${this.brand} car.`);
}
}

const myCar = new Car('Sedan', 'Toyota');
myCar.move();

5.4. instanceof 类型检查

5.4.1. instanceof

instanceof 运算符的工作原理没有改变。它依然是通过检查一个对象的 原型链 上是否存在某个构造函数的 prototype 对象来判断。class 语法只是让这个判断的对象来源更清晰。

1
2
3
4
const myCar = new Car('Sedan', 'Toyota');
console.log(myCar instanceof Car); // true
console.log(myCar instanceof Vehicle); // true,因为 Car.prototype 在 myCar 的原型链上
console.log(myCar instanceof Object); // true

5.5. OOP 在现代前端生态中的位置与实践

核心讨论: 既然 class 如此强大且结构清晰,为何在现代主流的 React/Vue 框架中,我们看到更多的是函数式组件?

  • 全面拥抱 OOP 的框架: Angular 是最彻底贯彻 OOP 和依赖注入思想的框架。其组件、服务、指令等一切皆为 class。对于有 Spring Boot 经验的开发者,Angular 的开发模式会感到非常亲切。在后端,NestJS 更是被誉为“Node.js 版的 Spring Boot”,它完全基于 TypeScript 和 OOP,其架构思想(模块、控制器、服务)与 Spring 高度相似,是 Java/C# 开发者进入 Node.js 世界的最佳桥梁。

  • 函数式范式的兴起: React Hooks 和 Vue 组合式 API 代表了另一种编程哲学。它们追求 极致的灵活性和逻辑复用,通过将逻辑拆分成可组合的函数单元,而不是封装在类中。

  • 优点: 逻辑复用粒度更细,代码更灵活。
    * 缺点: 如果没有良好的架构约束,容易导致逻辑分散、分层不清,即“代码随地乱飞”。

最佳实践:融合两种思想

我们不必陷入“非此即彼”的二元对立。最成熟的工程实践,是 结合两种范式的优点:使用 OOP 的 class 来构建清晰的、分层的业务逻辑(服务层),然后让函数式的 UI 组件(视图层)保持轻薄,只负责消费这些服务并渲染界面。

场景: 我们用 React Hooks 和一个业务逻辑 Class 来重构一个用户列表的加载功能。

1. 创建一个业务逻辑类 (服务层 - The “Service Layer”)
这个文件里没有任何 UI 框架的东西,是纯粹的、可独立测试的业务逻辑。
文件路径: services/UserService.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 这个类负责所有与用户数据相关的逻辑
class UserService {
async fetchUsers() {
try {
// 模拟 API 请求
const response = await new Promise(resolve => setTimeout(() => resolve([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
]), 1000));
return response;
} catch (error) {
console.error("Failed to fetch users", error);
return []; // 返回空数组作为降级处理
}
}
}

// 导出一个单例,整个应用共享这一个实例
export const userService = new UserService();

2. 在 UI 组件中使用这个 Service (视图层 - The “View Layer”)
这个 React 组件变得非常“薄”,只负责调用 Service 的方法,并根据返回的数据更新视图。
文件路径: components/UserList.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import React, { useState, useEffect } from 'react';
import { userService } from '../services/UserService'; // 导入我们的服务实例

function UserList() {
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
// 调用 service 的方法获取数据
userService.fetchUsers().then(data => {
setUsers(data);
setIsLoading(false);
});
}, []); // 空依赖数组确保只在组件挂载时执行一次

if (isLoading) {
return <div>Loading...</div>;
}

return (
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}

这样做的好处:

  • 关注点分离 (SoC): UserService 只关心业务逻辑,UserList 只关心 UI 展示。
  • 代码定位清晰: 业务问题去 services 目录找,UI 问题去 components 目录找。
  • 可测试性: UserService 可以被独立进行单元测试,无需渲染任何 UI。
  • 结构化: 您从 Spring Boot 中欣赏的清晰分层,通过这种方式在前端项目中得到了实现。

5.6. 本章核心原理与高频面试题

核心原理速查

class 语法对应的原型原理 (ES5 等价实现)核心价值
constructor(..)function ClassName(..)定义实例属性
method()ClassName.prototype.method = function() {}性能优化:在原型上定义共享方法
static method()ClassName.method = function() {}定义类级别的方法
class Child extends Parent寄生组合式继承代码简化:用单个关键字实现最复杂的继承模式
super()Parent.call(this, ...)继承父类实例属性
super.method()Parent.prototype.method.call(this)调用父类原型上的方法

高频面试题与陷阱

面试官深度追问
2025-08-28

请解释一下 JavaScript 的 class 和传统面向对象语言(比如 Java)的 class 有什么本质区别?

最本质的区别在于底层实现。Java 的 class 是一个严格的、编译时的实体,其实例化和继承都基于类。而 JavaScript 的 class 本质上是原型继承的语法糖。它的底层机制仍然是我们第四章讨论的原型链。class 并没有为 JavaScript 引入新的对象继承模型,只是提供了一套更清晰、更结构化的语法来操作已有的原型模型。

很好。那么,在子类的 constructor 中,为什么必须在使用 this 之前调用 super()

因为在 ES6 的类继承模型中,子类的实例是通过父类的构造函数来创建的。super() 的作用就是执行父类的构造函数,并让其返回的实例作为子类的 this。如果在调用 super() 之前就尝试访问 this,那么这个 this 实际上还不存在,因此 JavaScript 规定这是一种引用错误,以保证继承模型的正确性。


第六章:元编程与代理(Proxy)

摘要: 欢迎来到 JavaScript 中最接近“魔法”的领域。本章我们将探讨 元编程 的概念,即让代码有能力在运行时检查、修改甚至创造自身的行为。我们将深入学习实现这一思想的核心工具——Proxy 对象。您将了解到 Proxy 如何让我们能够“代理”一个对象,并拦截对其所有基本操作(如读取、赋值、删除),这与我们之前学习的 Object.defineProperty 相比,是一次彻底的、颠覆性的升级。最后,我们将通过一个简化的模型,揭示 Vue 3.x 响应式系统是如何基于 Proxy 构建的。


在本章,我们将像剥洋葱一样,层层深入 Proxy 的世界:

  1. 首先,我们将从 元编程的概念 入手,理解 Proxy 试图解决的根本问题是什么。
  2. 接着,我们将学习 Proxy 的基础语法,了解“目标 (target)”与“处理器 (handler)”的核心关系。
  3. 然后,我们将逐一深入 Proxy 提供的 13 种核心“陷阱” (Traps),学习如何拦截并自定义对象的每一种底层操作。
  4. 最后,我们将聚焦于 Proxy 的实战应用,特别是它如何优雅地解决了数据校验、访问控制,并成为了现代响应式框架的基石。

6.1. 元编程概念入门

元编程 的核心思想是:让代码去操作代码

在传统的编程模式中,我们的代码主要操作数据(数字、字符串、对象等)。而元编程则将代码本身也视为一种数据,允许我们编写出能够 在运行时分析、修改甚至生成其他代码 的程序。这赋予了语言极大的动态性和扩展性。

历史痛点: 在 ES6 的 Proxy 出现之前,如果我们想实现一个元编程的基本需求——“当一个对象的属性被读取或修改时,自动执行某些逻辑(如打印日志)”,该怎么做?我们只能依赖 ES5 的 Object.defineProperty()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Pre-Proxy 时代的痛苦尝试
const user = { name: 'Alice', age: 25 };
const loggedUser = {};

// 我们必须手动遍历每一个属性
Object.keys(user).forEach(key => {
Object.defineProperty(loggedUser, key, {
enumerable: true,
configurable: true,
get() {
console.log(`[LOG] 读取用户对象的属性: ${key}`);
return user[key];
},
set(newValue) {
console.log(`[LOG] 写入用户对象的属性: ${key} to ${newValue}`);
user[key] = newValue;
}
});
});

loggedUser.age = 26;
console.log(loggedUser.name);

这种方式的致命缺陷:

  1. 侵入性强且繁琐: 需要创建一个新对象并手动遍历、定义所有属性。
  2. 无法监听新增/删除: 如果后续为 user 对象新增一个属性 roleloggedUser 对此一无所知,无法进行拦截。同样,它也无法拦截 deletein 等操作。

解决方案: Proxy 的出现,正是为了提供一个 全面的、非侵入性的 元编程解决方案。它允许我们在目标对象之上架设一个虚拟的“代理层”,所有施加于该对象的操作,都会先经过这层代理,给了我们一个统一的、强大的拦截入口。


6.2. Proxy 基础

Proxy 是一个构造函数,用于生成代理实例。

语法: const proxy = new Proxy(target, handler);

  • target: 被代理的原始对象(可以是任何类型的对象,包括数组、函数等)。
  • handler: 一个配置对象,其属性是各种“陷阱”(trap)函数,用于定义代理的具体行为。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 1. 原始对象 (target)
const target = {
message1: "hello",
message2: "world"
};

// 2. 处理器对象 (handler)
const handler = {
// 定义一个 get 陷阱
// target:原始对象 prop:读取的属性名 receiver:代理对象
get: function(target, prop, receiver) {
console.log(`拦截读取属性: ${prop}`);
return target[prop];
}
};


// 3. 创建代理
const proxy = new Proxy(target, handler);

// 4. 所有通过 proxy 的读取操作都会被 get 陷阱拦截
console.log(proxy.message1);
console.log(proxy.message2);

注意:直接操作 target 对象(如 target.message1)不会触发代理。只有通过 proxy 实例进行的操作才会被拦截


6.3. 核心陷阱(Traps)详解

handler 对象可以定义多达 13 种“陷阱”,它们对应了 JavaScript 中对象的各种内部方法。我们来深入几个最核心的。

get(target, prop, receiver)

拦截 读取 属性的操作。
应用: 实现读取时的默认值、数据格式化、访问日志等。

1
2
3
4
5
6
7
8
9
10
11
12
const user = { name: 'Alice' };
const proxy = new Proxy(user, {
get(target, prop) {
if (prop in target) {
return target[prop];
} else {
return `属性 "${prop}" 不存在.`;
}
}
});
console.log(proxy.name); // "Alice"
console.log(proxy.age); // "属性 " age " 不存在."

set(target, prop, value, receiver)

拦截 设置 属性值的操作。它必须返回一个布尔值,true 代表赋值成功。
应用: 数据校验、触发更新、只读属性保护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const user = { age: 25 }

const proxy = new Proxy(user, {
set(target, prop, value) {
if (prop === "age") {
if (!Number.isInteger(value) || value < 0) {
throw new TypeError("年龄不合法")
}
}
target[prop] = value
return true
}
})

proxy.age = 26 // 成功
console.log(user.age) // 26
try {
proxy.age = -1; // 将会抛出错误
} catch (e) {
console.error(e.message);
}

has(target, prop)

拦截 prop in proxy 操作。
应用: 隐藏内部属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const config = {
user: 'admin',
_password: 'do-not-access' // _ 前缀约定为私有
};
const proxy = new Proxy(config, {
has(target, prop) {
if (prop.startsWith('_')) {
return false; // 假装它不存在
}
return prop in target;
}
});

console.log('user' in proxy); // true
console.log('_password' in proxy); // false

deleteProperty(target, prop)

拦截 delete proxy[prop] 操作。必须返回布尔值。
应用: 保护重要属性不被删除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const User = {
name: "Alice",
age: 25,
_password: "123456"
}

const proxy = new Proxy(User,{
deleteProperty(target,prop) {
if (prop.startsWith("_")) {
throw new Error("不能删除私有属性")
}
delete target[prop]
return true
}
})

try {
delete proxy._password
} catch (e) {
console.error(e.message)
}

console.log(proxy)

apply(target, thisArg, argumentsList)

当代理的目标是一个 函数 时,apply 陷阱会拦截函数的调用。
应用: 函数参数校验、调用日志、性能监控。

1
2
3
4
5
6
7
8
9
function add(a, b) { return a + b }
const proxy = new Proxy(add, {
apply(target, thisarg, argumentList) {
console.log(`函数被调用,参数为: ${argumentList.join(', ')}`);
return target.apply(thisarg, argumentList);
}
})

console.log(proxy(1, 2)) // 函数被调用,参数为: 1, 21

6.4. Reflect:Proxy 的最佳搭档

承上启下: 在我们自定义 Proxy 的陷阱函数时,通常在执行完自定义逻辑后,还需要执行原始的、默认的那个操作。例如,在一个 set 陷阱中,验证完数据后,我们还是需要将值赋给目标对象。我们应该如何安全、规范地完成这个默认操作呢?

答案就是 ReflectReflect 是 ES6 引入的一个新的内置对象,它提供了一系列与 Proxy 陷阱函数同名的静态方法。

核心设计哲学:

  1. 方法对应: Reflect 上的 13 个方法与 Proxy 的 13 个陷阱函数一一对应。Reflect.get() 就是 get 陷阱的默认行为,Reflect.set() 就是 set 陷阱的默认行为,以此类推。
  2. 函数式与状态报告: Reflect 将一些原有的、命令式的 Object 操作(如 delete obj.prop)改为了更可靠的函数式操作。例如,Reflect.deleteProperty(obj, 'prop') 会返回一个布尔值来明确告知你操作是否成功,而不是像 delete 那样在非严格模式下静默失败。

最佳实践: 在 Proxy 的陷阱函数中,总是使用对应的 Reflect 方法来执行默认操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const user = { name: 'Alice', age: 25 };

const handler = {
get(target, prop, receiver) {
console.log(`[Reflect] Reading property: ${prop}`);
// 使用 Reflect.get 执行默认的读取操作
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
console.log(`[Reflect] Setting property: ${prop} to ${value}`);
return Reflect.set(target, prop, value, receiver);
}
}

const proxy = new Proxy(user, handler);
proxy.age = 26;
console.log(proxy.name);

Reflectreceiver 的重要性

你可能会问:在 get 陷阱里,用 Reflect.get(target, prop, receiver) 和直接用 target[prop] 有什么区别?在大多数情况下没有,但在处理访问器属性 (getter) 时,区别是巨大的。

receiver 参数保证了当原始对象上有 getter 时,getter 内部的 this 会正确地指向代理对象 proxy,而不是原始对象 target

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const target = {
_name: 'Alice',
get name() {
// 这个 getter 依赖 this
return this._name;
}
};

const handler = {
get(target, prop, receiver) {
if (prop === 'name') {
console.log('Intercepting getter...');
// 如果用 target[prop],getter 内的 this 会指向 target,这是不正确的
// return target[prop];

// 必须用 Reflect.get 并传入 receiver,确保 this 指向 proxy
return Reflect.get(target, prop, receiver);
}
return Reflect.get(target, prop, receiver);
}
};

const proxy = new Proxy(target, handler);
console.log(proxy.name); // 正确执行,并被拦截

6.5. Proxy 的实战应用:响应式系统原理

现在我们拥有了 ProxyReflect 这两把利器,我们可以尝试构建Vue3极简的响应式模型了,Vue3框架就是靠这样类似的代码去实现响应式更新的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 用于存储依赖关系的数据结构
const dependencies = new Map();
let activeEffect = null; // 当前正在执行的“副作用”函数

function effect(fn) {
activeEffect = fn;
fn();
activeEffect = null;
}

function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
if (activeEffect) {
// 1. 收集依赖
if (!dependencies.has(target)) dependencies.set(target, new Map());
const depsMap = dependencies.get(target);
if (!depsMap.has(key)) depsMap.set(key, new Set());
depsMap.get(key).add(activeEffect);
}
// 使用 Reflect 执行默认行为,保证 this 指向正确
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
// 使用 Reflect 执行默认行为
const result = Reflect.set(target, key, value, receiver);
// 2. 触发更新
const deps = dependencies.get(target)?.get(key);
if (deps) deps.forEach(effect => effect());
return result;
}
});
}

// --- 实战使用 ---
const state = reactive({ count: 0 });
effect(() => {
console.log(`The count is: ${state.count}`);
});

// 当我们修改 state.count 时...
setTimeout(() => {
state.count++;
}, 1000)

这个模型解释了 Vue 3 的“魔法”:当 state.count++ 发生时,set 陷阱被触发,它找到了之前在 get 陷阱中通过 activeEffect 记录的那个 console.log 函数,并让它重新执行。ProxyReflect 的组合,让我们能够以一种非常干净和全面的方式实现这种“依赖收集”和“触发更新”的模式。


6.6. Proxy.revocable():创建可撤销的代理

有时,我们希望能够动态地关闭对一个对象的代理访问。Proxy.revocable() 方法可以创建一个可撤销的代理。

它返回一个包含两个属性的对象:

  • proxy: 代理对象本身。
  • revoke: 一个无参数的函数,调用它会撤销该代理。
1
2
3
4
5
6
7
8
9
10
11
12
13
const target = { data: 'secret' };
const { proxy, revoke } = Proxy.revocable(target, {});

console.log(proxy.data); // 'secret'

// 撤销代理
revoke();

try {
console.log(proxy.data); // 再次访问将抛出 TypeError
} catch (e) {
console.error(e.message);
}

6.7. 本章核心原理与高频面试题

核心原理速查

概念核心原理关键点
元编程代码操作代码Proxy 是 JavaScript 元编程的核心工具。
Proxy vs defineProperty代理 vs 劫持Proxy 在对象层面代理,可拦截13种操作,非侵入式;defineProperty 在属性层面劫持,只能拦截 get/set,且无法监听新增/删除属性。
Handler 与 Traps处理器对象与陷阱函数通过在 handler 中定义 get, set 等陷阱函数,可以拦截并自定义对象的底层操作。
Reflect默认行为的函数式实现Reflect 的方法与 Proxy 陷阱一一对应,是执行默认操作的最佳实践,能保证 this (receiver) 的正确传递。
响应式原理依赖收集与触发更新get 中收集依赖(谁用了我),在 set 中触发更新(通知用过我的人)。

高频面试题与陷阱

面试官深度追问
2025-08-28

为什么在 Proxy 的陷阱函数中,推荐使用 Reflect 来执行默认操作,而不是直接操作 target 对象?

主要有两个原因。第一是确保 this 指向的正确性。特别是在 get 陷阱中处理带有 getter 的属性时,如果直接用 target.prop 来获取值,getter 内部的 this 会指向原始的 target 对象。而使用 Reflect.get(target, prop, receiver),可以将 receiver(即 proxy 实例本身)传递进去,确保 getter 内的 this 正确地指向 proxy,这对于依赖 this 的复杂对象至关重要。

第二是为了代码的规范性和一致性。Reflect 对象上的方法与 Proxy 的陷阱函数是一一对应的,这使得 Reflect 成为了执行“默认行为”的天然选择,代码意图更清晰。同时,Reflect 的一些方法提供了比传统操作更可靠的状态报告,例如 Reflect.set 会返回布尔值表示成功与否,这比 target.prop = value 在非严格模式下的静默失败要健壮得多。

很好。那么,你能谈谈 Proxy 相对于 ES5 的 Object.defineProperty 有哪些核心优势吗?为什么 Vue 3 要用 Proxy 重写响应式系统?

Proxy 的优势是全方位、根本性的。主要有三点:

第一,拦截范围更广。Object.defineProperty 只能劫持对象的属性读取(get)和设置(set),而 Proxy 可以拦截多达 13 种底层操作,包括 in 操作 (has陷阱)、delete 操作 (deleteProperty陷阱)等。

第二,对原对象非侵入性。Proxy 创建的是一个全新的代理对象,我们对代理对象进行操作,不会对原对象产生任何污染。而 defineProperty 是直接修改原对象的属性描述符。

第三,也是对 Vue 来说最重要的一点,原生支持对新增属性和数组操作的监听。使用 defineProperty,必须在初始化时就遍历对象的所有属性进行劫持,对于后续新增的属性无能为力,Vue 2 不得不为此设计了特殊的 $set API。而 Proxy 是在整个对象层面进行代理,任何属性(无论何时添加)的访问都会被拦截。同时,它也能原生监听到数组索引的修改和 .length 属性的变化,而这些在 Vue 2 中也需要进行特殊的 hack 处理。


第七章: 异步 JavaScript

摘要: JavaScript 的核心特性之一是单线程,这意味着在任意时刻,它只能执行一件任务。这个特性简化了编程模型,但也带来了巨大的挑战:如果一个任务耗时过长(如网络请求),整个程序(包括用户界面)都将被阻塞。本章,我们将深入 JavaScript 为解决这一根本矛盾而设计的核心机制——异步编程。我们将从一个真实的“UI 阻塞”痛点出发,沿着“定时器与回调 -> Promise -> async/await”的技术演进路径,最终回归到底层核心“事件循环”,让您彻底掌握现代 JavaScript 的异步编程范式。


在本章,我们将遵循一条从实践到理论的探索之路:

  1. 首先,我们将直面 JavaScript 的单线程阻塞问题,理解异步的必要性。
  2. 接着,我们将学习最初的异步工具 setTimeout 与回调函数,并体会“回调地狱”的痛苦。
  3. 然后,我们将深入学习现代异步核心 Promise,包括其链式调用和所有重要的静态并发方法
  4. 紧接着,我们将掌握终极语法糖 async/await,学习如何编写优雅的异步代码。
  5. 最后,在掌握了所有工具后,我们将揭秘事件循环的底层模型,理解这一切是如何工作的。

7.1. 问题所在:JavaScript 的单线程与 UI 阻塞

核心痛点: JavaScript 在浏览器中执行时,JS 引擎与页面的渲染引擎共享同一个主线程。如果 JS 执行一个长时间的同步任务,主线程就会被“霸占”,导致页面渲染完全停止。

场景复现: 想象一下,页面上有一个按钮,点击后需要执行一个非常耗时的计算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>

<body>
<button onclick="handleClick()">Click me</button>
<p id="output"></p>
</body>
<script>
function handleClick() {
const output = document.getElementById('output');
output.textContent = 'Calculating...';
const start = Date.now()
while (Date.now() - start < 3000) {
// 阻塞三秒
}

output.textContent = 'Button clicked!';
}
</script>

</html>

实际体验: 当你点击按钮后,会发现页面立即卡死'Calculating...' 这段文本根本不会显示,整个浏览器窗口在 3 秒内无响应。3 秒后,页面恢复,并直接显示 'Calculation finished!'。这就是 UI 阻塞


7.2. 最初的解决方案:异步回调与定时器

解决方案: 我们可以使用浏览器提供的 setTimeout(callback, delay) API,将耗时任务变成一个异步任务。这会告诉浏览器:“请把这个任务(callback)放到后台,等 delay 毫秒后,再把它放进任务队列里等待执行。” 这样,主线程就不会被阻塞了。

1
2
3
4
5
6
7
8
9
10
11
function handleClick() {
const output = document.getElementById('output');
output.textContent = 'Calculating...';
// 使用 setTimeout 将耗时任务变为异步
setTimeout(() => {
const start = Date.now();
while (Date.now() - start < 3000) {
// 现在不会阻塞主线程
}
output.textContent = 'Calculation finished!';
}, 0); // 0ms 延迟意味着“尽快”执行,但必须在当前同步代码之后

体验改善: 再次点击按钮,'Calculating...' 会立刻显示,页面保持流畅。3 秒后,文本更新为 'Calculation finished!'。我们成功避免了 UI 阻塞。

新的痛点:回调地狱
当异步任务之间存在依赖关系时,例如,任务 B 必须在任务 A 完成后才能开始,我们就不得不将回调函数层层嵌套。

1
2
3
4
5
6
7
8
9
setTimeout(() => {
console.log('Task A finished');
setTimeout(() => {
console.log('Task B finished');
setTimeout(() => {
console.log('Task C finished');
}, 1000);
}, 1000);
}, 1000);

这种“毁灭金字塔”结构,使得代码难以阅读、理解和维护,错误处理也变得异常复杂。


7.3. 现代解决方案 (一):Promise 对象

ES6 引入 Promise,正是为了将异步流程从“深坑”中解放出来,实现线性化的书写方式。Promise 是一个代表异步操作最终完成或失败的代理对象,它拥有三种状态:pending(进行中)、fulfilled(已成功)、rejected(已失败)。

Promise 通过 .then().catch().finally()链式调用来组织异步流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function asyncTask(name, duration) {
// 返回一个Promise对象:他身上有两个参数,resolve和reject
// resolve:成功时调用
// reject:失败时调用
return new Promise((resolve, reject) => {
console.log(`任务 ${name} 开始...`);
setTimeout(() => {
// 开启一个定时器,内部判断任务名称如果为A则完成,否则则失败
if (name === 'A') {
resolve(`任务 ${name} 完成`);
} else {
reject(new Error(`任务 ${name} 失败`));
}
}, duration);
});
}

// 调用asyncTask函数,并传入任务名称和持续时间
// 使用.then方法链式调用,当任务A完成时,执行下一个任务B
// 使用.catch方法捕获错误,并打印错误信息
// 使用.finally方法在所有任务完成后执行

asyncTask('A', 1000)
.then(result => {
console.log(result); // 打印任务A完成
return asyncTask('B', 1000); // 立刻执行回调函数,执行B任务
})
.then(result => {
console.log(result); // 这里不会走,因为他已经失败了
})
.catch(error => {
console.error(error.message); // 打印任务B失败
})
.finally(() => {
console.log('所有任务完成');
});

7.4. 并发处理:Promise 的核心静态方法

当我们需要处理多个并发的异步任务时,Promise 提供了一组强大的静态方法。

Promise.all(iterable)

业务痛点: 页面初始化时,需要同时请求多个接口(例如,获取用户信息、获取产品列表),并且必须在所有数据都成功返回后才能渲染页面。

解决方案: Promise.all 接收一个 Promise 数组,返回一个新的 Promise。

  • 当所有 Promise 都 fulfilled,它才会 fulfilled,并且其结果是一个包含所有 Promise 结果的数组(顺序与输入一致)。
  • 只要有一个 Promise rejected,它就会立即 rejected,并且其原因是第一个失败的 Promise 的原因。
1
2
3
4
5
6
7
8
9
10
11
12
13
const p1 = Promise.resolve('User Info');
const p2 = new Promise(resolve => setTimeout(() => resolve('Product List'), 500));
const p3 = Promise.reject('API Error');

// 场景一: 全部成功
Promise.all([p1, p2]).then(results => {
console.log('全部成功:', results);
});

// 场景二: 有一个失败
Promise.all([p1, p2, p3]).catch(error => {
console.error('有一个失败:', error);
});

Promise.race(iterable)

业务痛点: 你需要为一个耗时可能很长的 API 请求设置一个超时限制。如果超过 2 秒还未返回,就视为失败。

解决方案: Promise.race 像一场赛跑,返回一个新的 Promise。这个 Promise 的状态会与第一个“撞线”(即第一个 settled,无论是 fulfilled 还是 rejected)的 Promise 的状态保持一致。

1
2
3
4
5
6
7
8
9
function timeout(delay) {
return new Promise((_, reject) => setTimeout(() => reject(new Error('请求超时')), delay));
}
function fetchData() {
return new Promise(resolve => setTimeout(() => resolve('数据接收成功'), 3000));
}
Promise.race([fetchData(), timeout(2000)])
.then(data => console.log(data))
.catch(error => console.error(error.message));

Promise.allSettled(iterable) & Promise.any(iterable)

  • Promise.allSettled: (我全都要) 等待所有 Promise 完成,无论成败,返回一个包含每个任务最终状态的对象数组。
    • 适用于需要知道所有结果的场景。
1
2
3
4
5
6
7
8
9
10
const promises = [Promise.resolve('成功1'), Promise.reject('失败2'), Promise.resolve('成功3')];
Promise.allSettled(promises).then(results => {
console.log(results);
// [
// { status: 'fulfilled', value: '成功1' },
// { status: 'rejected', reason: '失败2' },
// { status: 'fulfilled', value: '成功3' }
// ]
});

  • Promise.any: (谁快用谁,失败不管) 等待第一个 fulfilled 的 Promise。
    • 适用于有多个备用数据源,只需要最快的那一个的场景。
1
2
3
4
5
6
7
const p1 = new Promise(resolve => setTimeout(() => resolve('不会被执行'), 3000));
const p2 = new Promise((_, reject) => setTimeout(() => reject('请求超时'), 2000));
const p3 = new Promise(resolve => setTimeout(() => resolve('最后被执行的任务'), 300));

Promise.any([p1, p2, p3])
.then(results => console.log(results)) // 打印最后被执行的任务
.catch(error => console.error(error)); // 不会执行

7.5. 终极形态:async/await

async/awaitPromise 的语法糖,它允许我们用同步的、阻塞式的写法来处理异步的、非阻塞的逻辑,是目前处理异步的最佳实践

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const p1 = Promise.resolve('User Info');
const p2 = new Promise(resolve => setTimeout(() => resolve('Product List'), 500));
const p3 = Promise.resolve('API Data');


async function main() {
try {
const [userInfo, productList, apiData] = await Promise.all([p1, p2, p3])
console.log(userInfo, productList, apiData) // User Info Product List API Data
} catch (error) {
console.error(error)
}
}

main()

7.6. 底层揭秘:事件循环模型

在掌握了所有异步工具后,现在是时候回头揭秘这一切是如何工作的了。所有异步行为的根源,都在于我们最开始介绍的事件循环模型。无论是 setTimeout 的回调,还是 Promise.then,最终都是被放入不同的任务队列,等待事件循环的调度。

  • setTimeout, setInterval, I/O 等 -> 宏任务
  • Promise.then, async/await 等 -> 微任务 (更高优先级)

核心痛点: 浏览器是事件驱动的,用户的点击、网络数据的到达、定时器的触发都是异步事件。单线程的 JavaScript 必须有一套高效的机制来处理这些事件而不阻塞 UI。这套机制就是事件循环 (Event Loop)

核心原理: 事件循环模型由几个关键部分组成:

  • 调用栈: 一个后进先出 (LIFO) 的数据结构,用于执行所有的同步任务。

  • Web APIs (浏览器环境): 浏览器提供的异步功能接口 (setTimeout, fetch() 等)。异步任务会交给它们处理。

  • 任务队列:

  • 宏任务队列: 一个先进先出 (FIFO) 的队列,存放 setTimeout, setInterval, I/O, UI rendering 等任务的回调。
    * 微任务队列: 一个拥有更高优先级的队列,主要存放 Promise 的回调(.then, .catch, .finally), queueMicrotask 等。

    • 事件循环: 一个持续不断的进程,其工作流程如下:
  1. 执行调用栈中的所有同步任务,直到栈空。
    2. 检查微任务队列清空整个队列,依次执行所有微任务。如果在执行微任务的过程中,又产生了新的微任务,那么这些新的微任务也会被添加到队列末尾并在当前轮次被执行。
    3. 取出一个宏任务从宏任务队列推入调用栈中执行。
    4. 重复步骤 2 和 3。

7.7. 本章核心原理与高频面试题

核心原理速查

概念核心原理关键点
异步将耗时任务交给宿主环境,通过回调函数处理结果,以避免阻塞主线程。是 JS 实现流畅 UI 的基础。
事件循环同步任务 -> 清空微任务 -> 执行一个宏任务 的循环。这是所有异步行为的底层执行模型。
Promise代表异步结果的状态机对象。.then 链式调用解决了回调地狱。
Promise.all并发执行,一败俱败用于所有任务都必须成功的场景。
Promise.race并发执行,一决胜负(无论成败)。用于超时控制或竞速场景。
async/awaitPromise 的语法糖。用同步的写法实现异步逻辑,是现代 JS 异步编程的最佳实践。

高频面试题与陷阱

面试官深度追问
2025-08-28

请你详细解释一下 JavaScript 的事件循环机制 (Event Loop)。

好的。JavaScript 是单线程的,为了处理耗时操作而不阻塞主线程,它采用了一种基于事件循环的并发模型。这个模型主要由调用栈、任务队列和 Web APIs 组成。

首先,所有同步代码都在调用栈中执行。当遇到像 setTimeout 或网络请求这样的异步操作时,主线程不会等待,而是将其交给浏览器提供的 Web APIs 去处理,然后继续执行后续的同步代码。

当 Web API 中的异步操作完成后,它不会直接把结果返回给主线程,而是将其回调函数放入任务队列中排队。

任务队列又分为宏任务队列和微任务队列,微任务的优先级更高。像 setTimeout, I/O 操作的回调会进入宏任务队列;而 Promise.then的回调会进入微任务队列。

事件循环是一个持续的过程,它会不断检查调用栈。当调用栈为空时,它会先去清空整个微任务队列,将所有微任务的回调依次推入调用栈执行。微任务队列清空后,再从宏任务队列中取出一个任务推入调用栈执行。这个过程不断重复,就构成了事件循环。

理解得很透彻。那么 Promise 本身是同步的还是异步的?

new Promise() 这个构造函数本身是同步执行的。在 new Promise(executor) 时,传入的 executor 函数会立即、同步地执行。但是,executor 函数内部的 resolvereject 函数被调用时,它们会将后续的 .then.catch 的回调函数放入微任务队列,这个调度过程是异步的。所以,Promise 的初始化是同步的,但其结果的处理是异步的。


第八章:代码组织与高级特性

在深入探讨 JavaScript 与浏览器交互的核心——DOM 与 BOM 之前,本章将作为对 JavaScript 语言本身的一次增补与深化

这里涵盖的主题(如生成器、Symbol、国际化 API)可能不像前几章那样在日常业务中频繁出现,但它们是构成完整 JavaScript 知识体系的重要拼图,更是通往高级开发和理解框架底层原理的必经之路。掌握它们,将让您的知识体系更完整、更健壮。

让我们完成对语言本身的最后一次探索,为下一章真正进入激动人心的浏览器世界做好万全的准备。


在本章中,我们将拾遗补缺,探索 JavaScript 语言的更多边界:

  1. 首先,我们将系统学习 ES6 模块系统,这是现代前端工程化的基石。
  2. 接着,我们将深入 迭代器与生成器,揭示 for...of 循环背后的工作原理。
  3. 然后,我们将探讨 Symbol 的真正价值,以及如何利用它来扩展对象的底层行为。
  4. 紧接着,我们将建立健壮性编程的思维,学习 JavaScript 的错误处理机制。
  5. 最后,我们将接触 Intl 国际化 API严格模式,为编写更专业、更安全的代码添砖加瓦。

8.1. ES6 模块系统:importexport

8.1.1. 模块化的历史与必要性

历史痛点: 在 ES6 出现之前,JavaScript 语言本身没有模块化标准。开发者为了避免全局变量污染和组织代码,发明了多种模式:

  • IIFE (立即执行函数表达式) 模式: 通过函数作用域模拟私有变量,是早期的模块化雏形。
  • CommonJS (CJS): 主要用于服务器端(Node.js),使用 require() 同步加载模块。
  • AMD (Asynchronous Module Definition): 主要用于浏览器端,使用 define() 异步加载模块。

这些方案解决了有无问题,但标准不统一。因此,ES6 在语言层面引入了官方的模块化解决方案——ES Modules (ESM)

8.1.2. 核心语法详解

ESM 主要由 exportimport 两个关键字组成。一个文件就是一个模块。

export:从模块中导出功能

有两种导出方式:命名导出默认导出

  • 命名导出: 一个模块可以有多个命名导出。
    文件路径: utils/math.js

    1
    2
    3
    4
    5
    export const PI = 3.14159;

    export function sum(a, b) {
    return a + b;
    }
  • 默认导出: 一个模块只能有一个默认导出。它用于导出一个模块最核心的功能。
    文件路径: components/Button.js

    1
    2
    3
    4
    export default function Button() {
    // ... a component's implementation
    console.log('This is a default Button.');
    }

import:从其他模块导入功能

  • 导入命名导出: 必须使用花括号 {},且名称必须与导出的名称完全一致(可以使用 as 重命名)。

    1
    2
    3
    4
    // 导入 math.js
    import { PI, sum as add } from './utils/math.js';

    console.log(add(PI, 2));
  • 导入默认导出: 无需使用花括号,可以为导入的成员指定任意名称

    1
    2
    3
    4
    // 导入 Button.js
    import MyCustomButton from './components/Button.js';

    MyCustomButton();

8.1.3. 模块的加载机制

  • 静态解析: ESM 的 importexport 必须在模块的顶层使用,不能在 if 语句或函数调用中。这是因为 JavaScript 引擎在代码执行前需要对模块依赖关系进行静态分析,以构建依赖图和进行优化。

  • 动态 import(): 为了弥补静态导入无法按需加载的不足,ES2020 引入了动态 import()。它是一个函数,返回一个 Promise,可以在运行时根据条件异步加载模块。
    业务场景: 当用户点击某个按钮时,才加载一个体积较大的图表库。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    const chartBtn = document.getElementById('chartBtn');

    chartBtn.addEventListener('click', async () => {
    try {
    // 动态导入
    const ChartingLibrary = await import('./libs/charts.js');
    // 模块加载完毕后,使用其导出的功能
    ChartingLibrary.drawChart();
    } catch (error) {
    console.error('Failed to load chart library', error);
    }
    });

8.2. 迭代器(Iterators)与生成器(Generators)

8.2.1. 迭代协议再探

承上启下: 在第四章我们提到,for...of 可以遍历数组、字符串等,其背后的原理是迭代协议。现在我们来深入其内部。

  • Iterable Protocol (可迭代协议): 要求一个对象必须实现一个 [Symbol.iterator] 方法。
  • Iterator Protocol (迭代器协议): 要求 [Symbol.iterator] 方法返回一个“迭代器”对象。这个迭代器对象必须有一个 next() 方法,每次调用 next() 都会返回一个 { value, done } 对象。

8.2.2. 生成器函数 (function*)

手动实现迭代器协议非常繁琐。为此,ES6 提供了生成器函数,它是创建迭代器的语法糖。

  • 语法: 通过在 function 关键字后加上星号 * 来定义。
  • yield 关键字: 这是生成器的核心。它会暂停函数的执行并“产出”一个值。当迭代器的 next() 方法被再次调用时,函数会从暂停处继续执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 定义一个生成器函数
function* numberGenerator() {
console.log('Generator started');
yield 1; // 产出 1,暂停
console.log('Resumed after 1');
yield 2; // 产出 2,暂停
console.log('Resumed after 2');
yield 3; // 产出 3,暂停
console.log('Generator finished');
}

// 调用生成器函数,返回一个迭代器
const iterator = numberGenerator();

// 使用 for...of 自动遍历
for (const value of iterator) {
console.log(`Received value: ${value}`);
}

8.2.3. 高级应用

生成器是极其强大的工具,它的“暂停执行”能力是许多高级模式的基础,例如:

  • 实现惰性求值: 可以创建一个无限序列的生成器,而不会耗尽内存,因为值是按需生成的。
  • 简化异步流程: 在 async/await 出现之前,著名的 co.js 库就利用生成器和 yield 来模拟同步方式编写异步代码,是 async/await 的重要前身。

8.3. Symbol:创建独一无二的属性键

8.3.1. Symbol 的核心价值

Symbol 是 ES6 引入的一种新的原始数据类型。Symbol() 函数会返回一个全局唯一且不可变的值。

核心痛点: 当你希望为一个第三方对象添加一个属性,但又担心这个属性名会与对象已有的或未来可能添加的属性名发生冲突时,Symbol 是完美的解决方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const mySecretKey = Symbol('description'); // 'description' 仅用于调试

const user = {
name: 'Alice'
};

// 使用 Symbol 作为键,这个属性不会与任何字符串键冲突
user[mySecretKey] = 'This is my secret data';

console.log(user.name); // 'Alice'
console.log(user[mySecretKey]); // 'This is my secret data'

// Symbol 属性默认是不可枚举的
console.log(Object.keys(user)); // ['name']
for (const key in user) {
console.log(key); // 只打印 'name'
}

8.3.2. Well-Known Symbols

JavaScript 内置了一系列“著名”的 Symbol 值,它们作为对象的特殊属性键,可以让我们“钩入”并自定义语言的内部行为。

  • Symbol.iterator: 我们已经见过,拥有此属性的对象即为“可迭代的”。
  • Symbol.toStringTag: 修改 Object.prototype.toString.call(obj) 的返回值。
1
2
3
4
5
6
7
class MyCustomObject {
get [Symbol.toStringTag]() {
return 'Custom';
}
}
const myObj = new MyCustomObject();
console.log(Object.prototype.toString.call(myObj)); // "[object Custom]"

8.4. 健壮性编程:错误处理机制

8.4.1. try...catch...finally

  • try 块: 包裹可能抛出错误的代码。
  • catch (error) 块: 当 try 块中发生错误时,执行此块的代码。error 对象包含了错误的详细信息。
  • finally 块: 无论 try 块是否出错,此块的代码总会执行。常用于释放资源、关闭连接等清理工作。

8.4.2. 错误对象 (Error)

JavaScript 有多种内置的错误类型,它们都继承自 Error 对象:

  • ReferenceError: 访问不存在的变量。
  • TypeError: 值的类型不符合预期。
  • SyntaxError: 代码不符合语法规范。

最佳实践: 在应用中,我们应该创建并抛出自定义的错误类,以提供更明确的错误信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 创建自定义错误类型
class NetworkError extends Error {
constructor(message, status) {
super(message);
this.name = 'NetworkError';
this.status = status;
}
}

function fetchData() {
const success = false; // 模拟网络失败
if (!success) {
throw new NetworkError('Failed to fetch data from API', 500);
}
}

try {
fetchData();
} catch (error) {
if (error instanceof NetworkError) {
console.error(`[${error.name}] Status ${error.status}: ${error.message}`);
} else {
console.error('An unknown error occurred:', error);
}
}

8.5. 国际化 API (Intl)

核心痛点: 手动处理不同国家和地区的日期、数字、货币格式是一场噩梦。Intl 对象提供了标准化的解决方案。

  • Intl.DateTimeFormat: 格式化日期和时间。

    1
    2
    3
    const date = new Date();
    console.log(new Intl.DateTimeFormat('en-US').format(date)); // e.g., "8/28/2025"
    console.log(new Intl.DateTimeFormat('zh-CN', { dateStyle: 'full' }).format(date)); // e.g., "2025年8月28日星期四"
  • Intl.NumberFormat: 格式化数字和货币。

    1
    2
    3
    const number = 123456.789;
    console.log(new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(number)); // "123.456,79 €"
    console.log(new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(number)); // "¥123,456.79"

8.6. 严格模式 ('use strict')

核心价值: 严格模式是开发者主动选择的一种更规范、更安全的 JavaScript 执行模式。它能修复语言的一些“怪癖”,并将一些静默的错误转变为显式的异常抛出,但这个模式远不如我们之后要学习的TypeScript,了解即可

启用方式: 在脚本或函数的开头添加字符串字面量 'use strict';

1
2
3
4
5
6
7
8
9
10
11
12
'use strict';

// 严格模式下的主要变化
// 1. 禁止使用未声明的变量
// undeclaredVar = 1; // Uncaught ReferenceError

// 2. 函数中的 this 在全局调用时为 undefined,而非 window
function showThis() { console.log(this); }
showThis(); // undefined

// 3. 禁止删除不可删除的属性
// delete Object.prototype; // Uncaught TypeError

最佳实践: 现代前端项目(尤其是使用模块化的项目)默认都在严格模式下运行,我们应该始终采用这种模式来编写代码。


第九章:浏览器环境:DOM 深度剖析

摘要: 欢迎来到 JavaScript
应用最广泛、最核心的舞台——浏览器。在此前的章节中,我们已经完全掌握了 JavaScript
语言本身的内部原理。从本章开始,我们将把这些能力应用于与用户直接交互的界面。我们将深度剖析文档对象模型
(DOM)
,理解浏览器是如何将一份静态的 HTML
文档,转化为一个我们可以用代码动态操控的、活生生的对象树。您将学会如何精准地查找、遍历、修改、创建和删除页面上的任何元素,并掌握这一切操作背后的性能原理。


在本章中,我们将系统地学习 DOM 操作的每一个环节:

  1. 首先,我们将从 DOM 的核心概念 出发,理解其树形结构与节点类型。
  2. 接着,我们将学习如何 捕获和遍历 页面上的元素,这是所有操作的第一步。
  3. 然后,我们将掌握如何 修改元素的内容、属性与样式
  4. 紧接着,我们将学习如何 动态地创建和销毁元素,为页面赋予生命。
  5. 最后,我们将深入 DOM 的性能原理,学习如何编写高效、不卡顿的交互代码。

9.1. DOM 基础:文档树与节点

承上启下: 在我们能够用 JavaScript
“做”任何事之前,我们必须先理解我们操作的 对象 是什么。当浏览器加载一个 HTML
文件时,它做的不仅仅是显示文本。它在内存中进行了一项至关重要的工作:将这份纯文本的
HTML 代码,解析成一个 JavaScript
可以理解和操作的、结构化的对象模型。这个模型,就是 DOM。

9.1.1. DOM 到底是什么?

DOM (Document Object Model) 的核心思想是,将一份 HTML
文档表示为一个倒置的 树形结构 (Node Tree)

  • 文档的每一个部分——无论是整个 <html> 标签、一个 <p>
    元素、一段文本,甚至是一行注释——都被视为这个树上的一个 节点 (Node)
  • 这些节点之间存在着清晰的层级关系,就像一个家谱:<html> 是根节点,它有 head
    body 两个子节点;body 又可以有 div, p 等多个子节点。

通过这种方式,原本无序的文本标记,就被转换成了一个 JavaScript 可以通过标准 API
进行访问和修改的、严谨的对象集合。


9.1.2. Node 接口与节点类型

DOM 树中的所有节点,无论是什么类型,都继承自一个共同的 Node
接口。这意味着它们都共享一些通用的属性和方法。我们可以通过 nodeType
nodeName 这两个属性来区分不同类型的节点。

节点类型nodeType 常量nodeTypenodeName 返回值示例
元素节点 (Element)Node.ELEMENT_NODE1大写的标签名DIV, P, BODY
文本节点 (Text)Node.TEXT_NODE3#text<h1> 标签内的文字
注释节点 (Comment)Node.COMMENT_NODE8#comment``
文档节点 (Document)Node.DOCUMENT_NODE9`#document``document` 对象本身

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>

<body>
<div id="container">
Hello World
</div>
<script>
const container = document.getElementById("container");
// 容器: type=1, name=DIV
console.log(
`容器: type=${container.nodeType}, name=${container.nodeName}`,
);

const firstChild = container.firstChild;
// 第一个子节点: type=8, name=#text
console.log(
`第一个子节点: type=${firstChild.nodeType}, name=${firstChild.nodeName}`,
);
</script>
</body>
</html>
  • textContent: 这是一个非常实用的 Node
    属性,它会返回该节点及其所有后代节点中的 纯文本内容,并忽略所有 HTML 标签。

9.1.3. document 对象:一切的起点

document 对象是整个 DOM 树的根节点,也是 JavaScript
与页面交互的 唯一入口。它作为 window
对象的一个属性被全局提供,我们可以直接使用它来访问文档的任何部分。

document 对象提供了许多便捷的属性,用于快速访问文档中的关键节点和信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1. 访问文档的根元素 <html>
console.log("documentElement:", document.documentElement);

// 2. 访问 <head> 和 <body>
console.log("head:", document.head);
console.log("body:", document.body);

// 3. 读写文档标题
console.log("title:", document.title);
document.title = "New Page Title";
console.log("New title:", document.title);

// 4. 获取文档的完整 URL
console.log("URL:", document.URL);

// 5. 获取页面上的所有图片和表单集合 (返回 HTMLCollection)
console.log("Images count:", document.images.length);
console.log("Forms count:", document.forms.length);

9.2. 元素的查找与遍历

要操作页面,首先必须精准地找到我们想要操作的那个元素。

9.2.1. 元素查找 API

方法描述返回值
querySelector(selector)(推荐) 使用 CSS 选择器查找 第一个 匹配的元素。单个元素或 null
querySelectorAll(selector)(推荐) 使用 CSS 选择器查找 所有 匹配的元素。静态 NodeList
getElementById(id)通过 id 属性查找元素,速度最快单个元素或 null
getElementsByClassName(className)通过 class 名称查找。动态 HTMLCollection
getElementsByTagName(tagName)通过标签名查找。动态 HTMLCollection

示例 1: querySelectorgetElementById

1
2
3
4
5
6
7
8
9
10
11
<div id="box1">盒子1</div>
<div class="item">项目A</div>
<script>
// 使用 ID 选择器,效果同 getElementById
const box = document.querySelector('#box1');
console.log('通过 querySelector 找到:', box.textContent); // 盒子1

// 使用 getElementById,性能更优
const boxById = document.getElementById('box1');
console.log('通过 getElementById 找到:', boxById.textContent); // 盒子1
</script>

示例 2: querySelectorAllgetElementsByClassName

1
2
3
4
5
6
7
8
9
10
11
12
13
<ul>
<li class="fruit">苹果</li>
<li class="fruit">香蕉</li>
<li>面包(不是水果)</li>
</ul>
<script>
// 使用 querySelectorAll,返回静态 NodeList
const fruits = document.querySelectorAll('.fruit');
const fruitClass = document.getElementsByClassName("fruit")
console.log(fruitClass)
console.log('querySelectorAll 找到的水果数量:', fruits.length); // 2
fruits.forEach(fruit => console.log(`水果: ${fruit.textContent}`)); // 苹果 香蕉
</script>

9.2.2. 集合类型深度辨析:

HTMLCollection vs. NodeList 这是 DOM 操作中一个非常重要的原理性知识。getElementsBy... 系列方法返回的是 动态的 HTMLCollection,而 querySelectorAll 返回的是 静态的 NodeList

  • HTMLCollection (动态/实时): 如果在获取集合后,DOM 结构发生了变化(如新增或删除了匹配的元素),这个集合会 自动更新 以反映这些变化。
  • NodeList (静态快照): 它是在执行 querySelectorAll 时对 DOM 拍下的一张“照片”。之后 DOM 的任何变化都 不会影响 这个 NodeList 的内容。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<div id="container">
<p class="item">项目 1</p>
<p class="item">项目 2</p>
</div>
<button id="add-btn">添加新项目</button>

<script>
const container = document.getElementById('container');
const addBtn = document.getElementById('add-btn');

const liveCollection = container.getElementsByClassName('item');
const staticNodeList = container.querySelectorAll('.item');

console.log('获取时,动态集合长度:', liveCollection.length); // 2
console.log('获取时,静态集合长度:', staticNodeList.length); // 2

addBtn.onclick = () => {
const newItem = document.createElement('p');
newItem.className = 'item';
newItem.textContent = '新增项目';
container.append(newItem);

console.log('--- DOM 变化后 ---');
console.log('动态集合长度自动更新为:', liveCollection.length); // 3
console.log('静态集合长度保持不变:', staticNodeList.length); // 仍然是 2
};
</script>

9.2.3. 元素遍历 API

一旦获取了一个元素,我们就可以基于它来查找其亲属元素。

元素节点遍历 (推荐)

这组 API 只会返回 元素节点,自动忽略了元素间空白导致的文本节点,是日常开发的首选。

属性描述
parentElement返回父 元素
children返回一个包含所有子 元素HTMLCollection
firstElementChild返回第一个子 元素
lastElementChild返回最后一个子 元素
nextElementSibling返回下一个兄弟 元素
previousElementSibling返回上一个兄弟 元素
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
    <div id="container">
<p class="item">项目 1</p>
<p class="item">项目 2</p>
</div>
<button id="add-btn">添加新项目</button>


<script>
const container = document.querySelector("#container");
// 获取container的子元素
const container_child = container.children;
console.log(container_child); // HTMLCollection(2) [p.item, p.item]

// 获取container的父元素
const container_parent = container.parentElement;
console.log(container_parent); // body

// 获取container的第一个子元素
const container_frist_child = container.firstElementChild;
console.log(container_frist_child); // p.item

// 获取container的最后一个子元素
const container_last_child = container.lastElementChild;
console.log(container_last_child); // p.item

// 获取container的下一个兄弟元素
const container_next_sibling = container.nextElementSibling;
console.log(container_next_sibling); // button#add-btn

// 获取container的上一个兄弟元素
const container_previous_sibling = container.previousElementSibling;
console.log(container_previous_sibling); // null,这是因为在div的上方没有兄弟元素


</script>

通用节点遍历 (含陷阱)

这组 API 会返回 所有类型 的节点,包括元素、文本、注释等。在格式化的 HTML 代码中,元素间的换行和缩进会被视为空白 文本节点,使用这组 API 容易踩坑。

属性描述
parentNode返回父 节点
childNodes返回一个包含所有子 节点NodeList
firstChild返回第一个子 节点
lastChild返回最后一个子 节点
nextSibling返回下一个兄弟 节点
previousSibling返回上一个兄弟 节点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    <div id="container">
<p class="item">项目 1</p>
<p class="item">项目 2</p>
</div>
<button id="add-btn">添加新项目</button>

<script>
const container = document.querySelector("#container");

// 获取父节点
const container_parent_node = container.parentNode;
console.log(container_parent_node); // body

const container_childNodes = container.childNodes;
// 注意,上方代码是有换行的,所以他被视作为一个text节点
console.log(container_childNodes); // NodeList(5) [text, p.item, text, p.item, text]

// 其他方法就不演示了
</script>

9.3. 元素的操作:内容、属性与样式

承上启下:上一节我们学会了如何精准地“捕获”到页面元素。现在,是时候学习如何“改造”它们了。本节将聚焦于最常见的 DOM 操作:修改元素内部的 内容,管理它的 属性,以及动态切换它的 样式。我们将通过一系列小型实战场景,逐一攻克这些核心操作。

9.3.1. 微场景(一):动态更新用户资料卡

核心痛点: 假设我们从服务器获取了最新的用户信息,需要将其动态地展示在一个用户资料卡上。这涉及到安全地更新文本内容、修改图片地址以及绑定自定义数据。

1
2
3
4
5
6
7
8
9
10
11
<style>
.user-card { border: 1px solid #ccc; padding: 15px; width: 250px; border-radius: 8px; font-family: sans-serif; }
.user-card img { width: 80px; height: 80px; border-radius: 50%; float: left; margin-right: 15px; }
.user-card h3 { margin: 0 0 10px; }
.user-card p { color: #666; }
</style>
<div id="profile-card" class="user-card" data-user-id="1">
<img id="profile-avatar" src="https://picsum.photos/200/300" alt="用户头像">
<h3 id="profile-name">旧用户名</h3>
<p id="profile-bio">这是一段旧的个人简介。</p>
</div>

操作一:更新文本内容 (textContent vs innerHTML)

我们需要将获取到的用户名和简介更新到页面上。这里有两个主要的属性可用:

  • element.textContent: (安全/推荐) 它会将所有内容都作为纯文本处理。任何传入的 HTML 标签都会被直接显示为字符串,而不会被浏览器解析。这是更新文本内容的 最佳实践,能从根本上杜绝 XSS 跨站脚本攻击。
  • element.innerHTML: (危险/慎用) 它会将传入的字符串作为 HTML 解析。虽然功能强大,但如果内容来源不可信(如用户输入),恶意脚本(<script>, onerror)就可能被执行,导致严重的安全漏洞。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.user-card {
border: 1px solid #ccc;
padding: 15px;
width: 250px;
border-radius: 8px;
font-family: sans-serif;
}

.user-card img {
width: 80px;
height: 80px;
border-radius: 50%;
float: left;
margin-right: 15px;
}

.user-card h3 {
margin: 0 0 10px;
}

.user-card p {
color: #666;
}
</style>
</head>

<body>
<div id="profile-card" class="user-card" data-user-id="1">
<img id="profile-avatar" src="https://picsum.photos/200/300" alt="用户头像">
<h3 id="profile-name">旧用户名</h3>
<p id="profile-bio">这是一段旧的个人简介。</p>
<button onclick="updateProfile()">更新</button>
</div>

</body>
<script>
// 模拟获取到的新数据
const newData = {
name: "Prorise 博客",
bio: "一个追求极致的技术笔记平台。",
avatarUrl: "https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/avatar.png"
};

const nameEL = document.getElementById("profile-name");
const bioEL = document.getElementById("profile-bio");

function updateProfile() {
nameEL.textContent = newData.name;
bioEL.textContent = newData.bio;
}
</script>

</html>

操作二:管理 HTML 属性 (setAttribute & dataset)

接下来,我们需要更新头像的 src 属性,并可能需要读取或更新绑定在元素上的自定义数据(如用户 ID)。

  • 标准属性 (getAttribute/setAttribute): 用于读写 href, src, class, id 等 HTML 标准属性。
  • 自定义数据属性 (dataset): (推荐) 现代前端开发的最佳实践是,将与业务逻辑相关的自定义数据,通过 data-* 属性存储在 HTML 中。JavaScript 中可以通过 element.dataset 对象来方便地访问它们(data-user-id 会被自动转换为 dataset.userId)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.user-card {
border: 1px solid #ccc;
padding: 15px;
width: 250px;
border-radius: 8px;
font-family: sans-serif;
}

.user-card img {
width: 80px;
height: 80px;
border-radius: 50%;
float: left;
margin-right: 15px;
}

.user-card h3 {
margin: 0 0 10px;
}

.user-card p {
color: #666;
}
</style>
</head>

<body>
<div id="profile-card" class="user-card" data-user-id="1">
<img id="profile-avatar" src="https://picsum.photos/200/300" alt="用户头像">
<h3 id="profile-name">旧用户名</h3>
<p id="profile-bio">这是一段旧的个人简介。</p>
<button onclick="updateProfile()">更新</button>
</div>

</body>
<script>
// 模拟获取到的新数据
const newData = {
name: "Prorise 博客",
bio: "一个追求极致的技术笔记平台。",
avatarUrl: "https://prorise-blog.oss-cn-guangzhou.aliyuncs.com/cover/avatar.png"
};

const nameEL = document.getElementById("profile-name");
const bioEL = document.getElementById("profile-bio");
const cardEl = document.getElementById('profile-card');
const avatarEl = document.getElementById('profile-avatar');
const userId = cardEl.dataset.userId;
console.log(`资料卡的用户 ID 为: ${userId}`);

function updateProfile() {
nameEL.textContent = newData.name;
bioEL.textContent = newData.bio;
// 使用 setAttribute 更新标准属性 src
avatarEl.setAttribute('src', newData.avatarUrl);
console.log("头像 URL 已更新。");
}
</script>

</html>

9.3.2. 微场景(二):实现动态的视觉反馈

核心痛点 (Why): 在 Web 应用中,我们需要为用户的操作提供即时的视觉反馈。例如,点击一个按钮后,它的状态应该改变;或者,当用户在输入框中输入时,根据内容的有效性,输入框的边框应该变色。直接在 JavaScript 中逐行修改 style 属性会导致逻辑和表现的强耦合,难以维护。

解决方案: 通过 JavaScript 切换 CSS 类名。我们将不同状态的样式预先定义在 CSS 类中,JavaScript 只负责添加或移除这些类,实现行为与表现的分离。element.classList 是完成此任务的完美工具。

classList 方法描述
.add('class')添加一个类。
.remove('class')移除一个类。
.toggle('class')如果类存在则移除,不存在则添加。非常适合做状态切换。
.contains('class')检查是否包含指定的类,返回布尔值。

示例一:状态切换按钮 (使用 toggle)

这是 toggle 方法最经典的应用场景,用一行代码实现两种状态的切换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.follow-btn {
border: 1px solid #ccc;
padding: 10px 20px;
background-color: #fff;
border-radius: 15px;
cursor: pointer;
font-size: 16px;
font-weight: bold;
color: #333;
transition: all 0.3s ease;

}

.following {
background-color: #ff03ea;
color: #fff;
}
</style>
</head>

<body>
<button id="follow-btn" class="follow-btn" onclick="follow()">关注</button>

</body>
<script>
function follow() {
const followBtn = document.getElementById('follow-btn');
followBtn.classList.toggle('following');
}
</script>

</html>

示例二:表单输入验证反馈 (使用 add, remove)

这个场景更清晰地展示了如何根据不同的逻辑条件,精确地添加和移除类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.input-field {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
transition: border-color 0.3s ease;
}

.input-field.valid {
border-color: #2e7d32;
/* 绿色边框 */
}

.input-field.invalid {
border-color: #d32f2f;
/* 红色边框 */
}

.feedback-msg {
font-size: 12px;
height: 16px;
margin-top: 4px;
}

.feedback-msg.valid {
color: #2e7d32;
}

.feedback-msg.invalid {
color: #d32f2f;
}
</style>
</head>

<body>
<input type="password" id="password-input" class="input-field" placeholder="请输入至少8位的密码">
<p id="feedback-msg" class="feedback-msg"></p>
</body>
<script>
const passwordInput = document.getElementById('password-input');
const feedbackMsg = document.getElementById('feedback-msg');
// 我们通过后续要学习的DOM事件来监听input事件
passwordInput.addEventListener("input", () => {
const value = passwordInput.value;
if (value.length >= 8) {
passwordInput.classList.add('valid');
passwordInput.classList.remove('invalid');
feedbackMsg.textContent = '密码强度符合要求';
feedbackMsg.classList.add('valid');
feedbackMsg.classList.remove('invalid');
} else {
// 不满足条件:添加 'invalid',移除 'valid'
passwordInput.classList.add('invalid');
passwordInput.classList.remove('valid');

feedbackMsg.textContent = '密码长度不能少于8位';
feedbackMsg.classList.add('invalid');
feedbackMsg.classList.remove('valid');
}
})
</script>


</html>

9.3.3 微场景(三):实现一个可拖拽的卡片 (操作行内样式)

核心痛点: 有时,我们需要根据用户的实时交互(如鼠标移动)来计算并设置元素的样式,例如一个可拖拽元素的 lefttop 值。这些值是高度动态、实时计算的,不适合预先定义在 CSS 类中。

解决方案: 在这种场景下,直接操作元素的 element.style 对象是最直接有效的方式。

element.style 属性返回一个对象,它对应于该元素的 HTML style 行内属性。我们可以像操作普通 JS 对象一样,给它的属性赋值来动态修改样式。

注意:

  1. CSS 属性名中的连字符 (-) 需要转换为驼峰命名法(例如 background-color 变为 backgroundColor)。
  2. element.style 只能 读取 到行内样式,无法获取来自 <style> 标签或外部 CSS 文件的样式。

重要信息: 如果你是新手,不懂 Dom 的事件的话,可以先继续往后看,后续的章节我们会详细介绍

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<style>
#draggable-box {
position: absolute; /* 必须是可定位的元素 */
width: 100px;
height: 100px;
background-color: #007bff;
color: white;
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
user-select: none; /* 防止拖动时选中文本 */
}
#draggable-box:active {
cursor: grabbing;
}
</style>

<div id="draggable-box">拖动我</div>

<script>
const box = document.getElementById('draggable-box');
let isDragging = false;
let offsetX, offsetY;

box.addEventListener('mousedown', (e) => {
isDragging = true;
// 计算鼠标点击位置相对于元素左上角的偏移
offsetX = e.clientX - box.offsetLeft;
offsetY = e.clientY - box.offsetTop;
// 设置为 grabbing 光标样式
box.style.cursor = 'grabbing';
});

document.addEventListener('mousemove', (e) => {
if (isDragging) {
// 核心:实时更新 style.left 和 style.top
box.style.left = `${e.clientX - offsetX}px`;
box.style.top = `${e.clientY - offsetY}px`;
}
});

document.addEventListener('mouseup', () => {
isDragging = false;
box.style.cursor = 'grab'; // 恢复光标样式
});
</script>

9.4. 动态构建:创造与销毁元素

承上启下: 前面我们学会了如何查找和修改 已存在 的元素。但 Web 应用的魅力在于其 动态性——根据用户的操作或数据的变化,实时地在页面上创建全新的内容,或移除不再需要的部分。本节,我们将聚焦于 DOM 操作中最激动人心的部分:从无到有地创造元素,并将其注入页面,以及在适当的时候销毁它们。

9.4.1. 从零到一:创建新节点

在将一个新元素添加到页面之前,我们首先需要在内存中将它“创造”出来。这主要通过两个方法完成。

方法描述
document.createElement(tagName)创建一个指定标签名(如 'div', 'p')的 元素节点
document.createTextNode(text)创建一个包含指定文本的 文本节点

这两个方法创建出的节点,仅仅是存在于 JavaScript 的内存中,与页面上的 DOM 树还没有任何关系。我们可以像操作普通 JS 对象一样,先对它进行各种设置。

示例: 创建一个结构完整的、待插入的段落元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1. 创建一个 <p> 元素节点
const newParagraph = document.createElement('p');

// 2. 为其设置 class 和 id
newParagraph.className = 'comment';
newParagraph.id = 'comment-1';

// 3. 创建一个文本节点
const textContent = document.createTextNode('这是一条新的评论内容。');

// 4. 将文本节点作为子节点添加到 <p> 元素中
newParagraph.append(textContent);

// 此时,newParagraph 已经是一个完整的 DOM 节点,但还未显示在页面上
console.log('内存中创建的元素:', newParagraph);

9.4.2. 放入页面:插入节点

当节点在内存中准备就绪后,我们就可以使用插入 API 将它“挂载”到 DOM 树的指定位置。

现代节点插入 API

这组 API 更灵活、更直观,并且允许一次性插入多个节点,老版本的 API 我们就不学习了

方法描述
parentElement.append(...nodes)parentElement最后一个子节点之后 插入一个或多个节点。
parentElement.prepend(...nodes)parentElement第一个子节点之前 插入一个或多个节点。
element.before(...nodes)element 之前 插入一个或多个兄弟节点。
element.after(...nodes)element 之后 插入一个或多个兄弟节点。

示例: 实现一个简单的评论发布功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Elegant Comment Box</title>
<style>
.comment-box-container { margin: 0 auto; background-color: #ffffff; border-radius: 12px; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08); padding: 24px; width: 100%; max-width: 550px; box-sizing: border-box; }

.comment-textarea { width: 100%; height: 120px; padding: 12px; border: 1px solid #ccd0d5; border-radius: 8px; font-size: 16px; line-height: 1.4; resize: vertical; box-sizing: border-box; outline: none; transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; }

.comment-textarea:focus { border-color: #007bff; box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25); }

.send-button { background-color: #007bff; color: white; border: none; border-radius: 8px; padding: 10px 20px; font-size: 16px; font-weight: 600; cursor: pointer; outline: none; transition: background-color 0.2s ease-in-out; }


.comment-list ul {padding: 0;}

.comment-list ul li { list-style: none; }

.comment-content { background-color: #f0f2f5; padding: 12px; border-radius: 8px; margin-top: 12px; }

#comment-content { font-size: 16px; line-height: 1.4; margin: 0; }

</style>
</head>

<body>

<div class="comment-box-container">
<textarea class="comment-textarea" placeholder="在此输入您的评论..."></textarea>
<div style="display: flex; justify-content: flex-end; margin-top: 16px;">
<button class="send-button" onclick="sendComment()">发送</button>
</div>
<div class="comment-list" style="margin-top: 16px;">
<ul>
</ul>
</div>
</div>

</body>

<script>
function sendComment() {
const comment = document.querySelector('.comment-textarea').value;
if (!comment.trim()) return;
// 以comment_list ul 作为父节点,一下的所有操作都基于他来插入
const commentList = document.querySelector('.comment-list ul');

// 演示现代节点插入 API

// 1. append() - 在父元素的最后一个子节点之后插入
const newComment = document.createElement('li');
newComment.innerHTML = `
<div class="comment-content">
<p>末尾评论:${comment}</p>
</div>
`;
commentList.append(newComment); // 添加到列表末尾

// 2. prepend() - 在父元素的第一个子节点之前插入
const headerComment = document.createElement('li');
headerComment.innerHTML = `
<div class="comment-content" style="background-color: #e3f2fd;">
<p><strong>最新评论:</strong>${comment}</p>
</div>
`;
commentList.prepend(headerComment); // 添加到列表开头

// 3. before() - 在指定元素之前插入兄弟节点
const beforeElement = document.createElement('div');
beforeElement.innerHTML = '<p style="color: #666; font-size: 14px;">--- 评论分隔线 ---</p>';
newComment.before(beforeElement); // 在末尾评论前添加分隔线

// 4. after() - 在指定元素之后插入兄弟节点
const afterElement = document.createElement('div');
afterElement.innerHTML = '<p style="color: #666; font-size: 12px;">评论时间: ' + new Date().toLocaleString() + '</p>';
newComment.after(afterElement); // 在末尾评论后添加时间戳

// 清空输入框
document.querySelector('.comment-textarea').value = '';
}
</script>

</html>

9.4.3. 完成使命:移除与替换节点

现代节点移除/替换 API (2025 推荐)

这组 API 直接在目标元素上调用,非常直观。

方法描述
element.remove()element 从其父节点中 移除
element.replaceWith(...nodes)用一个或多个新节点 替换element

示例: 为每条评论添加删除功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Elegant Comment Box</title>
<style>
.comment-box-container { margin: 0 auto; background-color: #ffffff; border-radius: 12px; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08); padding: 24px; width: 100%; max-width: 550px; box-sizing: border-box; }

.comment-textarea { width: 100%; height: 120px; padding: 12px; border: 1px solid #ccd0d5; border-radius: 8px; font-size: 16px; line-height: 1.4; resize: vertical; box-sizing: border-box; outline: none; transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; }

.comment-textarea:focus { border-color: #007bff; box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25); }

.send-button { background-color: #007bff; color: white; border: none; border-radius: 8px; padding: 10px 20px; font-size: 16px; font-weight: 600; cursor: pointer; outline: none; transition: background-color 0.2s ease-in-out; }


.comment-list ul {padding: 0;}

.comment-list ul li { list-style: none; }

.comment-content { background-color: #f0f2f5; padding: 12px; border-radius: 8px; margin-top: 12px; position: relative; }

#comment-content { font-size: 16px; line-height: 1.4; margin: 0; }

.delete-button { position: absolute; top: 8px; right: 8px; background-color: #dc3545; color: white; border: none; border-radius: 4px; padding: 4px 8px; font-size: 12px; cursor: pointer; }

.delete-button:hover { background-color: #c82333; }

</style>
</head>

<body>

<div class="comment-box-container">
<textarea class="comment-textarea" placeholder="在此输入您的评论..."></textarea>
<div style="display: flex; justify-content: flex-end; margin-top: 16px;">
<button class="send-button" onclick="sendComment()">发送</button>
</div>
<div class="comment-list" style="margin-top: 16px;">
<ul>
</ul>
</div>
</div>

</body>

<script>
function deleteComment(element) {
// 演示 element.remove() - 将评论从DOM中移除
// closest() 会从 element 开始向上遍历其所有祖先节点,找到第一个匹配 'li' 的元素,并返回该元素。
element.closest('li').remove();
}

function replaceComment(element) {
// 演示 element.replaceWith() - 用新内容替换评论
const replacementComment = document.createElement('li');
replacementComment.innerHTML = `
<div class="comment-content" style="background-color: #ffeaa7;">
<p><em>此评论已被替换</em></p>
<button class="delete-button" onclick="deleteComment(this)">删除</button>
</div>
`;
element.closest('li').replaceWith(replacementComment);
}

function sendComment() {
const comment = document.querySelector('.comment-textarea').value;
if (!comment.trim()) return;
// 以comment_list ul 作为父节点,一下的所有操作都基于他来插入
const commentList = document.querySelector('.comment-list ul');

// 演示现代节点插入 API

// 1. append() - 在父元素的最后一个子节点之后插入
const newComment = document.createElement('li');
newComment.innerHTML = `
<div class="comment-content">
<p>末尾评论:${comment}</p>
<button class="delete-button" onclick="deleteComment(this)">删除</button>
</div>
`;
commentList.append(newComment); // 添加到列表末尾

// 2. prepend() - 在父元素的第一个子节点之前插入
const headerComment = document.createElement('li');
headerComment.innerHTML = `
<div class="comment-content" style="background-color: #e3f2fd;">
<p><strong>最新评论:</strong>${comment}</p>
<button class="delete-button" onclick="deleteComment(this)">删除</button>
<button class="delete-button" onclick="replaceComment(this)" style="right: 60px;">替换</button>
</div>
`;
commentList.prepend(headerComment); // 添加到列表开头

// 3. before() - 在指定元素之前插入兄弟节点
const beforeElement = document.createElement('div');
beforeElement.innerHTML = '<p style="color: #666; font-size: 14px;">--- 评论分隔线 ---</p>';
newComment.before(beforeElement); // 在末尾评论前添加分隔线

// 4. after() - 在指定元素之后插入兄弟节点
const afterElement = document.createElement('div');
afterElement.innerHTML = '<p style="color: #666; font-size: 12px;">评论时间: ' + new Date().toLocaleString() + '</p>';
newComment.after(afterElement); // 在末尾评论后添加时间戳

// 清空输入框
document.querySelector('.comment-textarea').value = '';
}
</script>

</html>

9.5. DOM 事件基础:监听与响应

我们已经学会了如何用 JavaScript 像上帝一样创造、修改和销毁页面元素。但目前,这些操作都只能在代码加载时执行一次。如何让页面“活”起来,响应用户的点击、鼠标的移动和键盘的输入?答案就是 事件。本节,我们将开启 DOM 交互的核心——事件监听,对于事件我们通常不需要去特殊记忆,在什么场景需要用到就去查一下就可以了

一个 事件 (Event),是浏览器通知我们“某件事发生了”的信号。例如:

  • 用户点击了一个按钮 (click 事件)
  • 鼠标指针移入一张图片 (mouseover 事件)
  • 用户在输入框中按下了键盘 (keydown 事件)
  • 整个页面资源加载完毕 (load 事件)

事件监听,就是我们编写一段代码(称为“事件监听器”或“事件处理器”),并将其“绑定”到某个元素上,告诉浏览器:“当这个元素上发生这种事件时,请执行我这段代码。”

9.5.1. 事件监听器的演进

在历史上,为元素绑定事件监听器的方式经历了几个阶段的演进。

阶段一:HTML on-* 属性

最早的方式是直接在 HTML 标签上使用 on<事件名> 属性。

1
<button onclick="alert('你点击了按钮!'); console.log('这是一个已废弃的用法。');">不要这样用</button>

缺点: 这种方式严重违反了“结构与行为分离”的原则,将 JavaScript 代码和 HTML 标记混杂在一起,极难维护。

阶段二:DOM0 级事件处理 (element.on*)

通过直接给元素的 on<事件名> 属性赋值一个函数,可以实现行为与结构的分离。

1
2
3
4
const btn = document.querySelector('#my-btn');
btn.onclick = function() {
console.log('DOM0 方式绑定的点击事件。');
};

缺点: 一个元素的一个事件只能绑定一个处理器。如果对同一个 btn.onclick 多次赋值,后面的会 覆盖 前面的,导致逻辑丢失。

9.5.2. 现代事件监听:addEventListener

addEventListener 是 W3C DOM2 级事件规范中定义的方法,也是当今所有现代浏览器支持的 标准事件监听方式

核心语法: element.addEventListener(type, listener, [options/useCapture]);

  • type: 事件类型字符串,没有 on 前缀,例如 'click', 'mouseover', 'keydown'
  • listener: 事件触发时要执行的函数(回调函数)。
  • 第三个参数(可选): 通常是一个布尔值,用于指定是在“捕获”阶段还是“冒泡”阶段执行(我们将在后续章节深入讲解事件流)。默认为 false(冒泡阶段)。

核心优势:

  1. 可以为一个事件绑定多个监听器,它们会按照添加的顺序依次执行,互不覆盖。
  2. 提供了更精细的控制,如通过 removeEventListener 移除监听。

示例一:为一个按钮添加多个点击事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<button id="multi-listener-btn">点我试试</button>
<script>
const btn = document.getElementById('multi-listener-btn');

// 添加第一个监听器
btn.addEventListener('click', () => {
console.log('监听器 1 被触发了!');
});

// 添加第二个监听器
btn.addEventListener('click', (event) => {
console.log(`监听器 2 被触发了!事件类型是: ${event.type}`);
});
</script>

移除事件监听器: removeEventListener

要移除一个事件监听器,必须使用 removeEventListener,并且需要满足两个条件:

  1. 事件类型、监听函数、以及第三个参数必须与添加时 完全一致
  2. 因此,被移除的监听函数 不能是匿名函数,必须是一个有引用的函数(如命名函数或函数变量)。

示例二:一个只触发一次的事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>

<body>
<button id="once-btn">click me</button>
</body>
<script>
const btn = document.getElementById('once-btn');

function handleFirstClick() {
alert("click");
btn.removeEventListener("click", handleFirstClick);
}
btn.addEventListener("click", handleFirstClick);
</script>

</html>

点击按钮,控制台会输出消息。再次点击,将不再有任何反应,因为监听器已被成功移除。


9.6. DOM 事件(下):事件流与事件对象

9.6.1. 事件流:捕获与冒泡

核心原理: 当您点击一个深层嵌套的按钮时,您不仅仅是点击了那个按钮。从根节点 <html> 到那个按钮,路径上的所有祖先元素,实际上都“感知”到了这次点击。事件在 DOM 树中传播的顺序,就称为 事件流 (Event Flow)

W3C 标准规定,事件流分为三个阶段:

  1. 捕获阶段: 事件从文档的根节点(window -> document -> <html>)开始,逐级 向下 传播,直到达到真正的目标元素。
  2. 目标阶段: 事件到达目标元素。
  3. 冒泡阶段: 事件从目标元素开始,逐级 向上 冒泡,直到再次回到文档的根节点。

默认情况下,我们使用 addEventListener 绑定的事件监听器,只会在 冒泡阶段 被触发。

addEventListener 的第三个参数:

  • element.addEventListener('click', handler, false); (或不写): handler冒泡阶段 执行。
  • element.addEventListener('click', handler, true);: handler捕获阶段 执行。

我们来看一个在实际开发中非常常见的场景,这个场景经常因为对事件流理解不清而导致 bug。
场景:一个可以点击的卡片,但卡片内有一个功能独立的“删除”按钮。

  • 目标:点击卡片空白处,触发“查看详情”;点击卡片内的“删除”按钮, 触发“删除”操作。
  • 常见的坑:点击“删除”按钮后,不仅触发了“删除”,还意外地触发了“查看详情”。

下面是这个问题的 错误实现,它清晰地暴露了不处理事件冒泡所带来的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<!DOCTYPE html>
<html>
<head>
<title>事件冒泡问题演示</title>
<style>
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; background-color: #f0f0f0; margin: 0; }
.card {
width: 250px;
padding: 20px;
border: 1px solid #ccc;
border-radius: 8px;
background-color: white;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
cursor: pointer;
transition: transform 0.2s;
}
.card:hover { transform: translateY(-5px); }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.delete-btn {
padding: 5px 10px;
border: none;
background-color: #e74c3c;
color: white;
border-radius: 5px;
cursor: pointer;
}
</style>
</head>
<body>

<div id="product-card" class="card">
<div class="card-header">
<h4>商品卡片</h4>
<button id="delete-button" class="delete-btn">删除</button>
</div>
<p>点击卡片空白处查看详情。</p>
</div>

<script>
const card = document.getElementById('product-card');
const deleteBtn = document.getElementById('delete-button');

// 给整个卡片绑定"查看详情"事件
card.addEventListener('click', () => {
alert('事件冒泡了!触发了卡片的点击事件 -> 准备跳转详情页...');
});

// 给删除按钮绑定"删除"事件
deleteBtn.addEventListener('click', () => {
alert('删除了!触发了删除按钮的点击事件 -> 正在执行删除操作...');
});
</script>

</body>
</html>

你会发现,两个事件都被触发了。这是因为 click 事件在“删除”按钮上触发后,会继续向 上冒泡 到它的父元素 div#product-card,从而也触发了绑定在卡片上的监听器。这显然不是我们想要的结果。


现在,我们用 event.stopPropagation() 来修正这个问题。这个方法可以阻止事件继续向上冒泡。

1
2
3
4
5
6
// 给删除按钮绑定 "删除" 事件
deleteBtn.addEventListener('click', (event) => {
// 阻止事件冒泡
event.stopPropagation();
alert('删除了!触发了删除按钮的点击事件 -> 正在执行删除操作...');
});

现在点击删除按钮您将看不到上述的问题了!


9.6.2. 事件对象 (Event Object)

当一个事件发生时,浏览器会自动创建一个 事件对象(通常命名为 evente),并将其作为唯一的参数传递给事件监听函数。这个对象包含了关于该事件的所有详细信息,是我们与事件交互的关键。

核心属性/方法描述
event.target事件的真正发起者。在事件流中,它始终是那个被用户直接交互的、最深层的元素(例如,被点击的按钮或链接)。
event.currentTarget当前正在执行监听器的那个元素。也就是你调用 addEventListener 时绑定的那个元素。
event.type事件的类型(如 'click', 'keydown')。
event.preventDefault()(方法) 阻止元素的 默认行为。例如,阻止 <a> 标签的跳转,或 <form> 的提交。
event.stopPropagation()(方法) 阻止事件继续传播(通常指阻止冒泡)。事件流将在此处停止,不会再触发父级元素的同类型事件监听器。

场景示例:一个可交互的待办事项列表

想象一个待办事项列表,我们希望实现以下功能:

  1. 事件委托:只在父容器 <ul> 上设置一个点击监听器来管理所有子项,而不是给每个 <li> 或按钮都单独绑定事件,这样更高效。
  2. 点击待办事项的 文本,可以将其标记为“已完成”(添加删除线)。
  3. 点击每项后面的 “移除”链接,应该 只删除 该项,而 不触发“标记完成”的逻辑,并且 不刷新 页面。

这个场景完美地展示了上述所有概念的协同工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.todo-list {
border: 1px solid #ccc;
padding: 10px;
width: 300px;
list-style: none;
}

.todo-list li {
padding: 8px;
margin: 5px 0;
background-color: #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
}

.todo-list li.completed .text {
text-decoration: line-through;
color: #888;
}

.todo-list .remove-btn {
color: red;
text-decoration: none;
font-size: 12px;
}
</style>
</head>

<body>
<ul id="todo-list" class="todo-list">
<li><span class="text">学习 JavaScript 事件流</span><a href="#" class="remove-btn">移除</a></li>
<li><span class="text">写一个很棒的例子</span><a href="#" class="remove-btn">移除</a></li>
<li><span class="text">休息一下</span><a href="#" class="remove-btn">移除</a></li>
</ul>
</body>
<script>
const list = document.getElementById('todo-list');
list.addEventListener('click', (event) => {
console.log('--- 事件触发 ---');
console.log('event.target:', event.target.tagName, `(class: ${event.target.className})`); // 用户实际点击的元素
console.log('event.currentTarget:', event.currentTarget.tagName); // 监听器绑定的元素,永远是 UL

// 判断用户点击的是不是“移除”按钮
if (event.target.classList.contains('remove-btn')) {
// 1. 阻止 <a> 标签的默认跳转行为
event.preventDefault();

// 2. 阻止事件向上冒泡到 li 或 ul,避免触发下面的“标记完成”逻辑
event.stopPropagation();

// 找到父级 li 元素并移除它
const itemToRemove = event.target.parentElement;
itemToRemove.remove();

} else {
// 如果点击的不是“移除”按钮,就执行“标记完成”逻辑
// event.target 可能是 span 或 li,我们需要找到 li
const targetLi = event.target.closest('li');
if (targetLi) {
// 切换类状态
targetLi.classList.toggle('completed');
}
}
})
</script>

</html>

9.7. 高级事件模式:事件委托

在上一节的待办事项列表中,我们只给父元素 <ul> 添加了一个事件监听器,却成功地管理了所有 <li> 子项的点击事件,包括它们的完成状态和删除按钮。这种高效的模式,就是 事件委托

核心痛点: 想象一下,一个拥有 1000 个列表项的 <ul>。如果我们为每一个 <li> 都单独调用 addEventListener,会发生什么?

  1. 性能问题: 内存中会创建 1000 个独立的监听器函数,占用大量内存,可能导致页面响应变慢。
  2. 动态内容问题: 如果我们通过 JavaScript 动态地向这个列表中添加一个新的 <li>,新添加的项 不会 自动绑定上事件监听器,我们需要手动再次绑定,代码会变得非常复杂。

解决方案: 事件委托 正是为了解决这两个问题而生。它的核心原理是利用我们上一节学到的 事件冒泡 机制。我们不再给每个子元素单独设置监听器,而是只给它们的共同父元素设置一个监听器。当任何一个子元素被点击时,事件会冒泡到父元素,父元素的监听器就会被触发。

实现的关键: 在父元素的监听器中,我们通过 event.target 属性,就能精确地判断出事件的真正来源是哪个子元素,然后执行相应的逻辑。

实战场景:一个动态的、可交互的标签选择器

我们将构建一个标签选择器。初始时有几个标签,用户可以点击“添加新标签”来动态增加更多标签。我们希望实现:

  • 无论何时,点击任何一个标签,都能在 alert 打印出它的内容。
  • 整个过程只使用 一个 事件监听器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#tag-container {
border: 1px solid #ccc;
padding: 10px;
width: 400px;
}

.tag {
display: inline-block;
padding: 5px 10px;
margin: 5px;
background-color: #007bff;
color: white;
border-radius: 15px;
cursor: pointer;
transition: background-color 0.2s;
}

.tag:hover {
background-color: #0056b3;
}

#add-tag-btn {
margin-top: 10px;
}
</style>
</head>

<body>
<div id="tag-container">
<span class="tag">JavaScript</span>
<span class="tag">HTML</span>
<span class="tag">CSS</span>
</div>
<button id="add-tag-btn">添加新标签</button>
</body>
<script>
const tagContainer = document.getElementById('tag-container');
const addTagBtn = document.getElementById('add-tag-btn');
let tagCounter = 0;

// 核心:只在父容器上设置一个监听器
tagContainer.addEventListener('click', (event) => {
// 步骤1: 检查事件来源 (event.target) 是否是我们关心的子元素
if (event.target.classList.contains('tag')) {
// 步骤2: 如果是,则执行针对该子元素的逻辑
const tagName = event.target.textContent;
alert(`你点击了标签: ${tagName}`);
}
});

// 用于动态添加新标签的逻辑
addTagBtn.addEventListener('click', () => {
tagCounter++;
const newTag = document.createElement('span');
newTag.className = 'tag';
newTag.textContent = `新标签 ${tagCounter}`;
tagContainer.append(newTag);
});
</script>

</html>

9.8. 精准定位:获取元素尺寸与位置 (重点)

在前端开发中,我们不仅能改变元素,更需要能够 读取 它在页面布局中的精确信息。无论是实现“Tooltip 提示”、“无限滚动加载”还是“拖拽功能”,都离不开对元素尺寸和位置的精确掌控。

然而,DOM 提供了多套看似相似的 API,它们的参照物和计算方式各不相同,极易混淆。本章将彻底厘清这些概念,并把它们分为两大核心任务:

  1. 测量盒子:这个元素到底有多大?(涉及 offsetWidth, clientHeight, scrollHeight
  2. 定位盒子:这个元素在页面的什么位置?(涉及 getBoundingClientRect, offsetTop/offsetLeft

我们将通过一个个具体的实战场景,来掌握这些关键的 API。

9.8.1. 测量盒子:元素有多大?

在定位一个元素之前,我们首先要知道它自身的尺寸信息。DOM 提供了三套核心的“测量尺”,分别用于不同的场景。

属性包含内容一句话解释
offsetWidth/offsetHeight内容 + padding + border视觉尺寸:元素在屏幕上占据的完整空间。
clientWidth/clientHeight内容 + padding内部尺寸:元素内部可供内容显示的区域大小,不含边框和滚动条。
scrollHeight/scrollWidth所有 内容(包括被隐藏的)内容总尺寸:如果把所有内容平铺开,它所需要的总高度/宽度。

实战场景:实现“无限滚动加载”

核心痛点: 当列表内容非常多时,一次性加载会非常慢。最佳体验是当用户滚动到列表底部时,再自动加载下一页数据。这就需要我们精确判断“用户是否已滚动到底部”。

解决方案: 判断滚动条是否触底,完美地诠释了上述三个尺寸属性的协作关系。我们需要用到:

  1. element.scrollHeight: 内容的总高度。
  2. element.clientHeight: 容器的可视高度。
  3. element.scrollTop: 内容已经被向上卷去的距离。

“已滚动距离 (scrollTop)” + “可视区高度 (clientHeight)” ≈ “内容总高度 (scrollHeight)” 时,我们就可以判定用户已经滚动到了底部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#scroll-list {
position: relative;
height: 300px;
width: 300px;
/* 当内容超出容器时,显示滚动条 */
overflow-y: scroll;
border: 1px solid #625f5f;
padding: 10px;
}

#scroll-list p {
margin: 0 0 10px;
padding: 5px;
background: #f4f4f4;
}

#loading-indicator {
display: none;
text-align: center;
color: #999;
margin-top: 10px;
}
</style>
</head>

<body>
<div id="scroll-list">

</div>

</body>
<script>
const list = document.getElementById('scroll-list');
let itemCounter = 1;
let isLoading = false;
let loading;

// 创建一个加载更多的样式函数作为备用
function createLoadingIndicator() {
loading = document.createElement('div');
loading.id = 'loading-indicator';
loading.textContent = '加载更多中...';
loading.style.display = 'none';
loading.style.textAlign = 'center';
loading.style.color = '#999';
loading.style.marginTop = '10px';
}

function loadMoreItems() {
isLoading = true;
if (!loading) {
// 如果loading不存在,则创建一个
createLoadingIndicator();
}
// 通过每一次加载,给他放到list元素的最后方
list.append(loading);
loading.style.display = "block";
setTimeout(() => {
for (let i = 0; i < 10; i++) {
const newItem = document.createElement('p');
newItem.textContent = `Item ${itemCounter + i}`;
list.append(newItem);
}
itemCounter += 10;
loading.style.display = "none";
isLoading = false;
}, 1000);
}
loadMoreItems(); // 初始化函数

list.addEventListener('scroll', () => {
const scrollPosition = list.scrollTop + list.clientHeight;
// 减 5 是设置一个缓冲距离,避免因计算误差等因素导致频繁触发加载,让滚动到底部的判断更合理。
if (scrollPosition >= list.scrollHeight - 5 && !isLoading) {
loadMoreItems();
}
})
</script>

</html>

9.8.2. 定位盒子:元素在哪里?

知道了元素多大,下一步就是确定它的位置。这也是最容易混淆的地方。DOM 提供了两套主要的“定位系统”:一套是基于 浏览器视口 的现代方案,另一套是基于 父元素 的传统方案。

A. 现代首选:相对于浏览器视口定位

核心痛点: 当鼠标悬停在一个元素上时,我们希望在它旁边弹出一个“提示框”(Tooltip)。这个提示框的位置必须根据目标元素 当前在浏览器窗口中的位置 来动态计算,无论页面滚动到哪里。

解决方案: 这种相对于 浏览器视口 (viewport) 的定位需求,最现代、最直接的工具就是 element.getBoundingClientRect()

它返回一个 DOMRect 对象,包含 left, top, right, bottom, width, height 等相对于 视口左上角 的精确坐标。

  • 最大特点:它的返回值是 动态的。当页面滚动时,元素相对于视口的位置会改变,getBoundingClientRect() 返回的 topleft 值也会随之改变。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<style>
.container { position: relative; padding-top: 50px; padding-left: 100px; height: 300px; }
#tooltip-target { padding: 10px 20px; border: 1px solid #666; cursor: pointer; }
#tooltip {
position: absolute; /* 关键:相对于文档或最近的定位祖先定位 */
display: none; /* 默认隐藏 */
background-color: #333; color: white; padding: 8px; border-radius: 4px;
pointer-events: none; /* 让鼠标可以“穿透”提示框 */
}
</style>

<div class="container">
<button id="tooltip-target">悬停在我身上</button>
<div id="tooltip">这是一个动态计算位置的提示框。</div>
</div>

<script>
const target = document.getElementById('tooltip-target');
const tooltip = document.getElementById('tooltip');

target.addEventListener('mouseenter', () => {
const rect = target.getBoundingClientRect();
// 不加window.scrollY和window.scrollX的话,当页面有滚动时,提示框位置会不准确,它只基于视口位置计算,不会随页面滚动而正确移动,导致提示框和目标元素位置错乱。
// .style.top/left 是相对于 offsetParent 的,此处为 body
// 所以需要加上页面的滚动距离来得到相对于文档的绝对位置
tooltip.style.top = `${rect.bottom + window.scrollY + 5}px`;
tooltip.style.left = `${rect.left + window.scrollX}px`;
tooltip.style.display = 'block';
});

target.addEventListener('mouseleave', () => {
tooltip.style.display = 'none';
});
</script>

注意getBoundingClientRect() 获取的是相对于视口的坐标。如果你的提示框是 position: absolute 并且其定位父级是 <body>,那么设置 .style.top 时需要加上 window.scrollY,才能将其从“视口坐标”转换为“文档坐标”。

B. 传统方案:相对于父元素定位

核心痛点: 在复杂的布局中,我们常常需要将一个元素(如弹出的下拉菜单)精确地定位在某个已定位的父容器 内部,而不是相对于整个浏览器窗口。

解决方案: 这正是 offset 家族属性的用武之地。

  • offsetParent: 一个至关重要的概念,它指的是在 DOM 树中,离当前元素最近的、CSS position 属性 不为 static 的祖先元素。
  • offsetLeft / offsetTop: 元素的外边框边缘,到其 offsetParent 内边距边缘的距离。
  • 最大特点:它的值是 静态的,基于 DOM 结构和 CSS 布局决定,不会因为页面滚动而改变

getBoundingClientRect vs offsetTop/Left 核心区别

特性element.getBoundingClientRect().topelement.offsetTop
参照物浏览器视口offsetParent
是否随滚动变化不会
常见用途Tooltip、判断元素是否可见、吸顶效果在已定位的容器内布局子元素(如自定义下拉菜单)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<style>
#wrapper {
position: relative;
/* 这是按钮的 offsetParent */
width: 400px;
height: 150px;
border: 2px solid green;
padding: 10px;
margin-left: 50px;
}

#menu-button {
position: absolute;
top: 20px;
left: 30px;
}

#dropdown-menu {
position: absolute;
/* 它的定位将基于 wrapper */
display: none;
border: 1px solid #ccc;
background: white;
list-style: none;
padding: 0;
margin: 0;
}
</style>

<body>
<div id="wrapper">
<button id="menu-button">点击展开菜单</button>
<ul id="dropdown-menu">
<li>选项 1</li>
<li>选项 2</li>
</ul>
</div>
</body>
<script>
const button = document.getElementById('menu-button');
const menu = document.getElementById('dropdown-menu');

button.addEventListener('click', () => {
// offsetLeft/Top 是相对于 offsetParent (即 #wrapper) 的坐标
// 按钮的 offsetParent 是: #wrapper
console.log(`按钮的 offsetParent 是: #${button.offsetParent.id}`);
// 按钮的 offsetTop: 20, offsetLeft: 30
console.log(`按钮的 offsetTop: ${button.offsetTop}, offsetLeft: ${button.offsetLeft}`);
// 按钮的 offsetHeight: 24 (默认大小)
console.log(`按钮的 offsetHeight: ${button.offsetHeight}`);


// 计算菜单的位置
// 菜单的 top = 按钮的 top + 按钮的“外尺寸”高度
const menuTop = button.offsetTop + button.offsetHeight;
// 菜单的 left = 按钮的 left
const menuLeft = button.offsetLeft;


menu.style.top = `${menuTop}px`;
menu.style.left = `${menuLeft}px`;
menu.style.display = 'block';

})
</script>

</html>

9.8.3. 动态定位:捕获鼠标的位置

掌握了元素的静态位置信息后,我们还需要处理动态的交互——鼠标。当用户点击、移动鼠标时,我们需要知道鼠标指针的精确坐标,才能将元素定位与用户操作关联起来。

event 对象提供了多套坐标属性,它们的 坐标系参照物 也各不相同。

坐标属性参照物描述
clientX / clientY浏览器视口最常用。鼠标指针相对于浏览器 当前可见窗口 左上角的坐标。
pageX / pageY文档鼠标指针相对于整个 HTML 文档左上角的坐标。pageY = clientY + 页面垂直滚动距离
offsetX / offsetY目标元素鼠标指针相对于触发事件的那个元素的 内边距(padding)左上角 的坐标。
screenX / screenY电脑屏幕鼠标指针相对于用户 整个显示器屏幕 左上角的坐标。不常用。

9.8.4. 综合实战:实现电商平台“图片放大镜”效果

承上启下: 理论学习的最终目的是应用于实战。为了将本章所学的 元素查找样式操作事件监听 以及 元素几何定位 等核心知识点融会贯通,我们将从零开始,用原生 JavaScript 复刻一个在电商平台中极其常见的高级交互——“图片放大镜”

这个案例将完美展现我们如何协同运用多个 DOM API 来构建一个复杂的、有状态的 UI 组件。它综合运用了:

  • 事件监听: mouseenter, mouseleave, mousemove 捕捉用户意图。
  • 几何定位: getBoundingClientRect() 获取中图容器的位置,结合 event.clientX/Y 计算出鼠标在容器内的 相对坐标
  • 尺寸测量: offsetWidth/Height 用于计算遮罩层移动的边界。
  • 样式操作: 实时更新遮罩层 (layer) 和大图背景 (large) 的位置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.goods-image {
width: 480px;
height: 400px;
position: relative;
display: flex;
font-family: sans-serif;
}

/* 中图容器 */
.middle {
width: 400px;
height: 400px;
background: #f5f5f5;
position: relative;
border-radius: 8px;
overflow: hidden;
cursor: move;
}

.middle img {
width: 100%;
height: 100%;
object-fit: cover;
}

/* 小图列表 */
.small {
width: 80px;
margin-left: 12px;
list-style: none;
padding: 0;
}

.small li {
width: 68px;
height: 68px;
margin-bottom: 12px;
cursor: pointer;
border: 2px solid #e8e8e8;
border-radius: 4px;
overflow: hidden;
transition: all 0.2s;
}

.small li:hover,
.small li.active {
border-color: #007bff;
}

.small li img {
width: 100%;
height: 100%;
object-fit: cover;
}

/* 放大镜的遮罩层 */
.layer {
width: 200px;
height: 200px;
background: rgba(0, 0, 0, 0.2);
left: 0;
top: 0;
position: absolute;
pointer-events: none;
border-radius: 4px;
backdrop-filter: blur(2px);
display: none;
/* 默认隐藏 */
}

/* 放大后的预览图容器 */
.large {
position: absolute;
top: 0;
left: 412px;
width: 400px;
height: 400px;
z-index: 500;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
background-repeat: no-repeat;
background-size: 800px 800px;
/* 大图尺寸为中图的两倍 */
background-color: #f8f8f8;
border: 1px solid #e8e8e8;
border-radius: 8px;
display: none;
/* 默认隐藏 */
}
</style>

</head>

<body>
<div class="goods-image">
<div class="middle">
<img src="https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=400&h=400&fit=crop" alt="" />
<div class="layer"></div>
</div>
<ul class="small">
<li class="active"><img
src="https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=400&h=400&fit=crop" alt="" />
</li>
<li><img src="https://images.unsplash.com/photo-1434389677669-e08b4cac3105?w=400&h=400&fit=crop" alt="" />
</li>
<li><img src="https://images.unsplash.com/photo-1445205170230-053b83016050?w=400&h=400&fit=crop" alt="" />
</li>
</ul>
<div class="large"></div>
</div>
</body>
<script>
// --- 步骤 1: 初始化与元素获取 ---
const middleBox = document.querySelector('.goods-image .middle');
const middleImg = document.querySelector('.middle img');
const layer = document.querySelector('.middle .layer');
const largeBox = document.querySelector('.goods-image .large');
const smallList = document.querySelector('.goods-image .small');

// 初始化大图的背景图片
largeBox.style.backgroundImage = `url(${middleImg.src})`;

// --- 步骤 2: 实现小图切换功能 (事件委托 + classList) ---
smallList.addEventListener('mouseover', (event) => {
// 确保事件源是 li 元素或其内部的 img
let targetLi = null;
if (event.target.tagName === 'IMG') {
targetLi = event.target.parentElement;
}

if (targetLi) {
// 移除旧的 active class
const currentActive = smallList.querySelector('.active');
if (currentActive) {
currentActive.classList.remove('active');
}

// 给当前 li 添加 active class
targetLi.classList.add('active');

// 更新中图和大图的背景
const newImgSrc = targetLi.querySelector('img').src;
middleImg.src = newImgSrc;
largeBox.style.backgroundImage = `url(${newImgSrc})`;
}
});

// --- 步骤 3: 实现放大镜的显示与隐藏 (mouseenter, mouseleave) ---
middleBox.addEventListener('mouseenter', () => {
layer.style.display = 'block';
largeBox.style.display = 'block';
});

middleBox.addEventListener('mouseleave', () => {
layer.style.display = 'none';
largeBox.style.display = 'none';
});

// --- 步骤 4: 核心逻辑 - 计算并更新滑块与大图位置 (mousemove + 几何定位) ---
middleBox.addEventListener("mousemove", (event) => {
// 1. 获取鼠标在 middleBox 内的坐标
const rect = middleBox.getBoundingClientRect();
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;

// 2. 计算滑块 (layer) 的目标位置 (让鼠标位于滑块中心)
let layerX = mouseX - layer.offsetWidth / 2;
let layerY = mouseY - layer.offsetHeight / 2;

// 3. 边界约束:确保滑块不会移出 middleBox
const maxX = middleBox.offsetWidth - layer.offsetWidth;
const maxY = middleBox.offsetHeight - layer.offsetHeight;
//
// Math.max(0, layerX) 确保 layerX 不小于 0(不会超出左边界)
// Math.min(layerX, maxX) 确保 layerX 不大于 maxX(不会超出右边界)
layerX = Math.max(0, Math.min(layerX, maxX));
// Math.max(0, layerY) 确保 layerY 不小于 0(不会超出上边界)
// Math.min(layerY, maxY) 确保 layerY 不大于 maxY(不会超出下边界)
layerY = Math.max(0, Math.min(layerY, maxY));

// 4. 应用滑块位置
layer.style.left = `${layerX}px`;
layer.style.top = `${layerY}px`;


// 5. 计算并应用大图的背景位置
// background-position 的参数说明:
// - 第一个参数控制水平方向的偏移量 (x轴),负值表示向左偏移
// - 第二个参数控制垂直方向的偏移量 (y轴),负值表示向上偏移
// 这里使用负值是因为:当滑块向右移动时,我们希望大图显示更右边的区域
// 通过负值偏移,实现了"相反方向"的视觉效果,模拟放大镜的真实体验
const scale = 2;
largeBox.style.backgroundPosition = `${-layerX * scale}px ${-layerY * scale}px`;

})
</script>

</html>

案例总结
这个看似复杂的交互,完全是由我们本章学习的基础知识构建而成。通过这个案例,我们可以深刻体会到,只有精准地获取和计算元素的尺寸与坐标,才能构建出像素级完美的动态交互效果。掌握本章内容,是您从基础 DOM 操作迈向高级 UI 开发的关键一步。


9.9. DOM 性能优化:重绘与回流

我们已经掌握了各式各样强大的 DOM API,可以随心所欲地改变页面。但一个不容忽视的问题是:DOM 操作是昂贵的。不恰当的、频繁的 DOM 操作是导致页面卡顿、动画掉帧、用户体验下降的头号元凶。

本节,我们将通过一个可视化的性能对比实验,来深入理解其背后的原理,并学会如何写出高性能的 DOM 操作代码。

9.9.1. 实战场景:瀑布流布局的初始定位

核心痛点: 假设我们需要为一个包含 1000 个图片卡的瀑布流布局进行初始定位。每个卡片的位置都需要通过 JavaScript 动态计算并设置。

让我们用两种截然不同的方式来实现这个需求,并直观地感受它们的性能差异。

版本一:性能灾难 (循环内“读写”交错)

在这个版本中,我们在循环内部对每个元素进行“写”操作(设置 style.top),然后又立即进行一次“读”操作(item.offsetTop,即使这个读取没有实际用途)。这种“写后即读”的模式是性能杀手。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#container {
position: relative;
width: 100%;
border: 1px solid #ccc;
height: 300px;
overflow-y: scroll;
}

.item {
position: absolute;
width: 100px;
height: 30px;
border: 1px solid steelblue;
background: aliceblue;
text-align: center;
line-height: 30px;
}
</style>

</head>

<body>
<div id="controls">
<button id="bad-btn">运行性能灾难版 (1000个元素)</button>
<div id="log-output">等待操作...</div>
</div>
<div id="container"></div>

</body>
<script>
const container = document.getElementById('container');
const badBtn = document.getElementById('bad-btn');
const log = document.getElementById('log-output');

badBtn.addEventListener('click', () => {
container.innerHTML = ''; // 清空容器
log.textContent = '开始渲染 (性能灾难版)...';
const startTime = performance.now();

for (let i = 0; i < 1000; i++) {
const item = document.createElement('div');
item.className = 'item';

// --- 性能杀手:写后即读 ---
// 1. "写"操作:修改元素的样式,这会被浏览器暂存
item.style.top = (i * 32) + 'px';
item.textContent = `Item ${i}`;
container.append(item);

// 2. "读"操作:为了获取准确的 top 值,强制浏览器清空队列,立刻执行回流
// 即使我们不使用这个值,这个读取行为本身就是命令
const uselessValue = item.offsetTop;
}

const endTime = performance.now();
log.textContent = `[性能灾难版] 渲染完成!耗时: ${ (endTime - startTime).toFixed(2) }ms`;
});
</script>

</html>

点击按钮,你会发现页面可能会卡顿数秒! 这就是“强制同步布局”的威力。每次循环,浏览器都不得不执行一次完整的“计算布局 -> 渲染”流程,1000 个元素就意味着近 1000 次回流。

9.9.2. 性能瓶颈的根源:浏览器的渲染队列

要理解为什么上述代码如此之慢,我们必须了解浏览器为了优化性能所做的一项重要工作:异步渲染队列

当你通过 JS 修改 DOM 样式时(“写”操作),浏览器并不会立即执行渲染。它会将这些操作先存放到一个队列中。然后,在某个合适的时机(通常是当前 JS 任务执行完毕后),浏览器会批量处理这个队列里的所有修改,计算一次布局(回流),然后绘制一次屏幕(重绘)。这种“攒一批再干”的模式极大地提升了性能。

然而,如果你在“写”操作之后,立即进行“读”操作(如获取 offsetTop, clientWidth 等需要精确布局信息的属性),就打破了这个机制。为了给你一个准确的值,浏览器必须 立即清空队列,强制执行回行和重绘

  • 回流: 当修改影响了元素的 几何属性(尺寸、位置、边距等),浏览器需重新计算页面上所有受影响元素的位置和大小。这是最昂贵的操作。
  • 重绘: 当修改只影响元素的 外观样式(颜色、背景等)而不影响布局时,浏览器只需重新“粉刷”受影响的区域。

核心原理: 回流必然导致重绘,重绘不一定导致回流。而 在“写”操作后立即“读”,会强制触发回流。我们的优化目标就是避免这种强制同步行为。

9.9.3. 解决方案:读写分离与批量操作

策略一:彻底分离“读”和“写”

解决方案: 遵循一个简单的黄金法则——先集中读取所有你需要的信息,再集中进行所有 DOM 的写入操作

在这个版本中,我们先在一个循环里计算出所有元素的目标 top 值并存储在一个数组中。这个过程完全不涉及 DOM 操作。然后,在第二个循环里,我们一次性地将所有样式应用到 DOM 元素上,中间不进行任何读取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#container {
position: relative;
width: 100%;
border: 1px solid #ccc;
height: 300px;
overflow-y: scroll;
}

.item {
position: absolute;
width: 100px;
height: 30px;
border: 1px solid steelblue;
background: aliceblue;
text-align: center;
line-height: 30px;
}
</style>

</head>

<body>
<div id="controls">
<button id="bad-btn">运行性能优化版 (1000个元素)</button>
<div id="log-output">等待操作...</div>
</div>
<div id="container"></div>

</body>
<script>
const container = document.getElementById('container');
const badBtn = document.getElementById('bad-btn');
const log = document.getElementById('log-output');

badBtn.addEventListener('click', () => {
container.innerHTML = '';
log.textContent = '开始渲染 (高性能版)...';
const startTime = performance.now();

const positions = [];
// --- 步骤 1: 集中"读" ---
// 在这个例子中,我们的计算不依赖于读取DOM,所以更简单。
// 但如果需要,所有读取操作都应在此阶段完成。
for (let i = 0; i < 1000; i++) {
positions.push(i * 32);
}

// --- 步骤 2: 集中"写" ---
for (let i = 0; i < 1000; i++) {
const item = document.createElement('div');
item.className = 'item';
item.style.top = positions[i] + 'px';
item.textContent = `Item ${i}`;
container.appendChild(item); // 只是放入队列,不会强制回流
}
const endTime = performance.now();
log.textContent = `[高性能版] 渲染完成!耗时: ${ (endTime - startTime).toFixed(2) }ms`;
});
</script>

</html>

点击按钮,你会发现渲染几乎是瞬时完成的! 因为所有的“写”操作都被浏览器有效地缓存和批量处理了,整个过程可能只触发了 一次回流

策略二:使用 DocumentFragment 进行批量插入

当需要创建并插入大量新元素时,DocumentFragment 是一个绝佳的工具。它是一个存在于内存中的、临时的 DOM 容器。

  • 工作原理: 先将所有新创建的元素添加到这个“内存碎片”中,这个过程 完全不会触发回流和重绘。最后,再一次性地将这个完整的“碎片”插入到真实 DOM 中。
  • 效果: 将上千次独立的插入操作,优化为 仅仅一次 对真实 DOM 的修改。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#container {
position: relative;
width: 100%;
border: 1px solid #ccc;
height: 300px;
overflow-y: scroll;
}

.item {
position: absolute;
width: 100px;
height: 30px;
border: 1px solid steelblue;
background: aliceblue;
text-align: center;
line-height: 30px;
}
</style>

</head>

<body>
<div id="controls">
<button id="fragment-btn">运行DocumentFragment优化版 (1000个元素)</button>
<div id="log-output">等待操作...</div>
</div>
<div id="container"></div>

</body>
<script>
const container = document.getElementById('container');
const fragmentBtn = document.getElementById('fragment-btn');
const log = document.getElementById('log-output');

fragmentBtn.addEventListener('click', () => {
container.innerHTML = '';
log.textContent = '开始渲染 (DocumentFragment优化版)...';
const startTime = performance.now();

// --- 使用DocumentFragment优化 ---
// DocumentFragment是一个轻量级的文档片段,不会触发回流
const fragment = document.createDocumentFragment();

for (let i = 0; i < 1000; i++) {
const item = document.createElement('div');
item.className = 'item';
item.style.top = (i * 32) + 'px';
item.textContent = `Item ${i}`;
// 将元素添加到fragment中,不会触发DOM回流
fragment.append(item);
}

// 一次性将所有元素添加到DOM中,只触发一次回流
container.append(fragment);

const endTime = performance.now();
log.textContent = `[DocumentFragment优化版] 渲染完成!耗时: ${ (endTime - startTime).toFixed(2) }ms`;
});
</script>

</html>

9.9.4. 深入理解:回流与重绘

我们在前面已经知道,在“写”操作后立即“读”,会强制浏览器清空渲染队列,触发回流。现在,让我们更系统地定义这两个概念。

浏览器的渲染过程可以大致分为:

  1. 解析 HTML 构建 DOM 树。
  2. 解析 CSS 构建 CSSOM 树。
  3. 将 DOM 和 CSSOM 合并,生成 渲染树
  4. 根据渲染树,计算每个节点在屏幕上的确切位置和大小,这个过程称为 布局回流
  5. 根据计算好的布局信息,将节点绘制到屏幕上,这个过程称为 绘制重绘

页面首次加载时,至少会经历一次回流和重绘。而我们后续的 DOM 和 CSSOM 操作,则会触发后续的回流与重绘。

什么是回流

回流 是指当渲染树中的一部分因为元素的规模尺寸、布局、隐藏等改变而需要重新构建的过程。可以把它想象成对网页的“骨架”或“蓝图”进行重新计算。一个节点的回流可能会导致其所有子节点以及 DOM 中紧随其后的同级节点、甚至父节点的“连锁反应”。

常见触发回流的操作:

  • 页面初始加载:这是不可避免的第一次回流。
  • 添加或删除可见的 DOM 元素
  • 元素位置改变position, top, left 等。
  • 元素尺寸改变width, height, padding, border, margin 等。
  • 元素内容改变:例如,文本数量或图片大小的改变,导致元素尺寸变化。
  • 字体大小改变
  • 调整浏览器窗口大小 (resize 事件)。
  • 获取需要计算的 DOM 属性:这就是我们之前遇到的“性能杀手”。当你读取如 offsetTop, offsetLeft, offsetWidth, offsetHeight, scrollTop, scrollLeft, clientTop, clientWidth, getComputedStyle() 等属性时,浏览器为了返回精确值,必须立即执行回流。

什么是重绘

重绘 是指当渲染树中的一些元素需要更新属性,而这些属性只影响元素的外观、风格,而不会影响其布局时,所发生的过程。可以把它想象成只对网页的某个部分重新“上色”或“化妆”,而不需要动其骨架。

常见触发重绘的属性:

  • color
  • border-style / border-radius
  • background 相关属性 (background-color, background-image 等)
  • visibility
  • text-decoration
  • outline 相关属性
  • box-shadow

核心关系: 回流必然导致重绘,但重绘不一定导致回流。比如,改变元素的 width,既改变了布局(回流),也需要重新绘制它(重绘)。而改变元素的 background-color,只影响外观,不影响布局,所以只会触发重绘,性能开销小得多。我们的首要优化目标是 尽量避免和减少回流

9.9.5. DOM 性能优化黄金法则总结

基于以上原理,我们可以总结出一些简单实用的高性能 DOM 操作法则:

  1. 读写分离,集中操作:这是最重要的法则。在修改 DOM 之前,先通过循环或其它方式将所有需要读取的值(如元素尺寸、位置)缓存到变量中。然后,在另一个集中的步骤中,完成所有的“写”操作(修改样式、增删元素)。

  2. 使用 CSS class 合并样式变更:不要逐条修改 style 属性,这可能导致多次回流。更好的做法是预先定义好一个 CSS 类,然后一次性地用 classNameclassList.add() 来切换样式。

    1
    2
    3
    4
    5
    6
    7
    8
    // 不推荐
    el.style.width = '100px';
    el.style.height = '100px';
    el.style.border = '1px solid red';

    // 推荐
    el.classList.add('new-style');
    /* CSS: .new-style { width: 100px; height: 100px; border: 1px solid red; } */
  3. 批量操作 DOM,善用 DocumentFragment:如前所述,当需要添加多个元素时,先将它们添加到 DocumentFragment 中,最后一次性追加到真实 DOM,将多次回流合并为一次。

  4. 对复杂动画使用 absolutefixed 定位:将需要执行动画的元素脱离文档流(position: absolute/fixed)。这样,它的变化只会影响自身和一个小的图层,而不会引起整个页面的回流,极大地提升动画性能。

  5. 谨慎使用 display: none:使用 display: none 隐藏元素会触发回流,而使用 visibility: hidden 只会触发重绘,因为它虽然不可见,但仍在布局中占据空间。根据需求选择合适的方式。

  6. 使用虚拟 DOM:现代前端框架(如 Vue, React)的性能法宝之一。它们通过在内存中维护一个轻量的 JavaScript 对象树(虚拟 DOM)来模拟真实 DOM。当状态变更时,它们会计算出新旧虚拟树的差异(Diff),然后只将这些最小化的差异批量应用到真实 DOM 上,从而最大限度地减少了直接、昂贵的 DOM 操作。


9.9.6. 控制执行时机:定时器、防抖与节流

除了减少单次操作的开销,控制 操作的频率 也是性能优化的关键,尤其是在处理高频触发的事件(如 resize, scroll, input)时。

基础工具:定时器

JavaScript 提供了 setTimeoutsetInterval 两个函数,它们向任务队列中添加定时任务,让我们能够延迟或周期性地执行代码。

  • setTimeout(callback, delay): 指定 callback 函数在 delay 毫秒之后 执行一次。它返回一个定时器 ID,可用于 clearTimeout() 取消。

    1
    2
    3
    4
    5
    6
    const timerId = setTimeout(() => {
    console.log("这段代码在1秒后执行");
    }, 1000);

    // 如果需要,可以取消它
    // clearTimeout(timerId);

    this 指向问题: 如果 setTimeout 的回调函数是一个对象的方法,该方法内部的 this 将默认指向全局对象(在浏览器中是 window),而不是该对象本身。可以使用箭头函数或 .bind() 来修正 this 指向。

  • setInterval(callback, delay): 指定 callback 函数 每隔 delay 毫秒就执行一次。它同样返回一个 ID,用于 clearInterval() 停止。

    下面的例子使用 setInterval 实现一个简单的淡出动画。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <title>SetInterval Animation</title>
    <style>
    #someDiv {
    width: 100px;
    height: 100px;
    background: steelblue;
    opacity: 1;
    transition: opacity 0.5s; /* 配合CSS过渡更平滑 */
    }
    </style>
    </head>
    <body>
    <div id="someDiv"></div>
    <script>
    const div = document.getElementById('someDiv');
    let opacity = 1;
    const fader = setInterval(function() {
    opacity -= 0.05;
    if (opacity > 0) {
    // 每次修改style都会触发重绘,频率过高会影响性能
    div.style.opacity = opacity;
    } else {
    opacity = 0;
    div.style.opacity = opacity;
    clearInterval(fader); // 必须清除,否则会无限执行
    }
    }, 30);
    </script>
    </body>
    </html>

    动画性能警示:虽然 setInterval 可以实现动画,但它不是最佳选择。它的执行时机不精确,且与浏览器渲染刷新率无关,可能导致掉帧。现代 Web 动画的首选是 requestAnimationFrame,它能确保动画函数在浏览器下一次重绘之前执行,从而实现更流畅、更高性能的动画效果。

实战进阶:防抖

想象一个场景:监听窗口的 scroll 事件来打印滚动条位置。

1
2
3
4
// 未优化的代码
window.addEventListener('scroll', () => {
console.log('滚动条位置:', window.scrollY);
});

如果你快速滚动页面,会发现控制台疯狂输出,函数执行频率极高!如果这个回调函数内部有复杂的 DOM 操作,将导致严重的性能问题。

防抖 的策略是:对于在短时间内连续触发的事件,只执行最后一次。就像电梯关门,只要有人在指定时间内进来,关门计时器就重置,直到最后一个人进来后计时结束,门才关闭。

实现防抖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function debounce(fn, delay) {
let timer = null; // 借助闭包保存定时器 ID

return function(...args) {
// 如果定时器已存在,则清除它,重新开始计时
if (timer) {
clearTimeout(timer);
}

// 设置新的定时器,delay 毫秒后执行真正的函数
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
}
}

应用防抖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<!DOCTYPE html>
<html>

<head>
<title>Debounce Demo</title>
<style>
body {
height: 2000px;
font-family: sans-serif;
}

#log-output {
position: fixed;
top: 10px;
left: 10px;
background: #fff;
border: 1px solid #ccc;
padding: 10px;
}
</style>
</head>

<body>
<div id="log-output">
<p><strong>未防抖:</strong> <span id="log-raw">0</span></p>
<p><strong>防抖 (500ms):</strong> <span id="log-debounced">0</span></p>
</div>
<script>
function debounce(fn, delay) {
let timer = null; // 借助闭包保存定时器 ID

return function (...args) {
// 如果定时器已存在,则清除它,重新开始计时
if (timer) {
clearTimeout(timer);
}

// 设置新的定时器,delay 毫秒后执行真正的函数
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
}
}
const logRaw = document.getElementById('log-raw');
const logDebounced = document.getElementById('log-debounced');
let rawCount = 0;
let debouncedCount = 0;

function updateLog() {
logDebounced.textContent = ++debouncedCount;
}

const debouncedUpdate = debounce(updateLog, 500);

window.addEventListener('scroll', () => {
logRaw.textContent = ++rawCount; // 未防抖,疯狂触发
debouncedUpdate(); // 防抖,只有停止滚动500ms后才触发
});
</script>
</body>

</html>

动手试试:快速滚动上面的沙箱页面,你会看到“未防抖”的计数器飞速增长,而“防抖”的计数器只有在你停止滚动半秒后才会更新。这对于搜索框输入建议、窗口 resize 事件等场景非常有用。

实战进阶:节流

防抖在某些场景下并不完美。比如,如果我们想在拖拽或滚动时实时更新某个元素的位置,防抖会导致只有在停止时才更新,体验不佳。这时就需要 节流

节流 的策略是:在指定的时间间隔内,事件处理函数 最多只执行一次,确保函数以一个可控的频率被调用。就像技能冷却,放了一次技能后,必须等 CD 转好才能放第二次。

实现节流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function throttle(fn, delay) {
let canRun = true; // 通过闭包保存一个状态锁

return function(...args) {
if (!canRun) {
return; // 如果锁是关闭的,直接返回
}

canRun = false; // 立即关闭锁
setTimeout(() => {
fn.apply(this, args);
canRun = true; // delay 毫秒后打开锁
}, delay);
}
}

应用节流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<!DOCTYPE html>
<html>

<head>
<title>Throttle Demo</title>
<style>
body {
height: 2000px;
font-family: sans-serif;
}

#log-output {
position: fixed;
top: 10px;
left: 10px;
background: #fff;
border: 1px solid #ccc;
padding: 10px;
}
</style>
</head>

<body>
<div id="log-output">
<p><strong>未节流:</strong> <span id="log-raw">0</span></p>
<p><strong>节流 (500ms):</strong> <span id="log-throttled">0</span></p>
</div>
<script>
function throttle(fn, delay) {
let canRun = true; // 通过闭包保存一个状态锁

return function (...args) {
if (!canRun) {
return; // 如果锁是关闭的,直接返回
}

canRun = false; // 立即关闭锁
setTimeout(() => {
fn.apply(this, args);
canRun = true; // delay 毫秒后打开锁
}, delay);
}
}
const logRaw = document.getElementById('log-raw');
const logThrottled = document.getElementById('log-throttled');
let rawCount = 0;
let throttledCount = 0;

function updateLog() {
logThrottled.textContent = ++throttledCount;
}

const throttledUpdate = throttle(updateLog, 500);

window.addEventListener('scroll', () => {
logRaw.textContent = ++rawCount; // 未节流,疯狂触发
throttledUpdate(); // 节流,每500ms最多触发一次
});
</script>
</body>

</html>

动手试试:持续上下滚动上面的沙箱页面,你会看到“未节流”的计数器依然飞速增长,而“节流”的计数器会以大约每半秒一次的稳定频率进行更新,既保证了响应性,又避免了性能浪费。

防抖与节流的应用场景总结

  • 使用防抖

    • 搜索框输入建议:用户输完一串字符后才发送请求,而不是每输入一个字母都发。
    • 文本编辑器自动保存:用户停止打字一段时间后才执行保存。
    • 窗口 resize 事件:当用户调整完窗口大小后,再重新计算布局。
  • 使用节流

    • DOM 元素拖拽:在拖拽过程中,按一定频率更新元素位置。
    • 页面滚动事件:如实现滚动加载、高亮导航栏等,需要持续响应但又不能过于频繁。
    • 游戏中的射击:按住开火键,子弹以固定的频率射出。

核心区别:防抖是“你别急,等你停了我再做”,强调 最终结果;节流是“你慢点,按我的节奏来”,强调 过程中的平均响应


9.10. 本章核心原理与高频面试题

核心原理速查

分类关键项 / 概念核心描述与最佳实践
核心模型DOM (文档对象模型)浏览器将 HTML 解析成的、可用 JS 操控的 树形节点结构
元素查找querySelector / querySelectorAll(推荐) 使用 CSS 选择器查找,返回单个元素或 静态 NodeList,功能强大且行为可预测。
内容操作textContent vs innerHTML优先使用 textContent 以避免 XSS 安全风险。仅在确切需要解析 HTML 时才使用 innerHTML
样式操作element.classList(推荐) 通过 add/remove/toggle 操作 CSS 类,实现行为与表现的分离,比直接操作 style 对象更优。
结构变更现代 API (append, remove 等)appendChild, removeChild 等传统 API 更简洁、功能更强(例如支持多个参数)。
事件模型事件流 (捕获与冒泡)事件在 DOM 树中从外到内(捕获),再从内到外(冒泡)的传播过程。
事件委托(核心模式) 在父元素上监听,利用事件冒泡和 event.target 处理子元素事件,高效且支持动态内容。
几何定位getBoundingClientRect()(推荐) 获取元素相对于 视口 的位置和尺寸,是实现 Tooltip、可视区判断等高级交互的首选。
性能优化回流 与 重绘回流(计算布局)代价极高。应避免频繁触发,如在循环中读取布局属性。
DocumentFragment(核心工具) 在内存中进行批量 DOM 操作的容器,最后一次性插入真实 DOM,可将多次回流优化为一次。

高频面试题与陷阱

面试官深度追问
2025-08-29

总结一下,DOM 的常见操作有哪些?

DOM 的常见操作可以归为四大类:

查找:这是所有操作的基础。主要是通过 querySelectorquerySelectorAll 使用 CSS 选择器来精准地找到一个或一批元素。

修改:找到元素后,我们可以修改它的内容(通过 textContentinnerHTML)、HTML 属性(通过 setAttributedataset)以及 CSS 样式(主要通过 classList)。

  1. 结构变更:这指的是动态地改变页面结构,包括创建新元素 (createElement)、将元素添加到页面 (append)、以及从页面移除元素 (remove)。
  1. 事件处理:为元素添加事件监听器 (addEventListener),以响应用户的交互,这是让页面“活”起来的关键。

很好。那你能解释一下什么是“事件代理”或“事件委托”吗?它主要解决了什么问题?

事件委托是一种利用事件冒泡机制的 DOM 事件处理模式。它的核心思想是,不给大量的子元素逐一绑定事件监听器,而是只给它们的共同父元素绑定一个监听器。

当某个子元素被触发事件时,这个事件会沿着 DOM 树向上冒泡,最终被父元素的监听器捕获。在父元素的监听函数中,我们可以通过检查 event.target 属性,来判断事件的真正来源是哪个子元素,然后执行相应的逻辑。

它主要解决了两个核心问题:第一是性能问题,极大地减少了事件监听器的数量,节省了内存;第二是动态内容问题,对于后续通过 JS 动态添加到父容器中的新子元素,这个委托的监听器依然对它们有效,无需重新绑定。

非常好。最后一个问题,如果让你写一个函数,判断一个元素是否完全出现在了浏览器的可视区域内,你会怎么实现?

我会使用 element.getBoundingClientRect() 方法来实现。这个方法返回一个对象,包含了元素相对于浏览器视口的 top, bottom, left, right 等坐标。

一个元素完全可见,必须同时满足四个条件:它的顶部必须在视口的上边界之下,它的底部必须在视口的下边界之上,它的左边必须在视口的左边界之右,它的右边必须在视口的右边界之左。


第十章:浏览器对象模型 (BOM) 与现代 Web API

摘要: 在第九章,我们征服了文档本身(DOM)。现在,我们将把视野扩大到承载文档的整个 浏览器环境。本章将深入探讨 浏览器对象模型 (BOM),这是一套让我们能够与浏览器窗口、导航历史、用户屏幕乃至客户端存储进行交互的强大 API。我们将从一个个真实且重要的开发场景出发,学习如何控制页面跳转、让网站拥有“记忆”、感知用户环境,并最终掌握一系列让网页功能比肩原生应用的 2025+ 现代 Web API。


在本章中,我们将围绕“赋予网页更多能力”这一核心,探索以下主题:

  1. 首先,我们将理解 window 对象 作为全局上下文的“上帝视角”。
  2. 接着,我们将学习如何通过 页面导航与历史管理,构建现代单页应用(SPA)的基石。
  3. 然后,我们将深入 客户端数据持久化 方案,让网页拥有“记忆”用户的能力。
  4. 紧接着,我们将学习如何进行 环境感知与特性检测,编写出更健壮的跨平台代码。
  5. 最后,我们将探索一系列 现代 Web API,为我们的网页赋予调用系统分享、安全读写剪贴板等原生能力。

10.1. window 对象:作为全局上下文的“上帝视角”

核心内容: 在我们与浏览器进行任何交互之前,必须先认识 JavaScript 在浏览器环境中的“根”对象——window。它身兼二职:既是 BOM 的核心,代表着整个浏览器窗口;又是所有 JavaScript 代码执行的 全局作用域 (Global Scope)

理解 window 作为全局作用域,意味着在脚本顶层声明的 var 变量和函数,都会自动成为 window 对象的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script>
var globalVar = "我是一个全局变量";
function globalFunc() {
console.log("我是一个全局函数");
}

// 我们可以通过 window 对象访问到它们
console.log('通过 window 访问变量:', window.globalVar);
window.globalFunc();

// let 和 const 声明的全局变量不会被挂载到 window 对象上
let blockScopedVar = "我不会出现在 window 上";
console.log('window 上有 blockScopedVar 吗?', window.blockScopedVar);
</script>

window 对象还提供了一些基础的、用于与用户进行简单模态交互的方法。

方法描述返回值
alert(message)弹出一个带有一段信息和一个“确定”按钮的警告框。undefined
confirm(message)弹出一个带有信息、一个“确定”和一个“取消”按钮的对话框。true (用户点击确定) 或 false (用户点击取消)
prompt(message, [default])弹出一个带有一段信息和一个文本输入框的对话框。用户输入的字符串,或 null (用户点击取消)

示例:一个简单的用户确认流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<button id="delete-btn">删除重要文件</button>
<p id="status-msg"></p>

<script>
const deleteBtn = document.getElementById('delete-btn');
const statusMsg = document.getElementById('status-msg');

deleteBtn.addEventListener('click', () => {
// 步骤 1: 询问用户是否确认
const isConfirmed = window.confirm('你确定要删除这个重要文件吗?此操作不可逆!');

if (isConfirmed) {
// 步骤 2: 如果确认,要求用户输入密码
const password = window.prompt('请输入你的管理员密码以确认删除:');

if (password === '123456') {
// 步骤 3: 密码正确,执行删除
statusMsg.textContent = '文件已成功删除。';
window.alert('操作成功!');
} else if (password !== null) { // 用户输入了但密码错误
statusMsg.textContent = '密码错误,删除操作已取消。';
} else { // 用户点击了取消
statusMsg.textContent = '用户取消了操作。';
}
} else {
statusMsg.textContent = '删除操作已取消。';
}
});
</script>

alert, confirm, prompt 都会 阻塞 JavaScript 的执行和页面的渲染,直到用户与之交互。它们在现代 Web 开发中应谨慎使用,通常只用于简单的调试或需要强行中断用户流程的场景。更复杂的交互应使用自定义的 HTML/CSS 模态框组件。


10.2. 页面导航与历史管理:构建单页应用 (SPA) 的基石

核心场景: 在传统网站中,每次点击链接都会导致整个页面的白屏、刷新和重新加载,这种体验稍显缓慢。而现代 Web 应用(如 GitHub, Gmail)感觉更像桌面程序,它们可以在不刷新整个页面的情况下,流畅地切换内容并同步更新浏览器地址栏中的 URL。

这种流畅的体验是如何实现的?答案就藏在 locationhistory 这两个 BOM 对象中。它们是所有现代前端框架(如 Vue Router, React Router)实现“客户端路由”的底层基石。

10.2.1. location 对象:URL 的编程接口

window.location 对象提供了对当前页面 URL 的详细信息的访问,并允许我们通过代码来触发页面跳转。

读取 URL 信息

location 对象将一个完整的 URL 精确地分解为多个部分,方便我们读取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<p>当前页面的 URL 信息:</p>
<pre id="location-output"></pre>
<script>
const output = document.getElementById('location-output');

// 假设当前 URL 是: https://www.example.com:8080/path/to/page?id=123#section-2

const info = `
href: ${location.href} (完整 URL)
protocol: ${location.protocol} (协议)
hostname: ${location.hostname} (主机名)
port: ${location.port} (端口)
host: ${location.host} (主机名 + 端口)
pathname: ${location.pathname} (路径)
search: ${location.search} (查询字符串)
hash: ${location.hash} (锚点)
`;

output.textContent = info.trim();
</script>

sandbox 环境下的 URL 可能比较特殊,但这些属性的分解方式在真实 URL 中是完全一致的。

修改 URL 以实现跳转

方法/属性描述对历史记录的影响
location.href = '...'(常用) 将页面导航到新的 URL。在历史记录中 新增 一条记录。
location.assign('...')功能与设置 href 完全相同。在历史记录中 新增 一条记录。
location.replace('...')用新的 URL 替换 当前页面。 在历史记录中新增记录,用户无法通过“后退”按钮返回。
location.reload()重新加载当前页面。-

10.2.2. history 对象:与浏览器会话历史交互

window.history 对象允许我们与浏览器的会话历史进行交互。

基础导航

这三个方法模拟了用户点击浏览器“前进”、“后退”按钮的行为。

  • history.back(): 后退一步。
  • history.forward(): 前进一步。
  • history.go(delta): 移动到指定位置。history.go(-1) 等同于 back()history.go(1) 等同于 forward()

SPA 路由核心:pushStatereplaceState

这正是实现 SPA “无刷新”导航的魔法所在。这两个方法可以在 不触发页面刷新的前提下,动态地修改浏览器地址栏的 URL,并管理会话历史。

  • history.pushState(state, title, url): 向会话历史栈中 推入 一个新的状态。
  • history.replaceState(state, title, url): 替换 当前的历史状态。

参数:

  • state: 一个与新历史记录相关联的 JavaScript 对象。当用户通过前进/后退导航到这个状态时,popstate 事件会携带这个对象。
  • title: 一个简短的标题,目前大部分浏览器会忽略此参数。
  • url: 新的历史记录的 URL。必须与当前页面同源。

popstate 事件: 当用户点击浏览器的前进/后退按钮,或者通过代码调用 history.back() 等方法导致活动历史记录发生变化时,window 会触发 popstate 事件。

综合示例:从零实现一个最简 SPA 路由器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
nav a {
margin: 0 10px;
cursor: pointer;
color: blue;
text-decoration: underline;
}

#app-root {
margin-top: 20px;
padding: 15px;
border: 1px solid #ccc;
}
</style>

</head>

<body>
<nav>
<a data-path="/">主页</a>
<a data-path="/about">关于我们</a>
<a data-path="/contact">联系方式</a>
</nav>
<div id="app-root"></div>
</body>

<script>
const appRoot = document.getElementById('app-root');
// 1. 定义一个简单的渲染函数,根据路径更新页面内容
function render(path) {
let content = '';
switch (path) {
case '/about':
content = '<h1>关于我们</h1><p>我们是一个追求极致的笔记平台</p>';
break;
case '/contact':
content = '<h1>联系方式</h1><p>请发送邮件至 3381292732@qq.com</p>';
break;
default:
content = '<h1>主页</h1><p>欢迎来到我们的网站!</p>';
}
appRoot.innerHTML = content;
}

// 2. 使用事件委托处理所有导航链接的点击
document.querySelector("nav").addEventListener("click", (e) => {
if (e.target.tagName === "A") {
e.preventDefault(); // 阻止链接的默认跳转行为
const path = e.target.dataset.path;
// 3. 使用 pushState 更新 URL 并添加历史记录
history.pushState({
path: path
}, '', path);

// 4. 手动调用渲染函数更新内容
render(path);

}
});

// 5. 监听 popstate 事件,处理浏览器的前进/后退操作
window.addEventListener('popstate', (e) => {
// e.state 就是我们 pushState 时存入的 state 对象
const path = e.state ? e.state.path : '/';
render(path);
});

// 初始加载时,渲染当前路径
render(location.pathname);
</script>

</html>

10.3. 客户端数据持久化:让网页拥有“记忆”

核心场景: HTTP 协议本身是 无状态 的,这意味着服务器默认不会“记住”你的上一次访问。然而,现代 Web 应用充满了需要“记忆”的场景:

  • 用户关闭了浏览器,下次打开时,网站依然保持着他的登录状态。
  • 用户将商品加入了购物车,刷新页面后,商品依然还在。
  • 用户填写一个复杂的表单,中途不小心关闭了标签页,重新打开后,之前填写的内容还在。

为了解决这些问题,浏览器提供了多种客户端存储技术,让我们的网页能够在用户的设备上持久化数据。

10.3.1. Web Storage API:现代客户端存储方案

Web Storage API 是 HTML5 引入的,旨在提供比 Cookie 更简单、更强大的客户端存储方案。它分为 localStoragesessionStorage 两种。

localStorage: 永久的本地存储

localStorage 用于 永久性 地在本地存储数据。除非用户手动清除浏览器缓存或代码主动删除,否则数据将永远存在,即使关闭浏览器或电脑重启。

API 方法描述
setItem(key, value)存储一个键值对。
getItem(key)根据键读取一个值。
removeItem(key)根据键删除一个键值对。
clear()清空所有存储的数据。

核心原理: localStorage 只能存储 字符串。如果要存储对象或数组,必须先用 JSON.stringify() 将其转换为 JSON 字符串,读取时再用 JSON.parse() 解析回来。

示例:实现一个可记忆的主题切换器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
body.dark-theme {
background-color: #333;
color: #eee;
}

#theme-switcher {
padding: 10px;
}
</style>
</head>

<body>
<button id="theme-switcher">切换主题</button>
<p>当前主题会永久保存在你的浏览器中。</p>
</body>
<script>
const switcherBtn = document.getElementById('theme-switcher');

// 函数:应用主题
function applyTheme(theme) {
if (theme === 'dark') {
document.body.classList.add('dark-theme');
} else {
document.body.classList.remove('dark-theme');
}
}

// 1. 页面加载时:从 localStorage 读取并应用已保存的主题
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
applyTheme(savedTheme);
}

// 2. 点击按钮时:切换主题并保存到 localStorage
switcherBtn.addEventListener('click', () => {
// 检查当前主题
const currentTheme = document.body.classList.contains('dark-theme') ? 'dark' : 'light';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';

// 应用新主题
applyTheme(newTheme);

// 将新主题保存到 localStorage
localStorage.setItem('theme', newTheme);
});
</script>

</html>

请尝试点击“切换主题”按钮,然后点击 sandbox 右上角的“重新运行”图标(模拟刷新页面),你会发现主题状态被成功保留了。


sessionStorage: 基于会话的临时存储

sessionStorage 的 API 与 localStorage 完全相同,但其生命周期完全不同:

  • 生命周期: sessionStorage 中存储的数据只在 当前浏览器标签页的会话期间 有效。一旦该标签页或浏览器被关闭,数据就会被清除。
  • 作用域: 数据只在当前标签页可见,在另一个标签页中打开同一个网站,也无法访问到。

核心应用场景: sessionStorage 非常适合存储一些 一次性的、临时的 会话数据,例如防止用户在填写多步骤表单时不小心刷新页面而导致数据丢失。


Cookie 是最传统的客户端存储技术。与 Web Storage 的主要区别在于,Cookie 的核心使命是 在客户端和服务器之间传递信息

核心原理: 一旦为一个域名设置了 Cookie,那么在后续每一次向该域名发送 HTTP 请求时,浏览器都会 自动 在请求头中带上这些 Cookie。服务器可以读取这些 Cookie 来识别用户、维持会话等。

Cookie 属性描述
Expires / Max-Age设置 Cookie 的过期时间。
Path指定 Cookie 生效的路径。
Domain指定 Cookie 生效的域名。
Secure(安全) 设置后,Cookie 只会在 HTTPS 连接中被发送。
HttpOnly(安全) 设置后,此 Cookie 无法被 JavaScript 访问 (document.cookie),只能由服务器读写。这是防止 XSS 攻击窃取用户会话 Cookie 的关键防御手段。
SameSite(安全) 控制 Cookie 是否随跨站请求发送,是防御 CSRF 攻击的核心机制。

JavaScript 操作:

1
2
3
4
5
6
7
8
9
10
11
12
// 写入一个有效期为 7 天的 cookie
const expiresDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toUTCString();
document.cookie = `username=Prorise; expires=${expiresDate}; path=/; SameSite=Lax`;

// 读取 cookie (需要手动解析字符串)
const cookies = document.cookie.split('; ').reduce((acc, cookie) => {
const [key, value] = cookie.split('=');
acc[key] = value;
return acc;
}, {});

console.log('当前页面的 Cookies:', cookies);

10.3.3. 存储方案对比与选型

特性localStoragesessionStorageCookie
生命周期永久单个会话(标签页)可设置过期时间
容量大小5MB ~ 10MB5MB ~ 10MB约 4KB
与服务器通信不参与不参与自动 随每次请求发送
API 易用性非常简单 (setItem, getItem)非常简单 (setItem, getItem)繁琐,需手动解析字符串
核心应用场景长期用户设置,离线数据临时表单数据,单页应用状态身份认证令牌 (Token),会话 ID
选型建议:
  • 需要长期保留在客户端,且不常与服务器交互的数据(如用户偏好设置、主题) -> 使用 localStorage
  • 仅在单次浏览会话中需要暂存的数据(如复杂表单的草稿) -> 使用 sessionStorage
  • 需要与服务器进行身份验证或状态保持的数据(如登录令牌 Session ID) -> 使用 Cookie,并务必设置好 HttpOnly, Secure, SameSite 等安全属性。

10.4. 环境感知与特性检测:编写健壮的跨平台代码

核心场景: 一个专业的 Web 应用,不应假设它运行在何种设备或浏览器上。用户的设备可能是高性能台式机,也可能是低性能手机;浏览器可能是最新的 Chrome,也可能是其他内核的浏览器。为了提供一致且可靠的用户体验,我们的代码必须具备“感知”其运行环境并作出相应调整的能力。本节,我们将学习如何利用 navigatorscreen 对象来实现这一点。

10.4.1. navigator 对象:探测浏览器与设备能力

window.navigator 对象是一个信息仓库,存储了关于浏览器本身及其所在环境的详细信息。

传统方式:用户代理嗅探 (User Agent Sniffing)

过去,开发者常常通过解析 navigator.userAgent 这个包含了浏览器、引擎、操作系统等信息的长字符串,来判断用户正在使用的浏览器。

1
2
3
4
5
6
7
8
9
// 获取用户代理字符串
const ua = navigator.userAgent;
console.log('你的 User Agent:', ua);

if (ua.includes('Chrome')) {
console.log('检测到 Chrome 浏览器。');
} else if (ua.includes('Firefox')) {
console.log('检测到 Firefox 浏览器。');
}

不要这样做! 用户代理嗅探是一种 脆弱且已被废弃 的做法。原因在于:

  1. User Agent 字符串非常复杂且可能被用户或插件修改;
  2. 新的浏览器层出不穷,维护一个准确的检测列表几乎不可能。

现代最佳实践:特性检测 (Feature Detection)

现代 Web 开发遵循一个核心原则:我们不应该关心“用户在用什么浏览器”,而应该关心“用户的浏览器支持什么功能”。 这就是特性检测。我们通过检查一个特定的 API 或属性是否存在,来决定是否使用它。

示例:安全地使用现代 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 场景 1:检查是否支持并使用剪贴板 API
const copyBtn = document.getElementById('copy-btn');
copyBtn.addEventListener('click', () => {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText('这是通过现代 API 复制的!')
.then(() => alert('已成功复制到剪贴板!'))
.catch(err => console.error('复制失败:', err));
} else {
// 如果不支持,可以提供降级方案,例如提示用户手动复制
alert('你的浏览器不支持自动复制功能,请手动复制。');
}
});

// 场景 2:检查网络状态
function updateOnlineStatus() {
const statusEl = document.getElementById('status');
statusEl.textContent = navigator.onLine ? '设备在线' : '设备离线';
statusEl.style.color = navigator.onLine ? 'green' : 'red';
}
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
updateOnlineStatus(); // 初始化

10.4.2. screen 对象:获取屏幕信息

window.screen 对象提供了关于用户显示器屏幕的信息,这对于需要进行窗口管理或收集分析数据的应用非常有用。

属性描述
screen.width / height用户屏幕的 完整 宽度和高度(以像素为单位)。
screen.availWidth / availHeight用户屏幕的 可用 宽度和高度,即减去了操作系统界面组件(如 Windows 任务栏或 macOS 菜单栏)后的空间。
screen.colorDepth返回屏幕的颜色深度(通常是 24 或 32)。
screen.pixelDepth返回屏幕的像素深度(现代设备上通常与 colorDepth 相同)。

示例:获取屏幕信息并打开一个适配的窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<button id="open-window-btn">打开一个适配窗口</button>
<pre id="screen-info"></pre>
<script>
const infoEl = document.getElementById('screen-info');
const openBtn = document.getElementById('open-window-btn');

// 显示屏幕信息
const screenInfo = `
完整分辨率: ${screen.width} x ${screen.height}
可用空间: ${screen.availWidth} x ${screen.availHeight}
颜色深度: ${screen.colorDepth} bits
`;
infoEl.textContent = screenInfo.trim();

// 点击按钮,打开一个占可用空间 80% 的新窗口
openBtn.addEventListener('click', () => {
const popupWidth = screen.availWidth * 0.8;
const popupHeight = screen.availHeight * 0.8;

// window.open 的第三个参数用于设置窗口特性
window.open(
'https://prorise666.site',
'/',
`width=${popupWidth},height=${popupHeight}`
);
});
</script>

window.open 可能会被浏览器的弹出窗口拦截器阻止。在实际应用中,通常需要由明确的用户操作(如点击)来触发。


10.5. 2025+ 现代 Web API 精选

承上启下: 传统的网页能力有限,但随着 Web 平台的发展,浏览器正不断地向 JavaScript 开放更多与操作系统底层交互的能力。这些现代 Web API 正在模糊网页与原生应用 (Native App) 之间的界限,让 Web 应用能够实现更丰富、更强大的功能。本节将精选介绍几个在 2025 年极具实用价值的现代 API。

10.5.1. Clipboard API (剪贴板 API)

核心痛点: 传统的 document.execCommand('copy') 方法是同步的,API 不友好,且在安全策略日益收紧的现代浏览器中,其行为变得不可靠。我们需要一种更安全、更强大的方式来与系统剪贴板交互。

解决方案: 现代的 Clipboard API (navigator.clipboard) 是基于 Promise 的、异步的,并且与浏览器的权限系统紧密集成,提供了更安全、更强大的读写能力。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<style>
#clipboard-demo textarea { width: 95%; height: 60px; margin-bottom: 10px; }
#clipboard-demo button { margin-right: 10px; }
</style>
<div id="clipboard-demo">
<textarea id="text-to-copy">这是你想要复制的文本。</textarea>
<button id="copy-btn">复制</button>
<button id="paste-btn">粘贴</button>
<p id="clipboard-status"></p>
</div>
<script>
const textEl = document.getElementById('text-to-copy');
const copyBtn = document.getElementById('copy-btn');
const pasteBtn = document.getElementById('paste-btn');
const statusEl = document.getElementById('clipboard-status');

// --- 写入剪贴板 ---
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(textEl.value)
.then(() => {
statusEl.textContent = '状态:已成功复制到剪贴板!';
console.log('复制成功');
})
.catch(err => {
statusEl.textContent = '状态:复制失败,请检查浏览器权限。';
console.error('复制失败:', err);
});
});

// --- 读取剪贴板 ---
pasteBtn.addEventListener('click', () => {
navigator.clipboard.readText()
.then(clipboardText => {
textEl.value = clipboardText;
statusEl.textContent = '状态:已成功从剪贴板粘贴!';
console.log('粘贴成功');
})
.catch(err => {
statusEl.textContent = '状态:粘贴失败,请检查浏览器权限。';
console.error('粘贴失败:', err);
});
});
</script>

出于安全考虑,浏览器通常会在首次调用剪贴板 API 时向用户请求权限。此外,读取剪贴板的操作通常要求页面处于激活状态。

10.5.2. Page Visibility API (页面可见性 API)

核心痛点: 用户打开了 20 个浏览器标签页,我们的页面在后台不可见,但页面上的轮播图动画、轮询服务器的请求却仍在消耗着用户的 CPU、电量和网络资源。

解决方案: Page Visibility API 允许我们的页面知道自己当前是否对用户可见。我们可以通过监听 document 上的 visibilitychange 事件,并检查 document.hidden (布尔值) 属性,来智能地暂停或恢复耗时任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<style>
#spinner {
width: 50px; height: 50px;
border: 5px solid #f3f3f3;
border-top: 5px solid #3498db;
border-radius: 50%;
animation: spin 2s linear infinite;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
#spinner.paused { animation-play-state: paused; }
</style>
<div id="spinner"></div>
<p id="visibility-status">当前页面可见,动画正在播放。</p>

<script>
const spinner = document.getElementById('spinner');
const status = document.getElementById('visibility-status');

document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// 页面已隐藏
spinner.classList.add('paused');
status.textContent = '页面已隐藏,动画暂停以节省资源。';
console.log('页面隐藏于', new Date().toLocaleTimeString());
} else {
// 页面已变得可见
spinner.classList.remove('paused');
status.textContent = '当前页面可见,动画正在播放。';
console.log('页面恢复于', new Date().toLocaleTimeString());
}
});
</script>

请尝试切换到另一个浏览器标签页,等待几秒钟,然后再切换回来,观察动画和文本的变化。

10.5.3. Web Share API (网页分享 API)

核心痛点: 在移动设备上,用户希望像分享原生 App 内容一样,方便地将网页分享到微信、短信或任何其他 App。传统的做法是为每个社交平台都做一个分享按钮,体验差且不完整。

解决方案: Web Share API (navigator.share()) 允许网页调用操作系统的 原生分享对话框,让用户可以选择任意已安装的应用进行分享。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<button id="share-btn">分享本页</button>
<p id="share-result"></p>
<script>
const shareBtn = document.getElementById('share-btn');
const shareResult = document.getElementById('share-result');

shareBtn.addEventListener('click', async () => {
// 1. 特性检测
if (!navigator.share) {
shareResult.textContent = '你的浏览器不支持 Web Share API。';
return;
}

const shareData = {
title: 'Prorise 技术笔记',
text: '快来看看这个超棒的笔记平台!',
url: 'https://prorise.site'
};

try {
// 2. 调用原生分享
await navigator.share(shareData);
shareResult.textContent = '感谢你的分享!';
} catch (err) {
shareResult.textContent = `分享失败: ${err.message}`;
}
});
</script>

Web Share API 必须在 安全上下文 (HTTPS) 中,并且通常需要由 明确的用户操作(如 click 事件)来触发。在 sandbox 中可能无法调用,但在真实的移动端浏览器上会弹出原生分享界面。

10.5.4. Permissions API (权限 API)

核心痛点: 在需要使用地理位置、通知等敏感功能前,如果我们能预先知道用户当前的授权状态(是“已授权”、“已拒绝”还是“需要询问”),就可以提供更友好的用户体验,而不是唐突地弹出一个权限请求框。

解决方案: Permissions API (navigator.permissions.query()) 提供了一种标准化的方式来查询用户对特定功能的授权状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>

<body>
<button id="geo-btn">请求地理位置</button>
<p id="permission-status">权限状态:未知</p>
</body>
<script>
const geoBtn = document.getElementById('geo-btn');
const statusEl = document.getElementById('permission-status');

async function checkGeoPermission() {
try {
// 1. 查询权限状态
const permissionStatus = await navigator.permissions.query({
name: 'geolocation'
});
console.log(permissionStatus.state)
statusEl.textContent = `权限状态: ${permissionStatus.state}`;

// 2. 如果是 prompt 状态,主动请求地理位置以触发权限提示
if (permissionStatus.state === 'prompt') {
navigator.geolocation.getCurrentPosition(
(position) => {
statusEl.textContent = `权限已授予,位置: ${position.coords.latitude}, ${position.coords.longitude}`;
},
(error) => {
statusEl.textContent = `权限被拒绝或出错: ${error.message}`;
}
);
}

// 3. 监听权限变化
permissionStatus.onchange = () => {
statusEl.textContent = `权限状态已变为: ${permissionStatus.state}`;
};
} catch (err) {
statusEl.textContent = `无法查询权限: ${err.message}`;
}
}

geoBtn.addEventListener('click', checkGeoPermission);
</script>

</html>