普通视图

发现新文章,点击刷新页面。
昨天 — 2026年5月9日首页

如何用 Recast 实现静态配置文件源码级读写

作者 heyCHEEMS
2026年5月9日 17:29

当你使用 Node.js 修改代码时,用正则直接操作字符串极其危险且难以维护。通过 AST 就可以像操作 JSON 一样精确地增删改查代码。

  • Babel (@babel/parser):负责执行将代码拆成 Token、将 Token 组装成树,把字符串拆解成一个个节点,如变量名、数值、函数。但是在修改并回写代码时,会丢失原有的缩进、空格和注释,导致代码格式全乱。
  • Recast:在解析时会记录每个节点的原始位置和格式,在回写阶段会进行自动对比,只更新你修改过的部分。

Recast 基础用法

  1. recast.parse:把代码字符串解析成一颗 AST 树。
  2. recast.visit:在树上找节点,比如找到名为 config 的变量。
  3. recast.types.builders (简称 b ) :假如你想把数字 1 改成 2,你需要用 builder 造出一个“数字 2”的节点来替换。

使用 recast.visit 时,可以从 path.node 拿到需要的数据。

path.node 常用属性

type:节点的类型(如 Identifier, Literal),这是判断“它是什么”的第一步。

loc:包含 startend 的行号列号,Recast 靠它实现精准的局部替换。

comments:存放该节点的注释信息,可以通过 b.commentLine 往里推入新注释。

变量声明 (VariableDeclarator)

id:左手边的变量名节点,通常 node.id.name 就能拿到 "port"

init:右手边的初始值节点,它是你要读取或替换的核心。

对象属性 (ObjectProperty)

key:键名,注意:如果是 { 'a-b': 1 },key 是 StringLiteral;如果是 { a: 1 },key 是 Identifier

value:键值,可以是任何表达式(数字、函数、另一个对象)。

computed:布尔值,如果为 true,说明是 { [prop]: 1 } 这种计算属性。

成员表达式 (MemberExpression)

object:点号左边的部分,如 process.env.PORT 中的 process.env

property:点号右边的部分,如 PORT

函数调用 (CallExpression)

arguments:一个数组,存放所有传入的参数节点,修改它就能增删函数参数。

字面量 (Literal 系列)

value:存放在 JS 里的实际值(如数字 8080,字符串 "localhost")。

raw:原始文本,比如源码写的是 0x10value16,而 raw 就是 "0x10"

1. 定义解析规则

const recast = require('recast');
const parser = require('@babel/parser');
const b = recast.types.builders; // 用来创建新的代码节点

const options = {
  // 解析器配置
  parser: {
    parse: source => parser.parse(source, {
      sourceType: 'module', // es模块化
      plugins: ['typescript'] // 开启 TS 插件
    })
  }
};

2. 从源码提取数据

比如把 const port = 8080 变成 JS 对象 { port: 8080 }

function getConfig(code) {
  const ast = recast.parse(code, options); // 先解析为 AST
  const result = {};

  recast.visit(ast, {
    // 遍历所有的变量定义
    visitVariableDeclarator(path) {
      const node = path.node;
      // node.id.name 是变量名,node.init.value 是变量的值
      result[node.id.name] = node.init.value; 
      return false; // 找到后停止向下搜寻
    }
  });
  return result;
}

3. 回写源码内容

假设把源码里的 port 改为 9090,并加上注释。

function updateConfig(oldCode, newValues) {
  const ast = recast.parse(oldCode, options); // 先解析为 AST

  recast.visit(ast, {
    visitVariableDeclarator(path) {
      const varName = path.node.id.name;
      if (newValues[varName]) {
        // 用 builder 创建一个新的 number 节点
        const newValueNode = b.numericLiteral(newValues[varName]);
        
        // 替换旧的初始值
        path.get('init').replace(newValueNode);

        // 添加一行注释
        path.parentPath.node.comments = [b.commentLine(' 自动生成的配置')];
      }
      return false;
    }
  });

  // 输出转换结果
  return recast.print(ast, { quote: 'single' }).code;
}

常用的遍历节点类型:

visitVariableDeclarator:匹配变量定义,如 const a = 1 中的 a = 1 部分。

visitObjectProperty:匹配对象属性,用于读写 { key: value } 中的键值对。

visitArrayExpression:匹配数组配置,常用于增删 [item1, item2] 中的元素。

visitImportDeclaration:匹配导入语句,用于分析或修改 import 的路径与成员。

visitExportNamedDeclaration:匹配导出语句,用于处理 export const config = {}

visitCallExpression:匹配函数调用,用于修改 init({ port: 80 }) 等执行语句的参数。

visitAssignmentExpression:匹配赋值操作,如修改 module.exports = {} 或变量重赋值。

visitMemberExpression:匹配成员访问,用于处理 process.env.NODE_ENV 这种点语法。

visitIdentifier:匹配所有标识符,即代码中出现的变量名、函数名或属性名。

visitStringLiteral / NumericLiteral:匹配字符串或数字字面量,用于直接改写基础值。

visitExpressionStatement:匹配独立的表达式语句,常用于在文件顶层插入新代码行。

visitIfStatement:匹配条件判断,用于自动化修改 if (isDev) 等逻辑分支。

visitArrowFunctionExpression:匹配箭头函数,用于重构或分析回调函数内容。

visitClassDeclaration:匹配类定义,用于提取类名、继承关系或修改装饰器。


4. 引用变量的提取与回写

处理节点时,引用变量是一个比较麻烦的地方。比如代码中不仅仅只是简单的 port: 8080,而是 port: DEFAULT_PORTpath: process.env.URL。直接读取 node.init.value 会得到 undefined,因为这些值不是字面量(Literal) ,而是标识符(Identifier)或成员表达式(MemberExpression) 。

提取逻辑示例

遇到如 IdentifierMemberExpression 等非字面量节点时,递归提取其完整路径,并用特殊标记(如 __isRef: true)包装,防止丢失引用关系。

// 递归提取引用路径
_getMemberPath(node) {
    if (node.type === 'Identifier') return node.name;
    if (node.type === 'MemberExpression') {
        // 递归向上拼接
        return `${this._getMemberPath(node.object)}.${node.property.name}`;
    }
    return '';
}

// 如果是引用则返回包装对象
if (node.type === 'Identifier' || node.type === 'MemberExpression') {
    return { __isRef: true, __refName: this._getMemberPath(node) };
}

回写逻辑示例

回写时识别标记,通过 split('.') 将路径切开,利用 reduce 配合 b.memberExpression 进行还原。

// 将字符串还原为 AST 
if (val && val.__isRef) {
    const parts = val.__refName.split('.');
    return parts.reduce((sum, cur) => {
        if (!sum) return b.identifier(cur); // 第一个基础标识符
        return b.memberExpression(sum, b.identifier(cur)); // 向下递归拼接
    }, null);
}

Recast 是如何进行自动对比的?

在执行 recast.parse 时,Recast 会为 AST 的每个节点打上一个隐藏的标签,记录该节点在原始字符串中的起始位置 loc.start 和结束位置 loc.end ,以及它周围的所有空格、换行、分号。

当你使用 replace 修改了某个节点或者它的属性时,Recast 会将该节点标记为脏节点 。

在执行 recast.print 时,Recast 的渲染器会遍历整棵树:

  • 如果是干净节点,则直接从原始字符串中,根据记录的 loc 坐标切出那一段文本。
  • 如果是脏节点,则递归调用生成器,根据 Babel 规则重新生成这一小段代码,并尝试参考父节点的缩进风格进行对齐。

昨天以前首页

记录一下自动化构建中 SSE 与子进程管理的三个坑

作者 heyCHEEMS
2026年4月25日 23:57

最近在写一个博客后台管理系统的轻量自动化部署接口,用 SSE 来流传输给前端打印实时构建日志,简单记录一下遇到的最主要的三个坑。

坑一:子进程杀不死

在 Node.js 中,我们习惯用 childProcess.kill()。但在运行 pnpm build 这种命令时,它会衍生出一大堆子进程(如 Vite 或 Webpack)。如果你只杀掉父进程,那些构建进程就会变成“孤儿进程”继续运行。

解决办法是 在 Windows 下要用 taskkill 配合 /T 参数杀掉整棵进程树,在 Linux 下则要开启 detached 模式并用负数 PID 来杀掉整个进程组。

这里涉及到了进程树与进程组,操作系统中,程序启动另一个程序即为父子关系。Linux 的 detached 模式相当于让子进程自立门户当“组长”,通过组 ID 即可实现“一锅端”。

// 后端执行终止子进程
stopProcess() {
    if (this.childProcess && this.childProcess.pid) {
        const pid = this.childProcess.pid;
        if (this.isWindows) {
            // Windows 通过 /T 杀掉子进程树,/F 强制终止
            exec(`taskkill /PID ${pid} /T /F`);
        } else {
            // Linux 或 Mac 通过负数 PID 杀死整个进程组
            process.kill(-pid, 'SIGKILL');
        }
        this.childProcess.removeAllListeners();
        this.childProcess = null;
    }
}

坑二:SSE 切换页面自动重连

当你开启构建后切换到其他标签页,浏览器为了节能会挂起网络请求。等你切回来时,浏览器发现连接断开并自动尝试重连。由于后端为了防止并发给任务加了锁,重连请求撞上正在运行的任务,后端就返回了错误。

fetch-event-source 库提供了一个参数叫 openWhenHidden,把它设为 true,就能绕过浏览器的节能限制,当浏览器最小化或切换到其它标签页时也保持连接。

// 前端请求配置
await fetchEventSource('/sse-api/deploy', {
    method: 'POST',
    openWhenHidden: true, // 切换标签页时不中断连接
    onmessage(ev) {
        const data = JSON.parse(ev.data);
        setLog(prev => prev + data.log);
    },
    onerror(err) {
        if (err.code === 409) {
            message.warning('后台已有任务在运行');
        }
    }
});

坑三:原生 API 的局限性

很多人觉得原生 EventSource 更轻量,但它其实是个黑盒:原生的 EventSource 默认不支持在请求中添加自定义请求头(如 Authorization)。如果你的博客后台接口需要 Token 验证,原生 API 只能被迫将 Token 挂在 URL 参数里。这会让 Token 暴露在服务器日志中,还显得非常不专业。

原生 SSE 强制要求必须是 GET 请求,但如果我们需要向后端发送一些复杂数据时,把这些东西塞进 URL 参数里既臃肿又不安全。用 Fetch 模拟 SSE,就可以轻松发起 POST 请求,把参数优雅地放在 Request Body 里。

原生 API 一旦断开,会按照浏览器内置的逻辑盲目重连。而 Fetch 模式配合 AbortController,可以让我们精准控制,什么时候该彻底断开,什么时候该带着上一次的 Last-Event-ID 重新寻找断点。

❌
❌