inputs、outputs、deps:任务缓存的粒度是自己声明出来的

2026-08-09

先把状态说清楚,免得读串了。

本文描述的是 OpenCut 重写版仓库(OpenCut-app/OpenCut)当前的代码结构与官方 README 声明的路线图。该版本尚未发布,Editor API、插件体系、MCP server、无头模式等均为官方声明的计划,我们没有见到可用实现。核对日 2026-08-09。

也就是说,下面要拆的是这个仓库里已经躺在磁盘上的配置文件,不是任何一个可以下载来用的产品。至于今天线上能用的那个 opencut.app,跑的是另一个仓库 OpenCut-app/opencut-classic(已归档、不再维护),它的构建体系跟本文说的完全不是一回事,别混着看。

一个 monorepo 最容易糊掉的地方

多包仓库里最常见的抱怨是:明明只改了一行文案,CI 却把所有包重新 build 了一遍;或者反过来,改了配置文件,构建却拿了旧缓存,跑出来的产物是错的。这两种毛病是同一个病根的两面——构建工具不知道一个任务到底吃什么、吐什么

不知道的时候只有两条路:要么保守,什么都重跑;要么激进,猜一个范围,猜错就发脏缓存。

OpenCut 重写版仓库选的是第三条:让任务自己把边界写出来。它的工具链是 proto + moon,README 给的步骤是先装 proto,再从仓库根跑 proto use 安装 .prototools 里钉住的工具,然后用 moon run 触发任务:

moon run web:dev       # localhost:5173
moon run api:dev       # localhost:8787
moon run desktop:dev   # 见 apps/desktop/README.md

顺带一提,classic(旧版)本地起在 3000,重写版 web 是 5173、api 是 8787,三个端口别记混。我们没有安装 proto/moon,也没有跑过任何一个 moon 任务,本文谈的全部是配置文件里写了什么。

apps/web/moon.yml 拆开看

这份文件的要点是这样的:

language: 'typescript'
layer: 'application'

tasks:
  dev:
    command: 'bun run dev'
    options:
      runInCI: false  # dev server: skipped in CI, never cached
  build:
    command: 'bun run build'
    inputs: ['src/**/*', 'public/**/*', 'vite.config.ts', 'tsconfig.json', 'package.json']
    outputs: ['dist']
  test:
    command: 'bun run test'
    inputs: ['src/**/*', 'tsconfig.json']
  deploy:
    command: 'bun run deploy'
    deps: ['~:build']

四个任务,四种写法,差异全在字段上,值得一个个看。

dev:一个明确声明自己不该被缓存的任务

dev 没有 inputs,也没有 outputs,只多了一个 runInCI: false,后面还带着作者自己写的注释:dev server 在 CI 里跳过,永不缓存。

这是个很好的起手示范。开发服务器是个长期运行、没有终点、没有产物的进程,它不存在「跑完了、结果可以存起来下次直接用」这回事。硬给它配 inputs/outputs 只会自找麻烦。作者干脆连声明都不写,只标一句「CI 里别碰我」。

判断依据可以直接抄过来:任务如果没有明确的结束状态和落盘产物,就别给它设计缓存,先想清楚它属不属于「可缓存」这一类,再谈粒度。

buildinputs 就是失效边界

buildinputs 列了五项:src/**/*public/**/*vite.config.tstsconfig.jsonpackage.json

这五项定义的不是「构建需要读什么文件」,而是**「什么东西变了,之前那次构建的结果就不能再用了」**。理解这一层,很多事情就顺了:

  • 源码变了要重建,所以 src/**/* 在里面;
  • 静态资源会被打包进产物,所以 public/**/* 在里面;
  • 构建配置和类型配置直接决定产物形态,所以 vite.config.tstsconfig.json 在里面;
  • 依赖版本变了产物也会变,所以 package.json 在里面。

**而重写版主仓根目录的 README、changelog/ 下的那几份 markdown、brand/ 里的设计资源,一个都不在这五项里。**改它们不会让 web:build 失效。这就是「粒度是自己声明出来的」这句话的实际含义——不是工具替你猜,是这一行 inputs 替你划的线。

这里有个通用权衡值得提醒,它属于构建缓存这一类工具的共性,不是 OpenCut 文档里的说法:inputs 写窄了和写宽了,代价并不对称。写宽了,无非是本该命中的缓存没命中,多跑一次构建,浪费时间但结果是对的;写窄了,漏掉的那个文件改动不会触发失效,你会拿到一份和源码对不上的产物,而且这种问题往往要等到线上才暴露。拿不准的时候,倾向于多列一项。

test:和 build 的差别本身就是信息

testinputs 只有两项:src/**/*tsconfig.json。比 build 少了 public/**/*vite.config.tspackage.json

这个差别是配置里明摆着的,读者能直接看到——同一个包里,不同任务的失效边界可以不一样。测试和构建吃的东西不同,凭什么共用一套 inputs?很多团队图省事在整个包上配一套全局 inputs,结果就是改任何一个文件所有任务全部重跑,缓存等于白配。

至于作者为什么恰好这么划这两项,README 和配置注释都没有说明,我们不做推断。但「不同任务分别声明」这个做法本身,是可以直接借鉴的。

outputsdeps:一个管复用,一个管顺序

build 有一行 outputs: ['dist']。声明产物目录,意义是让这次构建的结果成为一个可以被存起来、被后续任务取用的东西。没有 outputs,缓存命中最多只能省下「不重跑」,但产物从哪来就成了悬案。

deploy 则完全是另一个套路:它没有 inputs,也没有 outputs,只有一行 deps: ['~:build']

这一行替代的是团队里那句口口相传的「记得先 build 再 deploy」。把顺序写进配置,人就不需要记了,CI 也不会因为有人手滑跳过一步而发出一个空目录。(~ 前缀在这里指向同一个项目内的任务,确切语义以 moon 官方文档为准,我们没有跑过验证。)

同样,deploy 不声明 outputs 是合理的:它的效果发生在远端,不在磁盘上。

反过来的例子:一个主动关掉缓存的任务

重写版主仓根目录的 moon.yml 里还有一个 upload-logos 任务,选项是 cache: falserunInCI: false。它做的事情是:读 .env.local(缺失就报错退出),要求 R2_BUCKET 变量,把 brand/marks/*.svgwrangler r2 object put 传到 R2。

这个任务把「什么时候不该缓存」讲得比任何文档都清楚:

  1. 它有外部副作用。缓存的前提是「同样的输入必然得到同样的结果」,而上传这种动作的结果在别人的服务器上,本地缓存一命中,文件就没传上去。
  2. 它依赖本地机密。要读 .env.local、要 R2_BUCKET,这些东西不该、通常也不会进 CI 的常规流程,标 runInCI: false 是顺理成章的。

所以同一个仓库里,web:build 拼命想被缓存,upload-logos 拼命想不被缓存,两者不矛盾——缓存是按任务性质决定的,不是按仓库统一开关决定的

缓存要能复用,工具链得先一致

配套还有一层:重写版主仓的 .prototools 把版本钉死了。

# proto pins tool versions workspace-wide.
# Every developer and CI machine gets the exact same versions automatically.
moon = "2.3.3"
bun  = "1.3.11"
rust = "1.97.0"

注释里那句话是作者自己写的设计意图:每个开发者和每台 CI 机器自动拿到完全相同的版本。这一条和上面的 inputs 是配套的——inputs 管住了「源文件一不一样」,版本钉死管住了「跑它的那套工具一不一样」。两个变量都固定住,「相同输入 → 相同产物」这个前提才立得住脚。少了任何一半,缓存的可信度都要打折。

你可以拿去用的判断顺序

回到自己的项目,给一个任务写声明时,按这个顺序问:

  1. 它有终点吗? 没有(dev server、watch 模式、交互式工具)→ 不谈缓存,考虑标记为 CI 中跳过。
  2. 它有副作用吗? 上传、发布、写数据库、发通知 → cache: false,别让它被跳过。
  3. 它落盘吗? 落盘 → 声明 outputs,让产物可被复用;不落盘(比如纯校验)→ 只声明 inputs 就够了。
  4. 什么变了它必须重跑? 逐项列进 inputs,宁可多列一项。这一步是判断题不是填空题,不要照抄别的任务的 inputs
  5. 它必须排在谁后面? 写进 deps,别写进 README 里的操作步骤。

什么情况下这套读法不适用

一是任务本身不确定。如果构建过程里带了时间戳、随机数、联网拉取最新依赖,那么「相同输入相同产物」本来就不成立,声明 inputs 也救不回来,得先把不确定性拿掉。

二是跨项目的隐式依赖。上面这份 apps/web/moon.yml 只管 web 这一个包,本文没有依据说明这个仓库里各包之间是怎么互相声明依赖的,也没有读过其它包的完整配置,别把单包的结论推广成整仓的结论。

三是别把配置读成能力。这份 moon.yml 说明的是这个仓库的工程化组织方式,跟编辑器本身做到了哪一步是两回事。重写版主仓的 Cargo.toml 里,workspace 成员目前只有 apps/desktop 一个,crates/* 还被注释着;README 里那几条「即将到来」的东西——Editor API、第三方插件、跨三端共用一套代码库、MCP server、无头模式、编辑器内的脚本标签页——按官方自己的措辞是计划,我们没有见到可用实现。配置写得再工整,也不代表功能已经在那儿了。

顺带说一句,重写版主仓 README 明确写着,架构还在设计中,暂时不接受外部贡献。想跟进的话官方给的入口是 Discord 和开 issue,别急着提 PR。

延伸阅读


本文依据 OpenCut 官方仓库(github.com/OpenCut-app/OpenCut 与已归档的 github.com/OpenCut-app/opencut-classic)的 README、.prototoolsmoon.ymlapps/web/moon.ymlCargo.toml 整理,核对日 2026-08-09。本文内容为仓库源码与文档口径,我们没有编译或运行过任何版本的 OpenCut,也没有安装 proto/moon 或执行过任何 moon 任务。

本文描述的是 OpenCut 重写版仓库当前的代码结构与官方 README 声明的路线图。该版本尚未发布,Editor API、插件体系、MCP server、无头模式等均为官方声明的计划,我们没有见到可用实现。核对日 2026-08-09。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。