用户吐槽集中、返工最多的环节,是选择翻译范围。仓库里有上千个 Markdown 文件,横跨 docs、content、图片资源和 reusable 片段,让用户手写路径列表几乎不可用——我自己测试时就漏写过目录,翻译任务跑完才发现一大半内容根本没进流程。先后试过平铺清单、试过让后端直接返回整棵树,最后还是选了可视化文件树:在前端把仓库目录、翻译状态、勾选规则和后端配置文件合成一个可编辑视图。它不是简单展示目录,而是让用户用展开、勾选、半选和过滤来生成翻译范围,最终把选择结果保存为可重复执行的任务配置。
一、文件树组件解决什么问题
文档翻译工具面对的是多层级 Markdown 仓库。用户不可能手写每个路径,也不应该把整个仓库都丢给翻译任务——那样既浪费配额,又会把构建产物、图片这些不该翻的东西塞进流水线,翻译出来的文件也没法直接合回仓库。文件树组件要承担三个职责:看清目录结构、选择翻译范围、解释哪些文件会被执行。
在定方案之前,我实际比较过三种交互方式,差别主要体现在用户操作成本和配置准确性上:
| 方案 | 用户操作 | 配置准确性 | 适合场景 |
|---|---|---|---|
| 手写路径列表 | 输入 glob 或文件路径 | 依赖经验,容易漏写 | 熟悉仓库结构的开发者 |
| 平铺文件清单 | 在长列表里逐项勾选 | 中等,层级感弱 | 文件数量较少的项目 |
| 可视化文件树 | 按目录展开并批量勾选 | 较高,规则直观 | 文档目录深、文件多的仓库 |
对比下来,文件树更适合 GitHub Docs 这类内容仓库,因为 Markdown、YAML frontmatter、reusable 内容和静态资源往往分布在多个目录中,层级关系本身就是翻译策略的一部分——勾选一个 content/actions 目录,比写十行 glob 规则直观得多,也更容易被非技术背景的维护者理解。
还有一个细节我一开始没注意:文件树不只是”选文件”,它还得”解释规则”。用户勾选某目录后,执行前必须能说清楚这次会翻译哪些文件、跳过哪些文件。后来我们把勾选结果实时换算成预览列表,用户看到”将翻译 34 个文件、跳过 2 个图片目录”才愿意点保存,误操作率降了不少。
二、数据结构如何设计
数据结构这一步决定后面所有交互的复杂度。GitHub 的 Git Trees API 返回的是扁平数组,字段又多又杂,直接塞进组件会让渲染逻辑和接口格式耦合在一起,后续想换数据源就得改组件。我的做法是把后端返回的仓库文件列表统一转换成节点结构。
2.1 TreeNode 的字段
前端把后端返回的仓库文件列表转换成统一节点结构。每个节点只保留渲染和配置所需字段,避免把 GitHub API 原始响应直接塞进组件。这段类型定义解决的核心问题是”组件到底需要哪些字段、哪些字段该在什么时机出现”:
type TreeNode = {
path: string
name: string
type: 'file' | 'dir'
children?: TreeNode[]
checked: boolean
indeterminate: boolean
disabled?: boolean
status?: 'pending' | 'translated' | 'skipped'
}
path 用作稳定键,type 决定图标,checked 与 indeterminate 控制勾选状态,status 用于展示翻译进度或历史结果。这样组件既能服务初次配置,也能复用到任务结果页。这里踩过一个坑:disabled 一开始设计成 boolean,后来发现需要区分”后端过滤”和”用户临时跳过”两种原因,就补成了枚举语义,否则置灰提示文案说不清楚。
2.2 从路径数组构建树
后端通常更容易返回路径数组,例如 content/actions/index.md。前端再按 / 分割并插入树中。这一步看起来简单,但排序和配置回填是最容易出错的地方——目录和文件顺序乱、保存过的勾选状态对不上,都会让用户怀疑自己上次配置丢了。
- 初始化一个虚拟根节点;
- 遍历每个文件路径,按目录片段逐级查找或创建节点;
- 叶子节点标记为
file,中间节点标记为dir; - 对同级节点排序,目录排在文件前,名称按字典序排列;
- 根据已保存配置回填
checked和indeterminate。
这套流程把接口格式和界面状态解耦,后端无需理解前端树组件的展开状态,以后换成 GraphQL 接口或者本地目录扫描,前端只需要改转换函数,树组件本身不用动。
三、交互逻辑怎么落地
3.1 勾选与半选
文件树的难点不在展开,而在父子状态同步。选中目录时,子节点要全部选中;取消某个文件后,父目录要变成半选。这个逻辑我重写过三遍:第一版把状态写死在组件里,展开收起一次就乱;第二版用递归但没处理祖先回溯,勾选深层文件后顶层的半选状态是错的。
下面是最初抽象出来的两个纯函数,一个负责向下同步子节点,一个负责向上刷新父节点:
function setChecked(node: TreeNode, checked: boolean) {
node.checked = checked
node.indeterminate = false
node.children?.forEach(child => setChecked(child, checked))
}
function refreshParent(node: TreeNode) {
const children = node.children ?? []
const all = children.every(child => child.checked)
const none = children.every(child => !child.checked && !child.indeterminate)
node.checked = all
node.indeterminate = !all && !none
}
实际项目里还需要从当前节点向上回溯刷新祖先状态,setChecked 只管子树内部,refreshParent 只算单个父节点,向上那一步放在事件处理里逐层调用。这个逻辑建议封装为纯函数,组件只负责触发事件和渲染,测试也好写——我后来给这组函数补了十几个用例,覆盖三层嵌套的勾选、半选、全选切换,再没出过状态错乱。
3.2 禁用与过滤
不是所有文件都应该进入翻译任务。图片、二进制文件、锁文件、构建产物可以在后端过滤,也可以在前端置灰。我最初图省事在后端直接过滤掉,结果用户打开文件树发现目录”消失”了,第一反应是仓库坏了。前端置灰的好处是用户能理解为什么某些文件不参与翻译,而不是看见一个缺失的目录。
不同文件类型的处理策略当时是这样定下来的:
| 文件类型 | 推荐处理 | 理由 |
|---|---|---|
| Markdown | 可选中 | 主体翻译对象 |
| YAML frontmatter | 谨慎处理 | 部分字段可译,结构不能破坏 |
| 图片和压缩包 | 置灰或隐藏 | 不属于文本翻译范围 |
| 构建产物 | 默认隐藏 | 会增加噪声 |
这张表直接写进 README 当作约定,前端和后端共用同一套分类规则,避免两边标准不一致导致”前端能勾、后端拒绝”的怪现象。实际接入一个大型 monorepo 时,构建产物占了三成节点,默认隐藏之后文件树清爽很多。
四、如何生成翻译配置
4.1 从树提取路径
保存配置时,不应把整棵树提交给后端。原因很直接:树里带着展开状态、半选状态这些纯 UI 信息,后端不关心也不需要;而且树会随仓库变化而变老,任务真正执行时规则早该重新解析一遍。下面这段递归负责把勾选的叶子文件收集成路径数组:
function collectSelectedFiles(node: TreeNode, result: string[] = []) {
if (node.type === 'file' && node.checked && !node.disabled) {
result.push(node.path)
}
node.children?.forEach(child => collectSelectedFiles(child, result))
return result
}
如果仓库文件很多,可以提交目录级规则,例如 content/actions/**,由后端在执行任务前再次展开。这样配置更短,也更适合重复任务——我实测过三千个文件的仓库,文件级配置有三四千行,目录级规则只有几十行,可读性和 git 提交友好度完全不在一个量级。
4.2 配置预览
文件树旁边应提供配置预览:已选文件数、预计处理目录、跳过规则、目标语言。用户保存前能看到任务边界,后端执行时也能按同一规则复现。这个预览后来成了排查问题的利器——用户报”翻译少了文件”时,先让他看预览,一半问题当场就解释清楚了,不用去翻执行日志。
五、性能和可维护性
5.1 大目录渲染
当仓库文件达到数千个时,递归渲染会带来卡顿。我第一次接入 monorepo 仓库时,文件树渲染花了三秒多,展开目录时明显掉帧,滚动都跟着顿。更稳妥的做法是默认只渲染已展开分支,把未展开的分支用占位节点代替;搜索时再把命中的路径折叠到可见分支上,避免全量展开。若文件量继续增加,再引入虚拟列表,目前用展开分支策略已经够用。
5.2 状态放在哪里
状态分三层存储是我总结下来最省心的做法。展开状态适合放在前端本地,关掉页面丢掉无所谓;勾选结果适合保存到后端,它是任务配置的一部分;翻译状态由任务接口返回,前端只读展示。三类状态分开后,刷新页面不会丢配置,也不会把临时 UI 状态污染任务数据。之前试过把展开状态也塞进后端配置,结果用户每次打开文件树都要重新展开目录,体验反而更差。
到这里,文件树从数据结构、交互逻辑到配置生成的完整链路就闭环了:路径数组进来,规则化的翻译配置出去,中间所有状态都有明确的归属。
常见问题(FAQ)
Q1:文件树一定要后端返回树结构吗?
不一定。后端返回路径数组,前端构建树更灵活。
Q2:目录勾选后还要保存每个文件吗?
文件少可保存文件路径,文件多建议保存目录规则。
Q3:半选状态需要提交到后端吗?
不需要。半选是界面状态,后端只关心最终路径规则。