基于 AST 与 Proxy沙箱 的局部代码热验证
前言
在真实开发中系统中,我们常常会做/需要做一些代码运行或者检测工作。但是全量的代码运行消耗的时间是漫长的。那么我们有没有办法能够只处理我们修改的部分呢?答案是肯定的。
下面将验证介绍一种结合 AST (抽象语法树) 与 沙箱技术 的方案,局部代码热验证。
具体重服务mock代码会放在文章末尾
整体 -> 局部
我们切换一个方向:过去我们总是使用整体运行完拿到export的内容。在一些情况下,不论是 build 构建还是 dev 开发,我们通常都是全量编译打包一次。当然我们可以让他执行两次(比如只测某个函数),不过消耗的时间计算成本将会成倍上升,且容易受到文件中其他无关代码的干扰。
我们不再关注“整个文件”,而是关注 “当前选中的函数及其最小依赖集”。 通过 AST 技术,我们将代码像做手术一样“切”出来,只在内存中构建一个微型的运行环境。
code
先看AST分析转化部分
import { Node, Project, SyntaxKind } from 'ts-morph';
let lastCodeHash = '';
function extractMinimalUnitForFunction(sourceText: string, functionName: string): { code: string; changed: boolean } {
const project = new Project({ useInMemoryFileSystem: true });
const sourceFile = project.createSourceFile('heavy-service.ts', sourceText);
const topLevelDeclMap = new Map<string, Node>();
for (const stmt of sourceFile.getStatements()) {
if (Node.isFunctionDeclaration(stmt) && stmt.getName()) {
topLevelDeclMap.set(stmt.getName()!, stmt);
}
if (Node.isVariableStatement(stmt)) {
for (const decl of stmt.getDeclarationList().getDeclarations()) {
topLevelDeclMap.set(decl.getName(), stmt);
}
}
}
if (!topLevelDeclMap.has(functionName)) {
throw new Error(`未找到 ${functionName}`);
}
const neededSymbols = new Set<string>([functionName]);
const queue = [functionName];
while (queue.length > 0) {
const symbol = queue.shift()!;
const declNode = topLevelDeclMap.get(symbol);
if (!declNode) continue;
const ids = declNode.getDescendantsOfKind(SyntaxKind.Identifier);
for (const id of ids) {
const text = id.getText();
if (text === symbol) continue;
if (topLevelDeclMap.has(text) && !neededSymbols.has(text)) {
neededSymbols.add(text);
queue.push(text);
}
}
}
const allReferencedIds = new Set<string>();
for (const sym of neededSymbols) {
const node = topLevelDeclMap.get(sym);
if (!node) continue;
for (const id of node.getDescendantsOfKind(SyntaxKind.Identifier)) {
allReferencedIds.add(id.getText());
}
}
const importLines: string[] = [];
for (const stmt of sourceFile.getStatements()) {
if (!Node.isImportDeclaration(stmt)) continue;
const usedNames = stmt.getNamedImports()
.map((ni) => ni.getName())
.filter((n) => allReferencedIds.has(n));
if (usedNames.length > 0) {
const moduleName = stmt.getModuleSpecifierValue();
importLines.push(`import { ${usedNames.join(', ')} } from '${moduleName}';`);
}
}
const minimalStatements: Node[] = [];
for (const stmt of sourceFile.getStatements()) {
if (Node.isFunctionDeclaration(stmt) && stmt.getName() && neededSymbols.has(stmt.getName()!)) {
minimalStatements.push(stmt);
continue;
}
if (Node.isVariableStatement(stmt)) {
const names = stmt.getDeclarationList().getDeclarations().map((d) => d.getName());
if (names.some((n) => neededSymbols.has(n))) {
minimalStatements.push(stmt);
}
}
}
const declLines = minimalStatements.map((s) => s.getText());
const minimalCode = [...importLines, '', ...declLines].join('\n');
console.log('--- AST 提取的最小单元 ---\n', minimalCode, '\n--- 结束 ---\n');
const currentHash = hashCode(minimalCode);
const changed = currentHash !== lastCodeHash;
lastCodeHash = currentHash;
return { code: minimalCode, changed };
}
大致描述一下: 首先第一次执行扫描一遍文件,把所有的顶层函数名、变量名作为 Key,对应的 AST 节点作为 Value 存起来。这相当于给整个文件画了一张索引表。通过队列来做递归依赖查找,直到把所有嵌套调用的依赖全部找齐。
找齐了依赖还没完,它还要处理 import,进行treeShaking,最后计算生成的 minimalCode 的哈希值,如果我们改了文件中不相关的部分(比如改了另一个函数),这个最小单元的 Hash 就不会变。只有修改的代码真正影响到了目标函数时,changed 才会是 true。
这里面其实牵扯出一个概念:节点回溯
节点回溯
在编译器和代码分析领域,节点回溯(Node Traversal / Upward Walking) 就像是给 AST装上了“导航回程”系统。
如果说传统的 AST 遍历是“从树根向下寻找叶子”,那么节点回溯就是 “从叶子向上寻找祖先”。
例如:
我们修改了一个数字 10
-
定位: 你的编辑器告诉你,位置在第 500 行,对应 AST 里的
NumericLiteral。 -
回溯第一步: 它的
parent是一个BinaryExpression(例如x + 10)。 -
回溯第二步: 再往上,是一个
VariableDeclarator(例如const total = x + 10)。 -
回溯第三步: 再往上,是一个
BlockStatement(函数体的大括号)。 -
回溯终点: 最终碰到
FunctionDeclaration。
此时回溯停止。成功锁定:这次修改的影响范围就在函数FunctionDeclaration内。
相关import引用处理
这时候其实我们会发现代码中存在import { round2 } from './tax-utils'这种导入工具的方法,treeShaking也会认为他是真实存在的。而在真实开发中,这个导入可能是非常多的。可能相关的引用缠绕的太深不会比重新构建引用试图,编译一次耗时差多少。
我们可以考虑一下我们这个引用是否是全部真实需要的呢?如果需要我们可以保留编译进我们的文件内,不需要我们是否可以不要这些依赖。
proxy沙箱代理
当我们拿到了相关代码时,不做任何操作进行运行或者是打包其实本身自带的依赖的bundle还是会有很深引用层级,这时候我们可以使用proxy对我们要代理的对象路径进行更改,指定他们或者直接取消引用都是可以,但是为了代码的健壮性与稳定性,我们通常通过proxy进行代理访问。
// 定义你的调控配置
const config = {
// 强制 Mock 的路径模式
mockPatterns: ['./tax-utils'],
// 即使被引用也不提取源码,直接用 Proxy 占位
};
const proxyInjections: string[] = [];
const finalImportLines: string[] = [];
// 预设一个万能 Proxy 定义
const MAGIC_PROXY_DEF = `const __MAGIC_PROXY__ = new Proxy(() => __MAGIC_PROXY__, {
get: (target, prop) => {
// 关键:拦截系统转换请求
if (prop === Symbol.toPrimitive) return (hint) => (hint === 'number' ? 0 : '转成string了');
if (prop === 'toString' || prop === 'valueOf') return () => '走到toString了 ';
if (typeof prop === 'symbol') return '无路可走了只能undefined';
return __MAGIC_PROXY__;
},
apply: () => __MAGIC_PROXY__
});`;
// 按每句代码读取
for (const stmt of sourceFile.getStatements()) {
if (!Node.isImportDeclaration(stmt)) continue;
const modulePath = stmt.getModuleSpecifierValue();
const isMock = config.mockPatterns.some(p => modulePath.includes(p));
if (isMock) {
// 如果在 Mock 名单里,将 import 里的变量名全部指向 Proxy
const namedImports = stmt.getNamedImports().map(ni => ni.getName());
namedImports.forEach(name => {
proxyInjections.push(`const ${name} = __MAGIC_PROXY__;`);
});
} else {
// 否则,正常保留(或者递归提取源码)
finalImportLines.push(stmt.getText());
}
}
const declLines = minimalStatements.map((s) => s.getText());
const minimalCode = [
MAGIC_PROXY_DEF, // 1. 注入 Proxy 引擎
...proxyInjections, // 2. 注入被拦截的变量声明 (const round2 = ...)
'',
...finalImportLines, // 3. 注入真实的 Import (非 Mock 的路径)
'',
...declLines // 4. 注入目标函数及其内部依赖
].join('\n');
我采取了 “逻辑截断与指令重定向” 的策略。通过配置化的 依赖调控(Dependency Control) ,系统会对深层或重型的外部依赖进行“漂白”or “替换”:
-
拦截深层引用:当 AST 扫描到预设的拦截路径(如
./tax-utils)时,系统会切断递归,不再打包其源码。 -
注入递归代理(Recursive Proxy) :在生成的代码头部注入一个的万能代理对象
__MAGIC_PROXY__。
原理: 无论目标函数如何调用这些被拦截的依赖(如
service.user.get().name),Proxy 都会通过拦截get和apply陷阱,返回自身以确保链路不崩溃,从而实现逻辑执行的“硬件加速”。
![]()
最终,系统产出一段包含 [代理定义 + 拦截声明 + 真实 Import + 目标函数] 的纯粹代码段。这段代码被注入内存沙箱(如 vm 模块)进行“影子执行”。 这种姿势不仅甩掉了沉重的依赖包袱,更避开了昂贵的重排(Layout)与全量编译过程。
结尾
我们对“局部热验证”方案的探索,本质上是对现代前端工程两大核心思想的深度集成:
- AST 节点回溯(Node Traversal):语义化的精准 这不仅是 SlideJS 等解析引擎实现精准定位的基础,更是所有现代编译器(Babel, SWC, esbuild)的灵魂。它让我们脱离了低效的正则匹配,进入了“语义化操控”的时代。在本项目中,回溯机制确保了我们能以毫秒级速度,从海量源码中锁定受影响的“逻辑最小单元”。
- Proxy 沙箱代理:从“物理依赖”到“协议仿真” Proxy 劫持 是 微前端(隔离沙箱) 、Vue 3(响应式系统) 以及 Vite(依赖预构建拦截) 等基建工具的共同基石。在我们的方案中,它不仅用于隔离,更用于“欺骗”——通过伪造深层依赖的虚幻环境,让局部逻辑在脱离母体后依然能保持强健执行。
这里面之时还是比较干的,可以仔细运行读取一下练习。
// 重执行函数
import { normalizeIncome, round2 } from './tax-utils';
import { test } from './test-utils';
const serviceName = 'heavy-tax-service';
// 模拟重负载初始化(busy wait)
function sleepMs(ms: number): void {
const start = Date.now();
while (Date.now() - start < ms) {
// busy wait:模拟数据库连接、缓存预热等耗时操作
}
}
const taxRate = 0.13;
const extraFee = 12;
/**
* 目标函数:我们真正想热验证的逻辑。
* 依赖:taxRate、extraFee(本文件声明) + normalizeIncome、round2(来自 ./tax-utils)
*/
export function calculateTax(income: number): number {
const normalized = normalizeIncome(income);
const baseTax = normalized * taxRate + extraFee;
return round2(baseTax);
}
/**
* 对比函数:用于演示 AST diff 增量执行
* 当修改这个函数时,AST 分析会只执行这个函数及其依赖,跳过 sleepMs 等无关代码
*/
export function calculateDiscount(price: number): any {
const discountRate = 0.2;
const finalPrice = price * (1 - discountRate);
return {
value: round2(finalPrice),
test_value: test, // 来自 test-utils 的依赖,演示 AST 依赖提取
};
}
console.log('[heavy-service] bootstrapping huge runtime...');
// 关键耗时点:全量执行时会在这里阻塞约 2 秒
sleepMs(2000);
calculateTax(1000);
const runtimeConfig = {
region: process.env.REGION || 'cn',
featureFlag: true,
};
console.log('[heavy-service] side effects done', runtimeConfig, serviceName);