知

模块一 · 语言与 DOM

第 2 / 14 节21 分钟阅读型

类型、作用域与模块:JS 的三块地基

类型决定值怎么比较,作用域决定变量在哪可见,模块决定代码怎么组织。这一节把三块地基讲透,并给出高频追问的答法。

读完这一节,你能做到

  1. 说清七种原始类型与对象在比较、拷贝上的差异。
  2. 解释 var/let/const 的作用域与暂时性死区。
  3. 用闭包解释"变量为什么不会被回收"。
  4. 区分 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) + callES5 中最完整的方案
class 继承class Child extends Parent本质仍是原型链,只是语法糖

class 与 ES5 的两个差异:class 内部默认严格模式;class 不能被当作普通函数调用(Foo() 会直接报错)。

四个高频坑

  1. 改 prototype 上的引用属性,所有实例一起变。
  2. F.prototype = {} 会丢掉 constructor,需要手动补回。
  3. 箭头函数没有 prototype,不能作为构造函数。
  4. Object.create(null) 创建的对象没有原型链,不能调用 hasOwnProperty。

一句话总结:原型链是 JS 的继承机制,instanceof、for...in、属性查找都靠它;理解了原型链,class 就只是写法问题。

五、模块:ESM 与 CJS

维度ESMCJS
语法import/exportrequire/module.exports
加载静态解析,异步运行时同步
导出引用(活绑定)值的拷贝
循环依赖有提升,能拿到部分初始化可能拿到未完成的 exports
Tree-shaking支持不支持

ESM 的导出是活绑定:导出方改变量,导入方能拿到新值。CJS 导出的是当时的快照,所以解构后不会再更新。

循环依赖在 ESM 里表现为"暂时拿不到值",但不会崩;在 CJS 里可能拿到 {}。解决方式通常是拆模块或延迟调用。

工程中的模块选择

  • 新项目优先 ESM:静态分析、tree-shaking、浏览器原生支持。
  • 老 Node 项目可能仍是 CJS;混用时注意默认导出与具名导出的差异。
  • 动态加载用 import(),配合路由懒加载与代码分割。
  • 不要把有副作用的代码放在模块顶层,否则引入即执行,难以摇树。

判断一个模块是否“可摇树”:它是否只导出纯声明、没有顶层副作用、没有 CommonJS 写法。类型决定行为,作用域决定可见性,模块决定边界;三块地基稳了,后面的异步与渲染才接得上。

六、常见坑

  1. 用 == 比较对象与原始值,得到意外结果。
  2. 认为 const 声明的对象不可变(它只保证绑定不变)。
  3. 在循环里用 var 创建闭包,所有回调共享同一个变量。
  4. 解构 CJS 导出后,期望它跟随变化。
  5. 用 typeof 判断数组或 null。

七、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 写一段代码分别验证 typeof null、NaN、0.1+0.2 的行为。
  2. 用闭包实现一个只能递增、不能直接改值的计数器。
  3. 写两个互相 import 的 ESM 模块,观察循环依赖下的取值。

② 让 AI 写同一题

把第 2 题(作用域与闭包)交给 AI,补一句 不许用 var,要求解释每一次循环的值。

③ 对比要盯的点

  • 循环里它用 var 还是 let?var 下所有回调共享同一个变量。
  • 比较它用的是 == 还是 ===?有没有依赖隐式转换的地方。
  • 模块导出它给了默认导出还是具名导出?和题面要求一致吗?

④ 一句话取舍

它那版在循环里注册的三个回调,最终打印的是同一个值吗?

八、自测

  1. 原始类型与对象在比较大小时有什么差异?
  2. let 的暂时性死区是什么?
  3. 闭包为什么能让变量不被回收?
  4. ESM 与 CJS 的导出语义差异是什么?
  5. 循环依赖在两种模块体系下分别表现如何?
  6. 原型链的查找顺序是什么?为什么说 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;需要显式转换。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解