inputs、outputs、deps:任务缓存的粒度是自己声明出来的
先把状态说清楚,免得读串了。
本文描述的是 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 里别碰我」。
判断依据可以直接抄过来:任务如果没有明确的结束状态和落盘产物,就别给它设计缓存,先想清楚它属不属于「可缓存」这一类,再谈粒度。
build:inputs 就是失效边界
build 的 inputs 列了五项:src/**/*、public/**/*、vite.config.ts、tsconfig.json、package.json。
这五项定义的不是「构建需要读什么文件」,而是**「什么东西变了,之前那次构建的结果就不能再用了」**。理解这一层,很多事情就顺了:
- 源码变了要重建,所以
src/**/*在里面; - 静态资源会被打包进产物,所以
public/**/*在里面; - 构建配置和类型配置直接决定产物形态,所以
vite.config.ts和tsconfig.json在里面; - 依赖版本变了产物也会变,所以
package.json在里面。
**而重写版主仓根目录的 README、changelog/ 下的那几份 markdown、brand/ 里的设计资源,一个都不在这五项里。**改它们不会让 web:build 失效。这就是「粒度是自己声明出来的」这句话的实际含义——不是工具替你猜,是这一行 inputs 替你划的线。
这里有个通用权衡值得提醒,它属于构建缓存这一类工具的共性,不是 OpenCut 文档里的说法:inputs 写窄了和写宽了,代价并不对称。写宽了,无非是本该命中的缓存没命中,多跑一次构建,浪费时间但结果是对的;写窄了,漏掉的那个文件改动不会触发失效,你会拿到一份和源码对不上的产物,而且这种问题往往要等到线上才暴露。拿不准的时候,倾向于多列一项。
test:和 build 的差别本身就是信息
test 的 inputs 只有两项:src/**/* 和 tsconfig.json。比 build 少了 public/**/*、vite.config.ts、package.json。
这个差别是配置里明摆着的,读者能直接看到——同一个包里,不同任务的失效边界可以不一样。测试和构建吃的东西不同,凭什么共用一套 inputs?很多团队图省事在整个包上配一套全局 inputs,结果就是改任何一个文件所有任务全部重跑,缓存等于白配。
至于作者为什么恰好这么划这两项,README 和配置注释都没有说明,我们不做推断。但「不同任务分别声明」这个做法本身,是可以直接借鉴的。
outputs 与 deps:一个管复用,一个管顺序
build 有一行 outputs: ['dist']。声明产物目录,意义是让这次构建的结果成为一个可以被存起来、被后续任务取用的东西。没有 outputs,缓存命中最多只能省下「不重跑」,但产物从哪来就成了悬案。
deploy 则完全是另一个套路:它没有 inputs,也没有 outputs,只有一行 deps: ['~:build']。
这一行替代的是团队里那句口口相传的「记得先 build 再 deploy」。把顺序写进配置,人就不需要记了,CI 也不会因为有人手滑跳过一步而发出一个空目录。(~ 前缀在这里指向同一个项目内的任务,确切语义以 moon 官方文档为准,我们没有跑过验证。)
同样,deploy 不声明 outputs 是合理的:它的效果发生在远端,不在磁盘上。
反过来的例子:一个主动关掉缓存的任务
重写版主仓根目录的 moon.yml 里还有一个 upload-logos 任务,选项是 cache: false 加 runInCI: false。它做的事情是:读 .env.local(缺失就报错退出),要求 R2_BUCKET 变量,把 brand/marks/*.svg 用 wrangler r2 object put 传到 R2。
这个任务把「什么时候不该缓存」讲得比任何文档都清楚:
- 它有外部副作用。缓存的前提是「同样的输入必然得到同样的结果」,而上传这种动作的结果在别人的服务器上,本地缓存一命中,文件就没传上去。
- 它依赖本地机密。要读
.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 管住了「源文件一不一样」,版本钉死管住了「跑它的那套工具一不一样」。两个变量都固定住,「相同输入 → 相同产物」这个前提才立得住脚。少了任何一半,缓存的可信度都要打折。
你可以拿去用的判断顺序
回到自己的项目,给一个任务写声明时,按这个顺序问:
- 它有终点吗? 没有(dev server、watch 模式、交互式工具)→ 不谈缓存,考虑标记为 CI 中跳过。
- 它有副作用吗? 上传、发布、写数据库、发通知 →
cache: false,别让它被跳过。 - 它落盘吗? 落盘 → 声明
outputs,让产物可被复用;不落盘(比如纯校验)→ 只声明inputs就够了。 - 什么变了它必须重跑? 逐项列进
inputs,宁可多列一项。这一步是判断题不是填空题,不要照抄别的任务的inputs。 - 它必须排在谁后面? 写进
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。
延伸阅读
- 从 0.1.0 到 0.3.0:官方 changelog 记了些什么
- 重写版的栈:TanStack Start、Vite、Cloudflare 与 Rust gpui
- Editor API、插件、MCP server、无头渲染:五条承诺目前都还是承诺
本文依据 OpenCut 官方仓库(github.com/OpenCut-app/OpenCut 与已归档的 github.com/OpenCut-app/opencut-classic)的 README、.prototools、moon.yml、apps/web/moon.yml、Cargo.toml 整理,核对日 2026-08-09。本文内容为仓库源码与文档口径,我们没有编译或运行过任何版本的 OpenCut,也没有安装 proto/moon 或执行过任何 moon 任务。
本文描述的是 OpenCut 重写版仓库当前的代码结构与官方 README 声明的路线图。该版本尚未发布,Editor API、插件体系、MCP server、无头模式等均为官方声明的计划,我们没有见到可用实现。核对日 2026-08-09。