1. JavaScript简介与基本语法

JavaScript 是一种运行于浏览器中的脚本编程语言,被称为 “Web 的编程语言” (JavaScript Introduction)。它可以用来为网页添加交互效果、动态更新内容,并与用户进行交互。例如,点击按钮弹出提示框、实时校验表单输入等,都是通过 JavaScript 实现的。JavaScript 于 1995 年由 Brendan Eich 发明,并在 1997 年成为 ECMA 标准(即 ECMAScript) (JavaScript Introduction)。需要注意的是,JavaScript 与 Java 是完全不同的语言,名称相似纯属巧合。

嵌入和运行 JavaScript

通常,我们通过 <script> 标签将 JavaScript 代码嵌入 HTML 页面。可以直接在 HTML 文件中编写脚本,也可以将代码放在独立的 .js 文件并通过 <script src="path/to/file.js"></script> 引用。浏览器会按顺序加载并执行这些脚本。一个简单的 “Hello, World” 例子如下:

<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8">
  <title>Hello JavaScript</title>
</head>
<body>
  <h1>我的第一个网页</h1>
  <script>
    alert("Hello, JavaScript!");  // 弹出提示框显示消息
    console.log("Hello, Console!");  // 在开发者控制台输出消息
  </script>
</body>
</html>

在上述代码中,我们使用 alert() 弹出浏览器提示框,console.log() 则将信息输出到浏览器的控制台(按 F12 可打开开发者工具查看控制台)。当你在浏览器中打开这个 HTML 文件时,就会运行嵌入的 JavaScript 脚本。

基本语法规则

JavaScript 的语法与 C语言和 Java 等类似,采用大括号 {} 来划分代码块,使用 ; 分号来结束语句。值得注意的语法要点包括:

  • 大小写敏感:JavaScript 区分大小写,例如变量 myVarmyvar 是不同的。
  • 注释:可以使用 // 开始单行注释,或使用 /* ... */ 包围多行注释 (Grammar and types - JavaScript | MDN):
    // 这是单行注释
    /* 这是
       多行注释 */
    
  • 语句结束:可以使用分号 ; 来显式结束一条语句。JavaScript 存在自动分号插入机制,大多数情况下每行代码末尾的分号是可选的 (Grammar and types - JavaScript | MDN)。然而,出于避免歧义的考虑,推荐始终添加分号 (Grammar and types - JavaScript | MDN)。
  • 空白和缩进:空格、制表符和换行(空白字符)在 JavaScript 中通常被忽略,其作用仅在于提高代码可读性 (Grammar and types - JavaScript | MDN)。良好的缩进和换行能让代码结构更清晰,但不会影响程序逻辑。
  • 字面量与标识符:如同大多数语言,数字如 10、字符串如 "Hello"、布尔值如 true/false 都可以直接作为字面量使用。标识符(例如变量名、函数名)必须以字母、下划线 _ 或美元符号 $ 开头,后续字符可以是字母、数字、下划线或美元符 (Grammar and types - JavaScript | MDN)。标识符不能以数字开头,且不能使用保留关键字(如 if, for, let 等)作为名称。

示例:基本输出与操作

JavaScript 提供了一些简单的方法来与用户交互或输出结果:

  • 使用 alert("消息") 弹出浏览器警告框。
  • 使用 console.log("内容") 将消息打印到控制台(调试常用)。
  • 使用 document.write("HTML内容") 将内容直接写入页面(不常用,通常仅示例或测试时使用)。
// 将一句问候输出为提示框
alert("欢迎学习 JavaScript!");

// 计算两个数的和,并在控制台输出结果
let a = 5;
let b = 7;
let sum = a + b;
console.log("Sum = " + sum);  // 控制台显示 "Sum = 12"

以上代码首先弹出欢迎提示,然后计算 5 + 7 并打印结果。可以打开浏览器控制台查看 Sum = 12 的输出。

练习:创建一个包含 <script> 的简单 HTML 文件,使用 alert 显示一段自定义消息,并尝试使用 console.log 输出一些算术计算结果到控制台。

2. 变量与常量(var, let, const)

在编程中,变量用于存储和引用数据值。JavaScript 可以通过三种方式声明变量:varletconst (Grammar and types - JavaScript | MDN) (Grammar and types - JavaScript | MDN)。这三者的用法和特性有所不同:

举例说明:

// var 示例
if (true) {
  var x = 1;
}
console.log(x);  // 输出 1,因为 var 没有块级作用域

// let 示例
if (true) {
  let y = 2;
}
console.log(typeof y);  // 输出 "undefined",y在块外不可见

// const 示例
const PI = 3.14;
PI = 3.1415;  // 错误:尝试给常量重新赋值,会抛出 TypeError

上面第一段代码中,使用 var 声明的变量 x 在块外仍然存在。而使用 let 声明的 y 则只在块内部有效,块外访问会报错(这里用 typeof 检查得到 undefined)。const PI 被赋值后,再次赋值将导致错误。

变量提升var 声明的变量会被提升至函数顶部,未赋值时默认值为 undefined。而 letconst 则不会被提升,它们在声明之前处于暂时性死区,访问会抛出错误。这一点提醒我们应始终在使用变量之前先声明它们 (Grammar and types - JavaScript | MDN) (Grammar and types - JavaScript | MDN)。

初始化和未定义:如果声明变量却未赋初始值,变量的值默认为 undefined (JavaScript 语言概览 - JavaScript | MDN)。例如 let z; console.log(z); // undefinedconst 声明则必须同时初始化,否则会导致语法错误 (Grammar and types - JavaScript | MDN)。

最佳实践:在现代开发中,建议 避免使用 var,优先使用 letconst。对需要重新赋值的量用 let,始终不变的值用 const (const - JavaScript - MDN Web Docs - Mozilla)。很多风格指南(包括 MDN)都建议能用常量就用常量,只有在需要变更值时才使用 let (const - JavaScript - MDN Web Docs - Mozilla)。

练习:分别使用 varletconst 声明变量,体会它们在不同作用域中的行为差异。例如,在一个代码块内用 varlet 各声明一个变量,块外打印看看结果。尝试声明一个 const 常量且不赋值,观察会出现什么错误信息。

3. 数据类型与类型转换

JavaScript 是一门动态类型语言,这意味着变量本身没有类型,类型与值绑定,变量可以赋予任何类型的值 (JavaScript 语言概览 - JavaScript | MDN)。JavaScript 提供了 7 种原始数据类型(primitive type) (JavaScript 语言概览 - JavaScript | MDN)以及 对象(Object) 类型:

  • Number(数字):用于表示数字(整数或浮点数),例如 423.14。 (JavaScript 语言概览 - JavaScript | MDN)JavaScript 中所有数字本质上都是双精度64位浮点数 (JavaScript 语言概览 - JavaScript | MDN)。这意味着没有单独的整数类型,整数也被当作浮点数处理。这可能会带来一些精度问题,例如 0.1 + 0.2 并不精确等于 0.3(实际结果是 0.30000000000000004) (JavaScript 语言概览 - JavaScript | MDN)。Number 类型还有特殊值如 NaN(Not a Number,非数值)表示非法数字运算结果,Infinity(无穷大)和 -Infinity 分别表示正负无穷。 (JavaScript 语言概览 - JavaScript | MDN)

  • BigInt(大整数):表示任意精度的大整数。 (JavaScript 语言概览 - JavaScript | MDN)这是 ES2020 引入的新类型,用于处理超过 Number 能安全表示范围的整数。BigInt 类型的字面量在数字后加 n 表示,例如 9007199254740993n(即 2^53+1,以 n 结尾)。BigInt 支持标准算术运算,但不能直接与普通 Number 混用 (JavaScript 语言概览 - JavaScript | MDN)。

  • String(字符串):表示文本数据的字符串,例如 "Hello"'你好'。 (JavaScript 语言概览 - JavaScript | MDN)字符串使用单引号 '...' 或双引号 "..." 包裹。ES6 还引入了 模板字符串(模板字面量),用反引号 `...` 包裹,支持跨行和嵌入表达式,稍后会详述。

  • Boolean(布尔):表示逻辑上的真或假,只有两个取值:truefalse。 (JavaScript 语言概览 - JavaScript | MDN)布尔值常用于条件判断。

  • Undefined(未定义):表示“未定义”的值。当变量声明后未赋值时,默认就是 undefined (JavaScript 语言概览 - JavaScript | MDN)。它是一个特殊的原始值,表示“缺少值”。

  • Null(空值):表示“刻意为空”的值,常用来指示一个空对象引用。 (JavaScript 语言概览 - JavaScript | MDN)nullundefined 含义相似但不完全相同——undefined通常表示变量未初始化或不存在属性,而 null 多用于主动赋值,表示“此处无值”。

  • Symbol(符号):ES6 引入的原始类型,表示独一无二的标识符。 (JavaScript 语言概览 - JavaScript | MDN)每个 Symbol 值都是唯一的,可用作对象属性的键,避免与其他属性键冲突。Symbol 常用于底层操作和元编程,初学阶段用到的较少。

  • Object(对象):对象不是原始类型,而是引用类型。对象是键值对的集合,您可以把它看作是可变的哈希映射或字典。函数、数组、日期等在 JavaScript 中都是对象类型的值。 (JavaScript 语言概览 - JavaScript | MDN)对象在下一章节中详细介绍。

此外,JavaScript 有一些特殊的对象类型:

  • Function(函数):函数也是一种对象,可以理解为可执行的代码块,JavaScript 函数是“一等公民”,即函数可以像其它值一样赋给变量或作为参数传递 (JavaScript 语言概览 - JavaScript | MDN)。
  • Array(数组):数组是特殊的对象类型,用于按索引存储有序集合值,我们将在后面单独介绍数组。
  • Date(日期):内置的日期时间对象,用于表示时间点或进行日期运算。
  • RegExp(正则表达式):用于匹配字符串模式的对象。
  • Error(错误):表示运行时错误的信息对象。

动态类型与类型检查

由于 JavaScript 是动态类型语言,同一个变量可以随时赋予不同类型的值。例如:

let data = 42;        // data 是 Number
data = "Hello";       // 现在 data 是 String
data = false;         // data 变为 Boolean

虽然灵活,但这也要求开发者在使用变量时注意其当前类型。可以使用 typeof 运算符来检查一个值的类型:

console.log(typeof 123);         // "number"
console.log(typeof "abc");       // "string"
console.log(typeof true);        // "boolean"
console.log(typeof undefined);   // "undefined"
console.log(typeof null);        // "object" (历史原因,null被视为对象类型)
console.log(typeof {a:1});       // "object"
console.log(typeof Symbol());    // "symbol"
console.log(typeof function(){});// "function"(函数被视为特殊的对象)

上面的例子展示了用 typeof 检测各种值的类型。其中需要注意:typeof null 返回 "object",这是 JavaScript 早期遗留的一个设计错误,但实际 null 并不是真正的对象类型,我们可以将其视为独立的原始类型。

类型转换

JavaScript 在需要的情况下会对值进行类型转换(type conversion),包括显式转换隐式转换两种:

  • 显式转换:通过调用转换函数或方法,将值转换为指定类型。例如:

    • 转换为数字:使用全局函数 Number(),或 parseInt()(解析整数)、parseFloat()(解析浮点数)。例如 Number("123") 得到数字 123,parseInt("3.14") 得到 3。
    • 转换为字符串:使用全局函数 String(),或通过值的 toString() 方法。比如 String(123) 得到 "123"
    • 转换为布尔:使用 Boolean() 函数。例如 Boolean(0) 得到 falseBoolean("hello") 得到 true
  • 隐式转换:在算术运算、比较或逻辑运算时,JavaScript 会根据需要自动转换类型:

    • 字符串和数字相加时,数字会转换为字符串后再拼接。例如 "5" + 2 结果是字符串 "52"。相反,如果用减号 -,字符串会转换为数字再相减,例如 "5" - 2 结果是数字 3
    • 布尔值参与算术运算时,true 会被当作 1,false 当作 0。
    • 在条件判断等布尔环境中,非布尔类型会转换为布尔值,这涉及**真值(truthy)假值(falsy)**概念(详见下文)。

举例:

let result1 = "Hello" + 5;    // "Hello5"  (5 被转换为字符串后拼接)
let result2 = "5" * 2;        // 10        ("5" 被转换为数字后乘法运算)
let result3 = 1 + true;       // 2         (true 转换为1,所以结果1+1)

注意:字符串拼接是通过 + 运算符实现的。当 + 两侧有任意一方是字符串时,JavaScript 会把另一方也转换为字符串,再进行拼接 (JavaScript 语言概览 - JavaScript | MDN)。这点需要小心,尤其是在处理数值时。如果想确保数值相加,不被拼接,可以先用 Number() 将字符串转换,或使用其他算术运算符避免歧义。

真值(Truthy)与假值(Falsy)

在需要布尔值的环境(如 if 条件)中,JavaScript 会将其他类型转换为布尔值。转换规则是:某些值被视为“假”(falsy),它们转换为布尔时为 false;而其他大部分值都被视为“真”(truthy),转换为布尔时为 true (JavaScript 语言概览 - JavaScript | MDN)。

以下值被认为是 假值(falsy),会转换为 false

  • false
  • 0(数字零)以及 -0
  • ""(空字符串)以及 ''(空字符串)
  • undefined
  • null
  • NaN(非数值)

任何不在上述列表中的值,都被视为 真值(truthy),包括非空字符串(例如 "0""false" 都是真值,因为它们非空)、非零数字(包括 Infinity)、空对象/数组({}[] 也被当作真值)等。

例子:

if ("hello") {
  console.log("这将会执行,因为非空字符串是真值");
}

if (0) {
  console.log("这不会执行,因为 0 是假值");
}

let name = ""; 
if (!name) {
  console.log("名称未填写");  // name是空字符串,假值,加上!取反后条件为真,输出此行
}

通过上述规则,开发者可以简写一些判断逻辑。例如,利用 || 运算符可以给假值提供默认值:

let inputName = ""; 
let defaultName = "匿名";
let finalName = inputName || defaultName;
console.log(finalName);  // 输出 "匿名" ,因为 inputName 是空字符串(假值),返回 defaultName

这里使用 || (逻辑或)来选择值,当左边是假值时返回右边的值,当左边是真值时返回左边的值。这种技巧经常用于为变量设置默认值。

小结:理解 JavaScript 的数据类型和转换对于避免一些常见陷阱十分重要。例如,知道 undefinednull 的区别、===== 的区别(下一节介绍)、字符串和数字混用时的行为,都能帮助你编写更健壮的代码。

练习:尝试将不同类型转换为数字、字符串和布尔,验证结果是否符合预期。例如,把 "123" 转为数字,把 false 转为数字或字符串等。测试一些值在 if 条件下的布尔结果(如 if (undefined), if ("0") 等),总结真值和假值的规律。

4. 运算符

JavaScript 提供了丰富的运算符用于算术计算、值比较、逻辑判断等。常用的运算符包括算术运算符、比较运算符、赋值运算符、逻辑运算符和条件(三元)运算符等。

算术运算符

  • 加法 +,减法 -,乘法 *,除法 /:用于基本的数学运算。
  • 求余(取模) %:计算除法的余数。例如 5 % 2 等于 1(5 除以 2 余 1)。
  • 指数 **:计算幂次方(ES6 引入)。例如 2 ** 3 等于 8(2 的 3 次方)。

示例:

console.log(10 + 3);   // 13
console.log(10 - 3);   // 7
console.log(10 * 3);   // 30
console.log(10 / 3);   // 3.333...
console.log(10 % 3);   // 1
console.log(2 ** 4);   // 16

注意:当使用 + 运算符时,如果其中一侧是字符串,将触发字符串连接而非数学加法 (JavaScript 语言概览 - JavaScript | MDN)(见上一章)。其它算术运算符(- * / % **)在需要时会尝试将操作数转换为数字再计算。

比较运算符

  • 相等不相等==!= 会在比较前进行类型转换(宽松相等)。例如,5 == "5" 会返回 true,因为字符串 "5" 会被转换为数字 5 再比较 (JavaScript 语言概览 - JavaScript | MDN)。不推荐在代码中使用 ==!=,因为其自动类型转换机制可能导致难以发现的错误。
  • 全等不全等===!== 被称为严格相等严格不相等,它们在比较时不进行隐式类型转换,要求值和类型都相等才返回真值 (JavaScript 语言概览 - JavaScript | MDN)。例如,5 === "5" 返回 false,因为类型不同;null === undefined 也返回 false,因为它们是不同类型。通常 优先使用严格相等 (===) 来避免类型转换问题 (JavaScript 语言概览 - JavaScript | MDN)。
  • 大小比较:>(大于),<(小于),>=(大于等于),<=(小于等于),这些运算符会在比较前将操作数转换为数字(或字符串按字典序比较)后再判断大小。
    console.log(5 > 3);     // true
    console.log(5 < 3);     // false
    console.log(5 >= 5);    // true
    console.log("apple" < "banana"); // true (字符串按字母顺序比较)
    

赋值运算符

  • 简单赋值= 将右侧表达式的值赋给左侧的变量。
  • 复合赋值+=-=*=/=%= 等,将算术运算和赋值合并。例如,x += 5 相当于 x = x + 5 (JavaScript 语言概览 - JavaScript | MDN)。同理还有 **=(幂赋值),&&=||=??=(逻辑赋值,ES2021 引入)等。

示例:

let n = 10;
n += 5;  // 相当于 n = n + 5,现在 n 为 15
n *= 2;  // 相当于 n = n * 2,现在 n 为 30

逻辑运算符

  • 逻辑与 &&:当且仅当左右两个操作数都为真(truthy)时结果为真。若左侧为假(falsy),则直接返回左侧值,不再计算右侧(短路特性)。
  • 逻辑或 ||:只要左右任一为真就为真。若左侧为真,直接返回左侧值(短路),否则返回右侧值。
  • 逻辑非 !:一元运算符,返回操作数的布尔相反值。双重否定 !! 可用于将任意值转换为布尔类型。

逻辑运算符也遵循短路求值和真值/假值规则。例如:

console.log(true && false);   // false
console.log(true || false);   // true

// 短路示例:
let x = 0;
console.log(x || 5);          // 输出 5,因为 x 为假值,返回右侧 5
console.log(x && 5);          // 输出 0,因为 x 为假值,&& 短路直接返回 x

console.log(!true);           // false
console.log(!0);              // true (0 为假,!0 为真)

在以上例子中,x || 5 等于 5,相当于为 x 提供了默认值。x && 5 则因为 x 为假值,所以整个表达式为假,结果就是 x 自身的值(0)。逻辑运算符在 JavaScript 中返回的是原始操作数的值,而不一定是布尔值,这一点与某些语言不同。例如,"Hello" && "World" 返回 "World""Hello" || "World" 返回 "Hello" (JavaScript 语言概览 - JavaScript | MDN)。这种特性常被利用来写简洁的代码。

条件(三元)运算符

JavaScript 唯一的三元运算符是 条件运算符 condition ? expr1 : expr2。根据条件布尔值,返回第一个表达式或第二个表达式的值,相当于简写的 if-else

let age = 18;
let type = age >= 18 ? "成年人" : "未成年";
console.log(type);  // "成年人"

age >= 18 为真,则 type 被赋值 "成年人",否则赋值 "未成年"

运算符优先级和结合性

不同运算符的执行顺序由优先级决定,优先级高的先计算。一般来说,算术运算符优先于比较运算符,比较又高于逻辑运算符。但使用小括号 (...) 可以明确指定求值顺序,排除歧义。良好的习惯是即使符合预期的默认优先级,也可以适当使用括号来提高可读性。

例如,表达式 a + b * c 会先计算 b * c 再加 a,而 (a + b) * c 则会先算 a + b

特殊运算符

  • 逗号运算符 ,:依次计算多个表达式,并返回最后一个表达式的值。该运算符优先级很低,在实践中不常用到。
  • delete 运算符:用于删除对象的属性或数组的元素,例如 delete obj.key。删除成功则该属性变为 undefined(注意,delete 不会影响通过 var/let/const 声明的变量)。
  • typeof 运算符:上一节已讨论,用于获取类型字符串。
  • instanceof 运算符:用于判断一个对象是否是某构造函数的实例,例如 arr instanceof Array 检查 arr 是否为数组。
  • 扩展/展开运算符 ...:这是 ES6 引入的语法糖,可用于函数调用的参数展开、数组/对象展开,稍后ES6+特性部分详述。

练习:编写一些表达式来练习各种运算符。例如:计算一个圆的面积(使用 Math.PI * r ** 2);比较两个变量,使用三元运算根据大小输出不同字符串;尝试用 &&|| 写出带默认值的表达式。观察 ===== 的区别,例如 0 == false0 === false 的结果。

5. 条件语句(if, switch)

在编程中,条件语句允许根据不同情况执行不同的代码。JavaScript 提供了 if...elseswitch 两种主要的条件控制结构。

if...else 条件判断

if 语句根据一个表达式的布尔值(truthy/falsy)来决定是否执行某段代码。基本语法:

if (条件表达式) {
  // 当条件为真 (truthy) 时执行这里的代码
} else if (另一个条件表达式) {
  // 当第一个条件为假,而此条件为真时执行这里
} else {
  // 前面所有条件都为假 (falsy) 时执行这里
}

使用示例:

let score = 85;
if (score >= 90) {
  console.log("成绩:优秀");
} else if (score >= 60) {
  console.log("成绩:及格");
} else {
  console.log("成绩:不及格");
}

在这个例子中,程序会依次检查条件:如果 score >= 90,则输出“优秀”,否则检查 score >= 60,如果为真则输出“及格”,如果仍为假则执行最后的 else 分支输出“不及格”。

注意:JavaScript 没有独立的 elif 关键字,else if 实际上是紧跟在一个 else 后的新的 if 语句,只是写在一起形成多分支结构 (JavaScript 语言概览 - JavaScript | MDN) (JavaScript 语言概览 - JavaScript | MDN)。

单行简写:如果 ifelse 分支中的代码只有一条语句,可以省略大括号 {}。然而为了可读性和减少错误,不推荐省略大括号,哪怕只有一行,也建议保留。

switch 多分支选择

当有多个可能条件需要判断时,使用 switch 语句会使代码更整洁。switch 会将一个表达式的值与多个 case 分支进行严格相等比较 (===),并根据匹配结果执行相应的代码块:

let color = "blue";
switch (color) {
  case "red":
    console.log("红色");
    break;
  case "blue":
    console.log("蓝色");
    break;
  case "green":
    console.log("绿色");
    break;
  default:
    console.log("未知颜色");
}

执行逻辑如下:将 color 的值依次与每个 case 后的值比较,找到匹配的分支后,从该处开始执行代码,直到遇到 break 或结束 switchbreak 语句用于跳出 switch,防止继续执行后续分支的代码。如果没有 break,程序会贯穿(fall-through)执行下面的分支代码 (JavaScript 语言概览 - JavaScript | MDN)。贯穿有时可被利用,但大多数情况下应该避免遗漏 break 造成逻辑错误。

default 分支类似于 else,当没有任何 case 匹配时执行。它通常放在最后(也可以出现在中间,但最后一个分支不需要 break)。

需要注意,switch 的比较使用 严格相等 (===),不会进行隐式类型转换 (JavaScript 语言概览 - JavaScript | MDN)。例如,如果 color 是数字 1,但 case "1" 是字符串,则不会匹配。

条件嵌套与逻辑组合

可以在条件内部嵌套条件,或用逻辑运算符组合多个条件:

let isMember = true;
let age = 20;
if (isMember && age >= 18) {
  console.log("成年人会员");
}

if (!isMember || age < 18) {
  console.log("非成年或非会员");
}

第一个 if 要求 isMember 为真且年龄不小于18,两者同时满足才执行。第二个 if 则是在 isMember 为假年龄小于18时才执行。

提示:多重条件判断时,注意加括号明确逻辑。例如 (A && B) || CA && (B || C) 的含义不同。

三元运算符简化条件

如前章所述,可以使用 条件 ? 值1 : 值2 来替代简单的 if-else 赋值:

let access = (age >= 18) ? "允许进入" : "禁止进入";

这行代码根据 age 是否满18来选择字符串赋给 access,比起 if 写法更简洁。但在包含复杂逻辑或多语句的情况下,if-else 更清晰易读。

练习:使用 prompt(浏览器中弹出输入框)询问用户年龄,然后用 if-else 判断打印不同信息,例如区分儿童、青少年、成年人。试着将上述 switch 颜色示例改写为等效的 if-else if 结构。反之,也可以找一个多分支的 if-else 场景改用 switch 实现。

6. 循环语句(for, while, do...while)

循环语句使得我们可以重复执行某段代码。JavaScript 提供了多种循环结构,包括 whiledo...whilefor 以及 ES6 引入的 for...of、更早的 for...in 等。合理使用循环能高效地完成重复性任务。

while 循环

while 循环反复执行其循环体,直到条件不成立为止。基本语法:

while (条件表达式) {
  // 当条件为 true 时,执行此代码块,然后再次检查条件
}

在每次循环迭代开始时计算条件表达式,如果为真则执行循环体,然后再次检查条件,依此往复。若一开始条件就是假,循环体一次也不会执行。

示例:

let i = 1;
while (i <= 5) {
  console.log("计数:" + i);
  i++;
}

这段代码会输出 计数:1计数:5,然后停止。变量 i 从 1 开始,每次循环自增,直到不满足 i <= 5

需要注意防止死循环:如果条件始终为真,while 循环将永远不会结束。例如:

// ⚠️ 不要运行这段代码!会导致死循环
while (true) {
  // 无限循环
}

上例 while(true) 将进入无限循环,因此通常需要在循环内部通过 break 跳出或某种逻辑使条件变为假。

do...while 循环

do...while 循环与 while 类似,但先执行一次循环体,然后在循环末尾检查条件,根据条件决定是否继续。语法:

do {
  // 先执行一次此代码块
} while (条件表达式);

因为是后判断条件,所以无论条件最初是否为假,循环体至少执行一次 (JavaScript 语言概览 - JavaScript | MDN)。例如:

let password;
do {
  password = prompt("请输入密码(至少1个字符):");
} while (!password);
alert("输入的密码是:" + password);

这个循环会至少执行一次prompt 提示让用户输入。当用户输入为空字符串时,条件 !password 为真,将继续要求输入,直到获得非空值为止。

for 循环

for 循环是最常用的循环结构,语法紧凑,适合做固定次数的迭代。基本语法:

for (初始化; 条件; 每次迭代后执行的表达式) {
  // 循环体代码
}

执行过程:首先运行一次“初始化”部分,然后每次循环先检查“条件”,条件为真则执行循环体,循环体执行完后运行“迭代表达式”(如自增计数器),再回到条件检查,如此往复。

示例:

for (let j = 1; j <= 5; j++) {
  console.log(j);
}
// 输出 1 2 3 4 5(每行一个数字)

这里,j 初始为1,每次循环检查 j <= 5 是否为真,为真则打印 j 并执行 j++。当 j 增加到6时条件失败,循环结束。for 循环将计数器初始化、条件检查、自增逻辑都集中在了一行,结构清晰简洁 (JavaScript 语言概览 - JavaScript | MDN)。

注意for 循环中的初始化部分可以声明局部变量(如上例的 let j),该变量只在循环内部作用域可见。如果在循环外已声明变量,也可以在初始化部分使用,例如:

let k = 0;
for (; k < 3; k++) {
  console.log(k);
}

以上代码省略了初始化(直接用了外部的 k),但依然需要两个分号表示三个部分。结果输出 0 1 2,循环后 k 的值为 3。

其他循环结构: for...of 和 for...in

ES6 引入了 for...of 循环,用于遍历可迭代对象(Array、String、Map、Set 等)的元素 (JavaScript 语言概览 - JavaScript | MDN)。语法:

for (const element of someIterable) {
  // 对于每个元素,执行此代码块,element变量即为元素值
}

例如遍历数组:

let arr = ["A", "B", "C"];
for (const value of arr) {
  console.log(value);
}
// 输出 A B C

for...of 简化了数组遍历,不需要管理索引。它等价于其他语言如 Java/C# 的 for (Type x : collection) 语法 (JavaScript 语言概览 - JavaScript | MDN)。

for...in 则用于遍历对象的可枚举属性键 (JavaScript 语言概览 - JavaScript | MDN):

const obj = {name: "Tom", age: 20};
for (const key in obj) {
  console.log(key + ": " + obj[key]);
}
// 输出 name: Tom  和 age: 20

for...in 会遍历对象自身及继承的可枚举属性,不建议用于遍历数组(因为数组也被视作对象,for...in 会遍历索引但顺序可能不按数值顺序)。一般建议:数组遍历用forfor...of对象遍历用for...inObject.keys()等方法

循环控制:break 和 continue

  • break:立即跳出整个循环,不再执行后续迭代。 (JavaScript 语言概览 - JavaScript | MDN)在多层嵌套循环中,break 只跳出所在的那一层循环。如果需要跳出多层,可以使用标签(不常用)。
  • continue:跳过本次循环剩余代码,直接进入下一次迭代。continue 只结束当前这一次循环,后续迭代仍会进行。

示例:

for (let n = 1; n <= 10; n++) {
  if (n % 2 === 0) continue;  // 跳过偶数
  if (n > 7) break;           // 超过7则退出循环
  console.log(n);             // 输出1,3,5,7
}

代码中,遇到偶数时执行 continue 直接跳过打印;当 n 增长到8时,触发 break 跳出循环,因此 8 和 9、10 都不会打印。

练习:使用 for 循环计算 1 到 100 的和。尝试用 while 实现相同的功能。编写一个循环查找数组中的某个值,找到后使用 break 提前退出。用 for...of 遍历一个字符串,统计其中元音字母的数量。

7. 函数(声明式、表达式、箭头函数)

**函数(Function)**是执行特定任务或计算的可重用代码块。通过定义函数,我们可以封装逻辑,在需要时调用,从而避免重复代码,并提高代码组织性。

JavaScript 函数具备“一等公民”地位,这意味着函数可以像普通变量一样被赋值、传递和操作 (JavaScript 语言概览 - JavaScript | MDN)。我们先介绍三种常见的函数定义方式:

  1. 函数声明(Function Declaration)
  2. 函数表达式(Function Expression)
  3. 箭头函数(Arrow Function)(ES6 引入)

函数声明

函数声明使用 function 关键字,直接声明一个具名函数:

function 函数名(参数1, 参数2, ...) {
  // 函数体:执行的代码
  return 返回值;
}

例如:

function add(x, y) {
  const total = x + y;
  return total;
}

定义了一个名为 add 的函数,接受两个参数 xy,返回它们的和 (JavaScript 语言概览 - JavaScript | MDN)。可以通过函数名调用它:

let result = add(2, 3);
console.log(result);  // 5

参数:函数可以有多个参数,用逗号分隔。如果调用时未传入足够参数,则未提供的参数值为 undefined (JavaScript 语言概览 - JavaScript | MDN);多传入的参数会被忽略 (JavaScript 语言概览 - JavaScript | MDN)。可以利用这一机制实现参数可选的功能。

返回值:用 return 语句从函数返回结果,并终止函数执行 (JavaScript 语言概览 - JavaScript | MDN)。如果函数没有 return 或仅写 return 而未指定值,则返回值默认为 undefined (JavaScript 语言概览 - JavaScript | MDN)。return 只能返回一个值,但可以是对象或数组来包含多个值。

示例

function greet(name) {
  return "你好," + name + "!";
}
console.log(greet("小明"));  // 输出:"你好,小明!"

函数表达式

函数表达式将一个函数定义作为赋给变量。常见形式是匿名函数赋值给变量:

const multiply = function(a, b) {
  return a * b;
};
console.log(multiply(4, 5));  // 20

这里我们定义了匿名函数 function(a, b){...} 并赋给变量 multiply,之后通过 multiply() 调用。函数表达式也可以有名称(命名函数表达式),不过该名称只在函数自身作用域可见,主要用于递归或调试。

函数表达式的一个应用是立即执行函数表达式(IIFE),即定义后立即调用。写法是在函数表达式外加括号并紧随一对括号调用:

(function () {
  console.log("IIFE executed!");
})();

IIFE 常用于创建局部作用域或执行初始化代码。

箭头函数(Arrow Function)

ES6 引入了更简洁的箭头函数语法,用 => 定义函数。箭头函数通常以函数表达式形式使用。基本语法:

const 函数变量 = (参数1, 参数2, ...) => {
  // 函数体
  return 返回值;
};

例如,将前面的乘法函数改写为箭头函数:

const multiplyArrow = (a, b) => {
  return a * b;
};

箭头函数也可以进一步简写:如果函数体只有单个表达式,可以省略花括号和 return,结果即为该表达式的值 (JavaScript 语言概览 - JavaScript | MDN):

const multiplyArrow2 = (a, b) => a * b;

当箭头函数只有一个参数时,参数括号也可以省略;没有参数时则必须写空括号 ()。例如:

const square = x => x * x;
const greetArrow = () => console.log("Hello!");

箭头函数与普通函数的区别

  • this 绑定:箭头函数没有自身的 this,它的 this 值由外层(定义时的作用域)决定 (JavaScript 语言概览 - JavaScript | MDN)。这在处理回调时很方便,可避免因 this 指向变化而需要用 .bind 或保存 self = this 的做法。
  • arguments 对象:箭头函数也没有 arguments 对象。如果需要访问参数,可使用剩余参数(详见后述)来代替。
  • 不能用作构造函数:箭头函数不能使用 new 来调用,因为它没有 prototype,也没有自己的 this
  • 简洁:箭头函数适合用于简单操作或作为回调参数,使代码更简洁。但对于复杂函数,传统写法可读性更好一些。

函数参数高级特性

默认参数值:ES6 起可以为函数参数设置默认值 (JavaScript 语言概览 - JavaScript | MDN)。当对应参数未传或传入 undefined 时,采用默认值:

function greet(name = "游客") {
  console.log("你好," + name);
}
greet();            // 输出 "你好,游客"(未提供参数,使用默认值)
greet("小李");      // 输出 "你好,小李"

剩余参数(Rest参数):在参数列表最后使用 ...变量名,可以将多余的、未明确列出的参数收集到一个数组中 (JavaScript 语言概览 - JavaScript | MDN) (JavaScript 语言概览 - JavaScript | MDN)。例如:

function sumAll(...numbers) {
  let sum = 0;
  for (const num of numbers) {
    sum += num;
  }
  return sum;
}
console.log(sumAll(1, 2, 3, 4));  // 10

调用 sumAll(1,2,3,4) 时,numbers 数组为 [1,2,3,4]。Rest 参数让函数可以接受可变数量的实参。每个函数只能有一个 Rest 参数且必须是最后一个。

参数解构:可以通过解构赋值直接将对象或数组参数展开为多个变量(见后面 ES6 部分)。例如:

function getFullName({firstName, lastName}) {
  return `${firstName} ${lastName}`;
}
let person = { firstName: "李", lastName: "雷", age: 30 };
console.log( getFullName(person) );  // "李 雷"

这里利用了对象解构,将传入对象的属性直接映射为同名局部变量使用 (JavaScript 语言概览 - JavaScript | MDN) (JavaScript 语言概览 - JavaScript | MDN)。

函数作用域和生命周期

  • 局部变量:在函数内部用 var(函数作用域)或 let/const(块作用域)声明的变量,在函数执行完毕后就被销毁,外部无法访问。同名局部变量会屏蔽外部同名变量(变量遮蔽)。
  • 全局变量:在任何函数之外声明的变量,或者不使用 var/let/const 直接赋值的变量,会成为全局变量,挂在全局对象(浏览器中是 window)上。在函数内部如果没有用 var/let/const 就直接赋值,会意外创建全局变量,需避免 (Grammar and types - JavaScript | MDN) (Grammar and types - JavaScript | MDN)(严格模式下会报错)。
  • 静态作用域:JavaScript 使用词法作用域(静态作用域),函数的可见变量取决于函数定义的位置,而不是调用的位置。这涉及**闭包(closure)**的概念,即函数可以捕获定义时所在作用域的变量。

函数的调用方式

  • 普通函数调用:如 myFunc()this 在非严格模式下默认指向全局对象,在严格模式下则为 undefined
  • 方法调用:作为对象属性调用,如 obj.method(),此时函数内部的 this 指向该对象。
  • 构造器调用:通过 new 调用函数,如 new Person(),函数内部可以用 this 初始化新对象。如果函数没有显式返回对象,则默认返回新对象实例。
  • call/apply调用:使用 func.call(context, arg1, arg2)func.apply(context, [args]) 可以指定 this 并调用函数。

示例:函数综合示例

// 函数声明
function max(a, b) {
  return a > b ? a : b;
}

// 函数表达式
const min = function(a, b) {
  return a < b ? a : b;
};

// 箭头函数
const average = (a, b) => (a + b) / 2;

console.log( max(10, 5) );     // 10
console.log( min(10, 5) );     // 5
console.log( average(10, 5) ); // 7.5

以上定义了返回较大值的 max、返回较小值的 min,以及计算平均值的 average。可以看到不同定义方式在使用上没有区别,都可以通过函数名加参数列表调用。

练习:定义一个函数 isPrime(n) 判断整数是否为质数(素数)。采用不同方式(声明式/表达式/箭头)实现同一个功能,如计算阶乘的函数。尝试编写一个带默认参数和剩余参数的函数,例如模拟 Math.max 功能,接受不定数量参数返回最大值。

8. 数组与常用方法

**数组(Array)**是一种用于按序存储数据的复合类型。在 JavaScript 中,数组其实是一种特殊的对象,其索引是按序编号的字符串键,但数组提供了许多便利的特性和方法,使其在使用上类似其他语言的数组或列表 (JavaScript 语言概览 - JavaScript | MDN) (JavaScript 语言概览 - JavaScript | MDN)。

数组的创建与基本操作

  • 字面量创建:使用方括号 [...] 列出元素来创建数组,这是最常用方式 (JavaScript 语言概览 - JavaScript | MDN)。

    let emptyArr = [];                  // 空数组
    let fruits = ["苹果", "香蕉", "橙子"];  // 含初始元素的数组
    
  • 通过构造器:使用 new Array() 创建。但不推荐这种方式,因为参数处理有些令人困惑(传单个数字 n 会创建长度为 n 的空数组)。

    let arr1 = new Array(5);    // 长度5的空数组(未初始化元素)
    let arr2 = new Array("A", "B"); // ["A", "B"]
    
  • 数组元素:数组的每个元素可以是任意类型,数组本身也可以包含不同类型的元素。

    let mixed = [1, "Hello", true, {x:10}, [2,3]];
    
  • 访问元素:使用索引访问元素,索引从 0 开始。

    let firstFruit = fruits[0];    // "苹果"
    fruits[2] = "葡萄";            // 将第三个元素改为"葡萄"
    console.log(fruits.length);   // 3,数组长度属性 length
    

    JavaScript 数组的 length 属性总是比最高的索引大 1 (JavaScript 语言概览 - JavaScript | MDN) (JavaScript 语言概览 - JavaScript | MDN)。修改已有元素不会影响 length,但如果通过赋值增加了超出当前长度的索引,length 会自动更新。例:

    let arr = ["a", "b", "c"];   // length = 3
    arr[5] = "f";
    console.log(arr.length);    // 6,索引5赋值使长度变为6
    console.log(arr);           // ['a','b','c', <2 empty items>, 'f']
    

    上例在索引3和4位置产生了“空项”(empty item),形成稀疏数组 (JavaScript 语言概览 - JavaScript | MDN)。一般应避免直接创建稀疏数组,否则引擎可能将其当作普通对象处理而失去性能优化 (JavaScript 语言概览 - JavaScript | MDN)。

  • 添加和删除元素:可以使用索引在数组末尾或中间添加元素。也可以使用数组方法添加/删除:

    • push(elem):将元素添加到数组末尾,返回新长度。
    • pop():移除数组最后一个元素,返回该元素。
    • unshift(elem):在数组开头插入元素,返回新长度。
    • shift():移除数组第一个元素,返回该元素。
    let arr = [1, 2, 3];
    arr.push(4);         // arr变为 [1,2,3,4],返回4
    arr.unshift(0);      // arr变为 [0,1,2,3,4],返回5
    arr.pop();           // 移除4,arr变为 [0,1,2,3],返回4
    arr.shift();         // 移除0,arr变为 [1,2,3],返回0
    
  • 删除/插入任意位置:使用 splice 方法:

    • arr.splice(startIndex, deleteCount, item1, item2, ...):从指定位置开始删除若干元素,并可插入新元素。返回被删除的元素数组。

    例如:

    let arr = ["a", "b", "c", "d"];
    arr.splice(1, 2, "x", "y"); 
    console.log(arr);            // ["a", "x", "y", "d"]
    // 解释:从索引1开始删除2个元素("b","c"),插入"x","y"
    

    splice 非常强大,可以单纯用于删除(只给前两个参数),或者插入(deleteCount设为0)。

  • 截取子数组:使用 slice 方法:

    • arr.slice(start, end):返回从索引 startend(不含)之间的元素组成的新数组。若省略 end 则一直到末尾。slice 不会修改原数组。

    例如:

    let arr = [1,2,3,4,5];
    let sub = arr.slice(1,4);
    console.log(sub);   // [2,3,4]
    
  • 合并数组:使用 concat 方法:

    • arr.concat(otherArr, value1, ...):返回一个新数组,是将当前数组与其他数组或值连接后的结果。原数组不变。
    let arr1 = [1,2];
    let arr2 = [3,4];
    let arr3 = arr1.concat(arr2, 5);
    console.log(arr3);  // [1,2,3,4,5]
    

遍历数组

遍历数组最基础的方法是使用 for 循环通过索引访问 (JavaScript 语言概览 - JavaScript | MDN) (JavaScript 语言概览 - JavaScript | MDN):

for (let i = 0; i < arr.length; i++) {
  console.log(arr[i]);
}

ES6 之后,可以使用更简洁的 for...of 来遍历数组元素 (JavaScript 语言概览 - JavaScript | MDN) (JavaScript 语言概览 - JavaScript | MDN):

for (const item of arr) {
  console.log(item);
}

还有一些数组方法可以隐式遍历数组,例如下面介绍的 forEachmap 等。

常用数组方法

JavaScript 提供了许多数组方法来操作和查询数组,这里介绍几个常用的:

  • indexOf(value):返回数组中第一次出现指定值的索引,找不到则返回 -1。lastIndexOf 则返回最后一次出现的索引。

    let arr = ["a","b","c","b"];
    console.log(arr.indexOf("b"));       // 1
    console.log(arr.lastIndexOf("b"));   // 3
    console.log(arr.indexOf("x"));       // -1 (不存在)
    
  • includes(value):ES7(ES2016) 引入的方法,判断数组是否包含某值,返回布尔。

    console.log(arr.includes("c"));  // true
    console.log(arr.includes("x"));  // false
    
  • forEach(callback):遍历数组,对每个元素执行提供的回调函数。无返回值(总是返回 undefined)。

    arr.forEach((element, index) => {
      console.log(index + ":" + element);
    });
    // 输出 "0:a", "1:b", "2:c", "3:b"
    
  • map(callback):遍历数组,对每个元素调用回调函数,将回调返回值组成新数组返回 (JavaScript 语言概览 - JavaScript | MDN)。原数组不变。

    let nums = [1, 2, 3];
    let squares = nums.map(x => x * x);
    console.log(squares);  // [1,4,9]   ([JavaScript 语言概览 - JavaScript | MDN](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Language_overview#:~:text=js))
    
  • filter(callback):筛选数组。返回一个新数组,包含回调函数返回真值对应的那些元素。

    let evens = nums.filter(x => x % 2 === 0);
    console.log(evens);    // [2] (筛选偶数)
    
  • reduce(callback, initialValue):归纳/汇总数组。依次累积地将数组元素应用回调函数。initialValue 是可选的初始累积值,若不提供则以第一个元素为初始累积值从第二个元素开始。

    let sum = nums.reduce((acc, val) => acc + val, 0);
    console.log(sum);  // 6 (计算数组和)
    
  • find(callback):返回第一个满足回调条件的元素值,没有则返回 undefined。findIndex 则返回索引。

  • sort(compareFunction):就地对数组排序,默认按字符串Unicode顺序排序。可选的比较函数可用于定义排序逻辑。注意:默认排序会把元素转换为字符串再比较,可能不符合预期,例如 [10, 2, 30].sort() 结果是 [10, 2, 30](因为 "10" < "2" 排序)。对于数字排序,通常需要提供比较函数:

    let arr = [10, 2, 30];
    arr.sort((a,b) => a - b);
    console.log(arr);  // [2, 10, 30]
    

    a - b 小于0表示 a 应排在前,结果升序排序。

  • reverse():就地倒转数组元素顺序。

  • join(separator):将数组所有元素连接成一个字符串,separator 是可选的分隔符字符串,默认用逗号分隔。

    let letters = ["a","b","c"];
    console.log(letters.join("-"));  // "a-b-c"
    

以上列举的是数组最常用的一些方法。更多方法例如 every(测试是否所有元素满足条件)、some(是否至少有一个满足条件)、flat(扁平化嵌套数组)等,可以在需要时查阅文档。

数组与对象的关系

正如开头提到的,数组在 JavaScript 中实际上是特殊的对象类型:索引实际上是对象的键,而数组拥有一个特殊的 length 属性会根据数值键自动更新 (JavaScript 语言概览 - JavaScript | MDN)。这意味着数组也可以像对象一样增加非数字的属性,但不建议这样做,因为会混淆数组和对象的使用场景。

数组也是对象,因此在比较时,要注意两个独立创建但内容相同的数组并不相等:

let arr1 = [1,2,3];
let arr2 = [1,2,3];
console.log(arr1 === arr2);  // false,不同对象引用

必须引用同一个数组对象的两个变量才相等。

练习:创建一个包含若干数字的数组,使用不同方法求出其中最大值(比如先排序或用 reduce)。编写一个函数,传入数组和一个值,使用循环或数组方法移除数组中等于该值的所有元素。尝试使用 mapfilter 对数组进行转换和过滤,例如把字符串数组转换为大写、筛选出长度大于 3 的字符串等。

9. 对象与面向对象编程(构造函数、原型、类)

**对象(Object)**是 JavaScript 的核心概念之一。对象以键-值对形式组织数据,可以表示现实中某个实体的各种属性。JavaScript 的对象类似于其他语言的“字典”、“哈希表”或“映射” (JavaScript 语言概览 - JavaScript | MDN)。与静态类型语言不同,JS 对象没有固定结构,可以动态增加、删除属性,十分灵活 (JavaScript 语言概览 - JavaScript | MDN)。

创建对象与属性访问

  • 对象字面量:使用花括号 {...} 定义对象是最简单的方式:

    let person = {
      name: "Alice",
      age: 25,
      "likes birds": true  // 属性名可以包含空格等特殊字符,但必须加引号
    };
    

    上例创建了一个包含属性 nameage"likes birds" 的对象。属性名是字符串(引号可省略,除非有特殊字符),值可以是任意类型。

  • 访问属性:有两种方式访问或设置对象属性:

    1. 点符号obj.key,属性名须是有效的标识符(不含空格等特殊字符)。例如 person.name 返回 "Alice"
    2. 方括号obj["key"],方括号中传入属性名字符串。必须用这种方式访问含特殊字符或变量名不确定的属性 (JavaScript 语言概览 - JavaScript | MDN)。例如 person["likes birds"] 返回 true

    方括号方式允许使用变量作为属性名:

    let prop = "age";
    console.log(person[prop]);  // 相当于 person["age"],输出 25
    

    如果访问不存在的属性,会得到 undefined

  • 修改和新增属性:通过赋值可以修改已有属性或添加新属性:

    person.age = 26;              // 修改 age
    person.city = "Beijing";      // 新增属性 city
    

    对象是动态的,可随时增删属性。

  • 删除属性:使用 delete 运算符删除对象的某个属性:

    delete person["likes birds"];
    

    删除后访问该属性将是 undefined

  • 检测属性:可以用 'key' in obj 来检查对象是否拥有某属性(包括属性值为 undefined 的情况):

    console.log("name" in person);   // true
    console.log("likes birds" in person); // false (刚被删除)
    

    也可以通过比较 obj.prop === undefined 来判断,但当属性值本身可能为 undefined 时需要谨慎。

对象的引用与比较

对象属于引用类型,赋值对象给一个新变量只是拷贝引用,两个变量指向同一个对象。对其中一个进行属性修改,另一个也会反映出更改 (JavaScript 语言概览 - JavaScript | MDN) (JavaScript 语言概览 - JavaScript | MDN):

let objA = { x: 1 };
let objB = objA;
objB.x = 5;
console.log(objA.x);  // 5,与objB引用同一对象

不同对象的引用永远不相等,即使它们有完全相同的内容 (JavaScript 语言概览 - JavaScript | MDN):

let o1 = { y: 2 };
let o2 = { y: 2 };
console.log(o1 === o2);  // false,不同对象

遍历对象属性

使用 for...in 可以遍历对象的可枚举属性 (JavaScript 语言概览 - JavaScript | MDN) (JavaScript 语言概览 - JavaScript | MDN):

for (let key in person) {
  console.log(key + ": " + person[key]);
}

这将输出对象中所有可枚举属性及其值。需要注意 for...in 会遍历对象原型链上的可枚举属性(后面介绍),因此在意外情况下可能得到不期望的键,可以用 obj.hasOwnProperty(key) 过滤仅自身属性。

面向对象编程入门

JavaScript 支持面向对象编程(OOP),只是与传统的基于类的语言有所不同。JS 采用原型(prototype)继承模型,每个对象都有一个关联的原型对象,它可被用来实现属性继承。

构造函数与原型

在 ES6 引入 class 语法之前,创建可复用对象模板的传统方法是定义构造函数。构造函数本质上是一个普通函数,约定名称首字母大写,然后使用 new 运算符来调用,以创建实例对象。

function Person(name, age) {
  // 构造函数,用于初始化对象
  this.name = name;
  this.age = age;
  // 注意:没有显式 return
}
// 添加方法到构造函数的原型上
Person.prototype.greet = function() {
  console.log("你好,我是" + this.name);
};

let p1 = new Person("小张", 30);
p1.greet();  // 输出 "你好,我是小张"

解释:

  • 当使用 new Person("小张", 30) 时,会做几件事:
    1. 创建一个新对象 p1
    2. 将新对象的原型指向 Person.prototype
    3. 调用构造函数 Person,并将函数内的 this 绑定到新对象 p1。因此,在构造函数中给 this 添加的属性 nameage 会设置在 p1 上。
    4. 返回新对象 p1(除非构造函数手动返回另一个对象)。
  • 我们给 Person.prototype 添加了方法 greet。这样所有由 Person 构造的对象都共享这一个方法实例。当调用 p1.greet() 时,JS 引擎会在 p1 自身找 greet 属性,找不到则查其原型 Person.prototype,在那里找到了并调用。
  • 通过原型,我们实现了类似类的方法继承,但底层机制是对象链。

原型链:每个对象都有一个内部链接指向其原型对象(可以通过 Object.getPrototypeOf(obj) 获取)。原型对象也可能有自己的原型,一直链向上,直到最顶层的原型通常是 Object.prototype,它的原型为 null。这种链接构成了原型链。属性查找会沿着原型链向上搜索,直到找到为止,找不到则返回 undefined

ES6 类(Class)

ES6 引入了类(class)语法,使创建对象原型和继承更加直观。需要理解的是,JS 的类只是语法糖,背后依然通过原型工作 (JavaScript 语言概览 - JavaScript | MDN)。使用类可以更简洁地定义构造和方法:

class PersonClass {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
  greet() {
    console.log(`你好,我是${this.name}!`);
  }
}

let p2 = new PersonClass("小李", 25);
p2.greet();  // 输出 "你好,我是小李!"

在这个定义中:

  • constructor 方法定义构造函数逻辑(相当于之前构造函数里的内容)。使用 new PersonClass(...) 会自动调用此构造器。
  • 其它在类内定义的方法(如 greet)会被添加到 PersonClass.prototype 上,从而被实例共享 (JavaScript 语言概览 - JavaScript | MDN)。
  • 类中定义的方法是不可枚举的(enumerable: false),且会自动设置属性描述符中不能修改class方法的可枚举性和可配置性,这点与直接修改 prototype 有细微区别,但对初学者影响不大。
  • 可以在类中使用 getter/setter(使用 get propName()set propName(val) 定义)来拦截属性访问,或用 static 定义静态方法/属性(属于类本身而非实例) (JavaScript 语言概览 - JavaScript | MDN)。ES2022 还增加了类的私有字段(属性名前加 # 定义,只能在类内部访问) (JavaScript 语言概览 - JavaScript | MDN)。

继承(Inheritance)

类可以通过 extends 关键字继承另一个类。继承机制让子类共享父类的方法并可以重写。示例:

class Animal {
  constructor(name) {
    this.name = name;
  }
  speak() {
    console.log(`${this.name} 发出了声音`);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);  // 调用父类构造函数,必须在子类构造函数中首先调用
    this.breed = breed;
  }
  speak() {
    console.log(`${this.name} 汪汪!`);  // 重写父类方法
  }
}

let d = new Dog("旺财", "柴犬");
d.speak();           // 输出 "旺财 汪汪!"
console.log(d instanceof Dog);    // true
console.log(d instanceof Animal); // true (继承链上属于Animal)

在这个例子中,Dog 类继承 Animal 类,因此 Dog.prototype 的原型指向 Animal.prototype,从而继承了 Animal 的方法。super 关键字用于调用父类的构造函数或方法。

如果在子类中未定义构造器,则会默认生成一个构造函数,它只调用 super(...args) 将参数传给父类构造器。

对于构造函数方式,可以通过让构造函数的 prototype 属性指向一个父类实例来实现继承,或者使用 Object.create 来设置原型,但这些手工方式较繁琐,有了 class extends 之后很少直接使用。

对象方法简写与 this

在对象字面量或类中定义方法时,可以使用简写形式,如:

let car = {
  brand: "Ford",
  start() {
    console.log(this.brand + " 启动");
  }
};
car.start();  // 输出 "Ford 启动"

这里 start() 是对象的方法,调用时 this 指向调用它的对象 car。要理解 this,需要知道函数的调用方式。在上例,通过 car.start() 调用,所以 thiscar。如果把方法提取出来独立调用,例如:

let startFn = car.start;
startFn();  // this 是 undefined (严格模式) 或 window (非严格模式)

这样调用时没有对象 context,this 就不是原来的对象了。这正是箭头函数没有自身 this 能帮我们避免的问题之一(箭头函数在定义时绑定外部 this,而不是调用时)。

内置对象与原型

JavaScript 中许多内置对象(如 Array, Date, Math, Function, Object 等)都可以看作是内建的类或构造函数。例如:

  • Array 是内置构造函数,所有数组如 [] 创建的实例,其原型都指向 Array.prototype。我们可以为 Array.prototype 增加方法(不建议修改内置原型,除非 polyfill)。
  • Date 是构造函数,用法 new Date() 创建日期对象。
  • Math 则是普通对象,里面封装了数学常数和函数(因为数学函数无需实例,所以 Math 没有构造函数)。
  • Object 是所有对象的基类,其原型 Object.prototype 上定义了一些通用方法,如 toString()hasOwnProperty() 等。

可以通过原型链来理解 instanceof 的结果,如 d instanceof Animal 为真,因为 Dog 子类继承了 Animal,所以 d.__proto__.__proto__ === Animal.prototype

总结:JavaScript 的对象系统灵活且强大。从简单地使用对象字面量存储数据,到利用构造函数/类和原型实现自定义类型和继承,都属于 JS 面向对象编程的范畴。对于初学者,先掌握对象的基本用法,然后逐步理解构造函数和类的概念。当你需要创建多个结构类似的对象实例(如用户、商品等),可以考虑使用类来定义属性和方法模板,方便批量创建对象并维护代码。

练习:定义一个构造函数 Student(name, grade),为其添加一个方法用来介绍自己(如打印"我是___, 年级___")。使用该构造函数创建多个学生对象。然后,用 ES6 class 重写上述功能。再尝试创建一个子类 PresidentialStudent 继承 Student,添加一个额外属性比如 duty 表示职务,并覆写介绍方法包含职务信息。创建实例测试继承行为。尝试使用 instanceof 检查这些对象与类的关系。

10. DOM 操作(获取元素、修改内容、样式、结构)

**DOM(文档对象模型)**是浏览器中表示网页结构的编程接口。浏览器将加载的 HTML 转换为 DOM 树,JavaScript 可以通过 DOM API 来访问和操作页面的内容、结构和样式,使网页动起来。简单来说,DOM 将网页中的元素(节点)表示为 JavaScript 对象,我们可以增删修改这些对象来更新页面。

获取 DOM 元素

首先,需要获取要操作的元素节点引用。常用的 DOM 查询方法有:

  • document.getElementById(id):根据元素的 id 获取元素节点。如果找不到则返回 null
  • document.getElementsByClassName(className):获取具有指定类名的一组元素(HTMLCollection,实时集合)。可以像数组一样通过索引访问元素。
  • document.getElementsByTagName(tagName):按标签名获取元素集合,例如 "div""p"
  • document.querySelector(cssSelector)推荐,使用 CSS 选择器语法,返回匹配的第一个元素。
  • document.querySelectorAll(cssSelector):返回匹配的所有元素(NodeList 静态集合)。

例如,有如下 HTML:

<div id="content" class="article">
  <p class="text">Hello <span>World</span></p>
</div>

可以通过 JavaScript 获取其中的元素:

let contentDiv = document.getElementById("content");
let textParas = document.getElementsByClassName("text");   // HTMLCollection
let spanEl = document.querySelector("#content span");      // <span>World</span>
let allSpans = document.querySelectorAll("span");          // 所有<span>元素的NodeList

querySelector/querySelectorAll 功能更强大,因为可以使用任意 CSS 选择器(类,id,属性选择,后代选择等)来定位元素。querySelectorAll 返回的是静态 NodeList,不会随DOM实时更新,而 getElementsByClassName/TagName 返回的 HTMLCollection 是实时的,会随 DOM 改变而改变。

修改元素内容

获取元素节点后,可以通过操作其属性或方法修改页面内容:

  • 文本内容element.textContent 属性表示元素内的纯文本内容(不含HTML标签) (JavaScript 语言概览 - JavaScript | MDN)。设置该属性会替换掉元素内部所有子节点为给定文本。类似的 element.innerText 在某些情况下会忽略隐藏元素的文本,通常建议使用 textContent
  • HTML内容element.innerHTML 属性可以获取或设置元素内部的 HTML 字符串。设置 innerHTML 会重新解析给定的 HTML 字符串并替换原内容。要小心避免直接插入不可信内容以防范 XSS 攻击。
  • 属性:通过 element.attrNameelement.getAttribute()/setAttribute() 操作元素属性。例如:
    let link = document.querySelector("a");
    console.log(link.href);            // 获取href属性值
    link.href = "https://example.com"; // 修改属性
    link.setAttribute("target", "_blank"); // 新增 target 属性
    
    注意,对于自定义的非标准属性,使用 setAttribute 添加,element.customProp 可能无法访问这类属性。
  • CSS 样式:可以通过 element.style 属性修改内联样式。style 是一个对象,对应 CSS 属性,用驼峰命名访问:
    contentDiv.style.backgroundColor = "yellow";
    contentDiv.style.fontSize = "18px";
    
    这样会直接在元素添加行内样式。如果要批量应用或移除样式,通常更好的是通过修改 class
    • element.className:获取或设置元素的类名字符串。
    • element.classList:提供了操作类名的便捷方法,例如 add, remove, toggle, contains
      contentDiv.classList.add("highlight");
      if (contentDiv.classList.contains("article")) {
        contentDiv.classList.remove("article");
      }
      contentDiv.classList.toggle("active"); // 有类则移除,无则添加
      

    使用 class 来控制样式通常比直接用 style 更好,因为样式定义集中在 CSS 文件,可维护性高。

修改页面结构(创建、插入、删除元素)

DOM 提供了创建和插入节点的方法,使我们可以动态添加或删除页面内容:

  • 创建新节点

    • document.createElement(tagName):创建指定标签的新元素节点。
    • document.createTextNode(text):创建包含文本的文本节点(不过通常直接设置 textContent 更方便)。
    • 例如:
      let newPara = document.createElement("p");
      newPara.textContent = "这是新段落";
      
  • 插入节点

    • parent.appendChild(newNode):将新节点加入作为父元素的最后一个子元素 (JavaScript 语言概览 - JavaScript | MDN)。
    • parent.insertBefore(newNode, referenceNode):将新节点插入到 referenceNode 之前。如果 referenceNode 为 null,效果等同 appendChild(插入末尾)。
    • ES6 新增了 parent.append(...nodes_or_strings)parent.prepend(...nodes_or_strings) 可以插入多个节点或文本,分别在子元素末尾或开头插入。
    • 还有 element.before(...nodes)element.after(...nodes) 可以在元素自身外部前后插入兄弟节点;element.replaceWith(newNode) 用新节点替换自身。
    • 例:
      let parent = document.getElementById("content");
      let p = document.createElement("p");
      p.textContent = "追加的一段文字";
      parent.appendChild(p);  // 在content容器末尾添加<p>
      
  • 删除节点

    • parent.removeChild(child):从父节点中删除一个子节点 (JavaScript 语言概览 - JavaScript | MDN)。调用后该子节点仍然存在于内存中,可存入变量稍后使用,只是从 DOM 树分离了。
    • 新的 element.remove() 方法可以直接删除自身节点,而不需要获取父元素。
    • 例:
      let firstItem = list.firstElementChild;
      list.removeChild(firstItem);  // 移除列表的第一个子元素
      // 等价于 firstItem.remove();
      
  • 替换节点

    • parent.replaceChild(newNode, oldNode):将父元素的某个子节点替换为新节点。

通过这些方法,我们可以动态地构建 DOM 树。例如创建一个新列表,或将已有元素移动到别的容器中等。

示例:动态更新 DOM

假设 HTML 中有一个 <ul id="taskList"></ul> 列表和一个输入框 <input id="taskInput"> 以及一个按钮 <button id="addBtn">添加</button>。我们可以通过 JS 实现点击按钮将输入内容添加为列表项:

const list = document.getElementById("taskList");
const input = document.getElementById("taskInput");
const btn = document.getElementById("addBtn");

btn.addEventListener("click", function() {
  let text = input.value.trim();
  if (text) {
    let li = document.createElement("li");
    li.textContent = text;
    list.appendChild(li);
    input.value = "";  // 清空输入框
  }
});

这里我们:

  • 获取列表、输入框、按钮元素。
  • 给按钮绑定一个点击事件监听(addEventListener 用于事件绑定,下一节详述)。
  • 在点击时,读取输入框文本,如果非空,创建一个新的 <li> 元素,设置其文本,然后附加到任务列表中,最后清空输入框。

这个例子演示了获取元素、读取/设置内容、创建元素、插入 DOM 等操作。

DOM 树遍历和节点关系

每个 DOM 元素节点都有一些属性来访问其关系节点:

  • element.parentElement:父元素节点。
  • element.children:子元素节点集合(HTMLCollection)。还有 element.childNodes 包含所有子节点(包括文本节点、注释)。
  • element.firstElementChildlastElementChild:第一个和最后一个子元素。
  • element.previousElementSiblingnextElementSibling:前一个和后一个兄弟元素。
  • 这些属性允许在 DOM 树中向上、向下、横向遍历。例如,可以用循环遍历某元素的所有子元素等。

DOM 属性 vs HTML属性

需要区分DOM属性HTML属性的区别:

  • DOM属性是JavaScript对象的属性,HTML属性是标签在页面源代码中写的属性。大多数情况下它们一一对应,例如 HTML <input type="text" value="hi">,对应 DOM 对象有 input.valueinput.type 属性。但有些差异:
    • 有些属性在 DOM 中的名称不同,如 class 属性对应 element.className 属性,因为 class 是保留字。
    • 修改 DOM属性通常会反映在页面元素上(例如 img.src),但不一定会改变 HTML源代码(除非重新序列化DOM)。
    • innerHTML/textContent 并不是HTML标签属性,而是 DOM提供的内容属性。

一般来说,通过 DOM属性(如 node.href, node.id, node.value 等)来读写比较方便,但 getAttribute/setAttribute 可以获取一些反映在HTML上但没有对应DOM属性的值(如 data- 属性、自定义属性)。

练习:尝试通过 JavaScript 更改网页的内容。例如,在页面上新建一个按钮,点击按钮时通过 DOM 操作改变某段文字的颜色或隐藏/显示一张图片。创建一个列表及一个文本框和按钮,实现点击按钮从文本框获取输入并添加为列表项。然后增加一个“删除”按钮在每个列表项,实现点击删除按钮能移除对应项(提示:可在添加项时同时创建删除按钮并设置事件)。

11. 事件绑定与事件委托

事件是在浏览器中进行交互的核心机制。当用户在页面上进行诸如点击、输入、提交、滚动等操作时,会触发相应的事件。我们可以为DOM元素绑定事件监听器,当事件发生时执行指定的代码,从而对用户操作做出响应。

事件基础

浏览器中的事件类型很多,常见的有:

  • 鼠标事件:click(单击)、dblclick(双击)、mousedown(按下)、mouseup(松开)、mousemovemouseentermouseleave 等。
  • 键盘事件:keydownkeyupkeypress 等。
  • 表单事件:submit(提交表单)、change(输入框等值改变)、input(用户输入时持续触发)、focusblur 等。
  • 其他:load(页面或资源加载完)、scroll(滚动)、resize(窗口大小改变)等等。

事件监听与绑定

有多种方法可以绑定事件处理函数(事件监听器):

  1. HTML 属性绑定:在HTML标签中直接使用事件属性,例如 <button onclick="doSomething()">点击</button>。这会在点击按钮时执行 doSomething() 函数。不推荐在现代开发中使用,因为将行为与结构混杂在HTML中,不利于维护。

  2. DOM 属性绑定:通过元素的事件属性设置处理函数:

    const btn = document.getElementById("myBtn");
    btn.onclick = function() {
      console.log("按钮被点击");
    };
    

    这种方式每种事件类型只能绑定一个处理器(后设置的会覆盖先设置的)。

  3. addEventListener:这是推荐的方式。使用 element.addEventListener(eventType, handler, [options]) 来为元素添加事件监听 (JavaScript Introduction)。它不会覆盖已有监听器,而且可以添加多个。示例:

    btn.addEventListener("click", () => {
      console.log("点击事件1");
    });
    btn.addEventListener("click", () => {
      console.log("点击事件2");
    });
    

    点击按钮会依次触发两个监听器。可以通过 removeEventListener 移除监听(前提是使用具名函数或保存了引用的函数)。

事件对象

当事件发生并执行处理函数时,浏览器会传入一个事件对象(通常命名为 evente),包含了与事件相关的信息。例如:

btn.addEventListener("click", function(event) {
  console.log("鼠标点击坐标:", event.clientX, event.clientY);
});

对于鼠标事件,event 对象包含鼠标坐标(相对窗口或文档)、点击的按钮、是否按下修饰键等信息;对于键盘事件,有按键码、键值等。所有事件对象都有一些共有属性:

  • event.type:事件类型,例如 "click"
  • event.target:事件触发的元素,即用户实际操作的那个元素。
  • event.currentTarget:事件当前所在的元素(当事件在冒泡或捕获阶段,这可能与 target 不同,见下文事件委托)。
  • event.preventDefault():阻止事件的默认动作。例如阻止表单提交、超链接跳转。
  • event.stopPropagation():停止事件向祖先元素的传播(冒泡)。
  • event.stopImmediatePropagation():停止事件传播,并阻止当前元素上其他相同事件类型监听器的执行。

事件传播:冒泡与捕获

DOM 中的事件传播分为三个阶段:

  1. 捕获阶段:事件从顶层(window)向下经过祖先节点捕获,直到目标元素。
  2. 目标阶段:事件在目标元素上触发监听器。
  3. 冒泡阶段:事件从目标元素向上冒泡,逐级向上传递给祖先节点。

默认情况下,大部分事件会冒泡,这意味着如果没有调用 event.stopPropagation(),一个元素上的点击会冒泡到其父元素、祖父元素,触发它们的点击监听(如果有)。也可以在捕获阶段监听事件,方法是将 addEventListener 的第三个参数设为 {capture: true}true。不过应用较少,一般处理冒泡阶段即可。

事件委托(事件代理)

事件委托是一种高效的事件处理模式。其思想是:如果有许多子元素需要处理类似的事件,不必给每个子元素都绑定监听器,而是在它们的共同父元素上绑定一次监听,通过事件冒泡来捕获子元素的事件,并统一处理 (【前端】详解JavaScript事件代理(事件委托) - 腾讯云)。

优点:

  • 减少内存占用(大量子元素共用一个监听器)。
  • 动态子元素无需单独绑定,因为新添加的子元素冒泡事件也能被父元素捕获。

示例:有一个列表,每个列表项都有“删除”按钮。可以不给每个按钮绑定点击事件,而是在列表容器上监听:

HTML:

<ul id="itemList">
  <li>Item1 <button class="del-btn">删除</button></li>
  <li>Item2 <button class="del-btn">删除</button></li>
  <!-- ... -->
</ul>

JS:

const list = document.getElementById("itemList");
list.addEventListener("click", function(e) {
  if (e.target.classList.contains("del-btn")) {
    // 点击的是删除按钮
    const li = e.target.closest("li");  // 找到按钮所在的<li>元素
    li.remove();  // 删除该项
  }
});

在这个例子中,我们把对删除按钮的点击处理委托给了<ul>父元素。点击事件在按钮触发后会冒泡到 <ul>,我们检查事件的 e.target 是否具有 del-btn 类,以判断是不是点击了删除按钮 (JS 事件委托原创 - CSDN博客)。如果是,则找到其父 <li> 删除。这样,不管列表有多少项、是否后来动态加入新项,都无需为每个按钮绑定事件,只此一个监听即可处理所有删除动作。

注意:使用事件委托,需要在处理函数中正确区分 event.target,以确认实际触发事件的子元素类型,避免对非目标子元素进行处理。

另一个常见的事件委托场景是菜单、表格、列表等容器,通过检查 event.target 来知道用户具体点了哪个子元素,从而执行相应动作。

清除事件监听

如果不再需要某事件监听,可以使用 removeEventListener 移除。用法与 addEventListener 类似,参数需要传入同一函数引用。匿名函数无法移除,因为没有引用。示例:

function onClick(e) {
  console.log("Clicked");
}
btn.addEventListener("click", onClick);
// ...稍后
btn.removeEventListener("click", onClick);

或者使用 element.onclick = null 也可以移除通过 DOM 属性绑定的事件。

事件默认行为

一些元素有默认行为,例如:

  • 点击链接 <a> 会导航或跳转。
  • 点击提交按钮会提交表单并刷新页面。
  • 双击文本会选中它。
  • 鼠标右键会弹出上下文菜单。 等等。

可以通过调用 event.preventDefault() 来阻止默认行为。例如在表单提交事件中 e.preventDefault() 可阻止表单提交,从而留在当前页面执行Ajax提交或验证逻辑。 (JavaScript 语言概览 - JavaScript | MDN)

常见错误

  • 绑错元素:确保 addEventListener 绑定在期望的元素上,比如想监听点击 <ul> 却写成监听 <li>,导致达不到效果。
  • 函数引用:addEventListener("click", myFunction()) 这样是错误的,应该传函数而不是调用结果,应写 addEventListener("click", myFunction) 或使用箭头函数定义内联处理逻辑。
  • 事件冒泡干扰:内层元素和外层元素都有监听器,可能需要根据需求决定是否用 stopPropagation 或注意处理逻辑避免重复执行。

练习:创建一个简单的计数器按钮,点击按钮计数加一并显示在按钮上。使用事件监听实现并尝试移除监听。创建一个列表,在父元素上实现事件委托:点击每一项会alert该项内容。然后在列表末尾动态添加一项,验证新的项点击也能正常响应(证明事件委托生效)。实践 preventDefault:制作一个链接,点击时不跳转而是使用 JS 进行提示。

12. 异步编程(回调函数、Promise、async/await)

JavaScript 在浏览器中的执行模型是单线程、非阻塞的,依靠**事件循环(Event Loop)**机制来实现异步操作 (JavaScript 语言概览 - JavaScript | MDN) (JavaScript 语言概览 - JavaScript | MDN)。简单来说,JS 引擎一次只能执行一个任务,但通过异步机制,耗时的操作(如网络请求、定时器、文件读取等)不会阻塞主线程,而是由浏览器或Node运行时在后台处理,待完成后再通知JS引擎执行指定的回调。这使得JavaScript可以同时处理多个任务(并发),即使本身没有多线程。

回调函数

**回调函数(Callback)**是最基本的异步处理方式。一个函数接收另一个函数作为参数,在某个时机调用它。常见如:

  • 定时器:setTimeout(callback, delay) 会在指定毫秒后执行回调函数。
  • DOM事件处理:element.addEventListener(event, callback) 在事件触发时调用回调。
  • AJAX 请求(旧式):通过提供回调在请求完成时拿到结果。

例如:

console.log("开始");
setTimeout(() => {
  console.log("1秒后输出");
}, 1000);
console.log("结束");

输出顺序将是:

开始
结束
1秒后输出

可见,setTimeout 的回调不会阻塞后续代码执行,而是在1秒延迟后异步执行。

回调地狱:当需要按顺序执行多个异步操作,并且后一个依赖前一个的结果时,就会出现嵌套的回调。例如:

doStep1(function(result1) {
  doStep2(result1, function(result2) {
    doStep3(result2, function(result3) {
      // ...继续嵌套
    });
  });
});

这种代码缩进层级不断增加,被戏称为“回调地狱”(callback hell),可读性和可维护性都变差。

Promise

为了解决回调地狱的问题,ES6 引入了 Promise 对象。Promise 提供了一种更优雅的异步结果处理方式,可以避免深层嵌套。

Promise 是什么:可以把 Promise 理解为一个“承诺”,它代表一个未来才会完成的异步操作结果。Promise 有三种状态:

  • pending(进行中,未完成)
  • fulfilled(已成功完成)
  • rejected(已失败拒绝)

一旦 Promise 从 pending 变为 fulfilled 或 rejected,就会有一个结果值或原因,并且状态不可再改变。

创建 Promise:

let promise = new Promise((resolve, reject) => {
  // 异步操作
  if (成功) {
    resolve(value);   // 完成,并传递结果值
  } else {
    reject(error);    // 失败,并传递错误原因
  }
});

通常我们不会手动创建 Promise,而是使用已有API返回的 Promise,例如 fetchaxios 请求库、fs.promises 等都返回 Promise。但是理解其原理有助于使用。

使用 Promise:Promise 实例有 .then.catch 方法用于接收结果:

  • promise.then(onFulfilled, onRejected):当 promise 成功时调用 onFulfilled(value),失败时可调用 onRejected(error)(或者也可用 .catch 方法指定失败回调)。
  • .then 会返回一个新的 Promise,因此可以链式调用多个 .then,形成Promise 链 (JavaScript 语言概览 - JavaScript | MDN)。

例如,将前面的嵌套回调改为 Promise:

doStep1()
  .then(result1 => {
    return doStep2(result1);
  })
  .then(result2 => {
    return doStep3(result2);
  })
  .then(result3 => {
    console.log("所有步骤完成", result3);
  })
  .catch(error => {
    console.error("过程出错", error);
  });

假设 doStepX 都返回 Promise,这样就可以平铺地写出串行的异步步骤,比起回调嵌套清晰许多。每个 .then 回调中返回的值会被包装为 Promise(如果不是Promise的话),并传给下一个 .then。如果在过程中发生错误(reject),会跳到后面的 .catch 执行。

Promise.all 和 Promise.race:Promise 提供一些辅助方法:

  • Promise.all([p1, p2, ...]):接受一组 Promise,返回一个新的 Promise。只有当所有 Promise 都 fulfilled 时成功(并给出所有结果数组),只要有一个 rejected 就立即 rejected。 (JavaScript 语言概览 - JavaScript | MDN)
  • Promise.race([p1, p2, ...]):返回第一个完成(无论成功或失败)的 Promise 的结果。

async/await

ES2017 引入了更高级的语法 async/await,它是对 Promise 的语法糖,使异步代码看起来像同步代码,更直观。

async 函数:在函数定义前加上 async 关键字,表示其内部可以使用 await。async 函数会隐式返回一个 Promise,函数的返回值将被自动包装为 Promise 的 resolve 值。如果函数中抛出异常,则返回的 Promise 被 reject。

await:只能在 async 函数中使用,用于等待一个 Promise 完成,并返回其结果。写法:let result = await somePromise;。这行代码会暂停 async 函数的执行,直到 somePromise fulfilled,然后将其结果赋给 result,再继续后面的代码。若 Promise 被 reject,则会抛出异常,需要用 try/catch 捕获。

用 async/await 重写上面的 Promise 链:

async function runSteps() {
  try {
    const result1 = await doStep1();
    const result2 = await doStep2(result1);
    const result3 = await doStep3(result2);
    console.log("所有步骤完成", result3);
  } catch (error) {
    console.error("过程出错", error);
  }
}
runSteps();

是不是看起来很像同步代码?逻辑从上到下,很自然地写出了按顺序执行的异步操作,await 让我们不用写 .then 回调就拿到了 Promise 结果。如果发生异常,可以像同步代码一样用 try...catch 捕获。

async/await 极大地简化了异步代码的编写和阅读,因此在现代 JavaScript 中被广泛采用。

典型异步场景

  • 定时任务:使用 setTimeoutsetInterval(周期定时,每隔一段时间执行)。注意 setInterval 如果间隔过短或任务耗时长,可能累积多次触发尚未执行完,使用不当会导致性能问题,可以考虑递归 setTimeout 代替。
  • 网络请求:浏览器可用 fetch API(返回 Promise),旧的 XMLHttpRequest 则用回调。Node.js 用 http模块或库(如 axios,返回 Promise)。
  • 文件I/O(Node.js):Node 提供异步文件读写函数(通过回调或返回 Promise 的 fs.promises)。
  • 数据库查询事件订阅 等许多操作都有异步接口。

Event Loop 简述

JavaScript 运行时(浏览器或 Node)维护了一个任务队列。同步代码在主线程按顺序执行,遇到异步任务时,像定时器到时、IO完成、事件触发等,这些事件的回调会被加入任务队列。事件循环不断检查主线程是否空闲,如空闲则取出队列中的任务执行。这样就实现了在单线程上交替处理多个任务。 (JavaScript 语言概览 - JavaScript | MDN) (JavaScript 语言概览 - JavaScript | MDN)

需要注意微任务(microtask)和宏任务(macrotask)的概念。Promise 的 .then 回调属于微任务,会在当前宏任务(比如当前脚本或事件回调)结束后、下一个宏任务开始前执行,比定时器等宏任务更早。通常无需深入这个机制,但遇到Promise回调顺序和setTimeout顺序的问题时,这个是原因所在。

练习:使用 setTimeout 打印一句话,两秒后再打印另一句话。用 Promise 模拟一个简单异步任务,例如创建一个函数返回一个 Promise,该 Promise 在1秒后 resolve 一个随机数。调用它并用 .then 输出获得的随机数。然后将其改造成 async 函数,通过 await 获得随机数并输出。试着用 Promise.all 并行执行多个这样的异步任务,测量总耗时与单独await几个的区别。阅读并了解浏览器提供的 fetch API,用 async/await 调用一个公共的REST API(比如 https://api.github.com/users/your_username)获取数据,处理返回的 JSON。

13. ES6+ 新特性(解构赋值、模板字符串、模块化等)

自 2015 年(ES6,也称 ES2015)以来,JavaScript 规范每年都会推出新特性。ES6 是一次巨大更新,引入了很多改变游戏规则的特性,如 let/const、箭头函数、Promise、Class 等,我们已经在前文逐一接触过。这里我们系统性总结一些重要且常用的 ES6+ 新语法和功能,使编码更便捷高效。

块级作用域(let 和 const)

letconst 用于声明变量和常量,具备块级作用域和更合理的作用域规则,已经在第2节详细介绍 (JavaScript 语言概览 - JavaScript | MDN)。相比之下,var 已经被现代实践淘汰,除非为了兼容旧环境,否则应优先使用 let/const

模板字符串(Template Strings)

模板字符串使用反引号 ` 包裹,可以在其中嵌入变量或表达式,且能够跨多行书写 (JavaScript 语言概览 - JavaScript | MDN)。嵌入变量的语法是 ${...}。例如:

let user = "Alice";
let msg = `您好,${user}! 今天是${new Date().toLocaleDateString()}.`;
console.log(msg);
// 输出: 您好,Alice! 今天是2025/4/25.

相较于普通字符串通过字符串连接拼接变量,模板字符串更简洁且可读性好 (JavaScript 语言概览 - JavaScript | MDN)。同时,模板字符串保留换行和缩进格式,可以方便生成多行文本。

还有标签模板高级用法,此处不展开(标签函数可以对模板字面量和插值进行处理,常用于实现多语言或安全转义等)。

解构赋值(Destructuring)

解构赋值允许从数组或对象中提取值,直接赋给变量,语法直观且简洁。

  • 数组解构

    let arr = [1, 2, 3];
    let [a, b, c] = arr;
    console.log(a, b, c); // 1 2 3
    

    也可以忽略某些元素或使用剩余元素

    let [first, , third] = [10, 20, 30];
    console.log(first, third); // 10 30
    
    let [head, ...rest] = [1, 2, 3, 4];
    console.log(head); // 1
    console.log(rest); // [2, 3, 4]  (剩余元素数组)
    

    剩余模式 ...rest 必须是最后一个,提取剩余未解构的元素为一个数组。

  • 对象解构

    let person = { name: "Bob", age: 30 };
    let { name: theName, age } = person;
    console.log(theName, age); // Bob 30
    

    对象解构通过属性名匹配。上例将 person.name 赋给变量 theName,将 person.age 赋给同名变量 age。如果想用同名变量,可以简写:

    let { name, age } = person;
    // 等价于 let name = person.name; let age = person.age;
    

    对象解构也支持嵌套和剩余:

    let student = { id: 1001, scores: { math: 90, eng: 80 }, hobby: "music" };
    let {
      id,
      scores: { math, eng },
      ...others
    } = student;
    console.log(id, math, eng); // 1001 90 80
    console.log(others); // { hobby: "music" }
    

    属性 scores 被解构赋值后,如果不需要,可不提取,否则也可以通过 scores: scoresObj 赋给一个变量。

  • 默认值:解构时可以为变量指定默认值,当对应值为 undefined 时使用默认:

    let [x=0, y=0] = [5];
    console.log(x, y); // 5 0 (y 未提供值,默认为0)
    
    let { title="未命名" } = { };
    console.log(title); // "未命名"
    

解构赋值在提取数据时非常方便,例如从函数返回的数组/对象中直接取得所需字段赋值给变量,避免繁琐的中间变量过程。

剩余参数与扩展运算符(...)

  • 剩余参数(Rest):函数参数的语法,在第7节函数部分已介绍 (JavaScript 语言概览 - JavaScript | MDN)。例如 function fn(...args) {},可接收不定数量参数作为数组。
  • 扩展(展开)运算符... 也可以用在函数调用或数组/对象字面量中,起到“展开”元素的作用。
    • 函数调用:fn(...iterable) 将可迭代对象(如数组)展开为多个独立的参数传入函数。例如:
      let arr = [3, 4];
      Math.max(1, 2, ...arr, 5);  // 等效于 Math.max(1, 2, 3, 4, 5)
      
      没有扩展运算符之前,需要用 Math.max.apply(null, arr) 或循环才能达成这种效果,有了 ... 非常直观。
    • 数组构造:可以合并数组或插入其它数组元素:
      let arr1 = [1,2], arr2=[3,4];
      let combined = [0, ...arr1, ...arr2, 5];
      console.log(combined); // [0,1,2,3,4,5]
      
    • 对象构造(ES2018):可以将对象属性展开到另一个对象中:
      let obj1 = { a:1, b:2 }, obj2 = { b:3, c:4 };
      let merged = { ...obj1, ...obj2 };
      console.log(merged); // { a:1, b:3, c:4 }
      
      如果有同名属性,后面的会覆盖前面的(上例 b:3 覆盖 b:2)。展开对象也常用于浅拷贝对象:let copy = { ...originalObj };

箭头函数

箭头函数是 ES6 提供的更短的函数语法,并且没有自己的 thisarguments,使得回调函数使用更方便。箭头函数在第7节函数部分已详细讲解,包括语法和 this 绑定差异。

快速回顾:(x, y) => x + y 是一个箭头函数,等价于 function(x, y){ return x+y; }

Promise

Promise 也是 ES6 的重要新特性,在上一节已讨论。它极大改善了异步编程的模式,并为后续的 async/await 奠定了基础。

Class 类

Class 类语法也是 ES6 引入的,便于实现对象的构造和继承。关于类的用法在第9节对象与OOP部分已有说明,包括构造函数、继承、静态属性、私有属性等内容 (JavaScript 语言概览 - JavaScript | MDN) (JavaScript 语言概览 - JavaScript | MDN)。

模块化(Modules)

在 ES6 之前,JavaScript 没有官方的模块系统,Web开发通常通过 <script> 标签引入多个文件,全局作用域共享变量,容易冲突。ES6 提供了内置的模块化语法:importexport (JavaScript 语言概览 - JavaScript | MDN)。

  • 导出(export):一个模块就是一个独立的文件。可以在文件内用 export 导出希望公开的变量、函数或类。

    // math.js
    export const PI = 3.14;
    export function add(x, y) { return x + y; }
    export default function subtract(x, y) { return x - y; }
    

    上例中,导出了一个命名常量 PI、一个命名函数 add,以及一个默认导出函数 subtract。一个模块只能有一个默认导出(default)。

  • 导入(import):在另一个文件中使用 import 引入:

    // main.js
    import { PI, add } from './math.js';
    import sub from './math.js';  // 导入默认导出,可以取任意名字,这里取名 sub
    console.log(add(5,5), PI);
    console.log(sub(10, 3));
    

    import { PI, add } 会引入 math.js 模块里对应导出的内容,名称需匹配导出名称。import sub from './math.js' 则引入默认导出 subtract,并重命名为 sub 使用。 (JavaScript 语言概览 - JavaScript | MDN)

    也可以整体导入:

    import * as math from './math.js';
    console.log(math.PI);
    console.log(math.add(2,3));
    

    这会创建一个命名空间对象 math,包含模块导出的所有属性和方法。

注意:在浏览器使用 ES6 模块,需要在 HTML 中用 <script type="module" src="main.js"></script> 来加载起始模块 (JavaScript 语言概览 - JavaScript | MDN)。模块文件默认是严格模式,并且各模块有自己的作用域,不会污染全局。模块也支持 import.meta 获取元信息,import() 动态导入(返回 Promise)等高级用法。

其他ES6+新特性速览

  • Symbol (ES6):独一无二的值,用于对象属性键,可避免属性名冲突。
  • 迭代器/生成器 (ES6):可以自定义遍历行为。function* 定义生成器函数,使用 yield 产生序列值。
  • Map/Set (ES6):新的集合类型。Map是键值对字典,支持非字符串键;Set是值的集合且值唯一。
  • TypedArray (ES6):类型化数组,用于处理二进制数据(ArrayBuffer, Uint8Array等)。
  • Intl (ES6):国际化API,用于本地化字符串、数字、日期格式。
  • Proxy (ES6):可拦截和定制对象的操作,如读写属性等,实现观察、虚拟属性等高级功能。
  • String.includes, Array.includes (ES2016):更方便地判断包含子串或元素。
  • 指数运算符 ** (ES2016):已在运算符部分提及。
  • async/await (ES2017):已在异步编程节介绍,大幅简化异步代码。
  • Object.values, Object.entries (ES2017):类似 Object.keys,获取对象的值数组、键值对数组。
  • 字符串填充 padStart/padEnd (ES2017):将字符串补全到指定长度。
  • Object Rest/Spread (ES2018):对象解构剩余和扩展,与数组的 ... 类似,已在解构部分提及。
  • Promise.finally (ES2018):在 Promise 结束后执行清理逻辑,无论成功或失败。
  • BigInt (ES2020):大整数类型,前述数据类型部分有提及,可表示任意长度整数,用 n 作为后缀字面量。
  • 可选链 ?. (ES2020):安全地访问深层属性,避免 undefined 错误。例如 obj.user?.address?.street,如果中间任一为空就短路返回 undefined。
  • 空值合并 ?? (ES2020):提供一个默认值,仅在左侧值为 nullundefined 时返回默认值。比如 let name = inputName ?? "匿名";
  • Promise.allSettled (ES2020):类似 Promise.all,但不会在有reject时短路,返回每个Promise的结果状态。
  • String.replaceAll (ES2021):替换字符串中所有匹配子串。
  • 逻辑赋值运算 (ES2021):&&=, ||=, ??= 分别当左侧为真/假/null或undefined时赋值。
  • 顶级 await (ES2022):在模块的顶级作用域可以直接使用 await,而无需包裹在 async 函数中。

以上并非完整列表,但涵盖了ES6及后续版本一些常用且有用的特性。现代前端项目一般都会使用构建工具编译,因此可以放心使用这些新语法,编译工具会转译为兼容旧环境的代码(如果需要)。

练习:使用解构赋值交换两个变量的值,不用临时变量。比如 let x=1, y=2; [x, y] = [y, x];。尝试用模板字符串生成一段多行HTML文本,插入网页中。用剩余参数编写一个函数,计算不定数量数字的平均值。定义一个对象,包含嵌套对象和数组,用解构赋值提取其中的几个值和元素。写两个模块文件,互相导出和导入一些函数/变量,在浏览器环境下尝试运行(需要使用 <script type="module">)。尝试使用可选链操作符简化深层属性判断,例如有嵌套对象 a = { b: { c: 5 } },比较 a && a.b && a.b.ca?.b?.c 的结果。

14. 常用内置对象(Math、Date、JSON、String、Array 等)

JavaScript 提供了一系列内置对象,提供常用功能和工具。这些对象在全局作用域下可直接使用,无需导入。我们之前已经使用过一些(如 ArrayString 等),这里按类别做一个归纳介绍。

Math 对象

Math 是一个内置对象,包含数学常数和函数 (JavaScript 语言概览 - JavaScript | MDN)。它不是构造函数(不能 new Math()),其属性和方法都是静态的,直接用 Math. 调用。

常用属性:

  • Math.PI 圆周率 π(3.1415926...) (JavaScript Introduction)。
  • Math.E 自然常数 e。
  • Math.LN2, Math.LN10, Math.SQRT2 等,数学常数值。

常用方法:

  • 四舍五入相关:Math.round(x) 四舍五入,Math.floor(x) 向下取整,Math.ceil(x) 向上取整,Math.trunc(x) 去掉小数部分(ES2015,引擎不支持时可用 x|0 做整数截断但仅对32位整数有效)。
    Math.round(4.7); // 5
    Math.floor(4.7); // 4
    Math.ceil(4.2);  // 5
    Math.trunc(-4.9);// -4
    
  • Math.max(a, b, ...), Math.min(a, b, ...):返回参数中的最大/最小值。可以配合扩展运算符传数组:Math.max(...arr)
  • Math.random():返回 [0, 1) 区间内一个伪随机数(不包含1)。常用于生成随机数。比如生成1到10的随机整数:Math.floor(Math.random()*10) + 1
  • 指数和对数:Math.pow(x, y) 计算 x 的 y 次幂(ES7 后可用 x ** y 更简洁)。Math.sqrt(x) 平方根,Math.cbrt(x) 立方根。Math.log(x) 自然对数,Math.log10(x)Math.log2(x) 十/二进制对数。
  • 三角函数:Math.sin, Math.cos, Math.tan 等(参数为弧度)。Math.asin, Math.acos, Math.atan, Math.atan2
  • 其他:Math.abs(x) 绝对值,Math.sign(x) 返回符号(正数1,负数-1,零0),Math.hypot(x,y,...) 计算平方和的平方根(欧几里得范数)。

Date 对象

Date 用于表示日期和时间。使用 new Date() 构造:

  • 不传参数:得到当前日期时间。
  • 传入毫秒时间戳(从1970-1-1开始的毫秒数,正数为UTC之后):new Date(1672531200000)
  • 传入日期时间字符串:new Date("2025-04-25T15:23:06")(各浏览器支持略不同,最好用标准格式)。
  • 传入年,月,日,...:new Date(year, monthIndex, day, hours, minutes, seconds, ms),注意月的索引0表示1月,11表示12月 (JavaScript 语言概览 - JavaScript | MDN)。

Date 实例方法分为两类:本地时间和UTC时间。以 get 开头获取,set 开头设置。例如:

  • getFullYear(), getMonth(), getDate():年、月、日。getMonth() 返回 0-11 (0=1月) (JavaScript 语言概览 - JavaScript | MDN)。
  • getHours(), getMinutes(), getSeconds(), getMilliseconds():时分秒毫秒。
  • getDay():星期几 (0=Sunday, 6=Saturday)。
  • getTime():返回时间戳(1970以来毫秒数)。
  • UTC 版本如 getUTCFullYear() 等返回 UTC 时间。

设置对应属性如 setFullYear(year), setMonth(index), setDate(day), setHours(h) 等。

Date 其他重要方法:

  • Date.now():静态方法,返回当前时间戳(相当于 new Date().getTime())。

  • date.toISOString():将日期转换为 ISO 8601 字符串,如 "2025-04-25T06:23:06.000Z"(UTC时间)。

  • date.toLocaleDateString([locales, options])toLocaleTimeString()toLocaleString():按指定区域格式化日期、时间或完整日期时间。例如:

    let d = new Date();
    console.log(d.toLocaleDateString("zh-CN")); // "2025/4/25"
    console.log(d.toLocaleTimeString("zh-CN")); // "15:23:06"
    

    可通过 options 指定格式选项,如 { year: 'numeric', month: 'long', day: '2-digit' } 等。

  • Date.parse(str):解析日期字符串为时间戳(毫秒数)。只能解析标准格式或实现支持的格式,通常不如 new Date(str) 直观,用处有限。

由于 Date 基于系统时区,在进行时区转换、日期计算时要小心处理时区偏移。第三方库如 Moment.js、Luxon、date-fns 等可以简化复杂日期处理。但对于简单场景,内JavaScript 是网页的编程语言,可以动态更新 HTML 和 CSS 内容,并进行数据计算、校验等操作。本教程将循序渐进地介绍 JavaScript 基础知识,适合零编程经验的初学者学习。我们将从基本语法开始,逐步涵盖变量、数据类型、运算符、流程控制、函数、数组、对象、DOM 操作、事件处理、异步编程、ES6+新特性等主题。每部分配有通俗的讲解和示例代码,还有可供练习的小任务。

目录:

  1. JavaScript 简介与基本语法
  2. 变量与常量(var, let, const)
  3. 数据类型与类型转换
  4. 运算符
  5. 条件语句(if, switch)
  6. 循环语句(for, while, do...while)
  7. 函数(声明式、表达式、箭头函数)
  8. 数组与常用方法
  9. 对象与面向对象编程(构造函数、原型、类)
  10. DOM 操作(获取元素、修改内容、样式、结构)
  11. 事件绑定与事件委托
  12. 异步编程(回调、Promise、async/await)
  13. ES6+ 新特性(解构赋值、模板字符串、模块化等)
  14. 常用内置对象(Math、Date、JSON、String、Array等)
  15. 浏览器与调试基础
  16. 简单项目实战:待办事项清单

每章包含详尽的讲解示例代码,并穿插练习题,帮助读者加深理解。在动手实践中掌握JavaScript的基础,为进一步学习前端开发打下坚实基础。让我们开始 JavaScript 之旅吧!

1. JavaScript简介与基本语法

JavaScript 是一种跨平台、面向对象的脚本语言,最初用于网页交互,如今也被广泛应用于服务器(Node.js)、桌面和移动应用开发 (JavaScript 语言概览 - JavaScript | MDN)。对于初学者,可以简单理解为:JavaScript 可以让网页动起来

HTML 定义页面内容,CSS 控制页面样式,而 JavaScript 则负责实现交互和逻辑。浏览器内置 JS 引擎可以执行 JavaScript 代码,使其直接操作网页。

加载与执行 JavaScript

嵌入 HTML:通常通过 <script> 标签引入 JavaScript 脚本。可以内嵌代码,也可使用 src 属性加载外部 .js 文件。示例:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>JS 示例</title>
</head>
<body>
  <h1>欢迎来到我的网站</h1>
  <button id="myBtn">点我</button>

  <!-- 内联脚本 -->
  <script>
    document.getElementById("myBtn").onclick = function() {
      alert("按钮被点击了!");
    };
  </script>

  <!-- 外部脚本 -->
  <script src="main.js"></script>
</body>
</html>

浏览器会按文档顺序解析 HTML。当遇到 <script> 时,默认会暂停渲染去执行脚本,然后再继续解析后续 HTML。为避免阻塞渲染,通常将脚本放在 之前,或使用 defer/async 属性(高级用法,可暂不深究)。

注意:JavaScript 文件通常使用 .js 扩展名。在 <script> 标签中使用 type="module" 可以加载 ES6 模块化脚本(第13章详细介绍)。

书写基本语法

JavaScript 语法与大多数 C 风格语言相似。下面概述关键语法要点:

  • 区分大小写:例如变量 Ageage 是不同的。
  • 语句结束:每条语句建议以分号 ; 结尾。这不是强制的(JS 有自动插入分号规则),但为避免怪异错误,应始终加上分号。
  • 缩进与空白:空格、换行对程序运行无影响,仅为可读性服务。遵循统一的代码风格(如每层缩进2或4个空格)能提高代码可读性。
  • 注释:和 C/C++ 相同,用 // 开始单行注释,用 /* ... */ 包围多行注释。注释不会执行,可用于添加说明或调试暂时禁用代码:
    // 这是单行注释
    /* 这是
       多行注释 */
    

Hello, World!

在编程入门中,打印 “Hello, World!” 往往是第一个尝试。我们可以用 JavaScript 通过多种方式输出文本:

  • console.log():打印信息到浏览器控制台。
  • alert():弹出浏览器警告框(会阻塞进程,实际开发少用)。
  • document.write():将内容直接写入 HTML 文档(一般在简单测试时用)。
  • DOM 操作:通过修改 DOM 节点的文本来显示(稍后章节介绍)。

示例,在控制台和弹框中各显示 Hello:

console.log("Hello, World!");  // 控制台输出
alert("你好,世界!");           // 页面弹框

在浏览器调试控制台:按 F12 打开浏览器开发者工具,切换到 Console 面板,即可看到 console.log 输出的内容 (The Modern JavaScript Tutorial)。建议经常使用控制台来调试和查看变量。

在 HTML 中嵌入:可以将上述脚本写在 <script> 标签内,然后在浏览器打开 HTML,即可看到效果。alert 弹框会直接弹出,“Hello, World!” 则打印在控制台。

关键字与标识符

  • 关键字:JavaScript 有一些保留关键字,如 if, else, for, function, var, return, class 等。这些词有特殊含义,不能用作变量或函数名。
  • 标识符:即变量、函数名等自定义名字。规则:只能包含字母、数字、下划线 _、美元符号 $,且不能以数字开头 (Grammar and types - JavaScript | MDN)。例合法标识符:myVar, _count, $elem, total123。标识符最好能描述用途,不宜过长或过于模糊。

交互:prompt 和 confirm

JavaScript 提供 prompt()confirm() 函数用于简单交互:

  • prompt(message, default):弹出提示框,询问用户输入文本。返回用户输入的字符串。若用户取消,则返回 null
  • confirm(message):弹出确认框,显示 message 和“确定/取消”按钮。返回 true(点确定)或 false(点取消)。

例:询问名字并确认是否进入页面:

let name = prompt("请输入您的名字:", "匿名");
if (name !== null) {
  let isAdult = confirm("你已年满18周岁吗?");
  if (isAdult) {
    alert("欢迎," + name + "!");
  } else {
    alert("抱歉,未成年禁止访问。");
  }
}

以上脚本说明:

  • 使用 prompt 获取用户输入的名字。如果用户点取消,name 将是 null
  • 然后用 confirm 显示一个简单的是/否问题,根据返回的布尔值做不同处理。
  • 字符串可以用加号 + 拼接,所以上例中构造了欢迎或拒绝的信息。

提示promptconfirm 交互友好性较差,会打断用户流程。实际应用更常用自定义的输入框或模态框。本教程使用它们仅为演示 JS 基本交互。

练习:尝试编写一个脚本,用 prompt 让用户输入两个数字,再计算这两个数字的和,用 alert 弹出结果。这将运用基本的输入、输出和算术操作知识。

2. 变量与常量(var, let, const)

变量是用于保存数据的命名容器。可以把变量想象为一个存储箱,里面存放着某个值,我们可以随时读取或更改。JavaScript 在不同版本中提供了三种声明变量的关键字:var(旧)、let(ES6 新)和 const(ES6 新)。理解它们的区别对编写可靠代码很重要。

var:函数作用域变量

var 是 JavaScript 早期唯一的变量声明方式。特点:

  • var 声明的变量作用域为函数或全局,不受代码块 {} 限制。这意味着在任何函数之外用 var 声明的变量是全局变量,在函数内声明的变量对整个函数都可见(包括声明之前,详见提升)。
  • 变量提升var 声明会被提升到所属作用域顶部。在实际执行前,JS 引擎已把所有 var 声明处理完但未赋值。所以你能在声明之前访问变量,只不过值为 undefined。这虽然看似方便,但容易导致逻辑错误。
  • 可以重复声明同一变量而不会报错(后声明相当于覆盖)。
  • var 不具有块级作用域:例如,在 iffor 块内声明的变量,块外仍能访问:
    if (true) {
      var test = 5;
    }
    console.log(test); // 5,变量未被限制在 if 内
    

由于 var 存在这些不直观的特性,在 ES6 引入 let/const 之后,已经不推荐使用 var,除非为了兼容旧环境。

let:块级作用域变量

let 于 ES6 (2015) 推出,用于声明普通变量。相较 var

  • 块级作用域let 变量只在其所在的代码块 {} 内有效。离开块就无法访问。举例:
    if (true) {
      let local = "局部";
      console.log(local); // "局部"
    }
    console.log(typeof local); // "undefined",local在块外不可见
    
  • 无变量提升let 存在于暂时性死区(TDZ)中,只有执行到声明语句才可访问。在声明之前访问会抛 ReferenceError
  • 不允许重复声明:同一作用域内,用 let 不能声明两个同名变量,否则报错。这避免了无意的重复定义。
  • 其他:let 声明的变量如未赋值,其初始值也是 undefined。可随时通过赋值语句修改其值。

简言之,let 更符合许多语言对变量作用域的直觉,也杜绝了 var 带来的常见坑。因此,以后用变量,就用 let(除非明确不会变动的量可用 const,见下)。

const:常量

const 同样具有块级作用域、无提升、不可重复声明的特点,与 let 基本一致,不同在于:

  • 必须立即初始化const x;(没有赋值)是语法错误。声明时必须给定初始值。
  • 不可重新赋值const 代表常量,一旦赋值不能用 = 改变。尝试重新赋值会抛 TypeError
  • 但如果常量引用的是对象或数组,其内容是可变的。const 保证的是绑定关系不变,即变量标识符始终指向同一个内存地址。如果该地址存储的是对象,我们仍可修改对象内部的属性。
    const obj = {a:1};
    obj.a = 2;     // 合法,修改对象内部状态
    // obj = {b:3}; // 错误,不能把obj引用指向新对象
    

const 通常用来声明不会再被重新赋值的变量,比如配置值、函数表达式、不会改变的对象/数组等。许多编码规范建议默认使用 const,只有在需要更改值时才使用 let

作用域与生命周期

总结三者作用域区别:

  • var:函数作用域(或全局作用域)。不支持块级作用域,存在变量提升。
  • let:块级作用域。所在块(以及子块)内有效。无提升。
  • const:块级作用域。必须初始化,之后值不可变。无提升。

生命周期:变量在其作用域中存在,当执行流离开作用域后(如函数返回,块结束),变量销毁,内存释放(如果无其他引用)。

示例对比

function demoVar() {
  if (true) {
    var x = 1;
    let y = 2;
    const z = 3;
  }
  console.log(x); // 1 (var穿透块作用域)
  // console.log(y); // ReferenceError: y is not defined
  // console.log(z); // ReferenceError: z is not defined
}
demoVar();

如上:

  • 在 if 块内,用 var x 声明,函数作用域使得 xdemoVar 整个函数内都存在,所以块外 console.log(x) 打印 1。
  • yzlet/const 声明,仅在 if 块有效,出了块无法访问,因此访问会报错。
  • 另外,如果我们在 demoVar 内部的 console.log 调换顺序,放在声明之前,var x 会输出 undefined(提升但未赋值),而 y, z 输出则报错(在声明之前访问导致暂时性死区)。

为什么 var 不推荐?

  1. 全局污染:全局作用域的 var 变量变成 window 对象的属性,容易不小心覆盖已有全局变量。
  2. 无块作用域:for 循环用 var 时,循环变量在循环外仍然泄漏,使逻辑复杂。例如用 var 写计时循环,会遇到经典的闭包坑,需要用额外函数处理。而 let 则因块级作用域自然解决此问题。
  3. 变量提升var 可以在使用后再声明,代码可读性差且易出错。let/const 则强制先声明再使用,符合认知顺序。

最佳实践:在现代 JavaScript 开发中,避免使用 var。除非你需要兼容极老的环境(比如 IE9 之前)且不能通过编译转译。

命名约定

变量名通常采用小驼峰(camelCase),比如 userName, maxCount。常量在某些团队约定中会使用全大写加下划线(SNAKE_CASE),但在 JS 中不强制,使用 const 声明即可表示常量,不一定非要全大写。关键是见名知意,一个好的变量名应能让人一眼了解其含义。

示例:使用 let 和 const

let age = 18;
const BIRTH_YEAR = 2005;

age = 19;             // 合法,let 变量可重新赋值
// BIRTH_YEAR = 2000; // 非法,const 常量不可更改

if (age >= 18) {
  let adult = true;
  console.log("成年标识:", adult);
}
// console.log(adult); // ReferenceError,这里 adult 已销毁

// var 的旧例子:
for (var i = 0; i < 3; i++) {
  // do something
}
console.log(i); // 3,i 泄漏到循环外

使用 let 的循环不会有上述泄漏问题:

for (let j = 0; j < 3; j++) { /*...*/ }
// console.log(j); // ReferenceError: j is not defined

练习:声明三个变量,分别存储您的姓名、年龄和是否已成年(布尔值)。使用 const 声明姓名(假设不会变化)、使用 let 声明年龄和成年标识。尝试打印这些变量,然后修改年龄变量的值并再次打印。体验在相同作用域用 let 重复声明变量是否会报错。最后,在浏览器控制台尝试声明一个 var 全局变量,看它是否成为 window 对象的属性(在控制台输入 window.yourVarName 查看)。

3. 数据类型与类型转换

JavaScript 是动态类型语言,每个变量可以随时赋予任何类型的值。但每个值本身有确定的类型。先了解JS有哪些数据类型:

原始类型(Primitive Types)

JavaScript 有 7 种原始类型(Primitive),即不可再分的数据值类型:

  1. Number(数字):JS 只有一种数字类型,包含整数和浮点数。采用64位双精度格式(IEEE 754)。最大安全整数约 ±9e15,超过这个范围整数计算可能失准。数字特殊值包括:

    • NaN(Not-a-Number):表示数学运算结果非法。例如 0/0parseInt("abc") 会得到 NaN。NaN 与任何值都不相等,包括自身(用 isNaN() 判断是否为 NaN)。
    • Infinity-Infinity:超出可表示范围的结果。例如 1/0 得 Infinity。可以用 isFinite(x) 检测是否是有限数。
    • 注意:浮点计算可能有精度误差。经典例子:0.1 + 0.2 !== 0.3,因内部二进制表示导致 0.1+0.2 得到 0.30000000000000004
    • 大整数:ES2020 引入了 BigInt 类型表示任意精度整数,用独立的原始类型BigInt,详见后文。
  2. String(字符串):表示文本数据。用单引号 '...'、双引号 "..." 或反引号 `...`(模板字符串)括起来都可以创建字符串。JS 字符串是不可变的,一旦创建其内容不可更改(修改会新建字符串)。可以使用 + 拼接字符串。详细操作方法在后续String对象部分介绍。

  3. Boolean(布尔):只有两个值:true(真)和 false(假)。常用于条件判断。

  4. Undefined(未定义):表示“未被赋值”的状态。任何变量在未赋值时默认值为 undefined (Grammar and types - JavaScript | MDN)。还可以用 undefined 来清除变量值(尽管习惯上更倾向赋 null)。

  5. Null(空值):表示“空”的对象引用。通常用 null 来初始化一个本应是对象的变量,当暂时没有对象可引用时。注意 typeof null 返回 "object",这是 JS 早期的一个bug,但已经写死在规范里。

  6. Symbol(符号):ES6 新增,表示独一无二的值。典型用法是作为对象的属性键(不会与其它键冲突)。每个 Symbol 值通过 Symbol() 函数创建,值独一无二。Symbols 超出本教程基础要求,初学可暂略。

  7. BigInt:ES2020 新增,为了解决 Number 无法精确表示大整数的问题。BigInt 可表示任意长度整数。字面量以 n 结尾表示 BigInt,如 123n。BigInt 和 Number 不能直接混用运算。本教程后面不会深入,但了解其存在即可。

对象类型(Object Type)

除上述原始类型外,其他都是对象。对象是属性的集合,可以看作键值对字典。数组、函数、日期、正则等都属于对象类型,拥有各自特殊行为。对象是引用类型,赋值对象变量其实是复制引用地址。

函数也属于对象(可调用的对象),因此 JS 把函数当做“一等公民”,这使函数可以作为值传递、赋值给变量等(后续详谈)。

初学阶段,先简单理解对象就是包含许多属性和方法的复杂数据,比如:

let person = { name: "Alice", age: 25 };

person 是对象,有属性 name 和 age。对象可以嵌套或组合形成更复杂的数据结构。

检测类型:typeof

JavaScript 提供 typeof 运算符返回一个值的类型字符串:

console.log(typeof 123);       // "number"
console.log(typeof "abc");     // "string"
console.log(typeof true);      // "boolean"
console.log(typeof undefined); // "undefined"
console.log(typeof null);      // **"object"**(历史原因)
console.log(typeof {a:1});     // "object"
console.log(typeof Symbol());  // "symbol"
console.log(typeof 123n);      // "bigint"
console.log(typeof function(){}); // "function"

特别提醒:

  • typeof null 返回 "object"。可以认为这是 JavaScript 的一个设计缺陷,但已无法更改。因此判断一个变量是否为 null 最好直接用 === null,不用 typeof。
  • typeof function 返回 "function",虽然函数也是对象,但 typeof 将其单独区分开来了(函数是对象的子类别)。
  • 注意:typeof 对未声明的变量也可用,不会报错,会返回 "undefined"。但对块级作用域中let/const 在 TDZ 阶段使用 typeof 仍会报错。

类型转换

JavaScript 常常在不同类型间自动或显式地转换。分为:

  • 显式转换:通过调用函数或运算符主动将值转换为指定类型。
  • 隐式转换:JavaScript 在运算或比较时自动进行的类型转换。

显式转换

  1. 转换为 字符串:调用全局函数 String(value) 或使用值的 .toString() 方法(多数类型都有)。加号连接字符串也会转换。如 123 + "" 结果 "123"。模板字符串更方便:`${value}` 也会把 value 转为字符串。
  2. 转换为 数字:使用 Number(value) 函数。如果转换失败则得到 NaN。特殊规则:
    • 字符串:若仅包含数字字符则转换相应数字,空字符串转 0,。含非数字内容则 NaN(可以用 parseInt/parseFloat 更宽松地提取数字)。
    • 布尔:true 转 1,false 转 0。
    • undefined 转 NaN,null 转 0。
    • 对象:先调用对象的 valueOftoString 转为原始值,再转换数字。 示例:
    Number("10");      // 10
    Number("10.5");    // 10.5
    Number("10abc");   // NaN
    Number(true);      // 1
    Number(null);      // 0
    Number(undefined); // NaN
    
  3. 转换为 布尔:使用 Boolean(value) 函数。以下值被视为 false,其余为 true(详细见下一节真值假值):

隐式转换

字符串连接+ 运算符用于数字加法,但如果任一操作数是字符串,则会把另一个也转为字符串再拼接。例如:

"5" + 2    // "52"  ("5" + "2")
5 + "2"    // "52"
"Hello" + true  // "Hellotrue"

其它算术运算(- * / %)对字符串操作数会尝试转为数字:

"5" - 2   // 3   ("5" 转为 5,再减2)
"5a" - 2  // NaN ("5a" 转为 NaN)

布尔参与运算

  • 布尔和数字运算时,true 视作 1,false 视作 0。
  • 与字符串拼接时,布尔转字符串 "true""false"

比较运算

  • < > <= >= 在比较不同类型时,会将它们转为数字再比较(字符串会被转数字,除了字符串间比较会逐字符按字典序)。
  • ==(宽松相等)也会进行复杂的类型转换,然后比较 (JavaScript 语言概览 - JavaScript | MDN)。例如:
    5 == "5"      // true (字符串 "5" 转为数字 5)
    0 == false    // true (0 == 0)
    "" == false   // true (空字符串转为0,再比较0 == 0)
    null == undefined // true (特殊规则,这两者宽松相等于彼此,但不等于任何其他值)
    
    宽松相等的规则很不直观,容易出bug,所以建议永远使用严格相等 ===
  • ===(严格相等)不会进行类型转换,类型不同直接返回 false (JavaScript 语言概览 - JavaScript | MDN)。例如 5 === "5" 为 false。

逻辑运算

  • !:先将值转为布尔取反。双重否定 !! 常用于将值转为真实的布尔类型。
  • && ||:它们有短路机制a && b 如果 a 转布尔为 false,就直接返回 a,否则返回 ba || ba 转布尔为 true,就返回 a,否则返回 b。重要的是,这两个运算返回的是原值而非布尔值。
    console.log( "" || "default");   // "default" ("" 为假,返回右侧)
    console.log( "Hello" || "default"); // "Hello" ("Hello" 为真,返回自身)
    console.log( 0 && "Yes");       // 0 (0 为假,&& 返回左侧)
    console.log( 5 && "Yes");       // "Yes" (5 为真,&& 返回右侧)
    
    这种特性常用于给变量设置默认值,如 let name = inputName || "匿名";,当 inputName 为 falsy(假值)时用默认字符串。

真值 (Truthiness) 和 假值 (Falsiness)

正如上面逻辑运算短路中涉及到的,JS 中有概念真值假值。一个值在需要布尔的地方(如 if 条件)会被强制转换为布尔。假值列表已经列举:false, 0, -0, "", null, undefined, NaN 都被视为 false (JavaScript 语言概览 - JavaScript | MDN)。其它都当作 true,包括 "0", "false"(非空字符串即使内容像 false也是真的),[](空数组真),{}(空对象真)等。

示例:

if ("hello") console.log("这会执行,因为 'hello' 是真值");
if (0) console.log("不会执行,因为 0 是假值");

理解真值/假值有助于你简化条件判断和避免陷阱:

  • 检查对象是否存在可直接 if (obj) { ... },当 obj 为 null/undefined 时条件为 false。
  • 但是要小心 0 和 "" 会被当作 false,有时需要明确区分。例如用户输入0应该视为有效输入,而 if(!input) 会把0误判为空,需要用更严谨的条件判断,如 input === null || input === undefined || input === "" 之类。

练习:尝试将不同类型转换为其他类型:用 String() 将数字、布尔转为字符串;用 Number() 将字符串(包括纯数字、带字母的、空字符串)转为数字,观察结果是否是 NaN 或具体数值;用 Boolean() 测试各种值(如 0, "0", {}, [], null, "false" 等)得到 true 还是 false,总结哪些是假值。然后写几个 == 比较(如 [] == 0, null == undefined, "5" == 5, "0" == false),再写对应的严格比较,看结果区别,体会为什么应使用 ===

4. 运算符

运算符用于对值进行操作。JavaScript 拥有算术、赋值、比较、逻辑、位运算符以及特殊的三元运算符等。本节主要介绍常用的算术、比较、赋值和逻辑运算符。

算术运算符

  • 加法 +:数值相加;有字符串操作数则执行拼接。
  • 减法 -:数值相减(字符串操作数会转数字)。
  • 乘法 *:数值相乘。
  • 除法 /:数值相除。
  • 取余 %:求余数(模运算)。例如 7 % 31
  • 指数 **:ES7(2016) 新增,求幂次方。如 2 ** 3 是 8,相当于 Math.pow(2, 3)

这些运算的结果:

console.log(5 + 2);    // 7
console.log(5 - 2);    // 3
console.log(5 * 2);    // 10
console.log(5 / 2);    // 2.5
console.log(5 % 2);    // 1
console.log(2 ** 3);   // 8

自增/自减++-- 为一元运算符,给变量加1或减1。用法有前置后置两种:

let a = 3;
console.log(a++); // 3 (先返回值再自增,a变4)
console.log(a);   // 4
console.log(++a); // 5 (先自增再返回值)
console.log(a);   // 5

前置 ++a 返回新值,后置 a++ 返回旧值。自增/减只能对变量用,不能对常量或表达式直接用(如 5++非法)。

一元加/减+- 也可以作为一元运算符:

  • +x 试将 x 转为数字(相当于 Number(x))。
  • -x 将 x 转为数字后再取相反数。

如:+"123" 得 123,+"123abc" 得 NaN;如果 x 本来就是数字,+x 无变化,-x 就是负数。

赋值运算符

  • 基础赋值:=,将右侧表达式结果赋给左侧变量。
  • 复合赋值:将算术与赋值结合,如:
    • x += y 相当于 x = x + y
    • x -= y 相当于 x = x - y
    • x *= y, x /= y, x %= y, x **= y 类似。
    • 还有位运算的复合如 x &= y 等,这里不展开。
  • 赋值运算符返回赋值后的值(但一般我们不利用它返回值,而是当语句用)。

例:

let x = 10;
x += 5;  // x = 15
x *= 2;  // x = 30

比较运算符

用于比较两个值,结果为布尔型:

  • 相等 ==不相等 !=:在比较前会进行类型转换 (JavaScript 语言概览 - JavaScript | MDN)。不推荐使用(容易出错),了解即可。
  • 全等 ===不全等 !==:严格比较,类型不同则不等 (JavaScript 语言概览 - JavaScript | MDN)。值类型相同时,再比较值。推荐使用这两个来判断相等。
    5 == "5";   // true  (字符串转换成数字5再比较)
    5 === "5";  // false (类型不同)
    null == undefined; // true (二者宽松相等的特殊情况)
    null === undefined; // false
    
  • 大于 >小于 <大于等于 >=小于等于 <=
    • 对数字按大小比较。
    • 对字符串按字典(Unicode)顺序比较:
      "apple" < "banana"; // true,因为 "a" < "b"
      "2" > "15";         // true,因为按字符比较 "2" > "1"
      
      注意,字符串的比较结果不一定符合数字大小直觉("2" > "15"),如要比较数字,应先转为 Number。
    • 不同类型会先转为数字再比较(字符串按数字处理,不能转则NaN,比较结果为 false)。
  • 三元/条件运算符 ? ::唯一一个三目运算符。有三个操作数,语法:条件 ? 表达式1 : 表达式2。当条件为真时整个表达式结果为表达式1的值,否则为表达式2。
    let access = (age >= 18) ? "允许" : "禁止";
    
    上例根据 age 赋值 access 字符串,相当于 if/else 的简写。建议在简单情况下使用,复杂嵌套的三元运算会降低可读性。

逻辑运算符

逻辑运算符作用于布尔值,并返回布尔或原始值:

  • &&:只有两个操作数都真,结果才为真。否则返回假值。
  • ||:只要任一为真,结果为真。否则返回假值。
  • !:一元运算符,将真变假,假变真。即 !true -> false, !0 -> true。双重否定 !!value 返回其真伪值的布尔形式。

JS 的 &&|| 与其他语言不同的是,它们返回原始操作数而不仅仅是 true/false:

  • a && b:如果 a 是假值,返回 a,否则返回 b。
  • a || b:如果 a 是真值,返回 a,否则返回 b。

举例:

console.log( true && 5 );   // 5  (true是真,返回第二个操作数)
console.log( false && 5 );  // false (第一个为假,直接返回它)
console.log( "Hello" || 0 );// "Hello" ("Hello"为真,返回自身)
console.log( "" || "默认" ); // "默认" (空串是假,返回右侧)

这种行为对处理默认值很有用,如 let result = input || "默认值";,当 input 为空字符串、0、null等假值时,result 会取 "默认值"。

短路&& 碰到第一个假值就不再计算后面的表达式,|| 遇到第一个真值就不再继续。因此可把 && 后面的表达式理解为“只有在前面为真时才执行”,把 || 后面的表达式理解为“只有在前面为假时才执行”。但要注意这种用法会返回具体的值而非布尔。

运算符优先级与结合性

在一个复杂表达式中,运算符的优先级决定了计算顺序。JS 运算符优先级大致如下(高到低):

  1. 括号 (...)
  2. 成员访问 obj.prop, 函数调用 func(), new(带参数)
  3. 一元运算(!, +, - 单目, ++, --, typeof, void, delete 等)
  4. 算术乘除 % * /
  5. 算术加减 + -
  6. 比较 < > <= >=
  7. 相等 == != === !==
  8. 按位与/异或/或(较少用)
  9. 逻辑与 &&
  10. 逻辑或 ||
  11. 条件 ? :
  12. 赋值 = += -= ...
  13. 逗号 ,

结合性指同级运算符的结合方向。如 = 是右结合(a = b = 5 会先计算 b=5 再赋给 a),算术、比较多数是左结合(从左到右计算)。

通常建议加上括号来明确顺序,尤其在不确定优先级时。例如 score >= 60 ? "合格" : "不合格" 就比省略括号写在更复杂表达式里可靠。即使符合默认规则,适当的括号也能提高可读性。

例子与练习

示例:计算矩形面积并判断大小:

let width = 5, height = 3;
let area = width * height;
console.log("面积是", area);
if (area >= 50) {
  console.log("面积很大");
} else if (area >= 20) {
  console.log("面积中等");
} else {
  console.log("面积较小");
}

练习

  1. 定义变量保存华氏温度值,计算对应摄氏温度,结果存入另一变量并输出(转换公式:C = (F - 32) * 5/9)。
  2. 编写代码比较两个变量 ab 大小,输出较大的一个。尝试用 if 和三元运算符分别实现。
  3. 给出三个数,使用逻辑运算符找出它们是否都在 0-100 范围内。如果用 && 实现。
  4. 阅读以下表达式结果并解释原因:
    • "5" + 3 * 2
    • true + true
    • 10 > "2"
    • null == 0null < 1
    • undefined == nullundefined === null

5. 条件语句(if, switch)

程序通常需要根据不同条件执行不同代码。条件语句允许我们构建这种分支逻辑。JavaScript 主要有 if...elseswitch 两种结构。

if...else 条件判断

最常用的条件语句。根据表达式的真假选择执行路径:

if (条件表达式) {
  // 条件为真时执行的代码块
} else if (另一条件) {
  // 前面的条件不满足且此条件为真时执行
} else {
  // 上述条件全不满足时执行
}

执行流程

  • 计算 if 括号中的条件表达式,JS 会将其转换为布尔值(真值/假值)。
  • true 则执行第一个 {} 内代码,之后整个 if...else 结束。
  • 如果为 false,跳过第一个块,若有 else if,则计算它的条件:
    • else if 条件为真,执行其块并结束。
    • 否则继续检查下一个 else if(可以有多个 else if)。
  • 如果所有条件都为假,且有最终的 else,则执行 else 块内容。

let score = 75;
if (score >= 90) {
  console.log("优秀");
} else if (score >= 60) {
  console.log("及格");
} else {
  console.log("不及格");
}

score 为 75,第一个条件 (>=90) 假,第二个条件 (>=60) 真,所以输出 "及格",后续不再执行。

提示

  • else if 并非新关键字,只是 else { if ... } 的简便写法。可根据需要有多个 else if 分支。
  • 大括号 {} 建议始终写,即使代码块只有一行。这避免一些语法陷阱(如 else 对齐问题),也提升可读性。
  • 条件表达式可以是任何值,JS 会将其隐式转为布尔。比如 if (name) 相当于判断 name 是否非空字符串;if (x) 检查 x 是否真值。

嵌套条件:可以在 if 内再放 if。不过嵌套层次不宜太深,否则代码结构复杂难读。可以通过逻辑运算简化一些嵌套。

三元运算符:如前所述,可作为简单条件的表达式版本。例如:

let type = (age < 12) ? "儿童" : (age < 18) ? "青少年" : "成年人";

这行代码实现了类似多分支的逻辑,但不建议嵌套太多 ? :,读起来费劲。仅限简单赋值场景使用,复杂逻辑用 if...else 清晰明了。

switch 多路选择

当有一个统一的判断变量需要跟多个值匹配时,switch 更直观。结构:

switch (表达式) {
  case 值1:
    // 当 表达式 === 值1 时执行这里
    [break;]
  case 值2:
    // 当 表达式 === 值2 时执行这里
    [break;]
  ...
  default:
    // 以上都不匹配时执行这里
}

说明

  • switch 会计算表达式的值,然后严格比较(===)各个 case 的值。匹配到对应 case 后,从那里开始执行。
  • break 用于跳出 switch。如果漏写 break,程序会继续往下执行下一个 case 的代码(称为“贯穿”或 fall-through)。
  • default 分支相当于 else,在没有匹配时执行。默认可写在最后(或中间任意处)。如果 default 不在最后且你希望之后不继续执行,也需要在 default 结尾写 break

示例:根据等级首字母输出完整等级名:

let grade = 'B';
switch (grade) {
  case 'A':
    console.log("优秀");
    break;
  case 'B':
    console.log("良好");
    break;
  case 'C':
    console.log("中等");
    break;
  case 'D':
    console.log("及格");
    break;
  case 'F':
    console.log("不及格");
    break;
  default:
    console.log("无效的等级");
}

grade 为 'B' 时,输出 "良好"。若没有 break,程序会继续执行后续 case 的内容,可能连续输出多个结果,这是多数情况下不想要的。因此不要忘记在每个 case 块结束处加 break,除非你刻意要贯穿(例如多个 case 执行同样代码)。

多个 case 合并:有时不同值执行相同代码,可以这样:

let color = "red";
switch (color) {
  case "red":
  case "pink":
    console.log("暖色调");
    break;
  case "blue":
  case "green":
    console.log("冷色调");
    break;
}

这里 "red" 和 "pink" 共用同一个代码块,因为 pink case 没有 break,执行完它紧接着进入下一个 case 输出“暖色调”。

何时用 switch:当判断逻辑涉及对单一变量或表达式与多个值比较时,switch 结构更清晰。若判断条件各不相干或是复杂条件,则用 if/else 更合适。

真值在条件中的应用

由于条件会将值转换为布尔,我们可以利用真值/假值特性简写一些判断:

let name = prompt("输入名字") || "匿名";

这里用 || 在 prompt 返回 falsy(用户取消或输入空串)时提供默认值"匿名",然后赋给 name。这比写if判断要简洁。再如:

if (user) {
  // 当 user 非 null/undefined/空串等假值时执行
}

但要避免滥用,否则可能意外把0、false这种有效值当作假处理了。因此在做数字或布尔判断时最好显式比较,而不是利用真值性质。

示例与练习

示例:简单登录验证:

let username = prompt("用户名:");
let password = prompt("密码:");
if (!username || !password) {
  alert("用户名或密码不能为空");
} else if (username === "admin" && password === "123456") {
  alert("登录成功");
} else {
  alert("登录失败:用户名或密码错误");
}

解释:

  • !username 检查用户名是否为空字符串或 null。
  • username === "admin" && password === "123456" 同时满足才算正确。
  • else 捕获其他情况(即有输入但不正确)。

练习

  1. 根据用户输入的年份,判断是否闰年。闰年条件:年份能被4整除但不能被100整除,或者能被400整除。用 if 实现,提示输入年份,用 alert 输出“是闰年”或“不是闰年”。
  2. 实现一个简单的计算器程序:提示用户输入两个数字和一个运算符(+ - * /),然后用 switch 对运算符进行不同 case,计算并输出结果。如果输入了无效的运算符,输出错误提示。
  3. 扩展上题,用 if 实现同样功能,并比较 switch 和 if 版本在代码清晰度上的区别。
  4. 写一个程序,根据季节名(春、夏、秋、冬)输出对应的月份范围。用 switch 实现,季节用英文 ("spring", "summer" 等) 来匹配 case,输出 "3-5月", "6-8月" 等。考虑 default 情况。
  5. 思考:if 可以比较任何条件甚至范围,为何 switch 的 case 不能写范围或条件表达式?(提示:switch 是通过 === 匹配固定值来工作)。

6. 循环语句(for, while, do...while)

循环语句可以让我们多次执行某段代码,适用于重复性任务或对数据集合的遍历。JavaScript 提供 while, do...while, for 以及 ES6 新增的 for...of, 早期还有 for...in 等。这里介绍最常用的三种基本循环结构。

while 循环

语法

while (条件表达式) {
  // 条件为真时重复执行的代码块
}

执行过程:每次循环开始前计算条件表达式,若结果为真(truthy),则执行循环体,然后回到顶部再次检查条件。如此反复,直到条件为假(falsy)时跳出循环。若一开始条件就是假,则循环体一次也不执行。

示例:计算 1 加到 5 的和:

let i = 1;
let sum = 0;
while (i <= 5) {
  sum += i;
  i++;
}
console.log("Sum = " + sum); // 输出 "Sum = 15"

说明:i 从1开始,每次循环累加到 sum,并将 i 自增,直到 i <= 5 不再成立(i 变为6)时结束循环。

警惕死循环:务必确保循环条件在循环内会改变,从而有机会变为 false,否则会陷入无限循环。例如:

// 不要运行这段,死循环!
let x = 1;
while (x > 0) {
  console.log(x);
  // x未改变,条件永远为 true
}

实际编码中如不小心构造了死循环,浏览器可能卡死,需要强制停止脚本。请小心设计循环条件和循环体逻辑。

do...while 循环

语法

do {
  // 先执行一次
} while (条件表达式);

区别在于先执行一次循环体,然后再检查条件。因此无论条件是否为真,循环体至少执行一次。常见场景:需要先做一次操作,然后基于结果决定是否继续,如反复 prompt 用户直到得到有效输入。

示例:要求用户输入一个非空字符串:

let input;
do {
  input = prompt("请输入非空字符串:");
} while (!input);
alert("你的输入是:" + input);

逻辑:先提示输入一次,然后 if 输入为空字符串或取消,则 !input 为 true,进入循环再次提示。直到用户输入内容(input 为 truthy)才跳出循环。因为使用 do...while,就算用户一开始就输满意,也能至少执行一次输出而不会漏掉提示。

for 循环

语法

for (初始化; 条件; 每次循环末尾执行的表达式) {
  // 循环体
}

for 循环将循环需求的3个要素(初始值、继续条件、每轮结束后的更新)集中写在一行,使得结构紧凑清晰。

执行过程:

  1. 执行初始化部分(一般用于定义循环计数变量)。
  2. 检查条件表达式,若为假则跳出循环;为真则执行循环体。
  3. 执行“末尾表达式”(通常是自增/自减计数),然后回到第2步再次判断条件,如此往复。

示例:用 for 循环计算 1 加到 N:

let N = 5;
let sum = 0;
for (let j = 1; j <= N; j++) {
  sum += j;
}
console.log(sum);

这里:

  • 初始化 let j = 1,循环计数从1开始。
  • 条件 j <= N 控制当 j 超过 N 时停止。
  • 末尾表达式 j++ 每轮让 j 自增1。
  • 循环体将 j 加到 sum。 最终 sum 得到 15(1+2+3+4+5)。

细节

  • 初始化部分可以声明新变量,也可以使用函数外定义的变量,或者甚至留空(如 for(; condition; step))。
  • 条件部分省略则被视为恒真,容易造成死循环,应慎用。
  • 末尾表达式也可以是复合操作或多条表达式(用逗号隔开),但多数情况下一个自增/自减就够。

for vs while:任何 for 循环都可改写成 while。例如上述 for 等价于:

let j = 1;
while (j <= N) {
  sum += j;
  j++;
}

两者并无性能差异,选择哪个主要看哪种写法更符合场景。for 更适合已知循环次数基于计数器的循环,而 while 则更灵活,比如等待某条件出现为止(循环次数未知)。

其他循环方式

for...of (ES6):用于遍历可迭代对象(数组、字符串等),在第8章数组中介绍。

for...in:用于遍历对象的可枚举属性,在第9章对象中介绍。不过 for...in 也可用来遍历数组索引,但不建议,因为它会遍历所有可枚举属性(包括数组自定义属性)且顺序可能不是数值顺序。

循环控制语句:break 和 continue

  • break:立即跳出整个循环。通常用于提前结束循环,例如找到目标就不必继续,或出现错误需要终止等。
  • continue:跳过本次循环后续代码,直接开始下一次迭代。常用于在某些情况下跳过不处理。

例:

for (let n = 1; n <= 10; n++) {
  if (n % 2 === 0) continue; // 跳过偶数
  if (n > 7) break;         // 超过7就退出循环
  console.log(n);
}
// 输出:1, 3, 5, 7

解释:循环1到10,遇到偶数直接 continue,不执行 console.log,所以只打印奇数。到 n=9 时,if(n>7) 条件满足,执行 break 跳出循环,所以 9 和 10 都没打印。

多层循环跳出:普通 break 只能跳出所在那一层循环。如果有嵌套循环并想直接跳出外层,可使用标签语句配合 break,但不常用。通常可以通过函数返回或调整逻辑避免深嵌套或跨级 break。

无限循环的应用

虽然死循环通常要避免,但在某些情况下,我们确实需要“无限循环”配合 break 来结束。例如服务器持续监听请求或游戏主循环。浏览器环境下无限循环会挂死页面,所以仅在需要时用在比如 Node.js 后端,或使用 setInterval 等非阻塞方式。

示例与练习

示例:找出一个数组中的最大值(使用循环,先不使用内置方法):

let arr = [17, 3, 9, 20, 14];
let max = arr[0];
for (let idx = 1; idx < arr.length; idx++) {
  if (arr[idx] > max) {
    max = arr[idx];
  }
}
console.log("最大值是", max);

这个例子遍历数组元素,不断更新 max。这里用 arr.length 来获取数组长度,for 非常适合数组遍历。

练习

  1. 使用 while 计算阶乘(n! = 12...*n),比如5的阶乘=120。
  2. 编写程序,使用 do...while 不断 prompt 用户输入数字,累加这些数字,直到用户输入的值非数字(比如按了取消或输入空字符),最后alert累计的总和。提示:使用 isNaN() 检查输入是否数字,prompt 返回字符串,需要用 Number() 转成数值。把判断写在 do...while 条件里。
  3. 输出九九乘法表,格式:
    1x1=1  
    1x2=2  2x2=4  
    1x3=3  2x3=6  3x3=9  
    ...   (每一行n,从1到9)
    
    尝试用两个嵌套 for 循环实现:外层循环控制行数 i=1..9,内层循环 j=1..i,每次打印 j x i = (j*i)。使用 document.write 或 console.log 输出结果。
  4. 思考:for 循环能否省略三个部分中的某些?如如何写一个无限循环的 for?(答案:for(;;) { ... } 是死循环,需要内部用 break 退出)
  5. 用 for...of(提示见第8章)遍历一个字符串,统计其中元音字母(aeiou)出现的次数。

7. 函数(声明式、表达式、箭头函数)

函数是在需要时可以被调用执行的一组语句封装。使用函数可以封装重复的逻辑、组织代码结构、复用代码片段。JavaScript 函数也是一种值,可以赋值给变量或作为参数传递。

定义函数的方式

  1. 函数声明(Function Declaration):以关键字 function 开头,后跟函数名和参数列表,然后是函数体:

    function 函数名(参数1, 参数2, ...) {
      // 函数体:实现特定任务的代码
      return 返回值;
    }
    

    例如:

    function greet(name) {
      return "你好," + name + "!";
    }
    

    声明函数不会执行它,只是定义。函数名遵循标识符规则,同一个作用域不能重复声明同名函数。

  2. 函数表达式(Function Expression):将一个函数定义(可以没有名字)赋值给变量。这种函数可以是匿名函数具名函数表达式

    const greet = function(name) {
      return "你好," + name;
    };
    

    这里 greet 变量被赋值为一个匿名函数。调用时仍然用 greet("张三")。函数表达式常用于作为参数传递或立即调用,也可以在模块模式下作为私有函数。具名函数表达式在实际开发中较少用,大多匿名即可。

  3. 箭头函数(Arrow Function):ES6 新语法,更简洁。用 => 定义函数。形式:

    const 函数名 = (参数1, 参数2, ...) => {
      // 函数体
      return 返回值;
    };
    

    若函数体只有单一表达式,可省略 {}return,结果就是该表达式的值:

    const add = (x, y) => x + y;
    

    没有参数时要写空括号 (), 一个参数可省略括号写成 param => ...。例如:

    const hello = () => console.log("Hello!");
    const square = n => n * n;
    

    箭头函数与普通函数最大区别在于**this**的行为不同(稍后讲)。

调用函数

定义了函数后,通过函数名和括号来调用执行。括号里传入参数值(实参)。返回值通过 return 语句确定。

let message = greet("小明");
console.log(message); // 输出 "你好,小明!"

参数

  • 在定义时列出的参数叫形参(placeholder),调用时传入的叫实参
  • JS 不会报错实参和形参数量不匹配的情况。未提供的参数默认为 undefined (JavaScript 语言概览 - JavaScript | MDN);多提供的参数将被忽略 (JavaScript 语言概览 - JavaScript | MDN)。
  • 可以给参数设置默认值(ES6):如 function greet(name = "游客") { ... },当调用时未传 name 或传 undefined 时,name 将取默认值。
  • ES6 提供剩余参数 ... 来获取多余参数为数组(详见后文解构章节或)。

返回值

  • return 将一个值返回。函数调用表达式将得到该返回值。
  • 若函数没有 return,或执行到函数末尾没有遇到 return,则默认返回 undefined (JavaScript 语言概览 - JavaScript | MDN)。
  • return 语句还能用来提前退出函数,不一定非要返回东西。例如:
    function checkAge(age) {
      if (age < 0) {
        return;  // 年龄无效,直接返回undefined退出
      }
      // ... 其他逻辑
    }
    
  • 一旦执行了 return,函数会立即退出,后续语句不执行。

作用域:函数内部可以访问外部变量(闭包特性),但反之不行(除非通过 return 或全局变量)。函数内声明的变量是局部变量,外部无法访问。

函数表达式 vs 声明

两者主要差异:

  • 提升:函数声明也会被提升到作用域顶部(类似 var 变量提升,但函数会提升并初始化可用)。因此可以在函数声明前调用它(虽然不建议这样写,会降低可读性)。函数表达式则像一般变量,要在赋值后才能使用。
  • 名称:函数声明有名字,用于递归或调试。匿名函数表达式没有名字,但可以通过变量引用递归(不过也可自己具名表达式实现递归)。
  • 用法:函数声明更直观,定义即具名函数;函数表达式可以在需要动态创建函数的情形或需要将函数作为值传递时使用。二者可按需选用。

箭头函数详细

除了语法简洁,箭头函数行为与普通函数略不同:

  • 没有自己的 this:箭头函数内部的 this 值取决于它外层(定义时所在上下文)的 this。所以在处理回调尤其是对象方法中,可以避免因调用方式导致的 this 丢失问题。
  • 没有 arguments 对象:在箭头函数中访问 arguments 会指向外层函数的 arguments。若需要收集参数,用 rest 参数 ...args
  • 不能用作构造函数:箭头函数没有 [[Construct]],因此不能使用 new 调用。如果尝试 new ArrowFunc() 会报错。
  • 没有 supernew.target 绑定(更高级场景涉及类继承和反射,不展开)。

一般地,箭头函数适合简短的小功能,尤其是不需要自己的 this的场景,如数组的回调,事件处理有时也可用箭头函数简化。不适用于需要使用 thisarguments 的场合,也不能用它定义对象方法(因为需要 this 是对象实例)。

函数作为值

JavaScript 函数本质上是 Function 类型对象,因此可以:

  • 赋值给变量:如上函数表达式。
  • 作为参数传递:回调函数广泛使用(见第12章异步)。
  • 作为对象属性,即对象的方法。
  • 存储在数组、映射等数据结构中。
  • 从函数返回函数,实现“函数工厂”。

例如:

function createMultiplier(factor) {
  return function(num) {
    return num * factor;
  };
}
let doubler = createMultiplier(2);
console.log(doubler(5)); // 10

createMultiplier 返回一个新函数,每个返回的函数都有自己的 factor(通过闭包记忆)。这种高阶函数(操作函数的函数)用法在 JS 中很常见。

变量作用域 & 闭包

在函数内部可以直接访问外部(尤其是全局)变量,这是因为词法作用域:函数在定义时就锁定了其外部变量引用关系。函数内部未声明就使用的变量,会向外层作用域逐级查找。

如果内部定义了与外层同名变量,则外层变量被“遮蔽”,只能通过特殊方法访问(如 window.varName)。局部变量优先级最高。

闭包指函数携带并记住其定义时的外部变量状态。如下:

function counter() {
  let count = 0;
  return function() {
    count++;
    console.log("当前count:", count);
  };
}
let c = counter();
c(); // 当前count: 1
c(); // 当前count: 2

counter() 返回了一个匿名函数,并引用了外部的 count。当我们多次调用返回的函数时,count 始终存在并累加。这就是闭包,尽管 counter 调用结束了,但它内部的局部变量 count 因被内部函数引用而没有被销毁。闭包广泛用于封装私有状态、实现模块化等。

示例与练习

示例:计算数组平均值的函数:

function average(arr) {
  if (!arr.length) return 0;
  let sum = 0;
  for (let val of arr) {
    sum += val;
  }
  return sum / arr.length;
}
console.log( average([10, 8, 6]) ); // 输出 8

这个函数使用 for...of 遍历数组求和,然后返回平均。注意函数的健壮性:若数组长度为0直接返回0以避免除以0。

示例:匿名函数作为参数:

let nums = [5, 2, 9];
nums.sort(function(a, b) {
  return a - b;
});
console.log(nums); // [2, 5, 9]

sort 方法接受一个比较函数参数,我们传入了匿名函数 (a, b) => a - b 来定义排序规则(升序)。这里可以用箭头函数简化:

nums.sort((a, b) => a - b);

练习

  1. 编写函数 isEven(n),如果参数是偶数返回 true,否则返回 false。
  2. 编写函数 max(a, b, c),返回三个数中的最大值。尝试两种方式:用 if 和用 Math.max 比较结果是否一致。
  3. 编写函数 capitalize(str),将传入字符串的首字母大写并返回。例如传入 "hello" 返回 "Hello"
  4. 编写函数 fahrenheitToCelsius(f),将华氏温度转换为摄氏温度,公式 C = (F - 32)*5/9,返回摄氏值。然后测试比如 fahrenheitToCelsius(100) 是否约等于 37.78。
  5. 写一个递归函数 factorial(n) 计算 n 的阶乘。递归思路:factorial(n) = n * factorial(n-1),基例 factorial(0)=1。验证 factorial(5) 是否为120。
  6. 扩展:试写一个匿名递归函数赋给变量来计算阶乘(即函数表达式自身调用),或者使用具名函数表达式
  7. 使用箭头函数写一个简化的求和函数,例如 const add = (a, b) => a + b; 并测试。
  8. 思考:为什么说函数声明有提升?试在函数声明前调用它(如 console.log( test() ); function test(){ return 1; }),看是否成功。而函数表达式在赋值前调用会怎样。
  9. 深入理解闭包:实现一个函数 makeCounter(), 调用它会返回一个新函数。当调用返回的函数时计数加1并返回当前值。如:
    let counterA = makeCounter();
    console.log(counterA()); // 1
    console.log(counterA()); // 2
    let counterB = makeCounter();
    console.log(counterB()); // 1  (B有自己独立的计数)
    
    分析为何 counterAcounterB 互不干扰。

8. 数组与常用方法

数组(Array)是用于存储有序数据序列的容器。JavaScript 数组与其他语言不同之处在于它是动态异质的:长度可变,可以包含任意类型混合的元素。底层实现其实是对象,但拥有特殊行为和大量方便的方法。

数组的创建

  1. 数组字面量:用方括号 [...] 包裹元素列表。例如:

    let emptyArr = [];               // 空数组
    let primes = [2, 3, 5, 7, 11];   // 数组有5个元素
    let mixed = [1, "hello", true];  // 支持不同类型混合
    

    推荐使用字面量创建,因为简单直观。

  2. Array 构造函数

    let arr1 = new Array();         // 空数组,等同于 []
    let arr2 = new Array(5);        // 长度5的空槽数组
    let arr3 = new Array(1, 2, 3);  // [1, 2, 3]
    

    需要注意的是,new Array(number) 会创建具有指定长度但无实际元素的数组(“空槽”,只能用索引赋值)。比如 Array(3) 不是 [undefined, undefined, undefined] 而是有 length=3 但无元素。这种数组处理起来有些差异,不太直观,所以不太建议使用这种方式初始化长度,不如用 fill 方法或循环填充值。

数组元素访问与属性

  • 访问元素:使用索引,数组索引从 0 开始。arr[0] 是第一个元素,arr[arr.length-1] 是最后一个。

  • 修改元素:通过索引赋值 arr[1] = newValue 修改对应位置。

  • 数组长度arr.length 属性表示数组长度(元素数量)。这是动态的,可读可写:

    • 读:获取当前元素个数。特别地,空数组 .length 为0。
    • 写:可以手动改变 length。如果设小,数组会被截断(多余元素删除);设大,则数组末尾会产生空槽。 但直接修改 length 不常用,更经常通过方法自动调整。
  • 稀疏数组:如果你给一个索引赋值且中间有跳过的索引,这些跳过的位置会成为空(empty)项。例如:

    let a = [1];
    a[5] = 6;
    console.log(a);        // [1, empty × 4, 6]
    console.log(a.length); // 6 (索引5赋值使length变6)
    

    这种有空洞的叫稀疏数组。稀疏数组的某些方法行为略异(如 for...of 会跳过 empty,for...in 也会跳过empty但会遍历继承属性)。一般应避免无意创建稀疏数组。

  • 数组也是对象:可以给数组赋非数字的键/属性,比如 arr.name = "Test",但这不会影响 length 也不会计入数组元素。通常不这么做,除非你想在数组上挂一些辅助属性,不然就当普通对象用更恰当。

添加和删除元素

在末尾添加/删除

  • push(element1, element2, ...):将一个或多个元素追加到数组末尾,返回新长度。
  • pop():删除数组最后一个元素,返回被删除的元素。如果数组为空则返回 undefined
let arr = [1, 2];
arr.push(3);
console.log(arr); // [1,2,3]
let last = arr.pop();
console.log(last); // 3
console.log(arr);  // [1,2]

在开头添加/删除

  • unshift(element1, ...):在数组开头插入元素,数组中已有元素整体后移。返回新长度。
  • shift():移除数组第一个元素,返回它,其后元素左移填补。
let arr = [2, 3];
arr.unshift(1);
console.log(arr); // [1,2,3]
let first = arr.shift();
console.log(first); // 1
console.log(arr);   // [2,3]

以上方法会改变原数组(原地操作)。

在任意位置添加/删除/替换

  • splice(startIndex, deleteCount, item1, item2, ...) 是功能很强的通用方法:
    • startIndex 开始删除 deleteCount 个元素(可为0),然后插入提供的新元素(可选)。返回一个数组,包含被删除的元素。
    • 可以执行删除、插入、替换的操作。
    let arr = ["a", "b", "c", "d"];
    arr.splice(1, 2, "X", "Y"); 
    console.log(arr); // ["a","X","Y","d"],位置1开始删2个("b","c"),插入"X","Y"
    
    • 插入:deleteCount 为 0。
    • 删除:不提供新元素参数,只写 deleteCount。
    • 替换:deleteCount 和新元素个数都大于0,则删除后插入等效替换。
    • splice 会更新 length 并返回删除的元素数组(若没有删除则返回 [])。

创建子数组

  • slice(begin, end):返回原数组从 beginend(不含)位置的浅拷贝,不修改原数组。
    • begin 可为负数表示从末尾算索引。省略 begin 则默认0。
    • end 可省略表示直到末尾,负数表示从末尾倒数计算。
    let arr = [0,1,2,3,4];
    console.log( arr.slice(1,4) ); // [1,2,3] (索引1到3)
    console.log( arr.slice(2) );   // [2,3,4] (索引2到末尾)
    console.log( arr.slice(-2) );  // [3,4]   (倒数2个)
    
  • 注意 slice()(不传参)可以用于复制整个数组(浅拷贝)。

合并数组

  • concat(arg1, arg2, ...):返回一个新数组,是原数组与参数的组合。参数可以是值(会直接插入)或数组(会展开数组元素插入):
    let arr = [1,2];
    console.log( arr.concat(3, [4,5]) ); // [1,2,3,4,5]
    
    原数组不变。

遍历数组

for循环:使用索引遍历:

for (let i = 0; i < arr.length; i++) {
  console.log(arr[i]);
}

for...of (ES6):更简洁,直接遍历元素:

for (let element of arr) {
  console.log(element);
}

for...of 不能获取索引,如需要索引,可以使用数组的 entries() 方法配合(或老式 for 或 forEach)。例如:

for (let [index, elem] of arr.entries()) {
  console.log(index, elem);
}

for...in:遍历数组时会迭代可枚举属性,包括索引,但因顺序不保证且会遍历继承属性,不推荐遍历数组。

forEach 方法:数组方法,以回调形式遍历元素:

arr.forEach(function(element, index, array) {
  console.log(index + " -> " + element);
});

forEach 会按顺序用回调访问每个元素,无法中途跳出循环(除非抛异常)。通常用 for...of 更直观,但 forEach 也常见于函数式编程风格。

常用数组方法

转换

  • toString():返回数组的字符串表示(元素用逗号分隔)。[1,2].toString()"1,2"
  • join(separator):类似 toString,但可指定分隔符:
    [1,2,3].join("-") // "1-2-3"
    

搜索

  • indexOf(value, fromIndex=0):从fromIndex起查找value第一次出现的索引,找不到返回 -1。用===判断相等。
  • lastIndexOf(value, fromIndex):从末尾向前找。
  • includes(value, fromIndex=0):ES7 (2016) 新增,返回布尔,表示数组是否包含某值。比 indexOf更直观。
    let arr = [10, 20, 30];
    arr.indexOf(20)    // 1
    arr.includes(20)   // true
    arr.includes(99)   // false
    

生成新数组的方法(不改原数组)

  • slice(begin, end) 前面讲过,用于获取子数组。
  • concat 讲过,用于合并数组。
  • map(callback):返回一个新数组,其元素是对原数组每个元素调用回调函数结果 (JavaScript 语言概览 - JavaScript | MDN)。
    let numbers = [1, 4, 9];
    let roots = numbers.map(x => Math.sqrt(x));
    console.log(roots); // [1, 2, 3]
    
  • filter(callback):返回满足回调条件的元素组成的新数组。举例,筛选偶数:
    let arr = [10, 15, 20, 25];
    let evens = arr.filter(n => n % 2 === 0);
    console.log(evens); // [10, 20]
    
  • slice/map/filter 都不会改变原数组,返回新数组。

汇总数组的方法

  • reduce(callback(accumulator, current, index, array), initialValue):遍历数组,将每次回调返回值作为累积结果传递给下一次。简言之,就是把数组归约为一个值。常用于求和、计算乘积、找最大值、拼接、聚合等。
    let sum = [1,2,3,4].reduce((acc, val) => acc + val, 0);
    console.log(sum); // 10
    
    acc 初始为 initialValue(如果提供),否则为数组第一个元素且从第二个元素开始迭代。上例 initialValue 给0,结果就是对1,2,3,4求和。reduce 强大但初学可暂浅尝,理解回调逻辑后会觉得它很方便。
  • reduceRight 类似 reduce,但从数组末尾向前迭代。

排序

  • sort(compareFunction):对数组就地排序,默认按字符串升序,需提供比较函数来实现数字等其他排序。比较函数 compare(a,b) 应返回:
    • 负数:表示 a 应排在 b 之前;
    • 0:顺序不变;
    • 正数:表示 a 应排在 b 之后。 数字升序例子:
    let arr = [15, 3, 8];
    arr.sort((a, b) => a - b);
    console.log(arr); // [3, 8, 15]
    

    如果不传比较函数,默认把元素转为字符串比较 Unicode 顺序,比如 ["b", "a", "c"].sort() 结果 ["a","b","c"],但数字 ["10","2"] 排序结果 ["10","2"](因为 "1" < "2")。所以对数字或自定义对象排序必须给 compareFunction。

  • reverse():颠倒数组中元素的顺序,就地修改并返回数组本身。

数组迭代方法(见上 map/filter,还有:)

  • forEach:前面提到,遍历执行回调,无返回。
  • every:测试是否所有元素都满足条件。若全部满足返回 true,一旦有元素回调返回 false,则整体 false。
  • some:测试是否至少有一个元素满足条件。一旦有元素返回 true,则 some 返回 true,否则全都不满足返回 false。
  • 例:检查数组是否全是正数:
    let nums = [1,5,-3,8];
    console.log( nums.every(n => n > 0) ); // false,有-3
    console.log( nums.some(n => n < 0) );  // true,有负数
    

其他有用方法

  • find(callback):返回第一个满足条件的元素值,如果没有则返回 undefined。
  • findIndex(callback):返回第一个满足条件元素的索引,没有则 -1。
  • flat(depth):ES2019,引入用于将嵌套数组按指定深度拍平成一维数组。例如 [1,[2,[3]]] 执行 flat(2) 结果 [1,2,3]
  • Array.isArray(obj):静态方法,用于判断一个对象是否为数组类型。因为 typeof 对数组返回 "object",所以需要此方法区分普通对象和数组。

总结:数组的特性与最佳实践

  • 索引基于 0,可随机访问。
  • 长度动态变化,push pop 等使其像栈使用,shift push 结合可当队列使用。
  • 数组的性能:末尾插入/删除快,开头插入/删除因为要挪动元素所以慢。遍历访问很快。需要频繁开头操作时,也可用 Array.prototype.unshift 但注意性能瓶颈。
  • 数组可以存不同类型值,但实际开发中,一个数组最好存同类数据,这样代码逻辑简单,也易于和类型检查/提示配合。
  • 利用数组方法可以写出简洁的函数式风格代码。例如用 map/filter 代替 for 循环处理数组,但要注意过度链式调用可能牺牲性能和可读性。平衡使用。

练习

  1. 创建一个包含若干字符串的数组,使用 for 循环和 for...of 分别遍历并打印每个字符串。
  2. 定义一个函数 arrayMax(arr) 返回数组最大值,不借助 Math.max,使用循环或 reduce 实现。测试输入 [3, 9, 7] 应返回9。
  3. 定义一个函数 remove(arr, value),从数组中删除所有等于 value 的元素。尝试使用 filter 返回过滤后的新数组。
  4. 定义一个函数 unique(arr),返回数组去重后的新数组。提示:可以借助 indexOfincludes 判断元素是否已存在另一数组中,或者使用高级结构 Set 简化(Set 是 ES6 新增的不含重复值的集合结构)。
  5. 使用数组方法完成:将一串英文句子翻转其单词顺序。例如输入 "how are you" 返回 "you are how". 提示:可以用 split(' ') 将字符串按空格分割成数组,再用 reverse(),最后用 join(' ') 拼回字符串。
  6. 定义一个二维数组,例如 [[1,2],[3,4],[5,6]],使用 flat() 将其拍平成一维数组 [1,2,3,4,5,6]
  7. 深入:比较 forEachfor...of 的区别。是否可以在 forEach 中使用 break 或 return 来终止循环?可以在 for...of 中使用 break 吗?验证一下。

9. 对象与面向对象编程(构造函数、原型、类)

对象是 JavaScript 的基础数据类型之一,用于保存键值对。对象可以模拟生活中的实体,将其属性和行为打包在一起。JS 本身很多重要东西都是对象(数组、函数、日期、正则都是特殊的对象)。理解对象对掌握 JS 十分关键。

创建对象与属性访问

字面量:使用花括号 { ... } 可以创建对象:

let person = {
  name: "Alice",
  age: 25
};

这里创建了一个对象 person,带有两个属性(键值对):键名 name"Alice",键名 age25。键名可以是标识符字符串(可不加引号但不能有空格或特殊字符)。值可以是任意类型,包括数组、对象、函数等。

访问属性

  • 点记法:对象.属性名,属性名必须是合法标识符。
    console.log(person.name); // "Alice"
    person.age = 26;          // 修改age
    
  • 方括号:对象["属性名"],属性名作为字符串(可以是表达式)。适用于属性名不确定或含特殊字符的情况。
    console.log(person["age"]); // 26
    let key = "name";
    console.log(person[key]);   // person["name"] -> "Alice"
    person["likes birds"] = true; // 含空格的键只能用方括号
    
    如果属性名包含空格、连字符等,必须用引号包裹且用 bracket 访问。
  • 访问不存在的属性会得到 undefined。可用 'prop' in obj 检查属性是否存在:
    console.log("gender" in person); // false
    
    in 区分属性存在与否,无论值是不是 undefined(obj.key === undefined 也可能是存在属性值就是undefined,和属性不存在不同)。

修改和新增属性:通过赋值新增或更新属性:

person.city = "北京";  // 新增city属性
person.age += 1;      // 将age属性值+1

删除属性用 delete 对象.属性

delete person["likes birds"];

删除成功返回 true,删除不存在的属性也返回 true。删除不会影响其他属性。

对象比较与引用

  • 对象赋值是按引用赋值。两个变量若指向同一对象,则对其中一个修改属性,另一个也会感知:
    let obj1 = {x:1};
    let obj2 = obj1;
    obj2.x = 5;
    console.log(obj1.x); // 5
    
  • 对象比较(== 或 ===)实际上比较引用地址是否相同。只有两个引用指向同一个对象才相等。即使两个不同对象内容完全一样,它们也不相等:
    let a = {y:2};
    let b = {y:2};
    console.log(a === b); // false,不同对象
    console.log(a == b);  // false
    
    若想比较对象内容是否相等,需要遍历比较每个属性值。

遍历对象属性

for...in 可以遍历对象自身的可枚举属性键:

for (let key in person) {
  console.log(key + ": " + person[key]);
}

这将遍历 person 对象所有可枚举属性,包括继承自原型的可枚举属性(若存在)。为确保只遍历对象自身属性,可在循环里加判断:

if (person.hasOwnProperty(key)) { ... }

或使用 Object.keys 获取自身键列表遍历。

Object. 静态方法*:

  • Object.keys(obj) 返回对象自身可枚举属性名数组。
  • Object.values(obj) (ES2017) 返回属性值数组。
  • Object.entries(obj) (ES2017) 返回 [key, value] 数组组成的数组。

例如:

console.log( Object.keys(person) );   // ["name","age","city"]
console.log( Object.values(person) ); // ["Alice", 26, "北京"]

对象的方法与 this

属性值可以是函数,这种属性称为方法。通过对象调用时,方法内部可以使用 this 引用该对象,从而访问对象其他属性。

let calculator = {
  x: 1,
  y: 2,
  sum: function() {
    return this.x + this.y;
  }
};
console.log( calculator.sum() ); // 3

这里 calculator.sum 是一个函数,通过 calculator.sum() 调用。函数内的 this 自动绑定为调用它的对象(calculator),因此 this.x 即 calculator.x。

重要this 的值是在函数调用时确定的,取决于调用方式:

  • obj.method() 调用时,method 内部 this 指向 obj。
  • 若将函数赋值给变量再调用,如 let f = calculator.sum; f(); 那么 this 将不是 calculator(在严格模式下是 undefined,非严格模式是全局对象)。这是 this 易出错之处。
  • 箭头函数没有自己的 this,它会捕获定义时外围作用域的 this。所以箭头函数适合作为嵌套回调使用,但不适合定义对象方法(因为它无法动态指向调用者)。

方法简写:ES6 对象字面量允许简写定义方法:

let calculator = {
  x: 1,
  y: 2,
  sum() { return this.x + this.y; }  // 等价于 sum: function() { ... }
};

面向对象编程(OOP)概念

JavaScript 本身支持 OOP,包括原型继承、构造函数以及 ES6 class 等,可以创建自定义对象类型,实例化多个具有相同结构的对象。下面分两种方式介绍:

构造函数与原型

构造函数是一种约定,使用 function 定义,以大写开头命名,用来初始化对象的函数。使用 new 运算符调用构造函数,会创建并返回一个对象实例,并将函数内的 this 绑定到新对象。

示例:定义一个构造函数,用于创建 Person 对象:

function Person(name, age) {
  this.name = name;
  this.age = age;
  // 可不显式 return,构造函数会自动返回 this
}
let p1 = new Person("张三", 20);
console.log(p1.name); // "张三"

当执行 new Person("张三",20) 时:

  1. 创建一个空对象 {}
  2. 将这个对象的原型链接到 Person.prototype 上(每个函数都有 prototype 属性,默认指向一个空对象)。
  3. 将 Person 函数内部的 this 指向这个新对象,并执行函数体。这会给新对象添加 name 和 age 属性。
  4. 如果函数没有返回其它对象,则隐式返回这个新对象。

这样 p1 就是 Person 的一个实例。我们可以创建多个实例:

let p2 = new Person("李四", 18);
console.log(p2.age); // 18

原型:每个 JavaScript 对象在创建时都会关联到一个原型对象。对于通过构造函数生成的对象实例,其原型就是构造函数的 prototype 属性值。可以在原型上定义共用的方法,这样所有实例都会继承这个方法,而不需要每个实例保存一份(节省内存)。

例如:

Person.prototype.greet = function() {
  console.log("你好,我是" + this.name);
};
p1.greet(); // "你好,我是张三"
p2.greet(); // "你好,我是李四"

当访问 p1.greet 时,JS 引擎发现 p1 自身没有 greet 属性,于是沿着原型链到 p1 的原型(Person.prototype)查找,找到后调用。这就是原型继承的机制。可以将通用的功能放在原型上,让所有实例共享。

instanceof:检查一个对象是否是某构造函数创建的实例:

console.log(p1 instanceof Person); // true
console.log(p1 instanceof Object); // true,所有对象最终继承自 Object

这通过检查对象的原型链上是否能找到构造函数的 prototype 来判断。

内置构造函数:Array, Function, Date 等都是内置构造函数。你创建的每个数组其实就是 new Array() 产生的。它们提供 prototype 上的方法,如 Array.prototype.push 等。

ES6 Classes

ES6 提供 class 语法让我们以更接近传统 OOP 语言的形式定义对象模型。需要注意的是class 只是语法糖,底层机制仍然是使用 prototype 和构造函数实现。

定义类的语法:

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
  greet() {
    console.log(`你好,我是${this.name}`);
  }
}

这与之前的构造函数 Person + Person.prototype.greet 效果相同。区别:

  • 使用 new Person("张三", 20) 调用类构造器,自动执行 constructor 方法。
  • 类内部定义的方法(如 greet)会被放在 Person.prototype 上,且不可枚举(默认 enumerable: false)。
  • 类中定义的方法没有逗号分隔,写法更清爽。
  • Class 声明不会提升,必须在使用前定义。

类支持继承

class Student extends Person {
  constructor(name, age, grade) {
    super(name, age);  // 调用父类构造
    this.grade = grade;
  }
  greet() {
    // 重写方法,仍可用 super 调父类方法
    console.log(`你好,我是${this.name},年级:${this.grade}`);
  }
}
let s1 = new Student("王五", 16, "高一");
s1.greet(); // "你好,我是王五,年级:高一"
console.log(s1 instanceof Student, s1 instanceof Person); // true true

extends 实现原型继承关系。子类的 prototype 会关联到父类的 prototype。super 用来调用父类的构造函数或方法。子类必须在 constructor 中先调用 super(...) 才能使用 this

类还支持:

  • 静态属性/方法:用 static 关键词定义,属于类本身而非实例。如 static info() { ... } 可通过 Person.info() 调用。
  • 私有属性(ES2022):属性名前加 # 定义,如 #salary = 5000,只能在类内部访问,实例上不能直接访问(处在实验阶段,浏览器支持情况需注意)。
  • getter/setter:可以用 get prop()set prop(value) 定义属性访问器,在使用 obj.prop 读取或赋值时会自动调用。

总之,class 语法更接近Java/C#的类概念,让代码组织和继承结构更直观。

内置对象原型与原型链

JS 中几乎所有东西都是对象,原型链构成了继承体系:

  • 所有对象最终的原型是 Object.prototype,它的原型为 null(原型链顶端)。
  • 数组原型 Array.prototype 继承自 Object.prototype,所以数组也有诸如 toString 方法。
  • 函数原型 Function.prototype 也是对象。
  • 对于字面量对象,原型就是 Object.prototype。
  • 原始类型对应的包装对象(String, Number, Boolean)的原型定义了原型方法,比如 String.prototype.toUpperCase。当你调用 "hello".toUpperCase(),JS 暂时把字符串包装成对象来调用原型方法。
  • nullundefined 没有原型,也没有属性和方法可用(所以访问 null.toString 会错)。

了解这个体系有助于明白,为什么可以对数组用 hasOwnProperty(从 Object.prototype 继承),对字符串用 indexOf(String.prototype 定义)。也解释了为啥可以自定义扩展内置原型的方法(比如给 Array.prototype 添加自定义方法),但不推荐这么做,因为会影响所有数组并可能与未来标准冲突。

举例与练习

示例:使用对象封装相关数据和方法:

let account = {
  owner: "李华",
  balance: 1000,
  deposit(amount) {
    this.balance += amount;
    console.log(`存入${amount},余额${this.balance}`);
  },
  withdraw(amount) {
    if (amount > this.balance) {
      console.log("余额不足");
    } else {
      this.balance -= amount;
      console.log(`取出${amount},余额${this.balance}`);
    }
  }
};
account.deposit(500);   // 存入500,余额1500
account.withdraw(2000); // 余额不足

account 对象把余额和操作封装在一起,比用独立函数和变量更有组织性。

练习

  1. 创建一个表示长方形的对象,包含长度和宽度属性,以及计算面积的方法。
    let rectangle = { width: 10, height: 5, area() { return this.width * this.height; } };
    console.log(rectangle.area()); // 50
    
    尝试修改 width 或 height 再调用 area,是否反映变化。
  2. 创建一个数组对象 personList,其中每个元素是一个包含 name 和 age 的对象。编写函数 findYoungest(list) 找出 list 中最年轻的人,返回其对象。可以循环比较年龄。
  3. 定义构造函数 Car(brand, speed),初始化品牌和速度属性,并添加方法 accelerate(amount) 将速度增加某值,brake(amount) 将速度减小某值但不得降到0以下。然后创建多个 Car 对象并调用方法。
  4. 将上面的 Car 改写为 ES6 类实现,测试方法功能是否一致。
  5. 定义构造函数 Animal(name),添加方法 speak() 默认输出 ${name} 发出了声音。然后定义子类构造函数 Dog(name) 继承 Animal,并重写 speak() 输出 ${name} 汪汪!。使用原型或 ES6 class 实现都可以。创建 Dog 实例验证继承效果和 instanceof 关系。
  6. 思考:JavaScript 没有私有属性机制(ES2022 私有属性除外),我们如何实现类似私有的效果?提示:可以通过闭包实现,将属性存在函数作用域内,只暴露读写的闭包函数。
  7. 扩展:使用 Object.assign(target, ...sources) 静态方法可以把多个对象属性合并到 target 对象中。试用它合并多个对象。再查阅 ... 对象展开的用法(第13章会提及)。