做后台管理系统的人大概都有过这个瞬间:产品经理甩过来一张 Windows 资源管理器的截图,说"就照这个做,我们的素材库/网盘/项目文件管理页要一模一样"。于是你打开 Vue,左边写一棵树,右边写一个列表,双击进去,点面包屑返回,看起来挺顺。然后需求开始加码——要能框选、要按住 Ctrl 挑几个、要拖到文件夹里、右键要出新文件夹、重命名要能校验、文件上万条不能卡。这时候你会发现,真正难的不是画界面,而是状态怎么放、几何怎么算、事件怎么收口。这篇就围绕"VUE 仿 windows 文件夹文件"这件事,把我在几个素材管理系统里踩过的坑和落地方案摊开讲,代码用 Vue 3 组合式 API 写,逻辑框架换成 Vue 2 或 React 也一样成立。
1. 动手之前先分清楚三种状态,混在一起必翻车
我见过太多这个功能的实现,一开始就是一棵深层嵌套的响应式大树,节点上挂着selected、expanded、editing、loading,再来一个watch(tree, handler, { deep: true })。前两百个节点跑得好好的,导入真实数据后直接白屏——不是因为渲染慢,是因为深度侦听一个几千节点的对象树,光建立依赖就够呛。
1.1 数据域、视图域、交互域必须切开
我的划分方式是这样:
| 状态域 | 存什么 | 生命周期 | 存放位置 |
|---|---|---|---|
| 数据域 | 节点本体:id、name、type、parentId、size、mtime | 跟随接口数据,需要持久化 | Pinia 的useFsStore |
| 视图域 | 当前路径、视图模式(图标/列表)、排序字段与方向、滚动位置 | 刷新即重置,可以存 URL query | 组件内 ref 或独立 store |
| 交互域 | 选中集合、框选矩形、拖拽中的源、剪贴板、重命名编辑 id | 高频变化,一秒可能变几十次 | shallowRef/reactive(new Set()) |
为什么要切开?因为三者的更新频率差了不止一个数量级。数据域一天变几次,视图域一次点击变一次,交互域鼠标一动就变。把它们塞进同一个响应式对象,等于让"鼠标划过一个框"去触发"整棵树重新收集依赖"。
提示:判断某个状态该放哪一域,就问一句"这个状态刷新页面后还应不应该存在"。应该存在的是数据域,不该存在的是视图域,一秒钟变好几次的是交互域。
1.2 路径用数组存,不要用字符串
'D:\\项目\\src\\components'这种字符串路径看着直观,用起来处处是坑:反斜杠在 JS 字符串里要转义、Windows 和类 Unix 分隔符不一致、中文路径遇到大小写比较要额外处理。
我改成数组:['此电脑', 'D盘', '项目', 'src']。好处立刻体现出来:面包屑直接v-for渲染;"上一级"就是path.slice(0, -1);判断两个节点是否是同一目录直接pathA.join('/') === pathB.join('/'),不需要处理任何转义;往 URL 里塞的时候join('|'),取出来split('|'),比编码整个路径字符串干净得多。
1.3 嵌套树和扁平表,选错一个后面全是补丁
这是我认为最关键的一次架构选择。先把两者摊开对比:
| 对比维度 | 嵌套树(children 数组) | 扁平 Map + childrenIds |
|---|---|---|
| 按 id 找节点 | 递归 O(深度) | O(1) 直接查表 |
| 移动一个节点 | 递归找父节点再 splice | 改两个数组,几行代码 |
| 深层响应式开销 | 高,层级越深越明显 | 低,Map 是浅层的 |
| 渲染树组件 | 天然递归,直接吃 children | 需要按需组装一层视图数据 |
| 移动端 / 大数据量 | 容易炸 | 稳 |
结论很明确:存储用扁平,渲染时组装。真正的数据结构长这样:
// 节点本体,全部塞进一个 Map // { id, name, type: 'folder' | 'file', parentId, size, mtime, ext, deletedAt } const nodeMap = shallowRef(new Map()) // 父子关系单独索引,避免每次都要全表扫 const childrenIndex = shallowRef(new Map()) // parentId -> id[]拿某个目录下的文件列表,就是:
function listChildren(folderId) { const ids = childrenIndex.value.get(folderId) || [] return ids.map(id => nodeMap.value.get(id)).filter(Boolean) }这里有两个细节值得说。第一,nodeMap用shallowRef,意味着我改节点内部字段时要整体替换引用,看起来麻烦一点,但换来的是 Vue 不会给 Map 里每个节点套 Proxy——上万个节点时这个差别非常明显。第二,childrenIndex的数组顺序就是渲染顺序的原始依据,排序时只重排这个数组,不动节点本体,撤销排序也不会有副作用。
2. 左侧目录树和右侧文件列表,怎么保证永远说的是同一个地方
左右联动是这类页面的第一道坎。用户点树上的节点,右边要跳;用户在右边双击文件夹进去,树要展开并高亮;用户点面包屑往回走,两边都要跟着变。新手最容易犯的错是在每个交互回调里各写一遍"同步三件事",写着写着就有六七处重复逻辑,改一处漏三处。
2.1 只保留一个真相:currentPath
我的做法是:所有操作只负责修改currentPath,然后由一个watch集中处理副作用。
const currentPath = ref(['此电脑']) watch(currentPath, (path) => { // 1. 展开树上对应的所有祖先节点 expandedIds.value = new Set([...expandedIds.value, ...getAncestorIds(path)]) // 2. 高亮当前节点(由树组件自己根据 currentPath 最后一项判断) // 3. 清空选中与框选 selection.clear() // 4. 重置滚动位置 listScrollTop.value = 0 // 5. 写回 URL,支持刷新还原 router.replace({ query: { p: path.join('|') } }) }, { flush: 'post' })flush: 'post'这个参数容易被忽略。默认的pre会在组件更新前触发侦听,如果回调里依赖了 DOM 尺寸(比如重新计算列数),拿到的还是旧的。放到post里最稳妥。
2.2 树的展开状态别写进节点数据
expanded千万别挂在节点对象上。理由有三:一是它属于交互域,不该跟着接口数据一起被序列化;二是展开状态经常批量变化("全部收起"按钮),挂在节点上要遍历整棵树;三是你要做"只展开到当前路径"这种功能时,从节点上清理旧状态很恶心。
用一个独立的Set就够了,判断展开就是expandedIds.has(node.id)。Vue 3 对Set的响应式支持是完整的,has()会被正确追踪,只要记得用reactive(new Set())或者整体替换shallowRef的写法,别用原始Set直接赋给ref后又原地add(ref包装的 Set 其实也能追踪,但团队里统一写法更省心)。
2.3 前进后退栈:存快照,并且后退时要清空前进栈
地址栏旁边那两个箭头,很多人第一版做出来是坏的。正确做法是两个栈:
const backStack = ref([]) // 存历史 path 数组 const forwardStack = ref([]) function navigateTo(path) { if (!isSamePath(path, currentPath.value)) { backStack.value.push([...currentPath.value]) forwardStack.value = [] // 关键:产生新分支,旧的前进历史作废 currentPath.value = path } } function goBack() { if (!backStack.value.length) return forwardStack.value.push([...currentPath.value]) currentPath.value = backStack.value.pop() } function goForward() { if (!forwardStack.value.length) return backStack.value.push([...currentPath.value]) currentPath.value = forwardStack.value.pop() }两个注意点。一是存快照(浅拷贝的数组)而不是索引,因为路径数组随时可能被替换,存引用会连带被改。二是forwardStack.value = []这行绝不能漏,否则用户"后退两次再点一个新目录",再按前进会跳到一个和当前上下文毫无关系的目录,这个 bug 在测试阶段很难被发现,上线后用户一用就觉得"这软件怪怪的"。
3. 上万条文件不卡的秘密:虚拟滚动加上"别乱加深响应"
一个素材库动辄几万个文件,全量渲染必然卡。虚拟滚动不是什么新鲜东西,但在这个场景里有几个特殊的坑。
3.1 定高行是省下 80% 工作量的前提
列表视图下我把每行固定成 36px(包括内边距),图标视图固定成单元格 96×104。定高之后,每一行的几何位置就是纯算术,不需要任何 DOM 测量。这一点在后面做框选的时候价值巨大。
核心逻辑封装成一个组合式函数:
import { ref, computed } from 'vue' export function useVirtualRows(itemHeight, buffer = 6) { const scrollTop = ref(0) const viewportHeight = ref(0) const total = ref(0) const startIndex = computed(() => Math.max(0, Math.floor(scrollTop.value / itemHeight) - buffer) ) const endIndex = computed(() => Math.min(total.value, Math.ceil((scrollTop.value + viewportHeight.value) / itemHeight) + buffer) ) const offsetY = computed(() => startIndex.value * itemHeight) const totalHeight = computed(() => total.value * itemHeight) function onScroll(e) { scrollTop.value = e.currentTarget.scrollTop } return { scrollTop, viewportHeight, total, startIndex, endIndex, offsetY, totalHeight, onScroll } }模板结构是三层的:
<div class="fs-scroll" @scroll.passive="onScroll"> <div class="fs-phantom" :style="{ height: totalHeight + 'px' }"></div> <div class="fs-window" :style="{ transform: `translateY(${offsetY}px)` }"> <div v-for="row in visibleRows" :key="row.id" class="fs-row">...</div> </div> </div>外层负责滚动,中间那层"幽灵"只提供总高度撑出滚动条,真正的窗口层用 transform 位移。用transform而不是top,是因为 transform 不参与布局计算,浏览器只需合成,滚动时不会引发布局回流。
buffer给 6 行是我实测比较舒服的值。太小了快速滚动会露白边,太大了每帧多渲染几行,一万条数据下差别不明显,但几十万条时缓冲行就是纯浪费。另外@scroll.passive一定要加,不加的话滚动监听会被浏览器当作可能调用preventDefault而阻塞合成线程,手机上尤其明显。
3.2 图标千万不要用字体图标
文件类型图标动辄几十种(文件夹、txt、pdf、xlsx、mp4、png……),我第一版用了某个字体图标库,结果项目里引入两三套图标字体,首屏字体文件加起来七八百 KB,而且字体加载完成前的"方块闪烁"在这个以视觉为主的页面里非常刺眼。
换成 SVG symbol 雪碧图之后:一个文件搞定所有图标,<use>引用,还能按扩展名做颜色主题。图标映射就是一个普通对象:
const EXT_ICON = { folder: '#i-folder', txt: '#i-txt', md: '#i-txt', log: '#i-txt', xls: '#i-excel', xlsx: '#i-excel', csv: '#i-excel', doc: '#i-word', docx: '#i-word', png: '#i-image', jpg: '#i-image', jpeg: '#i-image', webp: '#i-image', gif: '#i-image', mp4: '#i-video', mov: '#i-video', webm: '#i-video', pdf: '#i-pdf', zip: '#i-zip', rar: '#i-zip', '7z': '#i-zip' } function iconOf(node) { if (node.type === 'folder') return '#i-folder' return EXT_ICON[node.ext] || '#i-file' }提示:图标组件本身用
markRaw()包一下,或者干脆不封装成组件、直接写<svg><use/></svg>。上万个图标组件实例的创建与销毁开销,比你想象的大。
3.3 选中态:一个 Set 打败一万个布尔值
最直觉的方案是给每个节点算一个selected: true/false,然后在模板里加:class="{ active: row.selected }"。数据量一大,每次选中变化都要重新生成整个列表的视图模型,一秒钟框选扫过上百行,等于一秒钟重建上百次数组。
正确的做法是把选中集合单独放,每个行组件自己判断:
const selection = reactive({ ids: new Set(), anchor: null })行组件里:
const isActive = computed(() => selection.ids.has(props.row.id))Vue 3 对Set.has()的依赖追踪是精确到 key 的,只有has(id)被调用的那个 id 变化时,对应的行才会重新渲染。也就是说,框选扫过 100 行,实际只有这 100 个行组件更新,其余几千行纹丝不动。
但是这里有个新手 100% 会踩的坑:如果你把selection.ids写成普通的new Set()然后赋给ref,每次add之后视图不更新,你会以为是 Vue 的 bug。原因是ref(new Set())虽然对 Set 的方法调用做了劫持,但如果你在别处直接替换了.value指向的 Set,或者在某些边界情况下绕开了代理,追踪就断了。我现在的习惯是统一用reactive({ ids: new Set() }),然后只调方法不换对象,稳定得多。
3.4 排序用 Intl.Collator,别自己写比较函数
Windows 资源管理器的排序有个特点:新建文件夹2排在新建文件夹10前面,这叫自然排序。用普通的字符串比较,'10' < '2'会得到反过来的结果。
const collator = new Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' }) const sorters = { name: (a, b) => collator.compare(a.name, b.name), size: (a, b) => a.size - b.size, mtime: (a, b) => a.mtime - b.mtime } function sortedList(ids) { const list = ids.map(id => nodeMap.value.get(id)) const cmp = sorters[sortField.value] || sorters.name list.sort((a, b) => { // 文件夹永远在前,这是资源管理器的默认行为 if (a.type !== b.type) return a.type === 'folder' ? -1 : 1 const r = cmp(a, b) return sortDir.value === 'asc' ? r : -r }) return list }sensitivity: 'base'让大小写和重音不参与比较,正好符合 Windows 的"文件名不区分大小写"这一特性——这一点在后面校验重名时会再次遇到。
4. 框选、多选、拖拽:交互层最费劲的三块硬骨头
到这一段,页面已经能看了,但离"像 Windows"还差得远。真正区分"能用的 demo"和"能上线的产品"的就是这三块。
4.1 框选:用算术代替 DOM 测量
常规写法是mousemove时遍历所有行元素调getBoundingClientRect()判断是否相交。一万行?不可能。就算虚拟滚动只渲染了 20 行,每帧 20 次布局查询也足以让框选变得黏糊糊的——因为getBoundingClientRect()会强制浏览器做样式计算和布局,连续调用就是经典的布局抖动。
既然行高固定,几何位置完全可以用算的:
const ROW_H = 36 function onMarqueeMove(e) { const box = e.currentTarget.getBoundingClientRect() // 转成"内容坐标系"(含 scrollTop),不受虚拟滚动影响 const curY = e.clientY - box.top + scrollTop.value const curX = e.clientX - box.left rect.value = { top: Math.min(startY.value, curY), bottom: Math.max(startY.value, curY), left: Math.min(startX.value, curX), right: Math.max(startX.value, curX) } // 只算行区间,不碰 DOM const firstRow = Math.max(0, Math.floor(rect.value.top / ROW_H)) const lastRow = Math.min(visibleRows.value.length - 1, Math.ceil(rect.value.bottom / ROW_H)) const hit = new Set() for (let i = firstRow; i <= lastRow; i++) { hit.add(visibleRows.value[i].id) } selection.ids = hit // 整体替换,一次更新 }整个mousemove里没有一次 DOM 查询(除了起始时拿一次容器的 rect,那个可以缓存)。实测框选扫过整屏,帧率依然稳在 60。
有一个细节必须处理:鼠标拖出容器外时要继续响应,并且向上拖出时要自动滚动。做法是把mousemove和mouseup绑到window上,在mousedown时挂载、mouseup时卸载。自动滚动则用一个requestAnimationFrame循环,当clientY超出容器上下边界时按超出距离的比例调整scrollTop,超出越多滚得越快——这个"越界越多滚得越快"的手感,是 Windows 用户肌肉记忆的一部分,少了会觉得怪。
4.2 Ctrl 和 Shift 的选择语义
三种模式要记清楚:
| 操作 | 行为 | 实现要点 |
|---|---|---|
| 直接单击 | 清空原有选中,只选当前项 | 同时把 anchor 设为该项 |
| Ctrl + 单击 | 已选中则取消,未选中则加入 | anchor 也更新为该项 |
| Shift + 单击 | 从 anchor 到当前项的全部选中 | 替换原有选中,anchor 不变 |
| Ctrl + Shift + 单击 | 在原有选中基础上增补区间 | 追加而不替换 |
最容易写错的是 Shift 的锚点。很多人写成"上一次点击的项",结果连续 Shift 点击时区间会黏在一起。正确逻辑是:只有非 Shift 的点击才更新 anchor,Shift 点击永远以当前 anchor 为起点。
function handleClick(node, e) { const list = visibleRows.value const idx = list.findIndex(n => n.id === node.id) if (e.shiftKey && selection.anchor != null) { const from = list.findIndex(n => n.id === selection.anchor) const [s, t] = from < idx ? [from, idx] : [idx, from] const range = new Set(list.slice(s, t + 1).map(n => n.id)) selection.ids = e.ctrlKey ? new Set([...selection.ids, ...range]) : range } else if (e.ctrlKey) { const next = new Set(selection.ids) next.has(node.id) ? next.delete(node.id) : next.add(node.id) selection.ids = next selection.anchor = node.id } else { selection.ids = new Set([node.id]) selection.anchor = node.id } }注意selection.anchor存的是id 而不是索引。因为列表随时可能因为排序或筛选而重排,存索引会在重排后指向另一个文件。
4.3 拖拽:内拖和外拖必须分开处理
从桌面往浏览器里拖文件(外拖)和页面内部拖(内拖)是两套逻辑,套在一起必出问题。外拖靠dataTransfer.files判断,内拖我建议完全不走 HTML5 的 dataTransfer,用自己维护的一个dragState更可控。
原因很实在:HTML5 拖拽在dragstart里如果同步修改了 DOM 结构(比如给被拖元素加一个半透明的克隆类),某些浏览器会直接取消这次拖拽。而且dataTransfer在dragover阶段读取数据是受限的,判断"能不能放下"时拿不到数据,很别扭。
自己的状态长这样:
const dragState = reactive({ active: false, sourceIds: new Set(), overFolderId: null, copyMode: false })放置目标的判定规则,直接对齐 Windows:
| 放置目标 | 是否按 Ctrl | 结果 |
|---|---|---|
| 某个文件夹 | 否 | 移动进该文件夹 |
| 某个文件夹 | 是 | 复制进该文件夹 |
| 文件上 / 空白处 | 否 | 移动到当前目录 |
| 文件上 / 空白处 | 是 | 复制到当前目录 |
| 自身或自身的子孙 | 任意 | 拒绝,显示禁止光标 |
4.4 移动之前先做环检测,否则树会自锁
这是我在真实项目里被坑得最惨的一次。用户把一个叫「A」的文件夹拖进它自己的子文件夹「A/B/C」里,如果不做检测,childrenIndex里就会出现一个循环:A 的 children 包含 B,B 的 children 里又包含 A。结果是点击展开时组件无限递归,页面直接卡死。
检测逻辑就是顺着parentId往上爬:
function isDescendant(folderId, maybeDescendantId) { let cur = nodeMap.value.get(maybeDescendantId) while (cur && cur.parentId) { if (cur.parentId === folderId) return true cur = nodeMap.value.get(cur.parentId) } return false } function canDrop(ids, targetFolderId) { for (const id of ids) { if (id === targetFolderId) return false const node = nodeMap.value.get(id) if (node?.type === 'folder' && isDescendant(id, targetFolderId)) return false } return true }爬链的时候记得加一个最大深度保护(比如 32 层),防止历史脏数据里已经存在环导致死循环。这个防御看起来多余,真遇到一次就知道值了。
真正的移动操作要保证原子性——要么全成功要么全不动:
function moveNodes(ids, targetFolderId) { const nextNodes = new Map(nodeMap.value) const nextIndex = new Map([...childrenIndex.value].map(([k, v]) => [k, [...v]])) for (const id of ids) { const node = nextNodes.get(id) if (!node) continue const from = nextIndex.get(node.parentId) || [] nextIndex.set(node.parentId, from.filter(x => x !== id)) const to = nextIndex.get(targetFolderId) || [] to.push(id) nextIndex.set(targetFolderId, to) nextNodes.set(id, { ...node, parentId: targetFolderId }) } nodeMap.value = nextNodes childrenIndex.value = nextIndex }先在副本上完成全部改动,最后一次性替换两个 ref。好处是渲染层只会看到"改完的最终态",不会看到中间过程的半成品(比如一个文件短暂地同时出现在两个目录里),也天然支持撤销——把移动前的两个 Map 快照压进 undo 栈即可。
5. 右键菜单、重命名、新建与删除的边界处理
菜单和文件操作看起来是最简单的部分,实际上最容易出线上事故。因为这些都是"写操作",写错了用户的数据就乱了。
5.1 右键菜单的定位与边界翻转
菜单用<Teleport to="body">挂到 body 上,避免被列表容器的overflow: hidden裁掉。定位用position: fixed加坐标,同时必须做视口边界处理:
function placeMenu(x, y) { const MW = 184, MH = menuItems.length * 32 + 12 const left = x + MW > window.innerWidth ? x - MW : x const top = y + MH > window.innerHeight ? y - MH : y menuStyle.value = { left: `${left}px`, top: `${top}px` } }翻转而不是"夹住"(clamp),是因为菜单贴着鼠标出现更符合直觉。如果强行 clamp 到边界内,菜单会和鼠标拉开一段距离,用户得重新找一下自己在点哪儿。
菜单关闭的触发源有四个,少一个都会显得"粘手":点击菜单外的任意位置、按下 Esc、滚动列表、再次右键另一个目标(应该先关旧的再开新的)。
function closeOnAny(e) { if (!menuRef.value?.contains(e.target)) menuOpen.value = false } onMounted(() => { window.addEventListener('mousedown', closeOnAny, true) // 捕获阶段,先于业务逻辑 window.addEventListener('keydown', onEsc) })用捕获阶段监听mousedown,能保证菜单先关闭、再处理点击目标,避免"点菜单里的删除,结果点到了底下的文件"这种穿透问题。
5.2 重命名:用单个 editingId,不要每个节点挂布点
const editingId = ref(null) const editingDraft = ref('')只允许一个节点处于重命名状态,所以用单值就够了。挂在节点上(node.editing = true)会导致两个问题:一是要手动保证"开启新的重命名时关掉旧的",二是这个状态会跟着数据一起被序列化发到后端。
进入编辑态后要做的三件小事:输入框自动聚焦、选中不含扩展名的部分(Windows 的贴心行为)、按 Esc 取消。选中不含扩展名的部分是这样实现的:
function focusInput(el, node) { el.focus() const dot = node.type === 'file' ? node.name.lastIndexOf('.') : -1 if (dot > 0) el.setSelectionRange(0, dot) else el.select() }5.3 文件名校验:规则比你想的多
这一块必须写全,不然用户能造出各种奇怪的名字,后面接后端时才炸。Windows 的规则我整理成表格:
| 规则 | 示例 | 说明 |
|---|---|---|
| 不能为空或全空格 | " " | 前后空格会自动去掉 |
| 不含 9 个非法字符 | \ / : * ? " < > | | 正则一条搞定 |
| 不占用保留名 | CON、PRN、AUX、NUL、COM1~COM9、LPT1~LPT9 | 带扩展名也算,CON.txt同样非法 |
| 不能以点或空格结尾 | abc.、abc | 系统会自动去掉,所以要提前拦 |
| 长度不超过 255 | — | 按字符数而非字节数 |
| 同目录不重名 | — | 不区分大小写 |
最后一条特别容易漏。Report.xlsx和report.xlsx在 Windows 里是同一个文件,很多前端校验用===比较,导致能建出两个"看起来不一样"的文件,同步到后端后再冲突。校验函数:
const ILLEGAL = /[\\/:*?"<>|]/ const RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i function validateName(name, siblings, selfId) { if (!name || !name.trim()) return '名称不能为空' if (ILLEGAL.test(name)) return '名称不能包含 \\ / : * ? " < > |' if (RESERVED.test(name)) return '这是系统保留名称' if (/[. ]$/.test(name)) return '名称不能以空格或句点结尾' if ([...name].length > 255) return '名称过长' const lower = name.toLowerCase() const dup = siblings.some(s => s.id !== selfId && s.name.toLowerCase() === lower) if (dup) return '当前目录下已有同名项' return null }5.4 删除做成软删除,回收站才有戏
如果产品提到"回收站"三个字,那删除必须是软删除:给节点打一个deletedAt时间戳,从childrenIndex里摘掉,但不从nodeMap里删。回收站就是全表扫一遍deletedAt != null的节点。
还原的时候要处理一个边界:原目录可能已经被删了。我的处理是还原时检查parentId对应的节点是否还存在且未被删,不存在就退回根目录,同时给用户一条提示"原位置不存在,已还原到根目录"。
永久删除才是真删,而且删文件夹时要连子孙一起清理:
function purge(id) { const node = nodeMap.value.get(id) if (!node) return if (node.type === 'folder') { for (const childId of childrenIndex.value.get(id) || []) purge(childId) } nodeMap.value.delete(id) childrenIndex.value.delete(id) const parent = childrenIndex.value.get(node.parentId) || [] childrenIndex.value.set(node.parentId, parent.filter(x => x !== id)) }6. 键盘操作:把"像 Windows"这件事做到手感层面
功能都通了之后,决定用户是否愿意长期使用的是键盘。资源管理器的老用户几乎不用鼠标完成重命名、删除、返回上级这些操作。
6.1 方向键导航要先知道列数
↑↓ 是上下移动一行,在列表视图里就是index ± 1;在图标视图里是index ± columns。所以列数是必须的:
const columns = ref(1) function recalcColumns() { const w = gridRef.value?.clientWidth || 0 const CELL_W = 96 columns.value = Math.max(1, Math.floor(w / CELL_W)) }这里有个很隐蔽的坑:容器如果有纵向滚动条,clientWidth会把它减掉,但你在计算时如果用offsetWidth就会得到偏大的值,导致算出的列数比实际多一列,↑↓ 跳转就错位。统一用clientWidth或getBoundingClientRect().width减掉滚动条宽度。
还有一个更隐蔽的:列数变化时当前选中项的位置会变,用户的肌肉记忆突然失效。我的处理是列数变化时不重新计算选中项,只是让它保持选中状态,滚动跟随一下即可,不做额外跳转。
6.2 一套完整的按键映射
| 按键 | 行为 | 备注 |
|---|---|---|
| ↑ / ↓ | 上/下一行 | 图标视图按列数位移 |
| ← / → | 图标视图左右移一格 | 列表视图下用于收起/展开当前文件夹 |
| Home / End | 跳到首/末项 | 配合 Ctrl 也是一样 |
| Enter | 打开选中项 | 文件夹则进入,文件则预览 |
| F2 | 重命名选中项 | 单选时可用 |
| Delete | 移入回收站 | 支持多选 |
| Backspace | 返回上一级 | 非常 Windows 的细节 |
| Ctrl + A | 全选当前目录 | — |
| Ctrl + C / X / V | 复制 / 剪切 / 粘贴 | 需要维护剪贴板状态 |
| Esc | 关闭菜单 / 取消编辑 / 清空选中 | 优先级从上到下 |
| 字母键 | 按名称跳转定位 | 输入累积 500ms 内算一次 |
Esc 的多级语义最容易乱。我的处理是维护一个"当前最上层可取消的东西"的判断顺序:先看有没有打开菜单,再看有没有在编辑,最后才是清空选中。这样用户不需要思考,连按 Esc 就能一层层退出来。
6.3 键盘事件绑在容器上,不要绑在 window
绑在window上最省事,但会带来一堆麻烦:输入框里打字时按 Delete 会把文件删了,按 Backspace 会返回上级。页面里只要有一个搜索框就会出问题。
正确做法是给列表容器加tabindex="-1",把keydown绑在容器上,然后在进入页面时或点击空白处时focus()它。同时给一个可见的聚焦样式(细边框或高亮),让用户知道键盘操作生效了。当重命名输入框获得焦点时,因为事件冒泡的关系还是要判断一下,但比全局监听干净得多:
function onKeydown(e) { if (editingId.value) return // 编辑态交给输入框自己处理 const tag = e.target.tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return // ... 分发按键 }6.4 滚动跟随:别直接用 scrollIntoView
scrollIntoView会把元素滚到视口中间或者最小可见位置,但它是相对于最近的可滚动祖先的,在虚拟滚动里目标元素可能压根还没渲染出来,调用无效。
我的做法是根据索引手动算:
function ensureVisible(index) { const top = index * ROW_H const bottom = top + ROW_H const viewTop = scrollTop.value const viewBottom = viewTop + viewportHeight.value if (top < viewTop) scrollTop.value = top else if (bottom > viewBottom) scrollTop.value = bottom - viewportHeight.value scrollEl.value.scrollTop = scrollTop.value }手动算的另一个好处是可以留出一点边距,让选中项不要紧贴容器边缘,视觉上更舒服。
7. 真的要去读本地磁盘?先认清浏览器的边界
这一节说点务实的。很多人做这个功能时的第一反应是"让它像 Windows 一样能浏览我的电脑",但必须明确:浏览器出于安全原因,不能随意读取本地文件系统。有几条可行的路线,适用场景完全不同。
7.1 后端目录接口:最常用的路线
真实的企业素材库、网盘、项目管理系统,99% 都是后端提供目录列表接口,前端只做展示。接口返回的应该是已经排好序、带好层级关系的扁平结构:
{ "parentId": "dir_1024", "path": ["此电脑", "素材库", "2024"], "items": [ { "id": "d_1", "name": "字体", "type": "folder", "size": 0, "mtime": 1710000000000 }, { "id": "f_9", "name": "封面.png", "type": "file", "size": 248233, "mtime": 1710000500000 } ] }前端拿到后直接灌进nodeMap和childrenIndex。这里有个性能上的建议:一次请求一层,不要一次拉全树。用户点开哪个目录再拉哪个目录,把已拉取的节点缓存起来,返回上一级时直接用缓存渲染,同时静默刷新一次保证数据新鲜。
7.2 File System Access API:能读能写,但限制明确
window.showDirectoryPicker()可以拿到用户授权后的目录句柄,然后递归读取,甚至在用户授权下写回文件。这是最接近"真资源管理器"的方案。
但要注意几点:必须由用户手势触发(点击事件里直接调用,不能放在await之后);只能在安全上下文(HTTPS 或 localhost)下使用;不同浏览器的支持程度差别不小,必须准备降级方案。递归读取大目录时要控制并发,我一般用简单的并发池,一次最多 8 个目录同时在读,多了反而慢。
async function walk(handle, id, depth = 0) { if (depth > 12) return for await (const entry of handle.values()) { const childId = `${id}/${entry.name}` addNode({ id: childId, name: entry.name, type: entry.kind === 'directory' ? 'folder' : 'file', parentId: id }) if (entry.kind === 'directory') await walk(entry, childId, depth + 1) } }for await...of配合handle.values()是异步迭代器,天然是流式的,不会一次性把所有 entry 拿回来,内存友好。但它是串行的,大目录下会慢,我上面提到用并发池就是为了解决这个:先把当前层的 entry 全部await到数组,再对子目录分批并发。
7.3 webkitdirectory:只读快照,适合导入场景
<input type="file" webkitdirectory>能一次性拿到整个目录下所有文件,每个File对象上都带webkitRelativePath,格式是顶层目录/子目录/文件名。按/切开就能重建出目录树。
这条路线的定位很明确:一次性的导入。没有后续的写回能力,也不会有目录变化的通知。做"批量上传素材"这个场景非常合适,做"文件管理器"就不合适。
7.4 纯前端 Mock:开发期最省事的方案
后端接口没就绪的时候,我一般写一个 Mock 生成器,按扩展名池随机造几千个节点:
const EXTS = ['png', 'jpg', 'pdf', 'xlsx', 'docx', 'mp4', 'zip', 'txt', 'md'] const NAMES = ['封面', '合同', '预算表', '会议记录', '需求文档', '原型图', '宣传片'] function mockTree(rootId, folderCount, fileCount) { for (let i = 0; i < folderCount; i++) { const id = uid() addNode({ id, name: `目录-${i + 1}`, type: 'folder', parentId: rootId, mtime: Date.now() - i * 86400000 }) } for (let i = 0; i < fileCount; i++) { const ext = EXTS[i % EXTS.length] const id = uid() addNode({ id, name: `${NAMES[i % NAMES.length]}-${i + 1}.${ext}`, type: 'file', ext, parentId: rootId, size: Math.floor(Math.random() * 5e7), mtime: Date.now() - Math.floor(Math.random() * 3e10) }) } }Mock 生成器有个额外好处:它能帮你验证性能边界。我会专门造一个 5 万文件的目录跑一遍,看虚拟滚动、框选、排序在极端数据下的表现。这个测试在开发期做,比上线后被用户拿真实数据打脸便宜太多。
8. 几个我真实踩过的坑,以及最后的收尾
前面把体系讲完了,这一节只讲那些文档里不会写、只有真做过才知道的细节。
8.1 用 index 当 key,排序后选中全乱
v-for里图省事写:key="index",列表看起来没问题,一排序就发现选中的是别的文件、重命名框跑到了另一个元素上。原因很直白:Vue 复用 DOM 节点时是按 key 匹配的,key 用 index 相当于告诉 Vue "第 3 个位置还是同一个东西",排序后位置内容变了,复用的组件实例却还是旧的,内部状态全错位。永远用节点 id 当 key,这是硬规矩。
8.2 深度侦听整棵树,一导入真实数据就卡
watch(tree, fn, { deep: true })这个写法在小数据量下毫无问题,是它最大的迷惑性。等树有几千个节点,任何一次鼠标移动引起的选中态变化都可能触发侦听器重新遍历整棵树。替代方案是按需侦听具体字段,或者干脆用事件驱动——所有写操作都走统一的commit()方法,在方法里主动触发需要的计算,而不是被动侦听。
8.3 computed 里调 getBoundingClientRect,滚动时抖成筛子
我在一个版本里把"可见行数"写成computed(() => Math.ceil(el.getBoundingClientRect().height / ROW_H)),结果每次滚动都触发布局查询,滚动条一拖就掉帧。任何测量 DOM 的操作都应该放在 resize 监听或者onMounted里,算出来存进 ref,不要在 computed 里现算。这个原则在列表滚动的场景下尤其重要。
8.4 滚动条宽度导致的列数偏差
这个坑前面提过,值得再强调一次。在 Windows 上滚动条会占 15~17px,在 macOS 上默认是浮层不占宽度。同一份clientWidth计算代码在两个系统上得出的列数不同,macOS 上多一列,导致图标视图最后一行只放一个元素。用 CSS 的scrollbar-gutter: stable或者干脆手动预留一个固定宽度,能让两端表现一致。
8.5 拖拽取消时状态没清理
dragState.active如果在dragend里没清干净,下一次鼠标划过去会莫名其妙出现放置高亮。而且dragend在拖拽被浏览器中断时也会触发,比drop更可靠。我现在的习惯是dragend、drop、Esc三个地方都做一次状态重置,宁可多做也不漏——重置是幂等的,多做没有副作用。
8.6 一个关于剪贴板的小设计
Ctrl+C 之后如果只是存了 id 列表,用户在别的地方把那个文件重命名或者删掉了,再粘贴就会出现"复制了一个不存在的文件"。我的处理是:复制时存 id 列表,粘贴时逐个校验节点是否还存在、是否还是原来的 parentId;如果原节点已不在,直接跳过并在提示条里说明"有 2 个文件已失效,未执行粘贴"。这个小提示看起来多余,但用户遇到一次就会觉得这软件靠谱。
如果你想在这个基础上继续往上做,我建议的方向是"详情面板 + 预览":右侧加一个可折叠的侧栏,单选时展示缩略图、尺寸、修改时间、标签,双击文件时用<object>或<img>直接预览(图片、PDF、纯文本都好办),视频用<video>加URL.createObjectURL就能播。再往下就是标签体系和搜索,搜索这块只要在nodeMap上做一次带防抖的全表扫描就够了,几千个节点扫一遍也就几毫秒,没必要上索引——先跑起来,等真的到十万级再谈优化。