网页可以包含阅读时段列表、存放数字的输入字段,以及执行计算的按钮。在把这些元素连接至事件之前,先要理解负责运算的语言。JavaScript 的值有类型,函数建立作用域,而不同数组方法对返回什么值、修改哪些对象,有不同的规则。
以下例子以小型阅读记录进行计算。每段都可以视为独立程序:先预测结果,再追踪产生结果的操作。如果还未熟悉 JavaScript 执行与文档显示的分别,可以先重温浏览器、服务器与网页资源。
1. 绑定为值命名
识别名称区分大小写:minutes 与 Minutes 指不同的绑定。一般名称可以包含字母、数字、下划线与美元符号,但不能以数字开头,也不能使用保留关键字。
| 声明 | 可重新赋值给绑定? | 作用域与用途 |
|---|---|---|
let | 可以 | 代码块作用域;用于后续步骤会重新赋值的情况。 |
const | 不可以 | 代码块作用域;声明时必须初始化。 |
var | 可以 | 函数作用域而非代码块作用域;也允许重复声明。 |
访问 let 或 const 绑定之前,必须先完成其初始化。在同一词法作用域内,再次声明相同名称的绑定会产生错误。内层代码块可以声明自己的同名绑定,暂时遮蔽外层的名称。
let completed = 2;
completed += 1;
const target = 5;
{
const target = 8;
console.log(target); // 8
}
console.log(completed, target); // 3 5
const 固定的是绑定,不是所指对象的内容。常数数组仍然可以加入新元素;把绑定改为指向另一个数组,才是另一种操作。
JavaScript 采用动态类型:一个 let 绑定可以先存放数字,后来再存放字符串。类型属于值,绑定本身不会强制只容纳某一种固定类型。
2. 类型与转换决定表达式的意义
常见原始值包括字符串、数字、布尔值、undefined、null,以及 12n 这类大整数。Symbol 也是一种原始类型。数组与一般对象属于对象。typeof 返回值的类型,但要记住两项历史特例:typeof null 是 "object",数组也会得到 "object";应使用 Array.isArray(value) 辨认数组。
数字采用二进制浮点表示。算术运算包括 +、-、*、/、幂运算 ** 与余数 %。并非每个十进制小数都能精确表示。NaN 是表示数值结果无效的数字类型值,不是字符串。大整数属于另一种类型,一般算术运算不能直接混合大整数与 number 操作数。
例题
追踪加法与字符串拼接
console.log(2 + 3 + "4"); // "54"
console.log("2" + 3 + 4); // "234"
console.log(Number("12") + 3); // 15
console.log(String(12) + 3); // "123"
console.log(Number("later")); // NaN
这些加法由左至右组合。第一个表达式先以 2 + 3 得到数字 5,再与字符串相加,拼接成 "54"。第二个表达式的第一次相加已产生字符串,之后便再次拼接。明确使用 Number 与 String 转换,可以清楚表达预期操作。
=== 与 !== 不会先转换操作数类型。因此 3 === "3" 是 false,而 3 == "3" 是 true,因为宽松相等允许转换。预期接收数字的计算,应先明确转换,再比较预期类型的值。
条件会把值转为布尔值。Falsy 值包括 false、0、-0、0n、""、null、undefined 与 NaN;其他一般值则为 truthy。特别要注意,"0" 与 "false" 都是非空字符串,因此是 truthy。Boolean(value) 可以明确呈现这项转换。
逻辑 && 与 || 采用短路求值,返回其中一个操作数的值,未必是布尔值。!value 返回布尔转换后的否定值。位运算 & 与 | 处理位,不能在条件中直接当作逻辑运算符使用。
思考检查
预测数值与类型
"6" + 2、Number("6") + 2、6 === "6" 与 Boolean("0") 的值分别是什么?前两个表达式中,哪个产生数字?
解答 · 分清转换与计算
结果依次为 "62"、8、false 与 true。只有 Number("6") + 2 产生数字。第一式拼接字符串;严格相等保留数字与字符串的分别;非空字符串即使文字是 "0",仍然是 truthy。
3. 字符串是不可变的文字值
单引号与双引号都能建立字符串字面量。转义序列 \n、\" 与 \\ 分别表示换行、引号与反斜线。模板字面量使用反引号,并以 ${expression} 插入运算结果。
const raw = " chapter one, chapter two ";
const titles = raw.trim().split(", ");
const message = `${titles.length} chapters`;
console.log(titles); // ["chapter one", "chapter two"]
console.log(message); // "2 chapters"
console.log(titles.join(" / ")); // "chapter one / chapter two"
trim 返回移除开头与结尾空白后的新字符串,不会修改 raw。split 是返回数组的字符串方法,join 则是返回字符串的数组方法。字符串索引从零开始,但 JavaScript 的 length 与索引操作计算的是 UTF-16 编码单元。例如 "A".length 是 1,而 "📘".length 是 2;这并不是通用的可见字符计数。
正规表达式描述文字模式。在 /^[A-Z]{2}[0-9]{2}$/ 中,^ 与 $ 把匹配限制在完整输入,[A-Z]{2} 要求两个大写拉丁字母,[0-9]{2} 要求两个数字。其 .test("RD24") 结果为 true,.test("RD240") 则为 false。模式精确描述指定的格式规则,但不能证明该规则以外的事实。
4. 控制流程选择与重复工作
以 if/else 作分支;只需要在两个值之间选择时,可以用条件表达式 condition ? first : second。switch 选取匹配的 case,break 结束该 switch。回圈重复执行工作:while 在主体之前测试条件,do...while 在之后测试,for 则把初始化、条件与更新放在一起。
const sessions = [12, 0, 18, 7];
let total = 0;
for (const minutes of sessions) {
if (minutes === 0) continue;
total += minutes;
}
const label = total >= 30 ? "target reached" : "keep reading";
console.log(total, label); // 37 "target reached"
for...of 遍历可迭代的值。这里依次获取 12、0、18 与 7。continue 跳过目前一轮余下的操作;break 则会结束整个回圈。for...in 遍历可列举的属性键,目的不同,并非读取数组元素值的默认选择。
需要重新赋值给数组元素时,索引回圈很有用:sessions[i] = sessions[i] + 1 会改变元素。相反,对另一个 let 回圈绑定执行 minutes = minutes + 1,只会取代该局部绑定的值,不会改变原本获取该值的数组元素。
5. 函数分开输入、计算与结果
定义
参数、引数与返回值
参数是函数定义中的名称,引数是调用时实际传入的值。return 结束目前调用并提供结果。执行至函数结尾而没有返回值,结果是 undefined。
function readingTime(pages, minutesPerPage = 2) {
return pages * minutesPerPage;
}
console.log(readingTime(7)); // 14
console.log(readingTime(7, 3)); // 21
function describeSession(label, ...minutes) {
return `${label}: ${minutes.join(", ")}`;
}
console.log(describeSession("week", 8, 11)); // "week: 8, 11"
缺少引数时,其值是 undefined。默认参数在对应引数省略或明确为 undefined 时生效,传入 null 则不会启用默认值。剩余参数把其余引数收集成数组,而且必须放在最后。一般函数也有 arguments 对象,箭头函数则不建立自己的 arguments。
在所属作用域内,函数声明可以在声明位置之前被调用。以 const 存储的函数表达式,必须等到初始化执行后才能使用。以下箭头表达式与简短的一般函数都能计算平方:
const square = value => value * value;
const squareWithBody = value => {
return value * value;
};
console.log(square(6), squareWithBody(6)); // 36 36
表达式主体会把该表达式的结果作为返回值。主体使用大括号时,需要结果便要明确写出 return。箭头函数还会捕获外层的 this;一般方法则可以由调用方式决定 this。两者不会因为某段简短表达式看似相同,就可以任意互换。
6. 函数值、作用域与回调
makeAdder 指函数值,makeAdder(4) 则调用函数。回调是传给另一个函数、由后者调用的函数。传入 record,接收方可以稍后调用它;传入 record(),则会立即调用并传入其结果。
例题
追踪闭包中的两次回调
建立一个固定加 4 的函数,先直接使用,再把它当作两步计算中的转换函数。追踪每次内层调用收到的实参,分清返回的函数与它之后产生的数值结果。
function makeAdder(offset) {
return value => offset + value;
}
const addFour = makeAdder(4);
console.log(addFour(9)); // 13
function applyTwice(value, transform) {
return transform(transform(value));
}
console.log(applyTwice(3, addFour)); // 11
内层函数保留对建立它的那次调用中 offset 绑定的访问。函数与其外围词法环境的访问关系合起来,称为闭包。参数 value 属于每次内层调用。makeAdder 以外的程序不能直接访问其局部 offset 绑定。
makeAdder(4) 先返回函数,尚未执行加法。调用 addFour(9) 才把 4 加至 9,得到 13。在 applyTwice(3, addFour) 中,内层应用先把 3 转成 7,外层应用再把 7 转成 11。两次回调使用同一个捕获的偏移量,但接收不同的实参。
回调不一定是异步的。applyTwice 在同一次调用中立即执行回调。事件与计时器则以不同时机调用回调。调用函数、传入函数与调度函数是不同操作。
JavaScript 以值传递引数。当值是对象引用时,函数可以透过该引用改变共用对象;重新赋值给局部参数,不会重新赋值给调用端的绑定。立即调用函数表达式,例如 (() => { const note = "ready"; return note; })(),会建立局部作用域并调用一次。
思考检查
追踪回调与外围作用域
applyTwice(5, makeAdder(2)) 返回什么?所产生的内层函数被调用多少次?为什么仍可以访问它的 offset?
解答 · 追踪函数的建立与调用
makeAdder(2) 建立一个加 2 的函数。applyTwice 先以 5 调用它,得到 7;再以 7 调用,得到 9。内层函数共调用两次。外层调用返回后,闭包仍保留对 makeAdder(2) 中 offset 绑定的访问。
7. 方法与生成器有不同的调用行为
对象可以把函数存储为方法。在 readingLog.describe() 中,调用会把 readingLog 提供为一般方法的 this。生成器函数则建立生成器对象,透过 next() 推进执行。
const readingLog = {
title: "Evening reading",
describe() { return this.title; }
};
console.log(readingLog.describe()); // "Evening reading"
function* checkpoints() {
yield 10;
yield 20;
return 30;
}
const steps = checkpoints();
console.log(steps.next()); // { value: 10, done: false }
console.log(steps.next()); // { value: 20, done: false }
console.log(steps.next()); // { value: 30, done: true }
yield 暂停生成器并产生一个值,之后的 next() 令它继续执行。返回值会结束生成器,并带有 done: true,不是另一个由 yield 产生的项目。这种明确暂停与恢复的模型,有别于一直执行至返回的一般函数。
8. 数组是有顺序、具共用识别的对象
数组索引从零开始。元素可以属于不同类型,包括其他数组与对象。赋值复制的是引用值,不会建立新数组。
例题
分清别名、复制与修改
const original = [8, 15, 21];
const alias = original;
const copy = [...original];
alias[0] = 10;
copy.push(30);
console.log(original); // [10, 15, 21]
console.log(alias); // [10, 15, 21]
console.log(copy); // [8, 15, 21, 30]
alias 与 original 指向同一数组,copy 则指向第二个数组。展开语法、slice() 与 Array.from() 都建立浅复制,内层对象引用仍然共用。对对象数组作浅复制,并不会逐一复制每个对象。
剩余参数与展开语法在不同位置使用相同的三点符号。剩余参数收集引数,而 [...first, ...second] 把可迭代的值展开至新数组。解构则把元素取出至不同绑定:const [first, ...remaining] = [8, 15, 21] 令 first 得到 8,remaining 得到数组 [15, 21]。
9. 按修改行为与返回值选择操作
| 操作 | 修改原数组? | 返回值 |
|---|---|---|
slice(start, end) | 否 | 包含起点、不包含终点的新数组。 |
splice(start, count, ...items) | 是 | 被移除元素组成的数组。 |
push(...items)/unshift(...items) | 是 | 在末端/开头加入元素后的新长度。 |
pop()/shift() | 是 | 被移除的最后/第一个值;空数组则为 undefined。 |
reverse() | 是 | 顺序反转后的同一个数组。 |
sort(compare) | 是 | 按比较规则排序后的同一个数组。 |
const plan = ["read", "pause", "write"];
const preview = plan.slice(0, 2);
const removed = plan.splice(1, 1, "review", "discuss");
console.log(preview); // ["read", "pause"]
console.log(removed); // ["pause"]
console.log(plan); // ["read", "review", "discuss", "write"]
slice 与 splice 允许由末端开始计算的负起点。两者的第二个引数意义不同:slice 接收结束索引,splice 接收删除数目。混淆两者,便会改变操作所回答的问题。
默认 sort() 比较字符串表示,因此 [4, 20, 3].sort() 产生 [20, 3, 4]。数字递增排序应使用 (a, b) => a - b。要保留原数组,先建立浅复制再排序:[...values].sort((a, b) => a - b)。
10. 搜寻、筛选与转换
indexOf 与 lastIndexOf 返回匹配索引,找不到时返回 -1,比较采用严格相等。includes 回答某个值是否存在,也能识别 NaN。使用谓词时,find 返回第一个匹配元素或 undefined,filter 则返回全部匹配元素组成的数组。map 转换每个遍历的元素并返回新数组。forEach 执行回调工作并返回 undefined,不会收集回调的返回值。
const entries = [" HTML ", "CSS", "JavaScript"];
const clean = entries.map(title => title.trim());
const longTitles = clean.filter(title => title.length >= 4);
const firstLong = clean.find(title => title.length >= 4);
console.log(clean); // ["HTML", "CSS", "JavaScript"]
console.log(longTitles); // ["HTML", "JavaScript"]
console.log(firstLong); // "HTML"
console.log(clean.indexOf("CSS"), clean.includes("DOM")); // 1 false
对这些没有空洞的数组,map 保持元素数目与顺序;filter 保持保留元素的相对顺序,但可以缩短结果。这两个方法本身不会改变这里的输入数组。回调如果修改共用对象,仍然可能产生副作用,因此要同时检查回调与方法名称。
常见错误
回调的返回值需要接收方
values.forEach(value => value + 1) 计算加法后丢弃结果,不会取代数组元素。需要由这些和组成的新数组时,使用 values.map(value => value + 1);需要修改原数组时,则明确赋值给有索引的元素。
类数组对象有数字索引与 length,却未必提供数组方法。Array.from({ 0: "read", 1: "review", length: 2 }) 会建立真正的数组。浏览器的 HTMLCollection 与 NodeList 是相关例子:NodeList 有 forEach,却没有数组的 find 或 filter;HTMLCollection 也不提供这些数组方法。querySelectorAll 返回静态 NodeList,其他 NodeList 则可能是实时更新的。集合的行为取决于建立它的 API,不只是有没有 length 属性。
练习
1. 绑定与它所指的对象
解释为什么 const sessions = [10]; sessions.push(15); 合法,之后执行 sessions = [20] 却不合法。const copy = [...sessions] 会令内层对象彼此独立吗?
解答 · 解答 1:固定绑定与浅复制
push 改变已有数组,不会重新赋值给 sessions 绑定。后一项操作则尝试重新赋值给常数绑定。展开语法建立另一个外层数组,但元素保留各自的值,包括内层对象的引用。这些内层对象并没有被独立复制。
2. 遗漏的返回值
预测 const lengths = ["notes", "web"].map(text => { text.length; }); 的结果。只改写回调,令结果包含两个字符串长度。
解答 · 解答 2:表达式主体与代码块主体
结果是 [undefined, undefined],因为代码块主体没有 return 语句。使用 text => text.length 或 text => { return text.length; } 都会得到 [5, 3]。map 收集每次回调的返回值,而非回调计算后丢弃的表达式值。
3. 追踪数组变化
由 const tasks = ["read", "write", "review"]; 开始,先执行 const saved = tasks.slice(1);,再执行 const taken = tasks.splice(1, 1, "discuss");。写出最后三个数组的内容。
解答 · 解答 3:先复制,再编辑
saved 是 ["write", "review"],taken 是 ["write"],tasks 是 ["read", "discuss", "review"]。slice 先建立独立的外层数组,之后 splice 才修改 tasks。这些字符串元素是值,因此后来的取代不会改变 saved。
4. 定义回调处理流程
对 [9, 24, 15, 6] 产生新数组,只保留至少为 10 的值,再把保留值除以 3。保留原顺序与原数组。写出结果,并解释为什么两项操作的顺序重要。
解答 · 解答 4:先筛选,再转换
[9, 24, 15, 6].filter(value => value >= 10).map(value => value / 3) 得到 [8, 5]。筛选测试原本的值。先除以 3 再套用相同门槛 10,测试的便是不同数量,结果会是空数组。这里两个方法都返回新数组。
5. 恢复生成器
在 checkpoints 例子所示的三次 steps.next() 之后,再调用一次会返回什么?30 是由 yield 产生的项目吗?
解答 · 解答 5:完成状态会保留
下一次调用返回 { value: undefined, done: true }。生成器已完成,不会重新开始。30 是带有 done: true 的最终返回值,而不是由 yield 产生的值。