软件开发中的重复性操作往往处于一个尴尬的境地:它们不值得写入正式构建系统,但又频繁到足以让人厌烦。传统的解决方案是依赖 Shell 的历史功能 —— 重复按上方向键调出之前的命令,再按回车执行。这种被戏称为「Up Up Up」的工作流在处理单命令场景时确实够用,但一旦涉及多进程协调、参数化实验或条件分支,就立即暴露出严重的局限性。本文将深入探讨一种替代性的工程实践:通过 TypeScript 脚本文件(通常命名为 make.ts)来实现更可控、更可演化的构建与实验工作流。
标签模板语法的进程生成原理
理解 Make.ts 模式的技术核心,首先需要理解 JavaScript 标签模板语法(Tagged Template Literals)在进程生成中的独特优势。标准模板字符串会将变量与字面量拼接成单一字符串,而标签模板语法允许我们以结构化的方式访问模板的各个组成部分。
当执行 $ls ${dir}这样的调用时,标签函数接收两个独立的参数:字面量数组["ls ", ""]与插值数组["hello, world"]。这种分离意味着我们可以精确控制进程的每一个参数,而不是将它们笼统地塞进一个需要 Shell 解析的字符串中。在构建系统场景下,这意味着我们能够避免 Shell 注入风险、正确处理含空格的路径、以及精确传递边界情况下的参数。传统做法依赖字符串拼接并交给 Shell 解释,而标签模板语法让我们在 JavaScript 层面就完成参数的组织,直接传递给底层的 exec` 系统调用。
基于这一语法,配合 Deno 运行时与 dax 库,我们可以编写出既具可读性又保证参数精确性的进程执行代码。dax 库封装了底层的进程生成逻辑,将标签模板的输出直接用于 child_process.spawn,同时提供了标准输出捕获、超时控制、错误处理等构建场景必需的功能。这种组合形成了一个「单文件电池包含」的脚本环境 —— 无需额外的配置文件、依赖声明或工具链设置,脚本本身即可独立运行。
渐进式脚本演化模式
Make.ts 模式最引人注目的特性并非某一具体功能,而是它所支持的渐进式演化能力。在传统的 Shell 历史工作流中,命令一旦执行就很难被追溯和优化;而在文件化的脚本中,命令序列天然具备可编辑性,这为渐进式改进创造了条件。
演化的第一阶段是命令捕获。开发者不必一开始就编写「完整」的脚本,而只需将原本要直接输入终端的命令写入文件。这一步骤的心理门槛极低,但它解决了一个关键问题:Shell 历史是易失的,而文件是持久的。多次尝试才能调对的复杂命令、临时的调试选项、特定的路径配置 —— 这些「一次性」的知识一旦被捕获,就不再依赖于记忆或运气。
第二阶段是命令的组织与参数化。当单个命令需要被重复执行时,脚本中的自然语言描述(如注释或变量名)帮助我们识别可抽象的模式。原本散布在多次执行历史中的相似命令开始显现出共同的模式,我们可以将硬编码的值替换为变量,将重复的模式提取为函数。这一过程是渐进的 —— 每次改进都建立在已有代码之上,而非从头重写。
第三阶段是流程的自动化。当参数化的脚本成熟到可以处理多种场景时,循环结构和条件分支自然地被引入。原始的单一命令序列演化为可配置的工具,支持不同参数组合的批量执行、跨环境的部署验证、或者是多维度的性能基准测试。这种演化路径在文件化的脚本中几乎是平滑过渡的,而在 Shell 历史工作流中则需要「断裂式」的重构 —— 从历史中回溯命令、尝试组合它们、处理并发与错误,最终形成独立的脚本。这个重构过程往往因为太繁琐而被放弃,导致复杂工作流永远停留在「每次手动执行」的状态。
并发进程编排与错误处理
多进程场景是检验一个工作流模式实用性的试金石。在分布式系统开发、集群测试、或需要并行运行多个独立任务的场景中,手动管理多个终端窗口不仅效率低下,而且难以保证操作的一致性和可重复性。
Make.ts 模式利用 JavaScript 的 async/await 语法实现优雅的并发控制。Promise.all 接受一个进程执行承诺的数组,并等待所有进程完成。这与 Makefile 的并行目标类似,但有两点关键差异。首先,JavaScript 的并发原语是显式的 —— 开发者明确知道哪些任务被并行启动,它们的完成顺序是可预测的;其次,错误处理是细粒度的 —— 可以使用 try/catch 包装单个进程而不影响其他并行任务,或者收集所有进程的结果后统一处理。
更进一步的演进涉及进程的生命周期管理。通过 .spawn() 方法启动的进程返回控制柄,允许后续执行 .kill() 来终止它。这对于需要「启动 - 观察 - 终止」模式的实验性工作流尤为重要:启动一组监控进程,在特定条件触发后终止它们,然后收集和分析运行结果。整个过程可以用清晰的代码结构表达,而非依赖于手动发送信号或寻找进程 ID。
在进程的错误处理方面,Make.ts 模式区分了「预期错误」与「意外错误」。某些命令的失败是流程可以接受的 —— 例如尝试终止一个可能已经不存在的进程 —— 这时可以使用 .noThrow() 方法将错误转换为返回值的标记,而非抛出异常导致整个脚本终止。这种差异化的错误处理让脚本在面对不完美的执行环境时更具鲁棒性。
与传统 Makefile 的范式对比
将 Make.ts 模式与经典 Makefile 进行对比,可以更清晰地理解两种范式各自适合的场景。Makefile 的核心优势在于其声明式的依赖描述和增量编译能力:当且仅当源文件变更时,Make 才执行相应的构建步骤。这种语义级别的变更检测是命令行脚本难以复制的。
然而,Makefile 在表达灵活性上存在根本性的限制。首先,Makefile 的语法是为构建场景设计的,它的条件判断和循环结构相对简陋,复杂的逻辑往往需要借助 Shell 嵌套或外部脚本,这破坏了声明式语义的清晰性。其次,Makefile 的依赖关系是静态的 —— 它们在文件解析时确定,无法根据运行时条件动态调整。第三,Makefile 的错误处理是粗粒度的 —— 命令执行失败通常导致整个构建过程终止,缺乏细粒度的恢复机制。
Make.ts 模式并不试图取代 Makefile 的增量编译职责,而是填补了 Makefile 不擅长的中间地带:那些不够通用以至于不值得纳入正式构建系统、但又足够复杂以至于手动执行容易出错的操作。在这个定位中,脚本的临时性是优势而非缺陷 —— 它可以根据任务需要快速创建、完成使命后废弃或归档,而不需要维护永久的构建配置。
两种范式可以自然地协同:Makefile 负责项目的正式构建流程,Make.ts 脚本负责实验性任务、部署验证、集群操作等场外工作。开发者根据任务的正式程度选择合适的工具,而非试图用单一工具覆盖所有场景。
工程实践建议
成功采用 Make.ts 模式需要建立几个简单的工程约定。首先是命名约定 —— 使用一致的文件名(如始终命名为 make.ts)消除了「这个脚本该叫什么」的决策成本,同时也便于在项目间复用相同的编辑器和工具链配置。其次是忽略配置 —— 将 make.ts 加入版本控制的忽略列表(使用 .git/info/exclude 以保持本地个性化),确保个人实验脚本不会污染共享代码库。
在脚本语言的选择上,TypeScript 被推荐是因为它提供了类型系统在代码补全和重构检测方面的帮助,同时保留了 JavaScript 的动态性和「暴力解决」的灵活性。对于追求更轻量体验的开发者,Bun 提供了内置的类似 dax 的进程执行能力;唯一的权衡是 Bun 的格式化工具和语言服务器支持不如 Deno 完善。
标签模板语法的 $ 函数可以按需定制。除了直接调用 dax 的实现外,开发者可以包装自己的逻辑 —— 添加日志记录、收集执行指标、实现自定义的错误处理策略。这种可扩展性让脚本既能保持简单,又能随需求演进而增强。
最后,渐进式演化的心态比任何具体技术更重要。Make.ts 模式的价值不在于某个精巧的语法技巧,而在于它所鼓励的工作流转变:将临时命令视为可演化的脚本,将一次性的实验视为未来可复用工具的雏形。这种心态转变带来的长期收益,远超任何特定工具或技术选型。
资料来源:Alex Klizhentas 的博客分享(https://matklad.github.io/2026/01/27/make-ts.html)。