模块一 · 语言与 DOM
类型、作用域与模块:JS 的三块地基
类型决定值怎么比较,作用域决定变量在哪可见,模块决定代码怎么组织。这一节把三块地基讲透,并给出高频追问的答法。
读完这一节,你能做到
- 说清七种原始类型与对象在比较、拷贝上的差异。
- 解释 var/let/const 的作用域与暂时性死区。
- 用闭包解释"变量为什么不会被回收"。
- 区分 ESM 与 CJS 的加载时机与循环依赖表现。
一、类型:值语义与引用语义
JavaScript 有七种原始类型:undefined、null、boolean、number、string、symbol、bigint,其余都是对象。
原始类型按值比较,对象按引用比较:
("abc" ===
"abc"(
// true,值相同
{},
)) ===
{}; // false,引用不同
const a = { x: 1 };
const b = a;
b.x = 2;
console.log(a.x); // 2,共享同一个对象
几个容易踩的边界:
typeof null === 'object'是历史遗留,判断 null 要用x === null。NaN !== NaN,判断用Number.isNaN。0.1 + 0.2 !== 0.3,浮点数精度问题,比较要用误差范围。typeof function是'function',但函数本质是可调用对象。
二、类型转换:隐式规则要背,但不要依赖
1 + '2' // '12',加号遇到字符串转拼接
'3' - 1 // 2,减号强制转数字
[] + {} // '[object Object]'
null == undefined // true
null === undefined // false
== 会做类型转换,=== 不做。工程上统一用 ===,只有判断 null/undefined 时可以用 x == null 一次性覆盖两者。
对象转原始值会走 Symbol.toPrimitive → valueOf → toString 的顺序;理解这条链,才能解释 [] + {} 这类题。
三、作用域与闭包
| 声明 | 作用域 | 提升 | 重复声明 | 暂时性死区 |
|---|---|---|---|---|
| var | 函数 | 是,初始化为 undefined | 允许 | 无 |
| let | 块 | 是,但不初始化 | 不允许 | 有 |
| const | 块 | 同 let | 不允许 | 有 |
暂时性死区的意思是:在声明之前访问会抛错,而不是拿到 undefined。
闭包是"函数 + 它定义时的词法环境"。只要闭包还被引用,外部变量就不会被回收:
function counter() {
let count = 0;
return () => ++count;
}
const next = counter();
next(); // 1
next(); // 2
闭包的两个常见用途是封装私有状态与保存现场;常见问题是循环里用 var 捕获同一个变量,以及闭包持有大对象导致内存无法释放。
this 的指向
this 不看函数定义在哪,只看调用方式:
| 调用方式 | this 指向 |
|---|---|
| 普通函数调用 | 严格模式 undefined,非严格模式全局对象 |
| 对象方法调用 | 调用它的对象 |
| call/apply/bind | 显式指定的对象 |
| new 调用 | 新创建的实例 |
| 箭头函数 | 继承外层词法作用域,无法被改变 |
最常见的丢失场景是把方法当回调传递:
const obj = {
name: "a",
say() {
console.log(this.name);
},
};
const fn = obj.say;
fn(); // undefined,this 不再是 obj
修法有三种:obj.say.bind(obj)、箭头函数包一层、或把方法改成不依赖 this 的纯函数。
内存与回收
V8 用可达性分析:从根(全局对象、调用栈、闭包)出发能访问到的对象才保留。常见泄漏有四类:
- 未清理的定时器与事件监听。
- 闭包长期持有大对象。
- 全局缓存只增不减。
- 脱离 DOM 的节点仍被 JS 引用。
排查时用 Memory 面板做两次快照对比,看哪类对象在持续增长。
四、原型与原型链
三个概念先分清
| 概念 | 挂在哪 | 是什么 |
|---|---|---|
prototype | 构造函数上 | 实例的原型对象,Foo.prototype |
__proto__ | 实例上 | 指向创建它的构造函数的 prototype |
constructor | 原型对象上 | 指回构造函数本身 |
function Foo() {}
const f = new Foo();
f.__proto__ === Foo.prototype; // true
Foo.prototype.constructor === Foo; // true
f.constructor === Foo; // true,沿原型链找到的
原型链怎么查找
访问 f.name 时,引擎按这个顺序找:
f 自身属性 → f.__proto__(Foo.prototype)
→ Foo.prototype.__proto__(Object.prototype)→ null 为止
找到就返回,找不到返回 undefined。这条链就是原型链。
instanceof 就是在走原型链
function myInstanceof(obj, Ctor) {
let proto = Object.getPrototypeOf(obj);
while (proto) {
if (proto === Ctor.prototype) return true;
proto = Object.getPrototypeOf(proto);
}
return false;
}
继承的五代演进(面试常问"你用过哪几种")
| 方式 | 写法 | 问题 |
|---|---|---|
| 原型链继承 | Child.prototype = new Parent() | 引用属性被所有实例共享 |
| 构造函数继承 | 在子类里 Parent.call(this) | 拿不到父类原型上的方法 |
| 组合继承 | 上面两种都用 | 父构造函数被调用两次 |
| 寄生组合 | Object.create(Parent.prototype) + call | ES5 中最完整的方案 |
| class 继承 | class Child extends Parent | 本质仍是原型链,只是语法糖 |
class 与 ES5 的两个差异:class 内部默认严格模式;class 不能被当作普通函数调用(Foo() 会直接报错)。
四个高频坑
- 改
prototype上的引用属性,所有实例一起变。 F.prototype = {}会丢掉constructor,需要手动补回。- 箭头函数没有
prototype,不能作为构造函数。 Object.create(null)创建的对象没有原型链,不能调用hasOwnProperty。
一句话总结:原型链是 JS 的继承机制,
instanceof、for...in、属性查找都靠它;理解了原型链,class就只是写法问题。
五、模块:ESM 与 CJS
| 维度 | ESM | CJS |
|---|---|---|
| 语法 | import/export | require/module.exports |
| 加载 | 静态解析,异步 | 运行时同步 |
| 导出 | 引用(活绑定) | 值的拷贝 |
| 循环依赖 | 有提升,能拿到部分初始化 | 可能拿到未完成的 exports |
| Tree-shaking | 支持 | 不支持 |
ESM 的导出是活绑定:导出方改变量,导入方能拿到新值。CJS 导出的是当时的快照,所以解构后不会再更新。
循环依赖在 ESM 里表现为"暂时拿不到值",但不会崩;在 CJS 里可能拿到 {}。解决方式通常是拆模块或延迟调用。
工程中的模块选择
- 新项目优先 ESM:静态分析、tree-shaking、浏览器原生支持。
- 老 Node 项目可能仍是 CJS;混用时注意默认导出与具名导出的差异。
- 动态加载用
import(),配合路由懒加载与代码分割。 - 不要把有副作用的代码放在模块顶层,否则引入即执行,难以摇树。
判断一个模块是否“可摇树”:它是否只导出纯声明、没有顶层副作用、没有 CommonJS 写法。类型决定行为,作用域决定可见性,模块决定边界;三块地基稳了,后面的异步与渲染才接得上。
六、常见坑
- 用
==比较对象与原始值,得到意外结果。 - 认为
const声明的对象不可变(它只保证绑定不变)。 - 在循环里用 var 创建闭包,所有回调共享同一个变量。
- 解构 CJS 导出后,期望它跟随变化。
- 用
typeof判断数组或 null。
七、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 写一段代码分别验证
typeof null、NaN、0.1+0.2的行为。 - 用闭包实现一个只能递增、不能直接改值的计数器。
- 写两个互相 import 的 ESM 模块,观察循环依赖下的取值。
② 让 AI 写同一题
把第 2 题(作用域与闭包)交给 AI,补一句 不许用 var,要求解释每一次循环的值。
③ 对比要盯的点
- 循环里它用
var还是let?var下所有回调共享同一个变量。 - 比较它用的是
==还是===?有没有依赖隐式转换的地方。 - 模块导出它给了默认导出还是具名导出?和题面要求一致吗?
④ 一句话取舍
它那版在循环里注册的三个回调,最终打印的是同一个值吗?
八、自测
- 原始类型与对象在比较大小时有什么差异?
let的暂时性死区是什么?- 闭包为什么能让变量不被回收?
- ESM 与 CJS 的导出语义差异是什么?
- 循环依赖在两种模块体系下分别表现如何?
- 原型链的查找顺序是什么?为什么说 class 只是语法糖?
参考答案要点:值 vs 引用;声明前访问抛错;词法环境被引用;活绑定 vs 快照;部分初始化 vs 空对象;实例 → 构造函数原型 → Object.prototype → null,class 与 ES5 的原型关系一致,只多了严格模式与不可直接调用。
附录:高频追问
问:const 定义的数组能 push 吗? 能。const 限制的是绑定不能重新赋值,不限制对象内容;要冻结用 Object.freeze,但它是浅冻结。
问:var 和 let 在循环里有什么区别? var 是函数作用域,循环共用一份;let 是块作用域,每次迭代创建新的绑定,所以定时器能拿到各自的值。
问:闭包一定会导致内存泄漏吗? 不一定。只有闭包被长期持有且引用了大对象时才可能泄漏;不需要时把引用置空即可。
问:import() 和 import 的区别? 前者是动态导入,返回 Promise,可用于按需加载;后者是静态声明,会被打包器提前分析。
问:为什么 [] + {} 是 "[object Object]"? 两边都转原始值,数组转成空字符串,对象转成 [object Object],再做字符串拼接。
问:Number.isNaN 和 isNaN 的区别? 前者不做类型转换,只对真正的 NaN 返回 true;后者会先转数字,isNaN('abc') 也是 true。
问:Symbol 有什么用? 用作唯一键,避免属性名冲突;也可用于定义对象的内部行为,比如 Symbol.iterator。
问:BigInt 能和 Number 混算吗? 不能直接混算,会抛 TypeError;需要显式转换。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
- ★★★★★ 讲一下事件循环,宏任务和微任务的执行顺序是怎样的?
- ★★★★★ 什么是闭包?它有哪些实际应用和风险?
- ★★★★☆ Tree Shaking 的原理是什么?为什么有时清不掉无用代码?
- ★★★★☆ interface 和 type 有什么区别?什么时候用哪个?
提示:读完可以顺手做几道对应题目,检验是否真的理解