关于 js:3. 闭包、作用域、内存模型

一、闭包的本质:函数 + 其词法作用域环境

闭包(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;
}

注意:

  • letconst 不会被提升,访问前会报错(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)内存:用于存储原始类型变量和执行上下文信息

  • 原始类型:NumberStringBooleanundefinednullSymbolBigInt

  • 执行上下文(函数调用时的作用域、参数)

  • 存取速度快,生命周期短,先进后出(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)

  • bc 也变成不可达

  • 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);

objsetInterval 捕获,永远不会释放

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 算法限制缓存大小

  • 使用 WeakMapWeakSet 做缓存(会自动 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 - MemoryHeap 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.encodetool.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 加密/混淆中,闭包是用于隐藏关键逻辑和变量的核心手段,掌握闭包边界识别、变量提取、函数重构,就能有效逆向还原被包裹的核心逻辑。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值