JavaScript 教程:从变量到异步实战

一、JavaScript 是什么:脚本的执行时机与绑定方式

HTML 决定「这是什么内容」,CSS 决定「它怎么呈现」,JavaScript 决定「它怎么响应」。前两者是声明式的描述,JS 则是真正的编程语言:变量、分支、循环、函数,还自带操作页面本身的能力。它在浏览器里由 JS 引擎(V8、SpiderMonkey 等)逐行执行;同一门语言搬到服务器就是 Node.js——本教程聚焦浏览器这一侧。

学 JS 之前先建立一条直觉:页面在浏览器里是「边解析边执行」的。HTML 教程第一章讲过解析过程,其中顺序地读标签这一点,直接决定了脚本的行为。

<script> 放哪里:四种写法与各自的执行时机

<!-- ① 放在 head 里:先下载执行,再解析后面的 HTML -->
<head><script src="/js/app.js"></script></head>

<!-- ② 放在 </body> 之前:前面的 DOM 已经解析完 -->
<body>…<script src="/js/app.js"></script></body>

<!-- ③ head 里加 defer:并行下载,解析完再按顺序执行 -->
<head><script src="/js/app.js" defer></script></head>

<!-- ④ type="module":文件成为模块,自带 defer 行为与独立作用域 -->
<head><script src="/js/app.js" type="module"></script></head>
写法阻塞解析吗何时执行顺序保证
普通脚本(默认)是,下载并执行完才继续解析下载完立即执行按出现顺序,但都在解析中
</body> 之前前面的内容已解析完,无所谓立即执行按出现顺序
defer否,并行下载整个文档解析完成后严格按出现顺序
async否下载完立即执行(可能打断解析)不保证,谁先下完谁先跑
type="module"否同 defer按出现顺序(第二十章展开)

关键结论只有一条:脚本执行时,只有它前面已经解析过的 HTML 才能被拿到。写在 head 里的普通脚本去操作 #late 必然拿到 null,然后报出新手遇到的第一个错误 Cannot read properties of null。

示例 01:脚本的执行时机与绑定方式在新标签页打开

defer 还藏着一个细节:脚本在 DOMContentLoaded 之前执行,而 DOMContentLoaded 与 window 的 load 事件是两回事——前者在 DOM 树就绪时触发,后者要等图片、样式等全部资源加载完。想「DOM 一好就干活」,监听 DOMContentLoaded 或直接用 defer 即可,别用 load。

事件绑定:addEventListener 与 onclick 的差别

给元素挂行为有两种常见写法,差别值得先知道:

const btn = document.querySelector('#btn')

btn.onclick = () => console.log('第一个')
btn.onclick = () => console.log('第二个')   // 覆盖了上一个!最终只有这条生效

btn.addEventListener('click', () => console.log('第一个'))
btn.addEventListener('click', () => console.log('第二个'))   // 两个都会执行

onclick 是「属性赋值」,同名的只能存一个;addEventListener 是「列表登记」,可以叠多个、还能按需移除(removeEventListener,要求传同一函数引用)。还有第三种——HTML 属性内联 onclick="…"——把结构、行为写在一起,属于早期教程的写法,新代码不要再用了。事件的完整机制(冒泡、捕获、委托)在第十五章。

表达式、语句与分号

JS 里有两类语法单位:表达式产生一个值(1 + 2、user.name、函数调用),语句是一个动作(const x = 1、if (…) {…}、return)。分号在多数情况可以省略(自动分号插入,ASI),但 ASI 的规则不直观,有两条实践建议:团队内统一风格(本站统一不写行尾分号),以及不要依赖 ASI 救场——像 return 后换行这类位置,ASI 的插入点常常和你的意图相反。

二、变量与声明:const 优先,let 备用

现代 JS 的选型建议只有一句:默认用 const,确实需要重新赋值时才把它改成 let,var 基本不再使用。这不是审美偏好,var 有几条历史遗留规则会持续制造 bug,下面逐个看。

三兄弟对比

维度constletvar(老)
作用域块级 {}块级 {}函数级
重新赋值禁止(对绑定而言)允许允许
重复声明报错报错静默覆盖
声明前访问报错(TDZ)报错(TDZ)得到 undefined
提升有,但在 TDZ 中有,但在 TDZ 中提升且初始化为 undefined
const PI = 3.14
let count = 0
count = count + 1        // ✓ let 可以重新赋值

const user = { name: '阿蓝' }
user.name = '小青'        // ✓ 合法!改的是对象的属性
user = {}                // ✗ TypeError:向常量赋值(改的是绑定本身)

const 锁定的是「绑定」,不是值

这是 const 最容易被误解的一点:它保证变量永远指向同一个值,但完全不关心这个值内部能不能变。所以 const 声明对象后照样能改属性、push 数组。真想要「整个对象冻住」,用 Object.freeze()——注意它是浅冻结,嵌套的对象不受影响。

块级作用域与 TDZ

let 和 const 的作用域是「最近的这一对花括号」:

if (true) {
  let inner = '块内'
  var leak = '泄漏了'
}
console.log(inner)   // ✗ ReferenceError:块外根本不存在
console.log(leak)    // '泄漏了',var 是函数级作用域,从 if 里跑了出来

「声明前访问报错」这条叫暂时性死区(TDZ):从进入作用域到执行到声明语句之间,变量处于不可访问状态。对比 var 的「提前访问得到 undefined」——undefined 会装作没事继续跑,ReferenceError 则会立刻炸给你看,早报错远好过带病运行,这也是推荐 const/let 的原因之一。

示例 02:const 与 let 的行为边界在新标签页打开

var 的历史包袱:重复声明与循环泄漏

var 时代最常见的两个 bug:同名变量被第二次声明悄悄覆盖(不报任何错),以及 for 循环变量泄漏到外面:

for (var i = 0; i < 3; i++) { /* … */ }
console.log(i)   // 3 —— i 泄漏到了循环外面!

for (let j = 0; j < 3; j++) { /* … */ }
console.log(j)   // ✗ ReferenceError:let 的 j 只在循环体内

let 在 for 里还有一条更微妙的规则——每次迭代都会创建一个新的绑定,这让「循环里创建函数、函数记住当次的值」成为自然行为。这一点在第七章讲闭包时会看到它的完整价值。

命名规范

一条好名字省下十行注释。约定速查:

  • 变量与函数用 camelCase:userName、fetchList;
  • 真正常量用 UPPER_SNAKE_CASE:MAX_RETRY、API_BASE;
  • 布尔值带前缀:isReady、hasPermission、canSubmit;
  • 合法字符:字母、数字、$、_,不能数字开头,不能是保留字(class、return…);中文标识符合法但别用;
  • 名字要有语义:price 好于 p,list2、temp、data1 这种名字说明还没想清楚它是什么;
  • 一行只声明一个变量,别写 let a = 1, b = 2 的逗号链——日后增删都容易漏。

三、数据类型:原始值、对象与类型判断

JS 的值分两大家族:原始值(primitive,不可再分)与对象(object,属性的集合)。理解这条分界,等于拿到后面所有「为什么它没变」「为什么它跟着变了」类问题的钥匙。

八种类型与 typeof

原始值有七种:string、number、boolean、undefined、null、symbol、bigint;加上 object 一族(含数组、函数、日期等)就是全部。

值typeof 结果备注
'abc''string'
42 / 3.14'number'只有一种数字类型,不区分整数浮点
true'boolean'
undefined'undefined'「未定义」,变量声明未赋值的默认值
null'object'历史 bug,官方承认但为了兼容不改;判空用 === null
Symbol('id')'symbol'独一无二的标记
10n'bigint'超出安全范围的大整数
{} / []'object'数组也是 object,判定要用 Array.isArray()
function () {}'function'可调用对象,但归类上仍是对象
示例 03:类型判断、假值与转换在新标签页打开

原始值传「拷贝」,对象传「引用」

let a = 1
let b = a
b = 2
console.log(a)   // 1 —— 原始值各存各的

const x = { n: 1 }
const y = x
y.n = 2
console.log(x.n) // 2 —— 两个名字指向同一个对象!

function bump(obj) { obj.n++ }   // 参数也是引用传递
bump(x)
console.log(x.n) // 3 —— 函数里改的就是外面那个对象

所以「对象比较」不能靠 === 判断内容:{n:1} === {n:1} 永远是 false,因为比的是「是不是同一个对象」。判断内容相等需要逐字段比较,或用 JSON.stringify 做近似的深比较(有顺序敏感等局限)。

数字的两个经典雷区

0.1 + 0.2 === 0.3          // false!IEEE 754 双精度浮点的固有限制
Math.abs(0.1 + 0.2 - 0.3) < Number.EPSILON   // 浮点比较的正确姿势

NaN === NaN                // false!NaN 不等于任何值,包括自己
Number.isNaN(NaN)          // true,判断 NaN 要用它
isNaN('12px')              // true —— 老 isNaN 会先做类型转换,基本别用

金额场景不要用浮点数直接算账(乘 100 转整数分,或交给后端),这是行业铁律。

假值只有八个位置

if 里当条件用时,只有下面这些值算「假」,其余全是真:

false   0   -0   0n   ''   null   undefined   NaN

最容易踩的是「长得像假、其实是真」的成员:字符串 '0'、'false'、空格串 ' '、空数组 []、空对象 {}——统统为真。所以校验「用户填没填」应该判 value === '',而不是 if (value)。

四、运算符:相等、短路与可选链

运算符大多和别的语言相通,这一章只聚焦 JS 特有的、写错了最难发现的三组符号。

== 会做类型转换,=== 不会

1 == '1'        // true —— 字符串被转成数字
1 === '1'       // false —— 类型不同
null == undefined      // true —— 特例,唯一值得保留 == 的用法
[] == false            // true —— 空数组转成 '' 再转 0……
'0' == false           // true —— 新手最常踩的「字符串 0」

规则只有一条:日常一律用 === 与 !==;唯一例外是 x == null,它同时命中 null 和 undefined,读作「x 是空的吗」,写起来比 x === null || x === undefined 干净。

逻辑运算的返回值是「操作数」,短路是特性

&& 和 || 不返回布尔值,而是返回决定结果的那一侧操作数;并且左侧一旦能定结果,右侧就不执行(短路):

const name = input || '游客'      // input 为假值时用默认值
isReady && start()                // 常用简写:条件成立才执行
const count = data ?? 0           // ?? 只在 null / undefined 时兜底

|| 与 ?? 的区别是高频考点:|| 把 0 和空串也当成「没有值」,?? 只认 null / undefined。想保留用户填的 0 或 '' 时,必须用 ??:

0 || 100   // 100  ✗ 0 是假值,被兜底了
0 ?? 100   // 0    ✓ 0 是合法数字,保留

可选链 ?.:深层访问不再逐层判断

user?.profile?.name        // 中途任何一环是 null/undefined 就返回 undefined,不报错
user.greet?.()             // 方法存在才调用
user.tags?.[0]             // 数组下标同样适用
示例 04:相等、短路与可选链在新标签页打开

配套的逻辑赋值运算符 ??= / ||= / &&= 用来「只在满足条件时写入」:补配置默认值用 ??= 最精准——已设的值(哪怕是 0 或空串)不会被覆盖。

两个小符号

+ 既是加法也是字符串拼接:只要一侧是字符串,另一侧就被转成字符串('1' + 1 === '11');而 -、*、/ 只做数学,会把字符串强制转成数字再算('6' / '2' === 3)。字符串和数字混算前,先显式用 Number() 转换,比依赖隐式规则可靠。

++ 有前缀后缀之分(++i 先加再用、i++ 先用再加),但不要在表达式里使用自增——arr[i++] = arr[i] 这种代码只有作者读得懂,拆成两行。

五、流程控制:分支与循环

if / else 与提前 return

if 的语法不必多讲,值得刻意练习的是守卫子句写法:把「不满足就退出」的条件提前处理,主干逻辑就永远不会缩进三层以上。

function getDiscount(user) {
  if (!user) return 0           // 守卫:没有用户,直接返回
  if (!user.isVip) return 0     // 守卫:不是会员,直接返回
  return user.years > 3 ? 0.8 : 0.9   // 主干清晰,只有一行
}

三元表达式 条件 ? 值1 : 值2 适合「二选一取值」的场合,不要嵌套超过一层——两层以上就该改写成 if 或映射表。

switch:=== 比较与 break

switch 适合「一个值对多种情况」的分支,两条规则:比较用的是严格相等 ===(字符串 '5' 不会命中数字 5);每个 case 结尾要 break,否则会从命中的位置一路「穿透」执行到底。穿透有时是特性——多个值共用一段逻辑时让 case 连续排列即可。

示例 05:分支与循环,用 FizzBuzz 串起来在新标签页打开

三种循环与两种遍历

for (let i = 0; i < arr.length; i++) { }   // 经典 for:需要下标或跳步时用
while (条件) { }                            // 先判断,可能一次都不执行
do { } while (条件)                        // 先执行一遍,再判断

for (const item of arr) { }                // ✓ 遍历数组/字符串/Map/Set 用 for...of,拿值
for (const key in obj) { }                 // 遍历「键」;对象用 in,数组千万别用(见下)

for...in 用在数组上会给你字符串下标('0'、'1')而不是值,还会顺带翻出原型链上的可枚举属性,是教科书级的错误写法。数组和字符串用 for...of(或数组方法,第九章展开),普通对象用 for...in 或 Object.entries():

const user = { name: '阿蓝', age: 28 }
for (const [key, value] of Object.entries(user)) {
  console.log(key, value)   // 'name' '阿蓝' / 'age' 28
}

break 与 continue

  • break:立即跳出整个循环(找到就收工的场景);
  • continue:跳过本轮剩余部分,直接进入下一轮(过滤场景)。
for (let i = 1; i <= 60; i++) {
  if (i % 3 === 0 && i % 5 === 1) { found = i; break }   // 找到即停,不空转
}
for (let i = 1; i <= 20; i++) {
  if (i % 2 !== 0) continue   // 奇数跳过
  evens.push(i)
}

写循环时始终问自己两件事:终止条件会到达吗(while (true) 必须配合 break 或明确的退出分支);循环变量在推进吗(忘记 i++ 就是无限循环)。这两类 bug 在浏览器里的表现都是页面直接卡死。

六、函数:参数、返回值与作用域

函数是 JS 的基本复用单位:把一段逻辑包起来、给它起个名字、按需调用。HTML 教程里每个示例框能响应点击,背后都是函数在跑。这一章讲清函数的写法、参数与返回值的规则;作用域与 this 留到第七章,因为那要先消化一个事实:函数是可以被传来传去的值。

三种写法与「提升」

// ① 函数声明
function double(n) { return n * 2 }

// ② 函数表达式:把匿名函数存进变量
const double = function (n) { return n * 2 }

// ③ 箭头函数:表达式场景的简写
const double = (n) => { return n * 2 }
const double = n => n * 2   // 单参数省括号,单表达式省 {} 和 return

函数声明有提升(hoisting):整个函数被搬到作用域顶部,声明之前也能调用。函数表达式与箭头函数存进变量,遵守变量自己的规则——const 声明的处于 TDZ,先调用直接报 ReferenceError。实践中不要依赖提升,先定义后使用。

示例 06:函数的三种写法与参数机制在新标签页打开

参数:默认值只在 undefined 时触发

function greet(name = '游客') { return '你好,' + name }

greet()            // '你好,游客' —— 没传参
greet(undefined)   // '你好,游客' —— 传了 undefined
greet('')          // '你好,'     —— 空串是一个「值」,不触发默认
greet(null)        // '你好,null' —— null 也是一个「值」

这条规则与第四章的 ?? 完全一致:只有 undefined 代表「没有」。 与之配套的两条机制:

  • 剩余参数 function sum(...nums):把所有实参收成一个真数组(能用 map、reduce)。旧代码里的 arguments 是类数组,新代码不要再用了。
  • 展开运算符 sum(...[1, 2, 3]):反过来把数组摊成一个个实参。同一个 ...,放在形参位置是「收集」,放在实参位置是「展开」。

返回值:没有 return 就是 undefined

  • 函数没有 return(或 return 后面没有值)→ 返回 undefined;
  • return 会立即结束函数,后面的代码不再执行——利用这一点写「守卫子句」,把异常情况提前挡掉:
function divide(a, b) {
  if (b === 0) return '除数不能为 0'   // 守卫:先处理掉不能算的情况
  return a / b                        // 主干只有一行
}
  • 函数只能返回一个值;想返回多个数据就包成对象或数组,调用方解构:const { total, count } = stats(list)。
  • return 关键字后面不要换行——ASI 会在行尾补分号,把「返回值」悄悄变成「返回 undefined」。

箭头函数的适用边界

简写谱系(对应示例里的表格):

(a, b) => { return a + b }   // 完整形式
(a, b) => a + b              // 单表达式:{} 与 return 都能省
n => n * 2                   // 单参数:括号能省
() => 42                     // 无参数:括号不能省
n => ({ id: n })             // 想直接返回对象字面量:要包一层括号

选择建议:需要名字、需要复用时用函数声明;作为回调「一行搞定」时用箭头函数。箭头函数有两条硬限制,都和第七章的内容有关:它没有自己的 this(捕获外层的),也不能当构造函数(不能 new)。

函数是「一等公民」

在 JS 里函数就是一种值:能存进变量、放进对象、当参数传、当返回值返回。把函数当参数传给另一个函数(回调)是最常见的用法——事件监听、setTimeout、数组方法都在用:

button.addEventListener('click', () => { … })   // 点击时由浏览器回调
setTimeout(() => { … }, 1000)                     // 1 秒后回调一次
[1, 2, 3].map(n => n * 2)                         // map 对每个元素回调

命名也对号入座:普通函数用动词开头(fetchList、formatDate),判断类用 is / has 开头(isEmpty、hasPermission),事件处理用 handle 开头(handleClick)。

七、作用域与闭包:函数记住它的出生环境

函数不只是「一段代码」,它还会记住自己被定义时所处的环境——定义时能访问哪些变量,之后无论被带到哪里调用,这份访问权都不会丢。这条性质叫词法作用域,它衍生的最著名的机制就是闭包。

作用域链:从里往外找

const site = '我的博客'          // ① 全局

function outer() {
  const author = '阿蓝'          // ② outer 的局部

  function inner() {
    const title = '第一篇'       // ③ inner 的局部
    console.log(title, author, site)   // 三个都能访问:由内向外逐层找
  }
  inner()
}

变量查找永远遵循「由内向外」:先在本地找,找不到去外层,再到全局,都没有就 ReferenceError。反方向不行——outer 里访问不到 inner 的 title。函数每嵌套一层,就在作用域链上串一环。

闭包:内层函数「带走」了外层的变量

function createCounter() {
  let count = 0              // 外层局部变量
  return function () {
    count += 1               // 内层函数在用它
    return count
  }
}

const next = createCounter()
next()   // 1
next()   // 2 —— count 没有在两次调用间重置

createCounter 执行完毕后,按直觉它的局部变量该被回收;但因为返回出去的内层函数仍然引用着 count,JS 让这个变量继续存活。这就是闭包的定义式描述:闭包 = 函数 + 它定义时所在的环境。

闭包的三个典型用途:

  1. 私有状态:上面的 count 外部无法直接读写,只能通过 next() 推进——模块封装的最小版本;
  2. 状态工厂:createCounter() 每次调用生成一个独立计数器,互不干扰;
  3. 一次性 / 记忆化:once(fn) 让函数只生效第一次;memoize(fn) 缓存计算结果。
示例 07:闭包的三个用途与经典陷阱在新标签页打开

经典陷阱:var 循环与 let 循环

第二章提过 let 在 for 循环里「每次迭代一个新绑定」,现在能说清它救的是什么场了:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0)   // 3 3 3 —— 三段代码共享同一个 i
}

for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 0)   // 0 1 2 —— 每轮 j 是独立绑定
}

var 只有一份循环变量,等回调执行时循环早已结束、i 停在 3;let 每次迭代都创建一个新绑定,回调各自记住当轮的值。用 let 写循环就是这条坑的根治方案,setTimeout 换成事件监听、数组方法回调,现象完全一样。

内存提醒

闭包让外层变量不被回收,这既是特性也是成本:被长期持有的闭包如果引用了很大的数据(大数组、DOM 节点),会阻止它们被释放。实践上不需要紧张,只需记住一条:长生命周期的地方(全局、长期存在的定时器/监听器)别无意中拖住大对象;不再需要的监听器记得移除。

八、字符串:模板字符串与常用方法

字符串是不可变的原始值(第三章):所有「修改」方法都返回新字符串,原字符串纹丝不动。name.toUpperCase() 之后 name 还是原样——想把结果留住,要赋给变量。

模板字符串:拼接的现代写法

const user = { name: '阿蓝', points: 1280 }

// 旧:加号拼接,引号要数清楚
const msg1 = '用户 ' + user.name + ' 有 ' + user.points + ' 积分'

// 新:反引号模板,${} 里能放任意表达式
const msg2 = `用户 ${user.name} 有 ${user.points} 积分,下一级还差 ${2000 - user.points}`

模板字符串的三个能力:插入任意表达式(不是只能放变量)、跨行书写(换行不需要 \n)、标签模板(String.raw 等高级用法,先了解即可)。拼接 HTML 片段时也用它。

常用方法速查

方法作用示例
includes / startsWith / endsWith包含 / 以…开头 / 以…结尾'abc'.includes('b') → true
indexOf首次出现的下标,找不到返回 -1'a-b-c'.indexOf('-') → 1
slice(start, end)截取,支持负数下标(从尾部算)'hello'.slice(1, 4) → 'ell'
toUpperCase / toLowerCase大小写转换比较前统一大小写:a.toLowerCase() === b.toLowerCase()
trim去掉首尾空白(表单输入必做)' hi '.trim() → 'hi'
split按分隔符切成数组'a,b,c'.split(',') → ['a','b','c']
replace / replaceAll替换,支持正则'a-b'.replaceAll('-', '+') → 'a+b'
repeat / padStart / padEnd重复 / 补齐'7'.padStart(2, '0') → '07'
charAt(i) / at(-1)取单个字符'你好'.at(-1) → '好'
示例 08:字符串常用方法实操在新标签页打开

两个值得单独说的点

判断包含时 indexOf 的 -1。 if (str.indexOf('x')) 是错误写法:找不到返回 -1,是真值,if 居然会通过。正确写法是用 includes,或者显式写 if (str.indexOf('x') !== -1)。

slice 与 substring 的区别。 负数下标时 slice(-3) 表示「从倒数第 3 个开始」;substring 会把负数当 0,且参数顺序颠倒时自动交换——行为不一致且难记,统一用 slice。

九、数组:增删改查与 map / filter / reduce

数组是有序的值的集合,日常写法是字面量 [1, 2, 3]。它同时是「列表」也是「栈/队列」两种用法的载体:一端进出用 push / pop,两端进出用 push / shift。

创建与访问

const langs = ['HTML', 'CSS', 'JavaScript']   // 字面量,最常用
const empty = []                               // 空数组
const grid = Array.from({ length: 3 }, (_, i) => i * 2)   // [0, 2, 4],由长度生成

langs[0]        // 'HTML',下标从 0 开始
langs.at(-1)    // 'JavaScript',负数从尾部数,比 langs[langs.length - 1] 干净
langs.length    // 3,长度是属性不是方法

访问不存在的下标不报错,返回 undefined;length 可以写入(arr.length = 0 是清空数组的常用写法)。

增删改:记住哪端快

操作方法效果备注
尾部加push(x)返回新长度改原数组
尾部删pop()返回被删的元素改原数组
头部加unshift(x)返回新长度慢,整体后移
头部删shift()返回被删的元素慢,整体前移
任意位置splice(start, delCount, ...items)返回被删元素组成的数组就地插入/删除/替换三合一
截取不改原数组slice(start, end)返回新数组与字符串同名方法同语义
const list = ['a', 'b', 'c']
list.splice(1, 1, 'B', 'B2')   // 从下标 1 删 1 个,插入两个 → ['a', 'B', 'B2', 'c']
const copy = list.slice()      // 浅拷贝一份

遍历:for...of 与经典的 for

第五章已定下规则:数组用 for...of 拿值;需要下标时用经典 for,或者 arr.entries() 解构出 [index, value]。for...in 在数组上给字符串下标,继续回避。

for (const lang of langs) console.log(lang)

for (const [i, lang] of langs.entries()) {
  console.log(i, lang)          // 0 'HTML' / 1 'CSS' / 2 'JavaScript'
}

查找与判断

langs.includes('CSS')              // true —— 包含某个值吗
langs.indexOf('Java')              // -1 —— 找不到返回 -1(同样是那个坑)
langs.find(x => x.length > 4)      // 'JavaScript'?不,返回第一个长度>4的元素
langs.findIndex(x => x === 'CSS')  // 1

find 返回元素(找不到 undefined),findIndex 返回下标(找不到 -1),indexOf 严格相等比较、不做函数判断。以上是「查」,接下来一节的 map / filter / reduce 才是数组真正的主力。

示例 09a:数组的增删改查在新标签页打开

快速自查的三个坑

  • push 返回的是长度不是数组:const r = arr.push(1) 后拿 r 继续链式调用必然错——push 改的是原数组,不要接着用返回值。
  • splice 改原数组、slice 不改:名字只差一个字母,行为相反,拿不准时先查这条表。
  • sort 默认按字符串排序:[10, 2, 1].sort() 得到 [1, 10, 2]!数字排序必须传比较函数 sort((a, b) => a - b),下一节展开。

map / filter / reduce:用回调加工数组

数组的「遍历—加工」几乎都可以用这几个方法表达,它们都以回调函数为参数(第六章的一等公民),且都不改原数组(返回新数组/新值):

const nums = [1, 2, 3, 4, 5, 6]

nums.map(n => n * n)              // [1, 4, 9, 16, 25, 36]   每个元素变换 → 等长新数组
nums.filter(n => n % 2 === 0)     // [2, 4, 6]              按条件保留 → 变短的新数组
nums.reduce((acc, n) => acc + n, 0)   // 21                  从左到右汇总成一个值

nums.find(n => n > 3)             // 4       第一个满足的「元素」,没有则 undefined
nums.findIndex(n => n > 3)        // 3       第一个满足的「下标」,没有则 -1
nums.some(n => n > 5)             // true    「存在」满足的
nums.every(n => n > 0)            // true    「全部」满足
nums.includes(4)                  // true    是否包含某值(=== 比较)

三个主角的分工记住这张表:

方法一句话返回什么时候用
map逐个变换等长新数组「每个都要处理一遍」
filter逐个筛选变短(可能空)的新数组「挑出符合条件的」
reduce逐个累计任意类型的一个结果「求和 / 计数 / 分组 / 拼字符串」

reduce 的两个参数值得盯住:(acc, n) => 新acc 的回调,加一个初始值 0。初始值不同,结果类型就不同——传 0 求和、传 [] 收集、传 {} 分组。不传初始值时以第一个元素为起点,空数组会直接报错,所以请始终传初始值。

链式调用与短路顺序

const products = [
  { name: '键盘', price: 399, onSale: true },
  { name: '鼠标', price: 129, onSale: false },
  { name: '显示器', price: 1499, onSale: true }
]

const names = products
  .filter(p => p.onSale)          // 先筛
  .map(p => `${p.name} ¥${p.price}`)  // 再变换
示例 09b:map / filter / reduce 实战在新标签页打开

链条顺序影响性能与可读性:先 filter 后 map 能让 map 处理更少的元素;链条不要超过三步,更长的逻辑抽成命名函数。

sort 与「浅拷贝」

[10, 2, 1].sort()                     // [1, 10, 2] ✗ 默认转字符串逐字符比较
[10, 2, 1].sort((a, b) => a - b)      // [1, 2, 10] ✓ 升序
[10, 2, 1].sort((a, b) => b - a)      // [10, 2, 1] ✓ 降序

const sorted = [...nums].sort((a, b) => a - b)   // 想要新数组:先浅拷贝再排

sort 是这张表里唯一改原数组的方法(reverse 同理),所以复制一份再排序是最常见的写法:[...nums] 是展开成新数组(浅拷贝),Array.from(nums) 与 nums.slice() 等价。注意「浅」——数组元素若是对象,拷贝后两边的对象仍是同一批。

什么时候不用函数式三件套

需要 break、需要「边遍历边改原数组」、或者逻辑里有多个相互牵扯的状态时,老老实实写 for...of。方法链是给「数据管道」用的,不是用来炫技的。

十、对象:属性的读写、遍历与复制

对象是「键值对的集合」,用来给一组相关的数据一个名字:user.name、user.age 显然比 userName、userAge 两个散装变量好维护。

字面量、读写与点/方括号

const user = {
  name: '阿蓝',
  age: 28,
  'favorite-lang': 'JavaScript',   // 键名含连字符:写字面量时必须加引号
  greet() { return '你好' }        // 方法简写(等价于 greet: function () {})
}

user.name              // '阿蓝',点访问:键名是合法标识符时用
user['favorite-lang']  // 方括号访问:键名是变量或是非法标识符时必须用
const key = 'age'
user[key]              // 28 —— 方括号里放表达式

user.city = '杭州'      // 新增或改写
delete user.city       // 删除(少用;改成置 null 或重建对象更清晰)
'age' in user          // true,判断键是否存在
user.email ?? '未填'    // 读可能不存在的键 → undefined,用 ?? 兜底

属性的简写与解构

const name = '阿蓝', age = 28
const user = { name, age }        // 属性简写:变量名即键名

const { name: nick, age = 18 } = user   // 解构:取出并改名、给默认值
const { name, ...rest } = user          // 剩余属性收成新对象(浅拷贝)

解构可以嵌套(const { profile: { city } } = user),函数参数位置也常用:function render({ title, date = '今天' }) { … }——调用方传对象,顺序无关、可省略可读性好。

遍历与键值对

Object.keys(user)      // ['name', 'age', 'favorite-lang'] 键数组
Object.values(user)    // 值数组
Object.entries(user)   // [[键, 值], …],配合 for...of 或 Object.fromEntries
Object.assign({}, user)      // 浅拷贝合并(老写法)
const clone = { ...user }    // 展开浅拷贝,等价且更常用
{ ...a, ...b }               // 合并:同名键后者覆盖前者

遍历对象的位置回忆第五章:for...of 不能直接用在对象上(对象不是可迭代的),要 Object.entries() 转成数组再 for...of。

引用语义的三个现实后果

第三章说过「对象传引用」,落到日常就是三件事:

const a = { n: 1 }
const b = a          // 别名:不是拷贝!
b.n = 2              // a.n 也变成 2

const c = { ...a }   // 浅拷贝:第一层分开,嵌套对象仍共享
a.inner === c.inner  // 若 a 有嵌套对象,这是 true

JSON.parse(JSON.stringify(obj))   // 深拷贝的「穷人版」:丢函数、丢 undefined、日期变字符串

以及「不可变更新」的习惯:改状态时别原地改,创建一个改好的新对象。这一习惯对后面 DOM/框架更新(以及本站 Vue 代码)至关重要:

// 原地改:谁引用了它都会被牵连
user.age = 29

// 不可变更新:生成新对象,旧对象保持原样
const nextUser = { ...user, age: 29 }
示例 10:对象的读写、解构与不可变更新在新标签页打开

什么时候用对象、什么时候用 Map

键都是字符串/符号、结构固定(像一个「记录」)→ 对象字面量;键是动态的、任意类型的,或频繁增删 → Map:

const cache = new Map()
cache.set('k', 1).set(otherObj, 2)   // 键可以是对象,且清楚知道自己有几项
cache.get('k')       // 1
cache.has(otherObj)  // true

Map 还保证插入顺序、有直接的 size 属性;对象要数数量得 Object.keys(obj).length。日常优先对象,遇到「以对象为键」或大吞吐增删再换 Map。

十一、class:把「同类对象」的模板写成代码

对象字面量适合单件数据;如果同样结构的对象要反复创建、还要带一批方法,每次都手写字面量就是重复粘贴。这时用 class:先画一张图纸(类),再按图纸批量生产(实例)。new User('阿蓝', 28) 读作「按 User 的图纸造一个阿蓝」。

constructor 与 new:实例是怎么诞生的

class User {
  constructor(name, age) {
    this.name = name   // this 指向正在被创建的那个实例
    this.age = age
  }
  greet() {
    return `你好,我是 ${this.name}`   // 方法省略 function 关键字
  }
}

const alice = new User('阿蓝', 28)
alice.greet()   // '你好,我是阿蓝'

new 做四件事:创建一个空对象 → 把它的原型指向 User.prototype → 以这个对象为 this 执行 constructor → 返回它。constructor 里放「每个实例各自不同」的数据(名字、年龄);方法写在类体里,由所有实例共享。

方法在原型上:实例只装数据

Object.keys(alice)                             // ['name', 'age']——实例自己的属性
alice.hasOwnProperty('greet')                  // false——greet 不在实例自己身上
'greet' in alice                               // true——顺着原型链找得到
Object.getPrototypeOf(alice) === User.prototype  // true
Object.getOwnPropertyNames(User.prototype)     // ['constructor', 'greet']

读 alice.greet 时,引擎先看实例自己有没有,没有就顺着 User.prototype 找——找到才执行。这条「实例 → 类原型 → 父类原型 → Object.prototype → null」的查找路径就是原型链。方法只存一份放在原型上,而不是每个实例复制一份,这是它省内存的原因。

getter:把「算出来的值」伪装成属性

class User {
  // …constructor 同上
  get profile() {
    return `${this.name}(${this.age} 岁)`
  }
}

alice.profile   // '阿蓝(28 岁)'——写法像读属性,实际执行函数

getter 适合由已有数据派生的值:不存副本、每次读取实时计算,也就不会出现「改了 name 却忘了同步副本」的不一致。setter(set profile(v) { … })同理,把一次赋值变成校验或转换的入口。

私有字段:# 开头

第七章用闭包实现过「外部改不了」的私有状态,语言的现代方案是 # 字段:

class Counter {
  #count = 0                    // 私有字段:# 开头
  increment() { return ++this.#count }
}

const c = new Counter()
c.increment()   // 1
c.count         // undefined——外部读不到 #count
c.#count        // 语法错误:类外部根本写不出这个表达式

# 不是命名约定,是语法层面的封闭:外部看不见、改不动,只有类自己的方法能碰。余额、口令这类「绝不外泄」的数据就用它包装。

static:属于类整体、不属于实例的成员

class User {
  static population = 0   // 静态字段
  constructor(name) {
    this.name = name
    User.population++
  }
}

User.population   // 3——问类要,不是问实例要
alice.population  // undefined——实例身上没有

static 描述类整体的状态或工具:实例计数、User.fromJSON() 这样的工厂方法、配置常量。判断标准:这件事跟某一个实例有关(alice.greet()),还是跟类整体有关(User.population)。

继承:extends 与 super

class Admin extends User {
  constructor(name, age, level) {
    super(name, age)          // 先调父类 constructor,把父类那半建好
    this.level = level        // 再补子类独有的属性
  }
  greet() {
    return `${super.greet()},也是管理员`   // 覆盖父类方法,同时保留父类实现
  }
}

const admin = new Admin('青梧', 30, 3)
admin instanceof Admin   // true
admin instanceof User    // true——原型链把两代连在一起
alice instanceof Admin   // false——普通用户不是管理员

子类 constructor 里有一条硬规则:必须先 super() 才能使用 this——父类那半没建好之前,this 还不存在。子类不写 constructor 时,会自动调用父类 constructor。

什么时候用 class

  • 要批量制造结构相同、自带行为的对象(用户、订单、游戏角色)→ class
  • 只有一个存数据的单件对象 → 对象字面量就够,不必包一层类
  • 一堆互相无状态的纯计算 → 普通函数或模块组织更合适

判断标准不是「高级不高级」,而是「有没有实例这回事」。没有 new 的冲动,就用不着 class。

示例 11:class、实例与原型链在新标签页打开

十二、错误处理:错了以后怎么办

程序出错不可怕,怕的是出错后现场消失:页面白屏、按钮没反应,控制台一行红字,然后呢?错误处理就是给「出问题时」提前写好剧本——哪一步可能失败、失败后给什么替代结果、哪些错必须留证据给人看。

错误是对象:Error 自带现场信息

try {
  JSON.parse('{oops}')
} catch (err) {
  err.name      // 'SyntaxError'
  err.message   // 'Unexpected token o in JSON…'(措辞各浏览器略有差异)
  err.stack     // 从出错点出发的完整调用栈快照
}

try 块里任意一句抛错,本块剩下的都不执行,立刻跳到 catch;catch 跑完,程序从整个 try/catch 之后继续。name 是错误类型,message 是人话描述,stack 是调试时最有价值的部分——顺着它能找到谁一路调用到这个出错点。

throw:主动把错误扔出去

function divide(a, b) {
  if (b === 0) throw new RangeError('除数不能为 0')
  return a / b
}

throw 后面可以跟任何值,但永远抛 Error 实例:只有它自带 name / message / stack,能被工具链识别。抛裸字符串(throw '出错啦')会让 catch 方无从下手。

try / catch / finally:完整形态

function readSetting(key) {
  let text
  try {
    text = localStorage.getItem(key)
    return text === null ? null : JSON.parse(text)
  } catch (err) {
    console.warn('读设置失败,用默认值', err.message)
    return null
  } finally {
    console.log('无论成败都会执行')   // 释放资源、收尾的工作放这里
  }
}

finally 的特别之处:即使 try 里写了 return,它也先执行完(返回值先被暂存,finally 跑完才真正返回)。连接、文件句柄这类「用完必须关」的东西放 finally 里。

内置错误类型与自定义错误

常见类型:TypeError(对 null 读属性、调用了非函数)、ReferenceError(变量不存在)、RangeError(数值越界)、SyntaxError。要区分两种情况:代码本体的语法错误在解析阶段就挂了,try/catch 根本拦不到;而 JSON.parse 收到坏数据时抛的 SyntaxError 是运行时的,能拦。

业务错误则继承 Error 自己定义:

class ValidationError extends Error {
  constructor(field, message) {
    super(message)                 // Error 负责装好 message / stack
    this.name = 'ValidationError'  // 覆盖默认的 'Error'
    this.field = field             // 自定义信息:出错的是哪个字段
  }
}

try {
  validate(input)
} catch (err) {
  if (err instanceof ValidationError) {
    showFieldError(err.field, err.message)   // 认识的错误 → 就地处理
  } else {
    throw err                                // 不认识的 → 原样再抛
  }
}

自定义错误的价值在于「抛出方和捕获方有了约定」:instanceof 一判,字段名、错误文案都在,提示就能精确落到那一个输入框上。

捕获的分寸:只处理你懂的错

  • 认识的错误就处理:给用户一个能走下去的结果(默认值、字段提示)
  • 不认识的错误原样 throw err:里面可能藏着程序 bug,别假装没事
  • 绝不空 catch:catch {} 把现场直接抹掉,是日后最难查的 bug 来源
  • 能提前判断的别拿异常当流程控制:if (b === 0) return null 比 try/catch 更直白,异常留给「意外」
示例 12:try / catch / throw 与自定义错误在新标签页打开

十三、DOM 查询:怎么在页面里找到元素

第一章讲过浏览器把 HTML 解析成 DOM 树,从这一章开始 JavaScript 正式接管这棵树。第一步是「找到元素」——找不到,后面什么都改不了。

两个主力:querySelector 与 querySelectorAll

document.querySelector('.card')       // 第一个匹配 → 元素;找不到 → null
document.querySelectorAll('.card')    // 全部匹配 → NodeList;找不到 → 空集合
document.getElementById('staff-list') // 按 id 直取

括号里写的就是 CSS 教程里那套选择器,原样搬来:.tag、#list、li.card、[data-role="backend"],还能组合 ul > li:first-child。CSS 学过的选择器知识在这里全额复用。

两个高频要点:

  • querySelector 找不到返回 null——习惯写法是先判空再操作(if (!el) return),或者随手 ?.
  • querySelectorAll 返回的 NodeList 不是数组:有 forEach、能 for...of,但没有 map / filter;要加工先 Array.from()

顺着关系走,别只会从 document 全局查

const list = document.getElementById('staff-list')
list.children.length         // 子元素个数(只算元素,不含文本节点)
list.firstElementChild       // 第一个子元素
card.parentElement           // 父元素
card.nextElementSibling      // 下一个兄弟元素

从一个大容器出发逐层缩小范围,比从 document 全查更准(页面别处有同名 class 也不会串)也更快。

closest 与 matches:向上找与自我判断

span.closest('.card')       // 从自己出发逐层向上,返回第一个匹配的祖先;找不到 → null
card.matches('.highlight')  // 我匹配这个选择器吗 → true / false

closest 是第十五章「事件委托」的核心工具:不管点中卡片内部多深的小元素,closest('.card') 总能把你带回那张卡。

live 集合与静态快照,别记混

const live = list.getElementsByClassName('card')  // live:DOM 变了它跟着变
const snap = list.querySelectorAll('.card')       // 静态快照:查询那一刻定死

老接口(getElementsByClassName / getElementsByTagName)返回实时联动的 HTMLCollection,querySelectorAll 返回定格快照的 NodeList。示例里追加一张卡片就能看到两种行为分道扬镳。日常首选 querySelectorAll,行为可预期;需要最新结果重新查询一次就好。

示例 13:DOM 查询——找到页面上的元素在新标签页打开

十四、修改 DOM:怎么把页面改出内容

找到元素之后就是「改」。手段按重要性排序:创建与挂载、改内容、改状态、删除,最后组装成日常最常用的结构——渲染函数。

创建、拼装、挂载:三步造一个节点

const li = document.createElement('li')   // ① 造:游离在内存,还没上树
li.className = 'item'
li.textContent = '写周报'

const list = document.querySelector('#todo-list')
list.append(li)      // ② 挂到子节点末尾;prepend 挂到开头

先在内存里把子树拼装好、最后一步挂树,只触发一次布局;边挂边补会反复触发,规模一大就卡。插入全家桶还有 before / after(自己的前/后)、replaceWith;删除用 el.remove();清空子节点用 list.replaceChildren()。

改内容:textContent 与 innerHTML 的分界

el.textContent = '<b>加粗</b>'   // 原样当纯文本:页面上就是这串字符
el.innerHTML  = '<b>加粗</b>'    // 交给浏览器解析:真的变成加粗文字

铁律:挂用户输入永远走 textContent。innerHTML 只用于你完全掌控的模板字符串——评论框里塞一段 <img onerror=…>,innerHTML 会当场把脚本执行了,这就是 XSS 的入口。

改状态:classList 与 dataset

el.classList.add('done')       // 加
el.classList.remove('done')    // 删
el.classList.toggle('done')    // 切换,返回最终是否含有
el.classList.contains('done')  // 查

el.dataset.index = 2           // 写入 → 页面上是 data-index="2"
el.dataset.index               // 读回——永远是字符串

样式交给 class、附加状态记进 data-*、真正的数据放 JS 变量——「三权分立」。不必用 el.style.xxx 一个个敷(除非是真正动态计算的值),换一批 class 更整洁。

渲染函数:数据 → DOM,变了就重建

let tasks = [ … ]

function renderTasks() {
  list.replaceChildren()                 // 清空
  tasks.forEach((task, index) => {
    const li = document.createElement('li')
    li.dataset.index = index
    li.textContent = task.text
    list.append(li)
  })
}

数据变了就改数组、然后 renderTasks() 整块重建。看着「笨」(全删重造),换来一个关键好处:任何时刻 DOM 都严格等于数据的投影,不存在「忘了同步某处」的状态不一致——这正是 Vue 等框架的思路源头。反过来,用 el.remove() 直接删节点、绕过数据,下一次重建它就会复活:数据才是真相源。

事件委托预告:重建后按钮还能点

整块重建后,旧节点上的监听器全部作废。如果每次重建都重新绑定,代码会碎成一地 addEventListener。更省事的做法是把监听器挂在容器上,用 e.target.closest('.item') 判断点击落点——容器绑一次,子孙随便换。它的完整原理就是下一章。

示例 14:修改 DOM——从 createElement 到渲染函数在新标签页打开

十五、事件:用户和页面的对话

前面几章一直在用 addEventListener,现在把它的全貌补齐:事件对象里有什么、一次点击到底走了哪条路、为什么监听器该挂在容器上而不是每个按钮上。

监听、移除与选项

const onClick = (e) => { … }
btn.addEventListener('click', onClick)

btn.addEventListener('click', onClick, { once: true })     // 触发一次后自动移除
btn.addEventListener('click', onClick, { capture: true })  // 在捕获阶段接
btn.removeEventListener('click', onClick)  // 移除:必须传「同一个函数引用」

移除有个高频坑:removeEventListener 靠函数引用识别,传一个新写的箭头函数毫无作用——处理函数要先存成变量,注册和移除传的是同一个。

事件对象:target 与 currentTarget

list.addEventListener('click', (e) => {
  e.target          // 实际被点中的元素(可能很深,比如按钮里的文字)
  e.currentTarget   // 监听器挂着的元素(这里就是 list)
  e.type            // 'click'
})

事件从 document 向下「捕获」、到达目标、再向上「冒泡」。监听器默认在冒泡阶段触发;加了 { capture: true } 就在下行阶段触发。点内层元素时祖孙三代的监听器都会收到——示例里把这条链完整打印了一遍,对照着看一遍就不会再记混。

事件委托:一器多用

冒泡带来一个工程上的大招。列表有 100 个条目,逐个绑监听器就是 100 个函数,重建后还要全部重绑。换成只在容器上绑一个:

ul.addEventListener('click', (e) => {
  const li = e.target.closest('.item')   // 不管点得多深,先回到条目
  if (!li) return
  if (e.target.closest('.del')) { li.remove(); return }   // 点中删除按钮
  li.classList.toggle('done')                              // 否则算切换完成
})

好处一次说完:动态新增的条目自动生效(不用补绑)、重建后不失效(容器没换)、监听器数量与条目数无关(省内存)。这也是上一章渲染函数敢「全删重造」的底气。

preventDefault:把浏览器的默认动作拦下来

浏览器对很多事件有内置动作:点链接会跳转、提交表单会刷新页面、右键弹菜单。想接管就先拦:

link.addEventListener('click', (e) => {
  e.preventDefault()   // 取消默认动作
  // 校验、确认框、异步提交……自己安排
})

两个「拦」别用混:preventDefault 拦默认动作(事件本身还能继续冒泡);stopPropagation 拦事件传播(默认动作不受影响)。

常见事件类型速查

事件触发时机常用场景
click点击(键盘在按钮上回车也算)几乎所有交互
input每次输入、粘贴、删除实时校验、搜索建议
change值确定改变且失焦(select / checkbox 立即)下拉选择、勾选
submit表单提交拦默认、校验通过再放行
keydown / keyup按下 / 松开键,e.key 拿键名回车提交、快捷键
mouseenter / mouseleave鼠标进入 / 离开(不冒泡)悬停效果
scroll滚动吸顶、懒加载

用到再查、查了记住用法即可;找不到名字时去 MDN 搜「事件名 + MDN」是最快的路。

示例 15:事件——冒泡、委托与默认行为在新标签页打开

十六、表单:读取、校验与提交

表单是页面上唯一「用户往里写数据」的地方。把它拆成四件事:读取控件、实时反馈、拦下默认提交、校验通过后组装数据——按顺序走一遍,表单就没有神秘感了。

读取控件:value 与 checked

<input id="f-name" type="text">
<select id="f-city"><option>杭州</option>…</select>
<input type="checkbox" name="interest" value="js">
nameInput.value        // 文本框、密码框、下拉框:都用 .value(字符串)
checkbox.checked       // 复选框、单选框:用 .checked(true / false)
form.querySelectorAll('input[name="interest"]:checked')   // 只挑勾上的

:checked 是 CSS 伪类选择器在查询里的又一次复用。整表一把抓用 new FormData(form):可遍历出「键 → 值」序列。两个细节要记住:没勾的复选框根本不在 FormData 里;同名多选会各占一条。

手工组装对象 vs FormData 各有场景:手工版本只收想要的字段、能把值转成数字、勾选项收成数组;FormData 原貌打包,适合整个表单原样发给后端。

input 事件:输入时实时反馈

emailInput.addEventListener('input', () => {
  const ok = /^\S+@\S+\.\S+$/.test(emailInput.value.trim())
  hint.textContent = ok ? '格式看起来没问题' : '需要 名称@域名 的形式'
})

input 每次改动都触发(打一个字也触发),用来做即时提示;change 在失焦且值改变后才触发,适合「确定后再生效」的场景。

submit:拦下默认提交,统一校验

form.addEventListener('submit', (e) => {
  e.preventDefault()      // 不拦的话:页面刷新,你所有的 JS 状态清零
  const errors = {}       // 「字段 → 提示语」的错误对象
  if (!name.value.trim()) errors.name = '名字不能为空'
  if (!/^\S+@\S+\.\S+$/.test(email.value)) errors.email = '邮箱格式不对'
  if (!agree.checked) errors.agree = '需要先同意条款'
  renderErrors(errors)    // 用 Object.entries 逐个写到字段旁边
  if (Object.keys(errors).length) return   // 有错就停
  // 校验通过:组装数据,交给后端(第十九章 fetch)
})

三个要点:e.preventDefault() 必须是第一步;校验结果用「字段 → 提示语」的对象表达,Object.entries 就能派位到界面(第十章学的遍历在这里有了正经工作);最后一个 return 把「不通过」挡在门外。想再加体验细节,可以 focus() 到第一个出错字段。

提交按钮与回车是同一个事件

<button type="submit"> 和输入框里的回车,触发的都是 submit——两个入口,一份逻辑。反过来要小心:form 里的 <button> 不写 type 时默认就是 submit,想当普通按钮用必须显式写 type="button",这个默认值坑过无数人。

示例 16:表单——读取、实时校验与提交在新标签页打开

十七、BOM 与浏览器存储

到上一章为止,我们干的活都发生在「文档」里。但 JS 的宿主是浏览器,文档之外还有一整套围绕浏览器窗口的 API——常被叫作 BOM(Browser Object Model)。这一章认识四样东西:window、navigator、location、history,再加一个绕不开的实用主题:把数据存在用户的浏览器里。

window:全局对象,也是窗口

第七章讲过作用域链:变量从里往外找,链的最后一站是全局作用域。在浏览器里,这个「最后一站」有个具体的名字——window。它身兼两职:

  • 它是全局对象:var 声明的变量、函数声明,都会自动变成 window 的属性
  • 它是浏览器窗口:视口尺寸、弹窗、定时器(下一章)都挂在它名下
var appName = 'demo'
function boot() {}
window.appName       // 'demo':var 和函数声明自动成为 window 的属性
let secret = 1
window.secret        // undefined:let/const 不上 window

window.innerWidth    // 视口宽(CSS 像素)
window.alert('!')    // 这些全局函数,其实都是 window 的方法
alert('!')           // 平时省略前缀

navigator 提供宿主环境的信息:navigator.language(界面语言)、navigator.onLine(粗略的联网状态)、navigator.userAgent(浏览器标识字符串)。

UA 字符串是历史包袱:浏览器们互相伪装,字符串里塞满了别人的名字,解析它来判断「用户用什么浏览器」非常不可靠。正确姿势是特性检测——不看你是谁,看你有什么能力:

if ('geolocation' in navigator) {
  // 有定位能力才调用,没有就换方案
}

window.matchMedia('(prefers-color-scheme: dark)').matches
// 系统偏好深色?本站的明暗主题就是靠它读初始偏好的

location:地址栏的入口

location 描述当前页面地址,改它就等于导航。

location.href       // 完整地址:读是字符串,写是跳转
location.pathname   // 路径部分
location.search     // '?page=2'
location.hash       // '#who=alan',井号起

location.hash = 'who=alan'   // 只改 hash:不刷新页面、会进历史记录
location.reload()            // 重新加载(慎用,页面状态全丢)

地址栏参数交给 URLSearchParams 解析,比自己 split('&') 稳:

// 地址为 /search?page=2&tag=js&tag=dom 时
const params = new URLSearchParams(location.search)
params.get('page')     // '2'——注意永远是字符串,要算数得自己转
params.get('tag')      // 'js':同名参数只返回第一个
params.getAll('tag')   // ['js', 'dom']:全都要用 getAll
params.has('page')     // true

history:前进与后退

history.length 是当前会话的历史条数;back() / forward() 等价于点浏览器的前进后退。更常用的是 pushState / replaceState——不刷新页面也能改地址,前端路由(比如本站切换页面)的地基:

history.pushState({ page: 1 }, '', '/list?page=1')    // 新增一条历史:地址变了,页面没动
history.replaceState({ page: 2 }, '', '/list?page=2') // 替换当前这条,不加新记录

这对 API 受同源策略严格约束:本章示例跑在沙箱 iframe 里(没有自己的「源」),一调 pushState 就抛 SecurityError——所以示例里只演示 hash 这种「改地址不刷新」的手段。

alert / confirm / prompt:内置弹窗

alert('知道了')                            // 只有「确定」,无返回值
const ok = confirm('确定删除吗?')          // 确定 true / 取消 false
const nick = prompt('你叫什么?', '默认值')  // 确定 → 字符串(可为空串);取消 → null

三个都是阻塞式:脚本会停住等你点完,而且样式不可定制。正式产品里基本不用(替换成页面内的对话框),但调试和小工具里很顺手。

存储三件套:localStorage、sessionStorage、cookie

浏览器会在本地为用户存数据,三个容器,用途完全不同:

localStoragesessionStoragecookie
生命周期永久,除非主动清除标签页关掉即失可设过期时间
作用范围同源的所有标签页只在当前标签页同源 + 路径规则
容量约 5MB约 5MB4KB
会发给服务器吗不会不会每次请求都带上
典型用途主题、草稿、偏好一次性的流程状态会话凭证(给后端看)

口诀:前端自己用的数据放 localStorage / sessionStorage;要让后端看到的才用 cookie。

两种 storage 的 API 一模一样(第十章末尾见过 Map——这套是它的老前辈):

localStorage.setItem('theme', 'dark')   // 存
localStorage.getItem('theme')           // 'dark';键不存在返回 null(不是 undefined)
localStorage.removeItem('theme')        // 删一条
localStorage.clear()                    // 清空——会波及同源下的所有代码,慎用
localStorage.length                     // 现有几条

只认字符串:JSON 来中转

两个必记的点:存进去什么,取出来都是字符串;想存对象/数组要用 JSON 中转。

localStorage.setItem('count', 7)
const got = localStorage.getItem('count')
typeof got                // 'string'!拿到的是 '7',要算数得 Number(got)

const user = { name: '阿蓝', visits: 3 }
localStorage.setItem('app:user', JSON.stringify(user))     // 序列化进去
const saved = JSON.parse(localStorage.getItem('app:user')) // 反序列化出来
saved.name                // '阿蓝',类型保住了

JSON.stringify({ fn() {}, u: undefined })  // '{}'——函数和 undefined 直接消失
JSON.stringify(new Date())                 // 日期变字符串;要用 Date 得自己 new

键名建议带统一前缀(app:user、app:theme),避免和同源下其他代码撞名。这套 JSON 往返,也是下一章 fetch 与后端交换数据的预演。

存不进去怎么办:先探测,再兜底

localStorage 并非总能用:隐私模式、浏览器设置、沙箱 iframe 都会让它一写就抛异常(SecurityError);写爆容量(约 5MB)会抛 QuotaExceededError。所以正经代码不直接假设它可用:

try {
  localStorage.setItem('__probe__', '1')
  localStorage.removeItem('__probe__')
  // 没抛异常:可用
} catch (err) {
  // SecurityError 等:不可用,降级到内存 Map——程序换个方式继续跑
}

本章示例就跑在沙箱 iframe 里,你会亲眼看到探测失败、代码自动降级到内存的过程——正是第十二章的原则:预判得到的错误就处理掉,让程序活着。大文件(超过 5MB)不该硬塞 localStorage,那是 IndexedDB 的地盘。

最后一个安全习惯:localStorage 里的一切,同源脚本都能读。所以密码、登录凭证不要放进去——第十四章说过,XSS 注入的脚本第一件事就是翻 localStorage。

下一章换个维度:前面所有代码都是「立刻执行」的,下一章解决「等一等再执行」——定时器、回调、Promise 与 async / await。

示例 17:BOM 环境信息与带兜底的存储在新标签页打开

十八、异步:等一等再执行

前十七章的代码都是「一行做完再做下一行」。但浏览器里很多事急不来:网络请求要几百毫秒,定时器要等够时间,用户什么时候点按钮完全没谱。JS 的策略是不等——先往下走,事情完成时回头叫一个函数。这就是异步。

定时器:把函数「预约」到未来

setTimeout 把函数预约到「至少 N 毫秒后」执行,返回一个编号 id;clearTimeout(id) 可以反悔。setInterval 每隔 N 毫秒重复执行,配 clearInterval:

const id = setTimeout(() => { console.log('1 秒后见') }, 1000)
clearTimeout(id)          // 反悔:还没到点就取消

const clockId = setInterval(() => { console.log(new Date()) }, 1000)
clearInterval(clockId)    // 停止

关键词是「至少」:定时器不会打断正在执行的代码。如果一个循环要跑 3 秒,你设置的 1 秒定时器也得排队等它跑完:

setTimeout(() => console.log('定时器'), 0)
for (let i = 0; i < 3; i++) console.log('循环 ' + i)
// 输出:循环 0、循环 1、循环 2、定时器
// 0 毫秒也不是立刻:先让当前这轮同步代码跑完

回调:结果出来时叫我

早期 JS 的异步全写回调:把「拿到结果后要做的事」包成函数传进去。约定俗成的回调风格是错误优先——第一个参数放着错误:

orderTea((err, result) => {
  if (err) {
    console.log('失败:' + err.message)
    return
  }
  console.log('成功:' + result)
})

能跑,但有著名的「回调地狱」问题:上一步的结果是下一步的输入,只能一层套一层,代码向右缩进成金字塔,错误处理还要每层重复。

Promise:给「未来的结果」一个盒子

Promise 是一个代表未来结果的对象,三态:pending(等结果)→ fulfilled(成功)或 rejected(失败),状态一旦落定就不可逆。

function orderTea() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (Math.random() > 0.3) resolve('一杯珍珠奶茶')      // 兑现
      else reject(new Error('打烊了'))                       // 拒付
    }, 800)
  })
}

orderTea()
  .then((tea) => console.log('拿到:' + tea))       // 成功走这里
  .catch((err) => console.log('出错:' + err.message)) // 失败走这里
  .finally(() => console.log('收尾'))                // 都走这里

三个值得记住的点:

  • .then 里 return 的东西会被包成新的 Promise,所以可以链式往下串——这才解决了回调嵌套
  • new Promise(executor) 里的执行器函数是同步立刻执行的,真正异步的是它注册进去的定时器/网络回调
  • 忘了 .catch,失败会变成「未处理的拒绝」,控制台报 Uncaught (in promise)

async / await:把 Promise 写成人话

async 函数里可以 await 一个 Promise:等它落定,成功直接取出值,失败抛成异常——于是 try/catch 就能管异步错误了:

async function buyTea() {
  try {
    const tea = await orderTea()   // 等它落定;期间页面照常响应
    console.log('拿到:' + tea)
  } catch (err) {
    console.log('出错:' + err.message)
  }
}

注意:await「暂停」的只是这个 async 函数,不是整个页面——函数在 await 处让出控制权,其他事件照常跑,Promise 落定后它从下一行接着走。另外,async 函数永远返回 Promise,所以 buyTea() 拿到的是 Promise 而不是值;想在别处拿值,就继续 await 它。

串行还是并行:别让 await 排队

常见翻车点:三个互不依赖的任务,写成三个 await 串着等,总耗时是三者之和:

await task('洗菜', 600)   // 一个接着一个……总共约 1800ms
await task('切菜', 600)
await task('炒菜', 600)

const names = await Promise.all([task('烧水', 600), task('蒸饭', 600), task('备菜', 600)])
// 同时发车,约 600ms 拿到全部结果

Promise.all 一荣俱荣:任一失败,整体立刻以第一个错误 reject。口诀:有依赖才串行,没依赖用 Promise.all。

微任务与宏任务(够用版)

记住一个执行顺序就够用了:同步代码 → 微任务(Promise 回调、await 的续行)→ 宏任务(setTimeout / setInterval 回调):

setTimeout(() => console.log('定时器 0'), 0)
Promise.resolve().then(() => console.log('Promise'))
console.log('同步代码')
// 输出:同步代码 → Promise → 定时器 0

它能解释大部分「为什么日志顺序和我想的不一样」;更深的机制(事件循环、任务队列)用到再补。

示例 18:定时器、回调、Promise、async / await 与串并行对比在新标签页打开

十九、fetch 与 JSON:和服务器说上话

上一章的 Promise 和 async / await,最大的用武之地就是网络请求。这一章把「页面的数据从哪来」这件事说透:新前端的第一口粮,就是从服务器 fetch 回来的 JSON。

JSON:跨语言的数据交换格式

JSON(JavaScript Object Notation)长得像 JS 对象字面量,但它是格式不是代码:键必须双引号、没有注释、没有函数,值只允许字符串、数字、布尔、null、数组、对象。你要说规则就一句:能 JSON.stringify 出去、能 JSON.parse 回来,才算合法。

JSON.stringify({ name: '阿蓝', tags: ['js'] })   // '{"name":"阿蓝","tags":["js"]}'
JSON.parse('{"page": 2}')                        // { page: 2 }

JSON.parse 遇到非法文本会抛 SyntaxError——内容来自外部(网络、存储、用户)时,必须包 try/catch,这是第十二章就定下的纪律。

fetch 的第一个反直觉:404 也算「成功」

fetch(url) 返回 Promise,但它 resolve 的条件是「服务器有应答」——404、500 都是有应答,照样 resolve;只有断网、DNS 失败这类网络层错误才 reject。所以拿到结果后必须自己检查 res.ok(2xx 为 true):

  • resolve ≠ 请求成功,只等于「有回音」
  • 没查 res.ok 的代码,会在 404 时拿着错误页去 json(),然后以一种莫名其妙的方式崩掉

一个完整的「三段式」模板,以后所有 GET 都可以照抄:

async function getUsers() {
  try {
    const res = await fetch('/api/users')
    if (!res.ok) throw new Error('HTTP ' + res.status)
    return await res.json()
  } catch (err) {
    console.error('加载用户失败:', err.message)
    return []
  }
}

读响应体有三个常用方法:res.json()(解析成对象)、res.text()(原始字符串)、res.blob()(二进制)。注意响应体是一次性流:json() 读过了再 text() 会报错,二选一。

POST:把数据送上去

发数据用第二个参数:method、headers、body。body 必须是字符串——对象要先 JSON.stringify;Content-Type: application/json 相当于告诉服务器「我给你的是 JSON,请按这个解」:

await fetch('/api/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: '阿蓝', role: 'member' }),
})

查询参数用第十七章的 URLSearchParams 拼,省得手写转义:

const qs = new URLSearchParams({ q: '闭包', page: 2 })
fetch('/api/search?' + qs)   // /api/search?q=%E9%97%AD%E5%8C%85&page=2

新手翻车清单

  • 忘了 await:拿到的是 Promise 不是数据(users.length 是 undefined)
  • 忘了查 res.ok:404 当成功处理
  • body 忘 JSON.stringify:服务器收到 [object Object]
  • json() 当同步方法用:它是 async 的,要 await
  • 路径写相对 /api/... 时和页面不同源会被 CORS 拦:跨源需要服务器在响应头里点头(Access-Control-Allow-Origin),这是后端配置问题,不是 fetch 写法问题
  • 拿网络数据直接塞 innerHTML:XSS 老问题,回顾第十四章

本章示例的说明

示例跑在沙箱 iframe 里,没有可用的网络源,所以内置了一个「长得和 fetch 一样」的 fakeFetch(同样 600ms 延迟、同样返回带 status / ok / json() 的响应对象)来完整演示流程:200 / 404 / 500 / 断网四种结局、POST 提交、重新拉取列表。真实项目里把它换成 fetch,流程一字不差。

下一章换个话题:代码越写越多,全塞一个文件迟早失控——模块化怎么拆、怎么 import / export,以及交付前的工程习惯。

示例 19:fetch 流程——状态码、404/500/断网与 POST在新标签页打开

二十、模块与工程习惯

到上一章为止,代码都写在单个 script 里。真实项目动辄几千行,全塞一个文件有三宗罪:找不到、会撞名、没法复用。这一章解决「代码怎么组织」,顺路把交付前该有的工程习惯过一遍。

模块是什么:自带作用域的文件

一个 JS 文件加上几个 export,就是「模块」(ES Module)。浏览器原生支持,机制有四个要点:

  • 自带作用域:模块顶层的变量不会挂到 window(对比:传统 script 的顶层 var 和函数声明全是全局的,本章示例能看到这个差别的现场)
  • 显式接口:只有 export 出去的东西,外面才拿得到
  • 单例:同一个模块被多个地方 import,也只执行一次,共享同一份内部状态
  • 严格模式:模块里自动开启 'use strict',第二章那些「别这么干」从建议变成强制
<script type="module" src="./main.js"></script>

注意:file:// 直接双击打开会因为 CORS 报错——模块必须经 HTTP 访问(本地起个静态服务,或直接用下节说的构建工具)。

export 与 import 的几种写法

// utils.js —— 输出方
export function formatTime(date) { … }    // 具名导出:一个文件可以有很多个
export const MAX = 10
export default function render() { … }    // 默认导出:一个模块至多一个
// main.js —— 使用方
import { formatTime, MAX } from './utils.js'    // 具名导入:名字要对上
import { formatTime as fmt } from './utils.js'  // 重命名
import render from './utils.js'                 // 默认导入:名字自己起
import * as utils from './utils.js'             // 整个模块收成命名空间对象

import('./heavy.js').then((mod) => { … })       // 动态导入:用到才加载,返回 Promise

两条硬规矩:

  • 路径必须写 ./ 或 ../ 开头——写成 'utils.js' 会被当作 npm 包名去找,然后报「找不到模块」
  • import 会被提升到模块顶部,写在哪里都一样(别指望靠位置控制顺序)

default 还是具名?一个模块只做一件事、只有一个主出口 → default;工具集合(一排平级函数)→ 全部具名(更好搜、重构更安全)。团队里保持一致,比纠结选哪个更重要。

拆文件的原则:一条链,三个角色

小型项目够用的分法——utils(纯工具)→ store(数据与业务)→ main(入口/界面):

  • utils:不碰 DOM、不管业务,进什么出什么,最好测
  • store:管数据和规则(比如「count 不能超过 limit」),也不碰 DOM
  • main:只当接线员——把用户事件接到 store,把结果渲染到 DOM

依赖方向单向:main → store → utils。反过来(工具函数里 import 业务状态)就是循环依赖噩梦的开端。本章示例把第十四章的迷你待办按这个原则拆成三个文件,每个几十行,看完文件名就知道该去哪改。

命名与注释:改代码的人会谢你

  • 文件名小写连字符(user-store.js);函数名用动词短语(createUser、formatDate),布尔用 is/has/can 开头(isValid、hasNext)
  • 注释写「为什么」,不写「是什么」:i++ // i 加一 是噪音;// 兼容 iOS 15 的旧行为 才是信息
  • 删掉注释掉的死代码——git 记着呢,不差这一份

DevTools:出 bug 时的三件套

  • console 家族:console.table(arr) 把对象数组排成表、console.time / timeEnd 掐耗时、console.error 让日志带颜色和调用栈(本站示例的「迷你控制台」就是它的仿制品)
  • 断点:Sources 面板点行号打断点,鼠标悬停看变量值;条件断点只在条件成立时停下;在代码里写 debugger 语句也会停
  • Network 面板:上一章的请求全在这:状态码、请求头、响应体——接口挂没挂一眼便知

从手写 script 到工程化:缺的那一环

现在我们手动引入每个文件:<script type="module" src="./main.js">,main.js 再 import 其他。项目再大就会遇到新问题:几百个模块逐个写标签不现实、新语法旧浏览器不认、npm 上的包怎么用。这就是构建工具(现在的默认答案是 Vite)出场的原因:你安心写模块和装包,它负责打包、压缩、兼容旧浏览器。学完下一章的收尾实战,你就具备了看懂一个 Vite 项目 src/ 目录的全部前置知识。

示例 20:模块作用域、三文件拆分与运行效果在新标签页打开

二十一、综合实战:从「能跑」到「像样」

毕业考试不用另出题:把 21 章的技能串起来做一个待办清单就够了。需求列清楚——添加(含校验)、勾选完成、删除、按状态筛选、显示剩余数量、刷新后数据还在。先看一版「能跑但毛病一堆」的实现,再逐条改造成能进产品的水准。

第一版:「能跑」就行?

下面的版本功能真的都能用:回车添加、勾选、删除。但代码里有六个毛病,每一个都是前面某章点名讲过的——先玩一玩,看你能认出几个(提示:输入框里输入 <b>我有格式</b> 再回车,看看会发生什么)。

示例 21(改造前):能跑但毛病一堆的待办清单在新标签页打开

对照下面的清单找一找,看你说中了几个。

「能跑版」的毛病改造版怎么治出处
全局变量满天飞用闭包把状态藏起来,对外只暴露操作函数七、十
innerHTML 拼字符串createElement + textContent,用户输入永远当文本十四
onclick 属性、函数挂 windowaddEventListener + 事件委托,一个监听管全部条目十五
不 trim、不校验trim + 空值/长度拦截 + 错误提示八、十六
刷新即失JSON + localStorage,先探测再兜底十七
数据与界面搅在一起分出数据层 / 渲染层:一切改动 = 改数据 + 重新 render十四、二十

改造版还顺手加了一处毛病清单之外的东西:保存状态用 async / await 模拟异步落盘(真实项目里这里就是一次 fetch POST),右上角能看到「保存中… → 已保存」的流转。

第二版:改造版

示例 21(改造后):综合实战·待办清单在新标签页打开

改造版的骨架,其实就三件事

数据层用闭包藏住状态,对外只有一组操作函数——你会注意到所有修改都是「不可变更新」,而数组的 map / filter / reduce 全用上了:

function createStore(initial) {
  let todos = initial
  let seq = todos.reduce((max, t) => Math.max(max, t.id), 0)
  return {
    add(text) { todos = [...todos, { id: ++seq, text: text, done: false }] },
    toggle(id) { todos = todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)) },
    remove(id) { todos = todos.filter((t) => t.id !== id) },
    get() { return todos },
  }
}

渲染层一切从数据出发,事件用一个委托全部接管——数据变了就重新 render,界面永远和数据对得上,不存在「哪里忘了同步」:

function render() {
  list.replaceChildren(...visible().map(renderItem))
}

list.addEventListener('click', (e) => {
  const item = e.target.closest('.item')
  if (!item) return
  const id = Number(item.dataset.id)
  if (e.target.matches('.del')) { store.remove(id); commit('删除') }
  else if (e.target.matches('input[type="checkbox"]')) { store.toggle(id); commit('更新') }
})

剩下的都是普通代码。把示例在「在新标签页打开」后对着源码再读一遍——每一行都能对应回某一章。

你已经走完了什么

从第一章「script 放在哪」到能独立写出上面这个应用:变量、类型、函数、闭包、对象、类、错误、DOM、事件、表单、BOM、异步、fetch、模块——21 章没有一章是多余的,它们都在你刚才玩的这个页面里出现了。

接下来去哪:想直接上框架就学 Vue 或 React(你在读的这个站点就是 Vue 写的——回头看你写的 render(),是不是已经有点「响应式」的形状了);想补类型上 TypeScript;想吃透 npm 生态就认真学 Vite;想碰后端就顺着 Node.js 往下走。工具会换,但变量、函数、异步、模块这些地基不会。

最后一份检查清单

下面这些如果都能不看教程写出来、并且讲给别人听明白,这门课就毕业了:

  • 说清 var / let / const 的区别,以及「const 锁的是绑定不是值」
  • 分清原始值和对象传参的差别,说得出什么叫浅拷贝
  • 用解构、展开、可选链把日常代码写短而不晦涩
  • 写好箭头函数,解释闭包为什么能「记住」外层变量
  • 熟练用 map / filter / reduce / find 加工数组
  • 用 class 加私有字段写一个小型数据结构
  • 用 throw / try / catch / finally 搭起错误处理
  • 用 querySelector / createElement / textContent 安全地操作 DOM
  • 用事件委托让动态列表「一器多用」,说清 preventDefault 和 stopPropagation 的分工
  • 用 FormData 与 submit 事件读写、校验一张表单
  • 用 setTimeout / setInterval 排列时间,用 Promise 与 async / await 消费异步
  • 写 fetch 时记得查 res.ok,POST 时记得 JSON.stringify
  • 用 JSON 往返在 localStorage 里存对象,并做可用性探测与兜底
  • 把代码拆成 utils / store / main,理顺 import / export

清单里每一条,正文里都配了示例。以后哪条卡住了,回来翻对应那一章就好。

写在最后

教程到这儿就结束了,但「会 JS」这件事只能靠写出来。去找一个你自己每天真用得上的小需求——记账、单词卡、值班表,什么都行——用这 21 章的全部本事把它做出来。过程中卡住的每一处,都比再读一遍教程管用。

评论

为保障内容质量,评论需站长审核通过后才会展示;提交后没立刻看到属正常,请勿重复提交。

还没有评论,来抢沙发~