JS基础
作用域、预编译、闭包原理、内存泄漏、经典例题解析
目录
| 章节 | 核心内容 |
|---|---|
| 一、作用域 | 全局作用域、局部作用域、作用域链 |
| 二、预编译 | 编译步骤、声明提升、创建阶段 |
| 三、闭包 | 闭包的形成、作用、特性 |
| 四、var / let / const | 三者区别、暂时性死区(TDZ)、for 循环陷阱 |
| 五、数据类型 | 基本类型与引用类型、类型判断方法、值传递 vs 引用传递 |
| 六、深拷贝 vs 浅拷贝 | 浅拷贝方法、JSON 法、structuredClone、手写递归深拷贝 |
| 例题 | 4 道经典闭包/作用域例题 |
1. 作用域
变量与函数的可访问范围,控制着变量与函数的可见性和生命周期。
全局作用域
- 所有未定义直接赋值的变量自动声明为拥有全局作用域
function foo() { b = 2 }这种情况在全局打印b时仍为 2
局部作用域
- 函数作用域
- 形参也是局部变量
- 每个函数都会有自己的 函数作用域属性 scope,这个隐式属性只供引擎访问,其中存储了执行期上下文的集合
- 块状作用域
- 通过
let和const关键字可以在花括号{}内创建一个新的作用域
- 通过
作用域链
- 指的是变量查找的线路,即
[[scope]]中所存储的执行期上下文对象的集合,这个集合呈链式连接,我们把这种链式链接叫做作用域链。 - 每当一个函数被执行时,都会创建一个新的执行上下文,并形成一条作用域链。
- 有时把作用域称为运行期上下文:当函数执行时会创建一个称为执行期上下文的内部对象,一个执行期上下文定义了一个函数执行时的环境,函数每次执行时对应的上下文都是独一无二的,所以多次调用一个函数会创建多个执行期上下文,当函数执行完毕,它产生的执行期上下文会被销毁。
- 当所需要的变量在所在的作用域中查找不到的时候,它会一层一层向上查找,直到全局作用域还没有找到时就会放弃。这种一层一层的关系,就是作用域链。
例如:
var a = 1;
function fn() {
var a = 2;
function foo() {
console.log(a); // 2
}
foo();
}
fn();
输出 a 时由于 foo 函数内没有定义变量 a,所以往上一层查找,结果在 fn 函数内找到了,输出 a 的值为 2。
![[作用域链示意图.png]]
2. 预编译
编译的步骤分为以下三部分:
- 词法分析(词法单元)
- 语法解析(抽象语法树)
- 代码生成
注意: JS 编译发生在代码执行之前
声明提升
- 在编译时将变量的声明提升到当前作用域的顶端
- 函数声明整体提升
在 JavaScript 程序执行前,会先创建全局执行上下文(Global Execution Context),这个过程又分为两大阶段:创建阶段(又称变量环境/词法环境的初始化)和执行阶段。
创建阶段
- 全局对象创建:为全局作用域创建一个全局对象,并生成全局执行上下文。
- 词法环境和变量环境的建立:
- 函数声明:例如
function foo() {...}。在创建阶段,JavaScript 引擎会把整个函数体作为一个对象存储,并将标识符foo和函数对象的引用存储到全局环境中。这样,调用foo()时就可以直接找到该函数。 - 变量声明(var 声明):
var属于变量声明表达式(VariableDeclarationExpression)。在创建阶段,所有通过var声明的变量都会在当前的环境记录中被创建,并初始化为undefined。这一过程称为"变量提升"(Hoisting)。- 在全局上下文中,会为
var b创建一个标识符b,初始值为undefined(赋值操作b = 2会在执行阶段完成)。
- 函数声明:例如
-
注意: 在同一作用域内,如果有函数声明和变量声明同名,函数声明的优先级高于
var声明。并且后续的var声明不会覆盖已经存在的绑定(只影响赋值部分)。
进入函数 foo 的执行上下文
当执行 foo() 时,会创建一个新的执行上下文,这个上下文同样有创建和执行两个阶段。
在创建阶段内:
- 函数内部的变量声明:
var a被处理,创建一个绑定(内部环境记录中添加标识符a),初始值为undefined。这就意味着即使代码中var a = 1在console.log(a)之后写,变量a已经存在,但其值目前还是undefined。 - 函数内部的其他声明: 如果有其他函数声明、参数等,同样会被处理,但本例中只有
var a。
// 全局执行上下文(创建阶段):
var b; // 创建变量 b,初始值 undefined
console.log(b); // 此时尚未赋值,仍为 undefined
function foo() { // 创建函数 foo,函数体整体存储
var a; // 在 foo 的上下文中创建变量 a,初始值 undefined
console.log(a); // 此时 a 为 undefined
a = 1; // 赋值操作
console.log(a); // 此时 a 为 1
}
// 执行阶段(全局):
foo(); // 调用 foo() 时进入 foo 的执行上下文
console.log(b); // 此时尚未赋值,仍为 undefined
b = 2; // 全局变量 b 赋值为 2
console.log(b); // 此时 b 为 2
3. 闭包
闭包是由捆绑起来(封闭的)的函数和函数周围状态(词法环境)的引用组合而成。换言之,闭包让函数能访问它的外部作用域。在 JavaScript 中,闭包会随着函数的创建而同时创建。
即:在一个作用域中可以访问另一个函数内部的局部变量的函数
闭包的形成
function foo() {
var a = 1;
return function () {
console.log(a);
};
}
var bar = foo();
bar(); // 1
foo() 函数的执行结果返回给 bar,而此时由于变量 a 还在使用,因而没有被销毁,然后执行 bar() 函数。这样,我们就能在外部作用域访问到函数内部作用域的变量。这个就是闭包。
闭包的形成条件:
- 函数嵌套
- 内部函数引用外部函数的局部变量
闭包的作用
- 可以读取函数内部的变量
- 可以使变量的值长期保存在内存中,生命周期比较长。
- 可用来实现 JS 模块(jQuery 库等)
- JS 模块是具有特定功能的 JS 文件,将所有的数据和功能都封装在一个函数内部(私有的),只向外暴露一个包含多个方法的对象或函数,模块的使用者,只需要通过模块暴露的对象调用方法来实现对应的功能。
(function () {
var a = 1;
function test() {
return a;
}
window.module = { a, test }; // 向外暴露
})();
<script src="./1.js"></script>
<body>
<script>
console.log(module.a); // 1
console.log(module.test()); // 1
</script>
</body>
闭包的特性
- 每个函数都是闭包,函数能够记住自己定义时所处的作用域,函数走到了哪,定义时的作用域就到了哪。
- 内存泄漏
- 内存泄漏就是一个对象在你不需要它的时候仍然存在。所以不能滥用闭包。当我们使用完闭包后,应该将引用变量置为
null。
- 内存泄漏就是一个对象在你不需要它的时候仍然存在。所以不能滥用闭包。当我们使用完闭包后,应该将引用变量置为
// 错误写法
function outer() {
// 大对象,闭包会一直持有
const bigData = new Array(1000000).fill('leak');
return function inner() {
console.log(bigData);
};
}
const fn = outer();
// 即使不再使用 fn,bigData 永远无法被回收 → 内存泄漏
// ------------------------------------------------------------
// 修复写法
function outer() {
let bigData = new Array(1000000).fill('ok');
const inner = function () {
console.log(bigData);
};
// 提供销毁方法
inner.destroy = function () {
bigData = null; // 关键:解除引用
};
return inner;
}
const fn2 = outer();
// 使用完后手动销毁
fn2.destroy();
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<button>1</button>
<button>2</button>
<div id="result">11</div>
<script>
let btns = document.getElementsByTagName("button")
// 此处for循环里使用var声明的话,会使得每个按钮按完都是3
// 使用let的话会是对应的数字
for(let i = 0; i < btns.length; i++){
btns[i].onclick = function(){
console.log(i + 1)
resultDiv.innerHTML = i + 1
}
}
//如果不使用let也要达到这种对应数字的效果,可以使用如下方法
// 使用立即执行函数(IIFE)创建独立作用域
for(var i = 0; i < btns.length; i++){
(function(index){
btns[i].onclick = function(){
console.log(index + 1)
}
})(i)
}
</script>
</body>
</html>
4. var / let / const 的区别
| 特性 | var |
let |
const |
|---|---|---|---|
| 作用域 | 函数作用域 | 块作用域 | 块作用域 |
| 变量提升 | ✅ 提升,初始化为 undefined |
提升但不初始化(TDZ) | 提升但不初始化(TDZ) |
| 重复声明 | ✅ | ❌ | ❌ |
| 重新赋值 | ✅ | ✅ | ❌(对象属性可改变) |
挂载到 window |
✅(全局声明时) | ❌ | ❌ |
暂时性死区(TDZ,Temporal Dead Zone)
let 和 const 在声明前无法访问,访问会抛出 ReferenceError:
console.log(a); // undefined(var 提升,初始化为 undefined)
var a = 1;
console.log(b); // ❌ ReferenceError: Cannot access 'b' before initialization
let b = 2;
原因: let/const 在编译阶段虽然也被登记,但不会初始化,从代码块开始到声明语句之间的区域就是「暂时性死区」。
const 的注意点
const 禁止的是重新赋值,不是修改内容:
const obj = { name: 'Shea' };
obj.name = 'Gavin'; // ✅ 可以修改属性
obj = {}; // ❌ TypeError: Assignment to constant variable
const arr = [1, 2, 3];
arr.push(4); // ✅ 可以 push
arr = [1]; // ❌ TypeError
如需完全冻结对象,使用 Object.freeze():
const config = Object.freeze({ host: 'localhost', port: 3000 });
config.port = 8080; // 静默失败(严格模式下报错)
console.log(config.port); // 3000
面试常考:for 循环中的 var vs let
// ❌ var:所有回调共享同一个 i,循环结束后 i 已是 3
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 3 3 3
}
// ✅ let:每次迭代创建独立块作用域,完美解决
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 0 1 2
}
5. 数据类型
基本类型(值类型)
JS 共有 7 种基本类型,存储在栈中,复制时传递值本身:
| 类型 | 示例 | 备注 |
|---|---|---|
string |
'hello' |
字符串 |
number |
42、NaN、Infinity |
含浮点,只有一种数字类型 |
boolean |
true / false |
布尔值 |
null |
null |
空值;typeof null === 'object'(历史 bug) |
undefined |
undefined |
未定义 |
symbol |
Symbol('id') |
唯一标识符(ES6) |
bigint |
9007199254740991n |
大整数(ES2020) |
引用类型(对象类型)
除基本类型外,其他均为引用类型,数据存在堆中,变量保存的是堆的内存地址:
- 普通对象
{}、数组[]、函数function() {} - 内置对象:
Date、RegExp、Map、Set等
类型判断方法
① typeof
typeof 'hello' // 'string'
typeof 42 // 'number'
typeof true // 'boolean'
typeof undefined // 'undefined'
typeof Symbol() // 'symbol'
typeof 42n // 'bigint'
typeof null // 'object' ⚠️ 历史遗留 bug
typeof {} // 'object'
typeof [] // 'object'
typeof function() {} // 'function'
局限: 无法区分 null、普通对象、数组。
② instanceof
检测构造函数的 prototype 是否出现在对象的原型链上:
[] instanceof Array; // true
[] instanceof Object; // true(数组也是对象)
null instanceof Object; // false
局限: 不能判断基本类型;跨 iframe 时失效。
③ Object.prototype.toString(最准确)
const type = Object.prototype.toString;
type.call('hello'); // '[object String]'
type.call(42); // '[object Number]'
type.call(null); // '[object Null]'
type.call(undefined); // '[object Undefined]'
type.call([]); // '[object Array]'
type.call({}); // '[object Object]'
type.call(function() {}); // '[object Function]'
type.call(new Date()); // '[object Date]'
type.call(/reg/); // '[object RegExp]'
type.call(new Map()); // '[object Map]'
封装成工具函数:
function getType(val) {
return Object.prototype.toString.call(val).slice(8, -1).toLowerCase();
}
getType(null); // 'null'
getType([]); // 'array'
getType({}); // 'object'
getType(NaN); // 'number'(NaN 也是 number 类型)
值传递 vs 引用传递
// 基本类型:值传递,两个变量相互独立
let a = 1;
let b = a;
b = 2;
console.log(a); // 1,不受影响
// 引用类型:引用传递,两个变量指向同一对象
let obj1 = { name: 'Shea' };
let obj2 = obj1;
obj2.name = 'Gavin';
console.log(obj1.name); // 'Gavin',被影响了
6. 深拷贝 vs 浅拷贝
浅拷贝
只复制对象的第一层属性,嵌套对象仍然共享引用。
const obj = { name: 'Shea', address: { city: 'Shanghai' } };
const copy = { ...obj }; // 展开运算符(浅拷贝)
copy.name = 'Gavin'; // ✅ 不影响原对象(基本类型)
copy.address.city = 'Beijing'; // ❌ 影响原对象!共享了 address 的引用
console.log(obj.address.city); // 'Beijing'
常见浅拷贝方法:
Object.assign({}, obj){ ...obj }(展开运算符)arr.slice()arr.concat()
深拷贝
递归复制所有层级,完全独立。
方法一:JSON.parse + JSON.stringify
const obj = { name: 'Shea', hobbies: ['coding'] };
const deep = JSON.parse(JSON.stringify(obj));
deep.hobbies.push('reading');
console.log(obj.hobbies); // ['coding'] ✅ 完全独立
局限性(不适用场景):
- ❌
undefined、Function、Symbol会被忽略或丢失 - ❌
Date转成字符串,RegExp转成{} - ❌ 循环引用会报错
- ❌ 无法处理
Map、Set
方法二:structuredClone(ES2022,推荐)
const obj = {
name: 'Shea',
date: new Date(),
map: new Map([['key', 'value']]),
set: new Set([1, 2, 3]),
};
const deep = structuredClone(obj);
deep.name = 'Gavin';
console.log(obj.name); // 'Shea' ✅
console.log(deep.date); // 完整 Date 对象(非字符串)✅
console.log(deep.map); // Map 正常复制 ✅
局限: 不能处理 Function、Symbol、DOM 节点。
方法三:手写递归深拷贝
function deepClone(obj, map = new WeakMap()) {
// 基本类型直接返回
if (obj === null || typeof obj !== 'object') return obj;
// 处理循环引用
if (map.has(obj)) return map.get(obj);
// 处理特殊类型
if (obj instanceof Date) return new Date(obj);
if (obj instanceof RegExp) return new RegExp(obj);
if (obj instanceof Map) {
const result = new Map();
map.set(obj, result);
obj.forEach((v, k) => result.set(deepClone(k, map), deepClone(v, map)));
return result;
}
if (obj instanceof Set) {
const result = new Set();
map.set(obj, result);
obj.forEach(v => result.add(deepClone(v, map)));
return result;
}
// 普通对象或数组
const copy = Array.isArray(obj) ? [] : {};
map.set(obj, copy); // 先缓存,再递归(解决循环引用)
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
copy[key] = deepClone(obj[key], map);
}
}
return copy;
}
为什么用 WeakMap 而不是 Map?
WeakMap 的 key 是弱引用,当对象没有其他引用时会被 GC 回收,避免内存泄漏。
对比总结
| 方法 | 支持嵌套 | 处理特殊类型 | 循环引用 | 性能 |
|---|---|---|---|---|
{ ...obj } / Object.assign |
❌ 仅一层 | N/A | N/A | 最快 |
JSON.parse/stringify |
✅ | ❌ | ❌ 报错 | 快 |
structuredClone |
✅ | ✅(大部分) | ✅ | 中 |
| 手写递归 | ✅ | ✅(可定制) | ✅ | 中 |
例题
例题 1
var a, b; // 全局变量 a, b 声明
(function () {
console.log(a); // undefined
console.log(b); // undefined
var a = (b = 3); // 局部变量 a 首次声明,并赋值全局变量 b = 3
console.log(a); // 局部变量 a,为 3
console.log(b); // 沿着作用域链寻找到 b,为 3
})();
console.log(a); // 全局变量 a,为 undefined
console.log(b); // 3
例题 2
var friendName = 'World';
(function () {
if (typeof friendName === 'undefined') {
var friendName = 'Jack'; // 局部变量 friendName 声明,并变量提升
console.log('Goodbye ' + friendName);
} else {
console.log('Hello ' + friendName);
}
})();
// 输出 'Goodbye Jack'
例题 3
function fn1() {
console.log('fn1');
}
var fn2;
fn1();
fn2();
fn2 = function () {
console.log('fn2');
};
fn2();
// 输出
// fn1 — 第 6 行
// Uncaught TypeError: fn2 is not a function — 第 7 行
// 因为第 7 行报错,余下代码不执行
例题 4
function fun(n, o) {
console.log(o);
return {
fun: function (m) {
return fun(m, n);
},
};
}
var a = fun(0); // ①
a.fun(1); // ②
a.fun(2); // ③
a.fun(3); // ④
var b = fun(0) // ①
.fun(1) // ②
.fun(2) // ③
.fun(3); // ④
var c = fun(0).fun(1); // ①②
c.fun(2); // ③
c.fun(3); // ④
输出:
undefined // fun(0) — 第 9 行
0 // a.fun(1) — 第 9 行
0 // a.fun(2) — 第 9 行
0 // a.fun(3) — 第 9 行
undefined // fun(0) — 第 10 行
0 // .fun(1) — 第 10 行
1 // .fun(2) — 第 10 行
2 // .fun(3) — 第 10 行
undefined // fun(0) — 第 11 行
0 // .fun(1) — 第 11 行
1 // c.fun(2) — 第 11 行
1 // c.fun(3) — 第 11 行
解析
1. fun 函数的执行逻辑
每次调用 fun(n, o):
- 先打印传入的第二个参数
o - 返回一个对象,对象上的
fun方法会递归调用fun(m, n) - 新调用的
fun,第一个参数是m,第二个参数是上一次的n(形成闭包,保存了上一次的n)
2. 三种调用方式的区别
| 变量 | 调用方式 | 关键特征 | 核心原因 |
|---|---|---|---|
a |
保存初始对象,多次调用 | 后续每次调用 a.fun() 输出的 o 都是 0 |
a 始终是 fun(0) 第一次返回的对象,闭包保存的 n 永远是 0 |
b |
链式调用 | 每次 .fun() 都会更新 o 的值(0→1→2) |
每次 .fun() 都会返回一个新的对象,新对象的闭包保存了最新的 n |
c |
链式两次 + 普通调用 | 前两次更新,后两次固定为 1 |
c 保存了 fun(0).fun(1) 返回的对象,闭包的 n 固定为 1 |
逐段解析
① var a = fun(0); a.fun(1); a.fun(2); a.fun(3);
fun(0):o未传,打印undefined,返回对象 A(闭包保存n=0)a.fun(1):调用对象 A 的fun(1),递归执行fun(1, 0),打印0,返回新对象(但没有赋值给a)a.fun(2):还是调用对象 A,闭包n=0,递归执行fun(2, 0),打印0a.fun(3):同上,打印0
② var b = fun(0).fun(1).fun(2).fun(3);
fun(0):打印undefined,返回对象 A(n=0).fun(1):调用对象 A,递归执行fun(1, 0),打印0,返回对象 B(n=1).fun(2):调用对象 B,递归执行fun(2, 1),打印1,返回对象 C(n=2).fun(3):调用对象 C,递归执行fun(3, 2),打印2,返回对象 D(n=3),赋值给b
③ var c = fun(0).fun(1); c.fun(2); c.fun(3);
fun(0).fun(1):执行fun(0)打印undefined,再调用返回的对象,执行fun(1, 0)打印0,返回对象 B(n=1),赋值给cc.fun(2):调用对象 B,递归执行fun(2, 1),打印1,返回新对象(未赋值给c)c.fun(3):还是调用对象 B,闭包n=1,递归执行fun(3, 1),打印1
总结:链式调用会不断更新闭包中的 n,而保存对象后多次调用,只会使用对象创建时的闭包值。