一、闭包的本质:函数 + 其词法作用域环境
闭包(Closure)的本质可以概括为:
闭包是一个函数,以及它定义时捕获的词法作用域中的变量集合。
这意味着:即使外部函数已经返回或作用域结束,只要有内部函数引用了外部变量,那么这些变量依然不会被销毁。
1. 从语言底层解释闭包本质
1)JS 是“函数作用域 + 词法作用域”的语言
举例:
function outer() {
let a = 1;
return function inner() {
console.log(a); // inner 引用了 outer 中的 a
}
}
-
inner()
定义时,“捕获”了外层outer()
的作用域(这叫词法作用域,定义时决定,而非运行时)。 -
当
outer()
执行完毕时,a
本应被销毁。 -
但由于
inner
函数还在引用a
,这个变量不会被垃圾回收 —— 这就是闭包机制产生的根源。
2)JS 的函数创建过程:生成执行上下文 + 闭包绑定环境
简化版的机制如下:
function outer() {
var a = 10;
function inner() {
console.log(a);
}
return inner;
}
编译器会将 inner
和其引用到的外部变量 a
绑定在一起,称为闭包。
可以理解为内部函数携带了一个“包裹环境”:
inner = {
code: "console.log(a)",
environment: { a: 10 }
}
当调用 inner()
,JS 引擎会用它绑定的 environment
来解析变量。
3)闭包保持变量的“引用”,而非值的“拷贝”
function counter() {
let count = 0;
return function() {
count++;
console.log(count);
};
}
const c = counter();
c(); // 1
c(); // 2
注意,这里 count
并不是每次调用时初始化,而是闭包中的变量仍保持引用,即使函数执行多次,这些变量仍然是同一个内存空间中的变量。
4)内部函数“维持”外部函数活着
function outer() {
let secret = 42;
return function inner() {
console.log(secret);
};
}
const fn = outer(); // outer() 返回 inner,secret 被闭包捕获
// outer 执行完,但 secret 仍未被 GC
fn(); // 能访问 secret
这是闭包的关键特性:
-
外部函数执行完毕,正常变量应销毁
-
但因内部函数持有其引用,变量得以“继续存在”
所以闭包本质是一种变量持久化机制。
2. 闭包的运行机制图(逻辑结构)
可以把闭包看作下图的结构(抽象视角):
┌────────────────────────────┐
│ function inner() { ... } │
│ ↑ │
│ └── [[Environment]] → {a: 1} <-- outer 的作用域环境
└────────────────────────────┘
3. 闭包为何能用于 JS 加密/混淆?
因为:
-
私有变量不可从外部直接访问
-
可以构建仅在闭包中可见的“密钥、算法、状态”
-
逆向时必须“打断作用域”,把这些“封起来的变量”还原出来
例如:
(function(){
var key = "abc123";
window.encrypt = function(data) {
return data + key;
};
})();
加密逻辑隐藏在闭包中,你无法直接从 window.encrypt
看到 key
,只能通过分析闭包提取变量。
总结
闭包是函数 + 它定义时所“记住的外部作用域”,是 JS 中保持私有状态、构建封闭模块、模拟 OOP 的基础机制。
二、闭包的作用和泄漏问题
1. 闭包的作用(正面用途)
闭包是 JavaScript 的核心能力,具有很多正向作用:
1)模拟私有变量(JS 没有真正的 private)
function createCounter() {
let count = 0; // 私有变量
return {
increment() { count++; return count; },
decrement() { count--; return count; }
};
}
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.count); // undefined,外部无法访问
作用:封装状态,防止变量被外部随意修改(很多加密代码用这种方式隐藏“key”或“状态”)。
2)工厂函数、函数柯里化(提升复用性)
function makeAdder(x) {
return function(y) {
return x + y;
}
}
const add5 = makeAdder(5);
console.log(add5(10)); // 15
作用:创建参数预设的函数,提升函数抽象能力。
3)保持状态
function remember() {
let history = [];
return function (val) {
history.push(val);
return history;
};
}
const save = remember();
console.log(save('a')); // ['a']
console.log(save('b')); // ['a', 'b']
作用:实现“记忆型”逻辑,如缓存、历史记录等。
4)模块化封装(IIFE)
const module = (function() {
let secret = 'xxx';
function getSecret() { return secret; }
return { getSecret };
})();
作用:在早期没有模块系统的 JS 中,闭包是模拟模块、隔离变量的唯一方案。
2. 闭包的泄漏问题(负面作用)
虽然闭包强大,但用不好会导致内存泄漏 —— 闭包引用的变量常驻内存,无法释放。
什么是内存泄漏?
内存泄漏指无用的对象仍被引用,无法被垃圾回收(GC)清理,导致内存增长、性能下降甚至崩溃。
常见闭包导致的泄漏场景
1)事件监听器 + 闭包
function bind() {
let bigData = new Array(1000000).fill('*');
document.getElementById('btn').addEventListener('click', function() {
console.log(bigData.length);
});
}
-
闭包引用了
bigData
-
addEventListener
持有函数引用 -
bigData
永远不会被回收,除非removeEventListener
2)定时器引用闭包变量
function startTimer() {
let largeObj = { ... };
setInterval(() => {
console.log(largeObj);
}, 1000);
}
-
setInterval
会无限引用闭包环境,largeObj
永远存在 -
必须
clearInterval
才能解除引用
3)闭包 + DOM 引用
function leakDOM() {
let dom = document.getElementById('element');
return function() {
console.log(dom.innerHTML);
}
}
即使 DOM 从页面移除,dom
变量因闭包存在仍不能释放,形成“悬挂 DOM 引用”
4)缓存未控制生命周期
let cache = (function() {
let map = {};
return {
set(key, val) { map[key] = val },
get(key) { return map[key] }
};
})();
闭包中 map
没有限制大小或 TTL(超时),可能无限增长导致内存暴涨
如何避免闭包泄漏?
方法 | 原因 |
---|---|
及时解绑事件监听器 | removeEventListener 释放闭包引用 |
清除定时器 | clearInterval / clearTimeout |
手动断开闭包引用 | 将变量设为 null |
限制缓存生命周期 | 加 TTL 或 LRU 策略控制内存占用 |
不要过度嵌套闭包函数 | 保持可维护性、易清理性 |
3. 闭包泄漏在逆向中的意义
很多加密代码:
(function() {
var _secret = "加密用密钥";
function encrypt(data) {
return AES(data, _secret);
}
window.myEncrypt = encrypt;
})();
这种结构:
-
_secret
被闭包隐藏 -
用
debugger
断点进去,encrypt.[[Scopes]]
中可以查到_secret
-
或使用 AST / Babel 分析闭包结构,还原所有作用域变量
总结
维度 | 闭包的作用 | 闭包的风险 |
---|---|---|
私有封装 | 隐藏内部变量,形成“模块” | 外部无法直接访问,逆向难 |
持久状态 | 保留执行上下文和变量 | 容易导致变量无法被释放 |
工厂函数 | 构建个性化函数实例 | 可能间接产生大量引用 |
逆向分析 | 拆出混淆变量、解密逻辑 | 需打破闭包还原环境 |
三、作用域
1. 作用域 vs 执行上下文:区别与联系
概念 | 说明 |
---|---|
作用域(Scope) | 定义阶段决定的变量可访问范围,是静态结构 |
执行上下文(Execution Context) | 函数运行时创建的临时环境,包含作用域链、变量、this 等,属于动态结构 |
举例:
function foo() {
var a = 10;
function bar() {
console.log(a); // 找 a:bar 执行上下文 → bar 的作用域链 → foo → global
}
bar();
}
foo();
执行上下文在函数调用时创建,它持有一条作用域链来决定变量的可见性。
2. 词法作用域(静态作用域)
JS 使用 词法作用域(Lexical Scope):变量查找的路径在代码书写阶段就确定了,跟运行在哪无关。
举例:
var x = 1;
function outer() {
var x = 2;
function inner() {
console.log(x);
}
return inner;
}
var fn = outer();
fn(); // 输出 2,而不是 1!
虽然 fn()
是在全局执行,但它的作用域链已经在定义时绑定好了(outer → global)。
3. 作用域链(Scope Chain)
当访问一个变量时,JS 引擎会:
-
先查当前函数的变量对象(Activation Object);
-
如果找不到,往上一层作用域找(父作用域);
-
一直找,直到全局作用域或找不到报错。
作用域链就是每个执行上下文中保存的一个链表结构,它链接了当前环境与其父环境。
4. 变量对象 & 作用域链构建
每个函数执行上下文内部维护三个关键组件:
名称 | 内容 |
---|---|
变量对象(VO) | 当前上下文中的变量(包括函数参数) |
作用域链(Scope Chain) | 当前 + 所有上层作用域的链 |
this | 当前执行环境绑定的对象 |
5. 变量提升(Hoisting)和作用域的冲突点
var
变量和函数声明会被提升到作用域顶部:
function demo() {
console.log(a); // undefined,不是报错
var a = 10;
}
实际执行相当于:
function demo() {
var a;
console.log(a); // undefined
a = 10;
}
注意:
-
let
和const
不会被提升,访问前会报错(TDZ - 暂时性死区); -
函数声明会整体提升,但函数表达式不会。
6. 闭包的作用域链快照特性
闭包本质是函数保留了定义时作用域链的快照,哪怕外部函数早就执行完了,内部函数依然能访问其变量。
function outer() {
var x = 42;
return function inner() {
return x;
}
}
var f = outer();
console.log(f()); // 42
即使 outer 的上下文销毁了,它的变量 x
仍被闭包引用。
总结
点 | 内容 |
---|---|
🔹 作用域是静态的 | 函数定义时就决定了作用域链 |
🔹 执行上下文是动态的 | 函数每次调用都会创建新上下文 |
🔹 作用域链控制变量访问顺序 | 查不到才往上层作用域找 |
🔹 闭包保留定义时作用域链 | 所以能访问“失效”的父函数变量 |
🔹 混淆常依赖作用域藏变量 | 熟练追踪作用域链可还原逻辑 |
四、JS 内存模型、垃圾回收(Mark & Sweep)
1. JavaScript 内存模型(Memory Model)
JavaScript 的运行环境(如 V8)中,内存大体可分为两个区域:
1)栈(Stack)内存:用于存储原始类型变量和执行上下文信息
-
原始类型:
Number
、String
、Boolean
、undefined
、null
、Symbol
、BigInt
-
执行上下文(函数调用时的作用域、参数)
-
存取速度快,生命周期短,先进后出(LIFO)
let a = 10; // 存储在栈中
let b = "hi"; // 字符串是原始值,也在栈中
2)堆(Heap)内存:用于存储复杂对象和函数
-
对象、数组、函数、闭包、DOM 元素引用等
-
内存空间大,结构灵活,但访问速度慢
-
GC(垃圾回收器)主要负责这部分的内存回收
let obj = { name: "Tom" }; // obj 是栈变量,指向堆中对象
let arr = [1, 2, 3]; // 数组也存堆
2. 内存生命周期(Memory Lifecycle)
-
分配阶段:
JS 在变量声明、函数调用、创建对象时会分配内存空间。
-
使用阶段:
JS 引擎读取变量、执行代码,使用这些内存中的数据。
-
释放阶段(GC):
当内存不再被使用(变量“不可达”),由垃圾回收器负责释放。
3. 垃圾回收机制:Mark and Sweep(标记-清除)
JavaScript 的主流 GC 算法是 Mark-and-Sweep
,用于回收堆内存中“无用对象”。
步骤详解:
Step 1:从根对象(Root)出发“标记”可达对象
根对象有:
-
全局对象(浏览器中是
window
,Node 是global
) -
当前调用栈中的变量、函数参数
-
闭包中引用的变量(只要有引用链)
let obj = { name: "Tom" }; // obj 是栈变量,引用堆
GC 会从 window.obj
出发,递归遍历其所有属性引用。
Step 2:“扫描”堆中所有对象
-
被标记为“可达”的:保留
-
未被标记的对象:不可达(unreachable)
Step 3:清除这些“不可达对象”的内存
堆内存对象列表:
├── obj1
├── obj2
├── obj3 (未被引用)
└── obj4 (未被引用)
GC 会删除 obj3、obj4 占用的内存。
可达性图示例(引用图)
let a = {
b: {
c: 1
}
};
引用图(根:window):
window
└── a
└── b
└── c
GC 会从 window
出发一路向下,发现这些对象都“可达”,所以不会被回收。
但:
let a = { b: { c: 1 } };
a = null;
-
此时,原本链条断了(
a
赋值为 null) -
b
、c
也变成不可达 -
GC 会把整个
{ b: { c: 1 } }
回收
4. 为什么 JS 会内存泄漏(GC 不等于万能)
即使有 GC,也不能防止以下 “人为错误造成的强引用”:
1)闭包未释放外部引用
function outer() {
let largeObj = new Array(1000000);
return function inner() {
console.log(largeObj.length);
};
}
const closure = outer(); // closure 保持对 largeObj 的引用
→ largeObj
永远不会被释放
2)定时器引用未清除
let obj = { name: "leak" };
setInterval(() => {
console.log(obj.name); // obj 一直被引用
}, 1000);
→ obj
被 setInterval
捕获,永远不会释放
3)全局变量或 window 属性引用
window.leak = { a: 123 }; // 永远可达
5. 如何在 Chrome DevTools 分析 GC 与内存结构
打开 Chrome 开发者工具 → Memory:
-
Heap snapshot
-
拍摄堆快照,查找未释放对象
-
查看对象之间的引用链
-
-
Allocation instrumentation on timeline
-
分析哪些函数持续分配内存但未释放
-
-
Record Allocations
-
跟踪函数分配内存的行为与时间点
-
结合 JS 混淆/逆向分析视角看内存
在 JS 加密中:
-
加密逻辑常藏在闭包里
-
闭包保持对密钥/算法的堆引用
-
如果找不到“根引用”,这些变量就会“失联”
-
所以逆向时必须模拟作用域链、打断闭包结构
总结
JS 内存分为栈(原始 + 上下文)与堆(复杂对象);Mark & Sweep 从“根”出发,标记可达对象,清除其余;闭包、定时器、DOM 残留是常见泄漏来源;逆向必须熟悉内存引用链,才能精准还原变量与逻辑。
五、常见内存泄露场景
内存泄露 = 程序中已经不再使用的内存却没有被释放,仍然被引用。
-
导致 GC 无法释放
-
会让堆内存持续增长 → 变慢甚至崩溃
-
前端中常表现为:页面越来越卡、响应越来越慢、浏览器崩溃
1. JavaScript 中常见的 6 大内存泄漏场景
1)闭包引用外部变量未释放(最常见)
原因:闭包长期持有对外部变量的引用,导致外层函数的局部变量无法被 GC。
示例:
function outer() {
let largeData = new Array(1e6).fill("data");
return function inner() {
console.log(largeData.length);
};
}
let leaky = outer(); // largeData 被 inner 一直引用
largeData 原本应该在 outer 执行完后释放,但 inner 持有它,导致内存泄露。
解决:
-
不要长期持有不必要的闭包
-
用完后设置为
null
或用 WeakRef/WeakMap
2)定时器(setInterval / setTimeout)未清除
原因:函数体内引用了外部对象,如果定时器一直运行,就会导致这些对象无法释放。
示例:
let obj = { data: new Array(1e6).fill("leak") };
setInterval(() => {
console.log(obj.data[0]);
}, 1000); // obj 永远被引用
危害:长时间运行页面,内存会持续增长,最终崩溃。
解决:
-
页面销毁或不再需要时调用
clearInterval
-
用
WeakMap
保存定时器上下文
3)全局变量(或 window 属性)
原因:全局变量永远不会被 GC,因为它们始终可达。
示例:
window.leak = { bigData: new Array(1e6) }; // 永远不会被回收
解决:
-
不要滥用
var
(会挂载到 window),用let/const
-
不主动在 window 上挂属性
4)DOM 引用未清除(特别常见于 SPA 项目)
原因:JS 对象引用了 DOM 元素,页面虽然删除了该 DOM,但引用没断,导致 DOM 节点无法释放。
示例:
let dom = document.getElementById("btn");
let obj = {
handler: function () {
dom.addEventListener("click", () => {
console.log(dom.id);
});
}
};
然后在 HTML 中把 #btn
删除了 → JS 仍然持有它!
解决:
-
卸载组件时移除监听器
removeEventListener
-
断开 JS 到 DOM 的引用(
dom = null
)
5)缓存未清理
原因:手动缓存一些数据时忘了清理,特别是在单页应用中。
示例:
let cache = {};
function loadData(key, data) {
cache[key] = data;
}
长时间运行后 cache
占满内存,GC 无法清除。
解决:
-
用 LRU 算法限制缓存大小
-
使用
WeakMap
或WeakSet
做缓存(会自动 GC)
6)事件监听器引用上下文变量
原因:事件回调函数引用了外部变量或闭包变量,但事件监听没有移除
示例:
function setup() {
let huge = new Array(1e6).fill("data");
document.body.addEventListener("click", () => {
console.log(huge.length);
});
}
→ huge
永远被事件回调引用
解决:
-
页面卸载时手动
removeEventListener
-
避免闭包 + 事件绑定组合滥用
2. 内存泄露在逆向工程中的实战意义
1)分析闭包引用链:
-
判断某个混淆逻辑是否依赖上下文变量(如关键密钥)
2)重复运行脚本查看堆快照变化:
-
找出“变量未被释放”的真实引用来源
3)分析函数是否“绑定事件但未解绑”:
-
混淆代码中常这样绑定事件,隐藏真实入口
3. 实战工具推荐
工具 | 用法 |
---|---|
Chrome DevTools - Memory | Heap snapshot、Timeline、Detectors |
Chrome Lighthouse | 分析页面内存使用情况 |
LeakCanary(Android) | 检查 Android APP 的 JSBridge 泄露 |
WeakMap/WeakRef | 用于管理“可自动释放”的引用 |
4. 总结
闭包 + 定时器 + DOM 引用 + 全局变量,是 JS 中四大内存泄露陷阱;熟悉使用 Chrome Memory 工具找引用链,逆向时能找到隐藏变量、伪闭包和混淆逻辑的真实来源。
六、JS 加密/混淆中的闭包包裹逻辑与拆解技巧
闭包具有两个重要特性:
-
作用域隔离:变量不会暴露在全局,外部访问不到;
-
持久引用:内部函数可访问外部函数变量。
加密者利用这些特性,将核心算法、关键字符串、执行逻辑等隐藏在闭包内部,让你看不到、改不了、猜不透。
1. 常见“闭包包裹私有逻辑”模式
模式 1:自执行闭包隐藏函数
(function(){
var secret = "key123";
function encode(str) {
return str.split('').map(c => c + secret[0]).join('');
}
window._encode = encode;
})();
分析:
-
secret
是私有变量,外部无法访问; -
encode
依赖secret
,且被挂到全局; -
这是一种常见加密函数包裹模式。
拆解方式:
-
目标是还原
encode
的行为; -
思路 1:打断点调试
encode
,看secret
真实值; -
思路 2:将闭包修改为可见代码:
var secret = "key123";
function encode(str) {
return str.split('').map(c => c + secret[0]).join('');
}
window._encode = encode;
模式 2:传参闭包 + 字符串加密
(function(x, y){
var secret = x + y;
window.decrypt = function(str) {
return atob(str).split('').reverse().join('') + secret;
};
})("abc", "123");
分析:
-
secret
是根据闭包入参构造; -
加密者故意通过闭包参数传关键值;
-
函数体执行后只暴露
decrypt
接口。
拆解方式:
-
方式 1:追踪
decrypt
输入输出行为; -
方式 2:在执行前手动记录闭包传参值:
var secret = "abc123";
function decrypt(str) {
return atob(str).split('').reverse().join('') + secret;
}
window.decrypt = decrypt;
模式 3:函数工厂 + 多重闭包
var tool = (function(){
var key = "magic";
return {
encrypt: function(s) {
return s + key;
},
decode: (function(){
let reverse = str => str.split('').reverse().join('');
return function(s) {
return reverse(s) + key;
}
})()
};
})();
分析:
-
闭包返回对象;
-
多重嵌套闭包隐藏
key
; -
所有函数都能访问
key
,但外部不可改。
拆解方式:
-
利用浏览器调试查看
tool.encode
、tool.decode
执行结果; -
也可以手动复原:
var key = "magic";
var tool = {
encrypt: function(s) {
return s + key;
},
decode: function(s) {
return s.split('').reverse().join('') + key;
}
};
模式 4:混淆 + eval + 闭包组合
(function(){
var _ = function(a){ return a.split('').reverse().join(''); };
var code = ")(321'cba'(gol.elosnoc"; // 实际是:console.log('abc123')
eval(_(code));
})();
分析:
-
闭包隐藏了
_
和code
; -
核心逻辑是
eval(_)
执行还原代码; -
这种写法多用于混淆加壳或构造函数隐藏关键逻辑。
拆解方式:
-
打断点在
eval
前,看_(code)
的返回值; -
或者打印中间值:
var _ = function(a){ return a.split('').reverse().join(''); };
var code = ")(321'cba'(gol.elosnoc";
console.log(_(code)); // 打印真实执行代码
2. 进阶闭包混淆:闭包+eval+数组映射
示例:
(function(){
var _table = ['abc', 'def', 'ghi']; // 私有字符串映射表
function map(idx){ return _table[idx]; } // 映射函数,根据索引取值
function exec(code){ return eval(map(code)); } // 闭包中的 eval 调用
window.run = exec; // 向外暴露接口函数 run
})();
结构总览:
组成 | 含义 |
---|---|
(function(){ ... })() | 自执行闭包,构造私有作用域,保护 _table |
_table | 映射表,隐藏真实字符串代码 |
map(idx) | 通过索引获取字符串的函数,用于解混淆 |
exec(code) | 实际的执行函数,调用 eval(map(idx)) |
window.run = exec | 暴露接口给外部,但不暴露私有 _table |
拆解步骤:
1)还原 _table
内容
var _table = ['abc', 'def', 'ghi'];
2)替代 map() 调用
function map(idx){ return _table[idx]; }
map(1); // → "def"
3)去除闭包作用域保护(展开成全局)
var _table = ['abc', 'def', 'ghi'];
function run(code){
return eval(_table[code]);
}
4)还原所有 run(n) 的调用逻辑
如果你抓包/调试网页的时候看到:
run(1);
等价于:
eval("def");
补充:
真实场景中,_table
会被:
-
用 base64 加密:
_table = ["YWJj", "ZGVm"]
→atob(_table[i])
-
随机变量名:
var a = ['xyz']; var b = function(x){ return a[x]; }
-
混入逻辑判断、防调试语句
-
多级嵌套闭包、动态构造字符串:
_table[i]+_table[j]
3. 如何系统性地“拆闭包”?
技术 | 说明 |
---|---|
Beautify 格式化 | js-beautify / 浏览器 DevTools 让结构清晰 |
调试打断点 | 找到闭包内函数执行点,查看变量 |
替换闭包为全局函数 | 复制逻辑,解除作用域限制 |
使用控制台打印变量 | 插入 console.log ,观察变量 |
Patch + Hook | 用代码“钩子”截获闭包内函数/变量 |
AST 分析 | 用 Babel 把闭包结构解析为 AST,逐层提取 |
总结
JavaScript 加密/混淆中,闭包是用于隐藏关键逻辑和变量的核心手段,掌握闭包边界识别、变量提取、函数重构,就能有效逆向还原被包裹的核心逻辑。