JS入门
前言
- 这一篇讲 JavaScript 的语言本身:变量与作用域、类型与转换、对象与数组、函数与
this、流程控制、异常、异步(事件循环 / Promise / async-await)、常用内置对象、模块化。浏览器里的document、window那些属于下一篇《DOM 和 BOM》。 - 本文所有示例都在本机实跑过,环境是 Node v26.13.0(V8 引擎,与 Chrome 同源),输出原样贴在每个代码块下面的
text块里。- 一个格式差别:Node 的控制台打印数组会写成
[ 1, 2, 3 ](元素两侧有空格),浏览器控制台是[1, 2, 3]。值是一样的,只是显示风格。 - 另一个语义差别:Node 的普通
.js文件是 CommonJS 模块、默认宽松模式,和浏览器里不带type="module"的<script>行为一致;一旦写'use strict'或用模块,就是严格模式(下面有专门一段对比)。
- 一个格式差别:Node 的控制台打印数组会写成
JS 是怎么跑起来的
四种写法
1 |
|
| 写法 | 执行时机 | 说明 |
|---|---|---|
| 不带属性 | 解析到就停下来执行 | 阻塞 HTML 解析,所以老习惯是「脚本放 </body> 前」 |
defer |
文档解析完、DOMContentLoaded 之前 |
保持书写顺序执行,适合依赖 DOM 的业务脚本 |
async |
下载完就执行 | 谁先下载完谁先跑,适合埋点这类互不依赖的脚本 |
type="module" |
默认等同 defer |
严格模式、顶层 this 是 undefined、变量不泄漏到全局、可用 import/export |
- 调试用浏览器控制台(F12 → Console),
console.log只是最基础的一个:console.table(看数组/对象)、console.group、console.count、console.time/timeEnd都更好用。 - 控制台里可以直接写表达式求值;
$0代表当前在 Elements 里选中的元素。
alert / prompt / confirm
这三个弹窗会阻塞整个页面,只适合演示。真正的产品里用自定义弹窗组件。
1 | // 浏览器里才有;Node 环境没有 window.alert |
变量:var、let、const
1 | // 三种声明关键字:var(函数作用域)、let(块级)、const(块级 + 不可重新赋值) |
1 | var 声明 let 声明 const 声明 |
- 新代码的默认选择:
const优先,需要重新赋值才用let,var只在读旧代码时遇到。 let/const是块级作用域({}之内),var是函数作用域并且会「漏」出代码块:
1 | // 块级作用域:{} 里用 let/const 声明的变量,出了这块就没了 |
1 | 块内可见 / 块内也可见 |
- 提升(Hoisting)到底提升什么:
- 函数声明整体提升,连函数体一起,所以声明之前就能调用。
var只提升声明、不提升赋值,所以提前读到的是undefined。let/const提升但不初始化,提前访问会进「暂时性死区」直接报错。
1 | // 提升:函数声明整个被提前;var 只提前「声明」,不提前「赋值」 |
1 | 调用提升的函数 -> Hi, Bob |
- 面试经典:
for (var)里存的闭包全部拿到同一个变量。
1 | // 经典面试题:var 共享同一个变量,let 每轮循环新建一个绑定 |
1 | for(var) 里的闭包: [ 3, 3, 3 ] |
- 另外两个常见细节:
- 不写关键字直接赋值:宽松模式下会悄悄创建一个全局变量(严格模式抛
ReferenceError),这是最阴的 bug 来源之一。 const只锁「绑定」,不锁「内容」:const arr = [1]; arr.push(2);完全合法,arr = []才报错。
- 不写关键字直接赋值:宽松模式下会悄悄创建一个全局变量(严格模式抛
数据类型
一眼看清:8 种类型
| 类别 | 类型 | 举例 | typeof 结果 |
|---|---|---|---|
| 原始类型 | Number | 42、3.14、NaN、Infinity |
number |
| 原始类型 | String | 'a'、`模板${字符串}` |
string |
| 原始类型 | Boolean | true / false |
boolean |
| 原始类型 | Undefined | 未赋值的变量 | undefined |
| 原始类型 | Null | 显式的「空」 | object(历史 bug) |
| 原始类型 | Symbol | Symbol('id') |
symbol |
| 原始类型 | BigInt | 10n |
bigint |
| 引用类型 | Object | {}、[]、函数、Date、RegExp、Map… |
object(函数是 function) |
1 | // typeof 返回字符串;注意两个历史遗留坑:typeof null === 'object',typeof [] === 'object' |
1 | number string boolean undefined |
Number:只有一种数字类型,全是浮点
1 | // JS 只有一个数字类型 Number,全是 IEEE 754 双精度浮点 |
1 | 整数与小数同类型: number number 0.3333333333333333 |
- 需要精确小数(金额)时,用「分」为单位的整数存,或引第三方十进制库,别拿浮点直接加减比较。
Infinity/-Infinity/NaN都是合法的 number;NaN不等于任何东西,判断要用Number.isNaN(x)(注意老的全局isNaN('abc')会先把字符串转成数字,返回true,是个陷阱)。
BigInt:超大整数
1 | // BigInt:超出 Number 精度的整数,ES2020 引入,字面量结尾加 n |
1 | 字面量: 1234567890123456789012345678901234567890n |
真值与假值:只有 8 个假值
1 | // 只有 8 个假值,其余全部为真——空字符串、空数组、'0' 都是真值 |
1 | 8 个假值(值为类型): ''(string), 0(number), 0(number), 0(bigint), NaN(number), null(object), undefined(undefined), false(boolean) |
- 空数组、空对象、字符串
'0'、'false'全都是真值——if (arr)判断不了空数组,要看arr.length。
对象是引用,赋值只是「多一个名字」
1 | // 对象赋值只是「多一个名字」;展开运算符是浅拷贝,只复制第一层 |
1 | 改 b 会影响 a: 2 | a === b: true |
- 真正深拷贝:
structuredClone(obj)(现代浏览器和 Node 17+ 都支持,Map/Set/Date都能保留);JSON.parse(JSON.stringify(x))会丢函数和undefined、把Date变成字符串、遇到循环引用直接报错。
类型转换与比较
隐式转换的规则
1 | // 隐式转换:+ 只要有一边是字符串就变拼接,其它算术运算先把操作符转成数字 |
1 | '1' + 2 = 12 |
- 一句话记法:
+只要有一边是字符串就拼接;其它算术运算符(- * / %)先把两边转成数字。 - 关系运算符两侧都是字符串时按字典序比,所以
'10' > '9'是false;有一边是数字就转数字。
== 的坑清单
1 | // == 会做类型转换,=== 不会。记住:除了 null/undefined 互相等,其它一律用 === |
1 | 0 == '0' -> true / === -> false |
- 结论:除了判「null 或 undefined」这一种情况,一律用
===。 - 判空值的三种写法对比:
1 | // 实际写法建议:判空用显式比较,判假值用 Boolean 语义 |
1 | 输入顺序 : 0, , null, undef, false, NaN, , 0 |
- 想避免
==又想让NaN自反、+0与-0有别,用Object.is(NaN, NaN) // true、Object.is(0, -0) // false。
可选链 ?. 与空值合并 ??
1 | // 三目、短路、空值合并 |
1 | 三目: 良好 |
??只把null/undefined当「空」,||把所有假值都当「空」——给默认值时想用??,否则0、''会被吃掉。
对象与数组
对象的键与遍历
1 | // 对象的键只能是字符串或 Symbol;数字字面量键会被转成字符串并排在最前 |
1 | 键的顺序: [ '1000', 'dyn', 'short', '带空格' ] |
- 键的顺序规则:整数键升序在前 → 其余字符串键按插入顺序 → Symbol 键按插入顺序。
解构与展开
1 | // 解构与展开(...):从数组/对象里按位置或名字取值 |
1 | 数组解构: 1 2 [ 3, 4 ] |
数组增删
1 | // 增删:push/pop 在尾部,unshift/shift 在头部,splice 按索引增删改 |
1 | push 返回新长度: 3 [ 1, 2, 3 ] |
数组遍历与计算
1 | // 遍历类方法:map/filter/reduce 生成新值,forEach/find/some/every 只做判断 |
1 | map : [ 2, 4, 6, 8 ] |
排序与其它工具
1 | // sort 默认按字符串排序,必须传比较函数;还有扁平化、构造、排序稳定性 |
1 | 不传比较函数: [ 1, 10, 9 ] |
[10, 9, 1].sort()得到[1, 10, 9]是新手最常踩的一个坑:不传比较函数就按字符串排序。
Object 上的实用方法
1 | // Object 上的常用静态方法 |
1 | assign(浅合并): { a: 1, b: 20, c: 3 } |
for..in 与 for..of:一个拿键,一个拿值
1 | // for..in 遍历「键」(含原型链上的可枚举键),for..of 遍历「值」(要求可迭代) |
1 | for..in 数组拿到的是字符串下标: [ '0:string', '1:string' ] |
- 遍历对象用
Object.keys(obj)/Object.entries(obj)配for..of,别用for..in(它会连原型链上的可枚举属性一起遍历)。 - 需要「可中断 + 惰性」时,数组的
for..of支持break,而forEach不行。
Map / Set / Symbol
1 | // Map:任何类型都能当键;Set:自动去重。普通对象只能拿字符串/Symbol 当键 |
1 | size / 取值: 3 数字键 1 字符串键 1 |
- 什么时候不用普通对象而用
Map:键需要是对象/数字等非字符串、需要频繁增删与遍历、需要可靠的size。键的这一点差别最实用:
1 | // 普通对象的键会被字符串化,Map 的键不会——这是两者最实用的区别 |
1 | 普通对象里 1 和 "1" 是同一个键: [ '1', '[object Object]' ] |
函数
四种写法与参数语法
1 | // 声明方式:函数声明、函数表达式、箭头函数、立即执行 |
1 | 四种写法: 2 3 4 16 |
this 由「怎么调用」决定
1 | // this 由「怎么调用」决定,与定义在哪无关 |
1 | 1. 普通调用 : 全局对象 |
this的速记:- 点号左边是谁,
this就是谁。 - 没有点号(普通调用):宽松模式下是全局对象,严格模式下是
undefined。 new出来的是新实例。call/apply/bind由你手动指定。
- 点号左边是谁,
- 箭头函数没有自己的
this,它取定义时外层作用域的this——这正是它在回调里受欢迎的原因(回调函数 = 你把它交给别人,由别人在合适的时机反过来调用你这个函数):
1 | // 箭头函数没有自己的 this / arguments / prototype,这是它与普通函数的核心差别 |
1 | 普通函数被当回调丢出去: this 丢了 |
闭包
1 | // 闭包:内层函数记住了外层作用域里的变量,外层函数返回后变量依然活着 |
1 | 两个实例各自独立: 2 1 |
- 闭包 = 函数 + 它出生时所在的作用域。用途:私有状态、函数工厂、缓存(memoize)、防抖节流。
- 副作用是要当心内存占用:被闭包引用的大对象不会被回收。
生成器:能暂停的函数
1 | // 生成器:function* + yield,函数可以「暂停」并分次产出值(异步语法的基础) |
1 | 手动 next: {"value":3,"done":false} {"value":2,"done":false} |
function*+yieldproduce 一个迭代器;async/await在机制上就是生成器 + Promise 的封装。日常业务代码很少直接写生成器,但读库源码时会遇到。
类与继承(class 是语法糖)
1 | // class 是构造函数 + 原型链的语法糖;字段、方法、继承 |
1 | 旺财 发出声音(汪汪) | [动物] 旺财 | 匿名 发出声音 |
class X extends Y里用super()调父类构造、super.method()调父类方法;子类构造器里必须在使用this之前调用super()。#name是真私有(编译期检查),_name只是命名约定,谁都拦不住。- 静态方法属于类本身(
Animal.create),get/set定义访问器属性。 - 与 Python 对照:JS 的
this必须显式写在参数里(Python 的self是隐式的),这也是this会「丢」的根源。
按位运算(偶尔会用到)
1 | // 按位运算:先把数字转成 32 位整数再操作,日常只在掩码/取整/权限位里遇到 |
1 | 5 & 1 = 1 | 9 & 5 = 1 |
严格模式:让错误尽早暴露
1 | // 严格模式:'use strict'(模块、class 内部默认开启),把一批静默错误变成显式报错 |
1 | 宽松模式下未声明就赋值 -> 变成全局变量: number |
<script>里第一行写'use strict';,或者干脆用type="module"(默认严格)。- ES 模块、
class的类体内部天然是严格模式。
流程控制
条件与分支
1 | // 自动分号插入 + 缩进错觉:第二行并不属于 if |
1 | 良好 |
- JS 有「自动分号插入」(ASI),靠换行推断语句边界会出错,所以下一行的开头若是
(、[、`时,上一行必须显式写分号。 - 多行语句一律写大括号,别学「单行 if 省略 {}」。
switch:注意穿透
1 | // switch 用 === 比较;不写 break 会「穿透」到下一个 case 继续执行 |
1 | 穿透结果 describe(1): 一二 | describe(2): 二 | describe(9): 其它 |
- 每个
case结尾要么break/return/throw,要么明确写注释说明「故意穿透」。 switch内部是===比较,不能用它做范围判断。
循环
1 | // while / do...while / for / continue / break / 标签 |
1 | while 结束 i = 3 |
forEach里不能用break/continue;要中断请用for..of或普通for,判断存在性用some(返回 true 就停)。- 遍历数组下标时优先
for (let i = 0; ...)。 - 只关心元素就用
for..of。 - 需要索引和元素用
entries()。
三元与短路
上面「可选链 ?. 与空值合并 ??」一节里的实测输出就是三目与短路运算符的结果:a ? b : c 只是一个「有返回值的 if」,别把复杂逻辑塞进去写成一行天书。嵌套超过两层就改回 if/else。
异常处理
1 | // try/catch/finally 与 throw;catch 的参数可以省略 |
1 | 结果: 5 |
- 抛
Error的子类,不要抛字符串或数字:Error有name/message/stack,能被instanceof Error判断,也能被框架统一捕获。 - 内置错误类型(够用就好):
Error、TypeError(类型/调用方式不对)、ReferenceError(变量不存在)、SyntaxError(解析阶段就挂)、RangeError(值超出范围)、EvalError、URIError、AggregateError(多个错误打包,配合Promise.any)。 - 只读属性赋值、
null.x、把非函数当函数调用 →TypeError;访问未声明的变量 →ReferenceError。 finally里如果return或throw,会吞掉 try/catch 里的返回值/异常,别这么写。- 全局兜底(浏览器):
window.addEventListener('error', ...)抓同步报错,unhandledrejection抓没接住的 Promise 失败。Node 里是process.on('uncaughtException')/'unhandledRejection'。
1 | // 自定义错误:继承 Error,附带一个可供上层判断的 code |
1 | 业务错误: AppError | code = NOT_ENOUGH | 余额不足 |
异步:事件循环、Promise、async/await
先看清执行顺序
1 | // 事件循环:同步代码全部跑完,再跑微任务(Promise/queueMicrotask),最后才轮到定时器 |
1 | 同步开始 |
- 先认识两个词:任务队列是浏览器把「等着执行的回调」排成的队;微任务(
Promise.then这类)优先级高,宏任务(setTimeout、事件回调这类)每取一个跑完都要回去清一遍微任务。模型:同步代码一口气跑完 → 清空微任务队列(Promise.then、queueMicrotask、MutationObserver)→ 取一个宏任务(setTimeout、事件回调、IO)执行 → 再清微任务 → 循环。 - 所以「先注册的
setTimeout(0)」反而排在Promise.then后面。
定时器要能取消
1 | // 定时器的返回值要存下来才能取消 |
1 | 取消 setTimeout 后 hits = 0 |
setInterval不会等上一次回调跑完,任务重时容易堆积;轮询场景更稳的写法是「在回调里再setTimeout一次」。- 浏览器里
setTimeout(fn)的返回值是数字 id;Node 里返回对象——跨环境代码请用clearTimeout配对,别对返回值做算术。
Promise
1 | // Promise:把「传回调」变成「返回一个代表未来结果的对象」,可以链式接下去 |
1 | 1. 同步开始 |
- 三条纪律:
then的返回值会传给下一个then(返回 Promise 就会被等待),return是最容易漏的关键字;- 错误沿链向后冒泡,最后必须有一个
catch,否则变成 unhandled rejection; new Promise的执行体是同步的,resolve之后的代码还会继续跑,必要时return。
四个组合器
1 | // 四个组合器:all / allSettled / race / any |
1 | all 结果按「传入顺序」而不是完成顺序: [ 'a', 'b' ] |
| 方法 | 等全部 | 有一个失败就 | 取最快 | 典型用途 |
|---|---|---|---|---|
Promise.all |
✅ | 立刻 reject | — | 并行请求,要么全成功 |
Promise.allSettled |
✅ | 不 reject,逐个报状态 | — | 批量任务,需要知道每个结果 |
Promise.race |
— | 跟随最快的那个 | ✅ | 超时控制 |
Promise.any |
— | 全失败才 reject(AggregateError) |
✅ 第一个成功 | 多镜像/多源取其一 |
async/await
1 | // async/await:用「写法像同步」的方式使用 Promise;await 只能在 async 函数里用 |
1 | 成功路径: ALICE |
async函数永远返回 Promise;await只是「挂起这个函数」,不会阻塞其它代码,更不会卡住页面。await只能用在async函数内部(模块顶层的await是特例,浏览器模块与 Node ESM 都支持)。- 并行要用
Promise.all([...])再await,不要在循环里逐个await(那样就变成了串行)。
常用内置对象速查
String:不可变,所有方法返回新串
1 | // 字符串是不可变的:所有方法都返回新字符串,原串永远不变 |
1 | length: 16 | trim 后: "Hello, World" | 原串未变: true |
'😀'.length是 2、[...str].length是 1:length数的是 UTF-16 码元。要正确处理 emoji / 生僻字就用展开或Intl.Segmenter。- 拼接优先用模板字符串;
substring/substr是老 API,新代码统一用slice。
数字转换:parseInt / parseFloat / Number / +
1 | // 三个「字符串转数字」的工具,行为差别很大 |
1 | parseInt 从头读,遇到非法字符就停: [ 42, 42, 42 ] |
parseInt允许尾部有非法字符(parseInt('42abc')得 42),Number要求整串合法(否则NaN)。读用户输入用Number,或老老实实parseInt(s, 10)指明进制。- 字符串转数字的其它路子:
Math.trunc(s)、s | 0(会截成 32 位)、BigInt(s)、Number(s).toFixed(n)。
Math
1 | // Math 是对象,不是函数;所有取整函数的负数行为不一样 |
1 | random 区间 [0,1): true | 取 1~6 的整数: true |
Math.round(-0.5)得-0(向 +∞ 方向取整);要「四舍六入五成双」或精确十进制取整得自己实现。- 生成随机整数用
Math.floor(Math.random() * (max - min + 1)) + min;要密码级随机用crypto.getRandomValues()。
JSON
1 | // JSON.stringify 会「丢掉」它无法表示的值 |
1 | undefined/函数被删掉,NaN/Infinity 变 null,Date 变 ISO 字符串: {"s":"x","n":1,"b":true,"nul":null,"n2":null,"inf":null,"d":"1970-01-01T00:00:00.000Z"} |
Date
1 | // Date:内部存「本地时区无关」的毫秒时间戳,月份从 0 开始 |
1 | Date.now() 是数字时间戳: number true |
- 两个最容易翻车的点:月份从 0 开始;
'2026-11-02'这种 ISO 串按 UTC 解析、'2026/11/2'按本地时区解析。跨时区业务建议全链路传 ISO 字符串(带Z或偏移),只在展示层转本地。 - 日期格式化想稳定可控用
Intl.DateTimeFormat,或第三方dayjs(Date原生 API 设计得很差)。
定时器之外的浏览器 API(下一篇详述)
requestAnimationFrame(逐帧动画)、requestIdleCallback、IntersectionObserver(懒加载/曝光埋点)、MutationObserver(DOM 变化监听)、fetch(网络)、localStorage / sessionStorage / indexedDB(存储)、URL / URLSearchParams(解析地址)。
模块化
1 | // 模块化:import/export。这里用动态 import 现场建一个模块再引入 |
1 | 命名导出: 5 |
浏览器里怎么写:
1 | <!-- math.js --> |
type="module"的脚本自动具备:严格模式、独立作用域(不污染window)、defer语义、CORS 要求(file://直接打开会失败,必须走 http)。- CommonJS(
require/module.exports)是 Node 的历史格式,读写老项目会遇到;新项目里浏览器端一律 ES 模块,Node 端也推荐.mjs/"type": "module"。
常见坑速查
- 忘记写
let/const→ 变量泄漏成全局,或被后面的同名声明覆盖。 var+ 闭包 → 循环里的回调都拿到最后一个值(见上面实测的[3,3,3])。this丢了 → 把方法赋给变量再调用、或当回调传出去。解法:bind、包一层箭头函数、或改用 class 字段。0.1 + 0.2 !== 0.3→ 浮点固有误差,比较要留容差或改用整数/十进制库。[] == false为真但[] === false为假 → 别用==判空数组,用arr.length。[10, 9].sort()排错 → 必须传(a, b) => a - b。- 数组
forEach里return想中断循环 → 做不到,换for..of或some。 - 用对象当普通对象的键 → 键会被
String()成'[object Object]',两个不同对象互相覆盖;要按引用记东西就用Map/WeakMap。 - 箭头函数返回对象字面量 →
x => {a: 1}被解析成函数体(而且没有 return),要写成x => ({a: 1})。 - 深拷贝用
JSON.parse(JSON.stringify(x))→ 丢函数、undefined、Date变字符串、循环引用直接报错,改用structuredClone。 parseInt不写进制 → 老浏览器把'010'当八进制;统一写parseInt(s, 10)。await写在循环里 → 串行化,批量请求变慢,改Promise.all。- Promise 没接
catch→ 报错无声消失(控制台只有一条 unhandled rejection 警告)。 setInterval忘了clearInterval→ 组件卸载后仍在跑,页面越用越卡。- 在
for (const x in obj)里处理原型属性 → 加Object.hasOwn(obj, x)判断,或直接用Object.entries。 - 模块用
file://打不开 → 起个本地静态服务器(npx serve、python -m http.server)。
练习
- 用
map/filter/reduce三连完成:给定[{name, score}],筛出及格的人、按分数降序、拼成一句'张三(95)、李四(88)'。 - 亲手复现
for (var)与for (let)的闭包差异,再用「IIFE 捕获」和「bind」两种老办法把var版本修正。 - 写一个
debounce(fn, wait):连续调用只在最后一次之后执行。用console.log打印调用次数与真正执行的次数来验证(提示:需要setTimeout+clearTimeout+ 闭包)。 - 把一段回调风格的代码(
setTimeout嵌套三层)改写成Promise链,再改写成async/await,观察可读性差异。 - 用
structuredClone与JSON往返分别拷贝一个含Date和函数的对象,对比结果差异。