前言
在真实开发中系统中,我们常常会做/需要做一些代码运行或者检测工作。但是全量的代码运行消耗的时间是漫长的。那么我们有没有办法能够只处理我们修改的部分呢?答案是肯定的。
下面将验证介绍一种结合 AST (抽象语法树) 与 沙箱技术 的方案,局部代码热验证。
具体重服务mock代码会放在文章末尾
整体 -> 局部
我们切换一个方向:过去我们总是使用整体运行完拿到export的内容。在一些情况下,不论是 build 构建还是 dev 开发,我们通常都是全量编译打包一次。当然我们可以让他执行两次(比如只测某个函数),不过消耗的时间计算成本将会成倍上升,且容易受到文件中其他无关代码的干扰。
我们不再关注“整个文件”,而是关注 “当前选中的函数及其最小依赖集”。 通过 AST 技术,我们将代码像做手术一样“切”出来,只在内存中构建一个微型的运行环境。
code
先看AST分析转化部分
1import { Node, Project, SyntaxKind } from 'ts-morph'; 2 3let lastCodeHash = ''; 4 5function extractMinimalUnitForFunction(sourceText: string, functionName: string): { code: string; changed: boolean } { 6 const project = new Project({ useInMemoryFileSystem: true }); 7 const sourceFile = project.createSourceFile('heavy-service.ts', sourceText); 8 9 const topLevelDeclMap = new Map<string, Node>(); 10 11 for (const stmt of sourceFile.getStatements()) { 12 if (Node.isFunctionDeclaration(stmt) && stmt.getName()) { 13 topLevelDeclMap.set(stmt.getName()!, stmt); 14 } 15 if (Node.isVariableStatement(stmt)) { 16 for (const decl of stmt.getDeclarationList().getDeclarations()) { 17 topLevelDeclMap.set(decl.getName(), stmt); 18 } 19 } 20 } 21 22 if (!topLevelDeclMap.has(functionName)) { 23 throw new Error(`未找到 ${functionName}`); 24 } 25 26 const neededSymbols = new Set<string>([functionName]); 27 const queue = [functionName]; 28 29 while (queue.length > 0) { 30 const symbol = queue.shift()!; 31 const declNode = topLevelDeclMap.get(symbol); 32 if (!declNode) continue; 33 34 const ids = declNode.getDescendantsOfKind(SyntaxKind.Identifier); 35 for (const id of ids) { 36 const text = id.getText(); 37 if (text === symbol) continue; 38 if (topLevelDeclMap.has(text) && !neededSymbols.has(text)) { 39 neededSymbols.add(text); 40 queue.push(text); 41 } 42 } 43 } 44 45 const allReferencedIds = new Set<string>(); 46 for (const sym of neededSymbols) { 47 const node = topLevelDeclMap.get(sym); 48 if (!node) continue; 49 for (const id of node.getDescendantsOfKind(SyntaxKind.Identifier)) { 50 allReferencedIds.add(id.getText()); 51 } 52 } 53 54 const importLines: string[] = []; 55 for (const stmt of sourceFile.getStatements()) { 56 if (!Node.isImportDeclaration(stmt)) continue; 57 const usedNames = stmt.getNamedImports() 58 .map((ni) => ni.getName()) 59 .filter((n) => allReferencedIds.has(n)); 60 if (usedNames.length > 0) { 61 const moduleName = stmt.getModuleSpecifierValue(); 62 importLines.push([`import { ${usedNames.join(', ')} } from '${moduleName}';`](https://xplanc.org/primers/document/zh/10.Bash/90.%E5%B8%AE%E5%8A%A9%E6%89%8B%E5%86%8C/EX.join.md)); 63 } 64 } 65 66 const minimalStatements: Node[] = []; 67 for (const stmt of sourceFile.getStatements()) { 68 if (Node.isFunctionDeclaration(stmt) && stmt.getName() && neededSymbols.has(stmt.getName()!)) { 69 minimalStatements.push(stmt); 70 continue; 71 } 72 if (Node.isVariableStatement(stmt)) { 73 const names = stmt.getDeclarationList().getDeclarations().map((d) => d.getName()); 74 if (names.some((n) => neededSymbols.has(n))) { 75 minimalStatements.push(stmt); 76 } 77 } 78 } 79 80 const declLines = minimalStatements.map((s) => s.getText()); 81 const minimalCode = [...importLines, '', ...declLines].join('\n'); 82 83 console.log('--- AST 提取的最小单元 ---\n', minimalCode, '\n--- 结束 ---\n'); 84 85 const currentHash = hashCode(minimalCode); 86 const changed = currentHash !== lastCodeHash; 87 lastCodeHash = currentHash; 88 89 return { code: minimalCode, changed }; 90} 91
大致描述一下: 首先第一次执行扫描一遍文件,把所有的顶层函数名、变量名作为 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进行代理访问。
1 // 定义你的调控配置 2 const config = { 3 // 强制 Mock 的路径模式 4 mockPatterns: ['./tax-utils'], 5 // 即使被引用也不提取源码,直接用 Proxy 占位 6 }; 7 const proxyInjections: string[] = []; 8 const finalImportLines: string[] = []; 9 10 // 预设一个万能 Proxy 定义 11 const MAGIC_PROXY_DEF = `const __MAGIC_PROXY__ = new Proxy(() => __MAGIC_PROXY__, { 12 get: (target, prop) => { 13 // 关键:拦截系统转换请求 14 if (prop === Symbol.toPrimitive) return (hint) => (hint === 'number' ? 0 : '转成string了'); 15 if (prop === 'toString' || prop === 'valueOf') return () => '走到toString了 '; 16 if (typeof prop === 'symbol') return '无路可走了只能undefined'; 17 18 return __MAGIC_PROXY__; 19 }, 20 apply: () => __MAGIC_PROXY__ 21 });`; 22 23 // 按每句代码读取 24 for (const stmt of sourceFile.getStatements()) { 25 if (!Node.isImportDeclaration(stmt)) continue; 26 27 const modulePath = stmt.getModuleSpecifierValue(); 28 const isMock = config.mockPatterns.some(p => modulePath.includes(p)); 29 30 if (isMock) { 31 // 如果在 Mock 名单里,将 import 里的变量名全部指向 Proxy 32 const namedImports = stmt.getNamedImports().map(ni => ni.getName()); 33 namedImports.forEach(name => { 34 proxyInjections.push(`const ${name} = __MAGIC_PROXY__;`); 35 }); 36 } else { 37 // 否则,正常保留(或者递归提取源码) 38 finalImportLines.push(stmt.getText()); 39 } 40 } 41 42 const declLines = minimalStatements.map((s) => s.getText()); 43 const minimalCode = [ 44 MAGIC_PROXY_DEF, // 1. 注入 Proxy 引擎 45 ...proxyInjections, // 2. 注入被拦截的变量声明 (const round2 = ...) 46 '', 47 ...finalImportLines, // 3. 注入真实的 Import (非 Mock 的路径) 48 '', 49 ...declLines // 4. 注入目标函数及其内部依赖 50 ].join('\n'); 51
我采取了 “逻辑截断与指令重定向” 的策略。通过配置化的 依赖调控(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(依赖预构建拦截) 等基建工具的共同基石。在我们的方案中,它不仅用于隔离,更用于“欺骗”——通过伪造深层依赖的虚幻环境,让局部逻辑在脱离母体后依然能保持强健执行。
这里面之时还是比较干的,可以仔细运行读取一下练习。
1// 重执行函数 2import { normalizeIncome, round2 } from './tax-utils'; 3import { test } from './test-utils'; 4const serviceName = 'heavy-tax-service'; 5 6// 模拟重负载初始化(busy wait) 7function sleepMs(ms: number): void { 8 const start = Date.now(); 9 while (Date.now() - start < ms) { 10 // busy wait:模拟数据库连接、缓存预热等耗时操作 11 } 12} 13 14const taxRate = 0.13; 15const extraFee = 12; 16 17/** 18 * 目标函数:我们真正想热验证的逻辑。 19 * 依赖:taxRate、extraFee(本文件声明) + normalizeIncome、round2(来自 ./tax-utils) 20 */ 21export function calculateTax(income: number): number { 22 const normalized = normalizeIncome(income); 23 const baseTax = normalized * taxRate + extraFee; 24 return round2(baseTax); 25} 26 27/** 28 * 对比函数:用于演示 AST diff 增量执行 29 * 当修改这个函数时,AST 分析会只执行这个函数及其依赖,跳过 sleepMs 等无关代码 30 */ 31export function calculateDiscount(price: number): any { 32 const discountRate = 0.2; 33 const finalPrice = price * (1 - discountRate); 34 return { 35 value: round2(finalPrice), 36 test_value: test, // 来自 test-utils 的依赖,演示 AST 依赖提取 37 }; 38} 39 40console.log('[heavy-service] bootstrapping huge runtime...'); 41 42// 关键耗时点:全量执行时会在这里阻塞约 2 秒 43sleepMs(2000); 44calculateTax(1000); 45 46 47const runtimeConfig = { 48 region: process.env.REGION || 'cn', 49 featureFlag: true, 50}; 51 52console.log('[heavy-service] side effects done', runtimeConfig, serviceName); 53
thanks
《基于 AST 与 Proxy沙箱 的局部代码热验证》 是转载文章,点击查看原文。
