基于 AST 与 Proxy沙箱 的局部代码热验证

前言

在真实开发中系统中,我们常常会做/需要做一些代码运行或者检测工作。但是全量的代码运行消耗的时间是漫长的。那么我们有没有办法能够只处理我们修改的部分呢?答案是肯定的。

下面将验证介绍一种结合 AST (抽象语法树)沙箱技术 的方案,局部代码热验证。

具体重服务mock代码会放在文章末尾

整体 -> 局部

我们切换一个方向:过去我们总是使用整体运行完拿到export的内容。在一些情况下,不论是 build 构建还是 dev 开发,我们通常都是全量编译打包一次。当然我们可以让他执行两次(比如只测某个函数),不过消耗的时间计算成本将会成倍上升,且容易受到文件中其他无关代码的干扰。

我们不再关注"整个文件",而是关注 "当前选中的函数及其最小依赖集"。 通过 AST 技术,我们将代码像做手术一样"切"出来,只在内存中构建一个微型的运行环境。

code

先看AST分析转化部分

ini 复制代码
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

  1. 定位: 你的编辑器告诉你,位置在第 500 行,对应 AST 里的 NumericLiteral
  2. 回溯第一步: 它的 parent 是一个 BinaryExpression (例如 x + 10)。
  3. 回溯第二步: 再往上,是一个 VariableDeclarator (例如 const total = x + 10)。
  4. 回溯第三步: 再往上,是一个 BlockStatement(函数体的大括号)。
  5. 回溯终点: 最终碰到 FunctionDeclaration

此时回溯停止。成功锁定:这次修改的影响范围就在函数FunctionDeclaration内。

相关import引用处理

这时候其实我们会发现代码中存在import { round2 } from './tax-utils'这种导入工具的方法,treeShaking也会认为他是真实存在的。而在真实开发中,这个导入可能是非常多的。可能相关的引用缠绕的太深不会比重新构建引用试图,编译一次耗时差多少。

我们可以考虑一下我们这个引用是否是全部真实需要的呢?如果需要我们可以保留编译进我们的文件内,不需要我们是否可以不要这些依赖。

proxy沙箱代理

当我们拿到了相关代码时,不做任何操作进行运行或者是打包其实本身自带的依赖的bundle还是会有很深引用层级,这时候我们可以使用proxy对我们要代理的对象路径进行更改,指定他们或者直接取消引用都是可以,但是为了代码的健壮性与稳定性,我们通常通过proxy进行代理访问。

ini 复制代码
 // 定义你的调控配置
    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 都会通过拦截 getapply 陷阱,返回自身以确保链路不崩溃,从而实现逻辑执行的"硬件加速"。

最终,系统产出一段包含 [代理定义 + 拦截声明 + 真实 Import + 目标函数] 的纯粹代码段。这段代码被注入内存沙箱(如 vm 模块)进行"影子执行"。 这种姿势不仅甩掉了沉重的依赖包袱,更避开了昂贵的重排(Layout)与全量编译过程。

结尾

我们对"局部热验证"方案的探索,本质上是对现代前端工程两大核心思想的深度集成:

  • AST 节点回溯(Node Traversal):语义化的精准 这不仅是 SlideJS 等解析引擎实现精准定位的基础,更是所有现代编译器(Babel, SWC, esbuild)的灵魂。它让我们脱离了低效的正则匹配,进入了"语义化操控"的时代。在本项目中,回溯机制确保了我们能以毫秒级速度,从海量源码中锁定受影响的"逻辑最小单元"。
  • Proxy 沙箱代理:从"物理依赖"到"协议仿真" Proxy 劫持微前端(隔离沙箱)Vue 3(响应式系统) 以及 Vite(依赖预构建拦截) 等基建工具的共同基石。在我们的方案中,它不仅用于隔离,更用于"欺骗"------通过伪造深层依赖的虚幻环境,让局部逻辑在脱离母体后依然能保持强健执行。

这里面之时还是比较干的,可以仔细运行读取一下练习。

typescript 复制代码
// 重执行函数
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);

thanks

相关推荐
发现一只大呆瓜4 小时前
SSO单点登录:从同域到跨域实战
前端·javascript·面试
发现一只大呆瓜4 小时前
告别登录中断:前端双 Token无感刷新
前端·javascript·面试
Cg136269159745 小时前
JS-对象-Dom案例
开发语言·前端·javascript
无限大65 小时前
《AI观,观AI》:善用AI赋能|让AI成为你深耕核心、推进重心的“最强助手”
前端·后端
烛阴5 小时前
Claude Code Skill 从入门到自定义完整教程(Windows 版)
前端·ai编程·claude
无心水6 小时前
【任务调度:框架】11、分布式任务调度进阶:高可用、幂等性、性能优化三板斧
人工智能·分布式·后端·性能优化·架构·2025博客之星·分布式调度框架
lxh01136 小时前
数据流的中位数
开发语言·前端·javascript
神仙别闹6 小时前
基于NodeJS+Vue+MySQL实现一个在线编程笔试平台
前端·vue.js·mysql
zadyd7 小时前
Workflow or ReAct ?
前端·react.js·前端框架