【UniApp进阶】Vue3 组合式 API 手撸无限递归目录树
欢迎回到我们的UniApp 实战 AtomGit系列教程!
在上一期中,我们完成了代码仓详情页的基础架构,但那个文件列表是“平铺”的,点一个文件夹跳一页,体验极其复古。
作为一名有追求的前端开发者,我们要的是像 VS Code 那样丝滑、可折叠、层级分明的可视化目录树。
今天这篇文章,我们将抛弃所有笨重的第三方组件库,使用 Vue 3 组合式 API (Composition API),手把手带你从零实现一个高性能、支持懒加载的递归目录树组件。这里为了方便结合代码说明,把大部分的讲解都写进了注释里。
第一步:定义数据结构 (The Model)
为了让递归跑起来,我们必须标准化我们的节点数据。无论后端 API 返回什么样,我们都要转换成下面这种 TreeNode 格式:
// 标准节点接口定义
export interface TreeNode {
name: string;
path: string;
type: 'tree' | 'blob'; // 标准化后的类型
level: number;
isOpen: boolean;
loading: boolean;
children: TreeNode[];
// 还可以存一个 rawData 字段,把后端原始数据藏在里面,备用
rawData?: any;
}
这里可以用TypeScript定义接口,因为JS 没有接口定义。
那如何转化为这个 TreeNode 的格式呢?
我们要在 src/utils/fileAdapter.js 中写一个函数。这个函数的作用就像出国旅游的转换插头:不管墙上的插座(后端API)是圆的还是扁的,经过转换后,都能插进你的电脑(UI组件)。
/**
* 标准化节点转换器
* @param {Object} apiData - 后端返回的原始数据 (可能是 AtomGit, GitHub, Gitee...)
* @param {Number} level - 当前层级
* @returns {Object} - 符合 UI 组件要求的标准 TreeNode
*/
export function transformToNode(apiData, level = 0) {
// 1. 字段映射 (Mapping)
// 后端可能叫 name, filename, label... 我们统一转成 name
const name = apiData.name || apiData.filename || apiData.path.split('/').pop();
// 后端可能叫 path, full_path, url... 我们统一转成 path
const path = apiData.path || apiData.url;
// 2. 类型标准化 (Normalization)
// 后端可能返回: 'tree'/'blob' (AtomGit/GitHub)
// 也可能返回: 'dir'/'file' (Gitee)
// 我们统一转成: 'tree' (文件夹) 和 'blob' (文件)
let type = 'blob'; // 默认是文件
const rawType = apiData.type || '';
if (['tree', 'dir', 'directory', 'folder'].includes(rawType)) {
type = 'tree';
}
// 3. 构造标准对象 (UI State)
// 这里加上 UI 专用的状态,比如 isOpen, loading,这些后端是不会给你的
return {
name: name,
path: path,
type: type,
level: level,
// UI 交互状态初始值
isOpen: false,
loading: false,
children: [],
// 原始数据留个底 (可选),万一点击时需要用到 API 里的其他怪异字段
download_url: apiData.download_url,
_raw: apiData
};
}
在页面中使用的话,直接调用这个函数就行了
<script setup>
import { ref } from 'vue';
import request from '@/utils/request';
// 引入刚才写的转换器
import { transformToNode } from '@/utils/fileAdapter';
const fileList = ref([]);
const fetchFiles = async (path = '') => {
// ... loading ...
try {
// 1. 发送请求拿到“脏”数据
const res = await request.get(url, params);
// 假设 res 是数组: [{name: 'src', type: 'tree'}, ...]
// 2. 使用转换器进行“清洗” (关键步骤!)
// 无论 res 长什么样,经过 map 处理后,formattedFiles 必定是标准格式
const formattedFiles = res.map(item => transformToNode(item, 0)); // 0 代表根目录层级
// 3. 排序 (文件夹在文件前面)
formattedFiles.sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name);
return a.type === 'tree' ? -1 : 1;
});
// 4. 赋值给 UI
fileList.value = formattedFiles;
} catch (e) {
// ...
}
};
</script>
第二步:编写递归组件 (FileTreeNode.vue)
这是本教程的灵魂。在 components 目录下新建 FileTreeNode.vue。
1. Template (视图层)
我们需要利用 padding-left 来模拟层级缩进,利用 v-if 来控制子树的显示。
<template>
<view class="node-container">
<view
class="node-row"
:style="{ paddingLeft: (node.level * 30) + 'rpx' }"
@click="handleNodeClick"
:class="{ 'active-row': node.isOpen }"
>
<view class="icon-box">
<text v-if="node.loading" class="spin">↻</text>
<text v-else-if="node.type === 'tree'" class="icon">
{{ node.isOpen ? '📂' : '📁' }}
</text>
<text v-else class="icon">📄</text>
</view>
<text class="node-name">{{ node.name }}</text>
</view>
<view v-if="node.isOpen && node.children?.length">
<FileTreeNode
v-for="child in node.children"
:key="child.path"
:node="child"
@node-click="onChildBubble"
/>
</view>
</view>
</template>
2. Script Setup (逻辑层)
在 Vue 3 <script setup> 中实现递归,必须使用 defineOptions 显式命名组件,否则它找不到自己。
<script setup>
// ⚠️ 关键点1:必须命名,否则无法递归
defineOptions({
name: 'FileTreeNode'
});
const props = defineProps({
node: {
type: Object,
required: true
}
});
// 定义向外抛出的事件
const emit = defineEmits(['node-click']);
// --- 交互逻辑 ---
const handleNodeClick = () => {
const currentNode = props.node;
// 1. 如果是文件,直接抛出,让父页面处理跳转
if (currentNode.type === 'blob') {
emit('node-click', currentNode);
return;
}
// 2. 如果是文件夹,切换展开/收起
// 这里直接修改了 props 引用的对象的属性。
// 在递归树场景下,这是最高效的状态管理方式,避免了复杂的事件链。
currentNode.isOpen = !currentNode.isOpen;
// 3. 懒加载触发机制
// 如果展开了,但是 children 是空的,说明需要去 API 拉取数据
if (currentNode.isOpen && (!currentNode.children || currentNode.children.length === 0)) {
// 抛出一个带特殊标记的事件
emit('node-click', { ...currentNode, action: 'fetch-children' });
}
};
// --- 事件冒泡 ---
// 递归组件必须把底层子孙的点击事件一层层传上去
const onChildBubble = (event) => {
emit('node-click', event);
};
</script>
<style scoped>
.node-row {
display: flex;
align-items: center;
height: 88rpx;
border-bottom: 1rpx solid #f0f0f0;
background-color: #fff;
font-size: 28rpx;
color: #333;
}
.active-row { background-color: #f5f9ff; } /* 展开高亮 */
.icon-box { width: 60rpx; display: flex; justify-content: center; }
.node-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.spin { animation: rotate 1s linear infinite; color: #999; }
@keyframes rotate { 100% { transform: rotate(360deg); } }
</style>
第三步:父组件组装 (RepoDetail.vue)
现在我们把这个强大的组件放到页面里,并接上数据源。
<template>
<view class="page-container">
<view class="tree-wrapper">
<view class="header">项目目录</view>
<FileTreeNode v-for="item in treeData" :key="item.path" :node="item" @node-click="handleInteraction" />
<view v-if="treeData.length === 0" class="empty">加载中...</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import FileTreeNode from '@/components/FileTreeNode.vue';
import { transformToNode } from '@/utils/fileAdapter';
import request from '@/utils/request.js';
// 响应式数据源
const treeData = ref([]);
const keyword = 'vue';
const getData = () =>
request({
url: '/search/repositories',
method: 'get',
params: {
access_token: '你的token',
q: `${keyword}`
}
});
const fetchFiles = async (path = '') => {
try {
// 1. 发送请求拿到“脏”数据
const res = await getData();
console.log(res);
// 假设 res 是数组: [{name: 'src', type: 'tree'}, ...]
// 2. 使用转换器进行“清洗” (关键步骤!)
// 无论 res 长什么样,经过 map 处理后,formattedFiles 必定是标准格式
const formattedFiles = res.map((item) => transformToNode(item, 0)); // 0 代表根目录层级
// 3. 排序 (文件夹在文件前面)
formattedFiles.sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name);
return a.type === 'tree' ? -1 : 1;
});
// 4. 赋值给 UI
treeData.value = formattedFiles;
} catch (e) {
console.error(e);
}
};
// 2. 数据转换工厂 (重要!)
// 将 API 的原始数据,加工成带状态的 TreeNode
const createNode = (apiItem, level) => {
return {
...apiItem,
level: level, // 记录层级
isOpen: false, // 默认闭合
loading: false, // 默认无 loading
children: [] // 默认无子节点
};
};
// 3. 核心交互处理
const handleInteraction = async (node) => {
// 场景 A: 点击文件 -> 跳转详情
if (node.type === 'blob') {
console.log(`正在打开文件: ${node.path}`);
// uni.navigateTo({ url: `/pages/code/read?path=${node.path}` })
return;
}
// 场景 B: 需要懒加载子文件夹
if (node.action === 'fetch-children') {
// 这里的 node 是从事件传上来的副本,我们需要找到 treeData 里对应的那个引用
// 但在 FileTreeNode 里我们修改的是 props.node (引用类型),
// 所以这里的 node 其实就是响应式源数据本身!可以直接操作!
const targetNode = node; // 拿到引用
targetNode.loading = true;
try {
const res = await fetchFiles(targetNode.path);
// 核心:把新数据挂载到 children 上
// 注意 level 要在父节点基础上 + 1
const newNodes = res.map((item) => createNode(item, targetNode.level + 1));
// 排序:文件夹排前面
newNodes.sort((a, b) => (a.type === b.type ? 0 : a.type === 'tree' ? -1 : 1));
targetNode.children = newNodes;
} catch (e) {
targetNode.isOpen = false; // 失败就把文件夹合上
uni.showToast({ title: '加载失败', icon: 'none' });
} finally {
targetNode.loading = false;
}
}
};
// 初始化
onMounted(async () => {
const rootFiles = await fetchFiles('');
treeData.value = rootFiles.map((f) => createNode(f, 0));
});
</script>
<style scoped>
.page-container {
padding: 20rpx;
background: #f8f8f8;
min-height: 100vh;
}
.tree-wrapper {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
.header {
padding: 20rpx;
border-bottom: 1rpx solid #eee;
font-weight: bold;
}
.empty {
padding: 40rpx;
text-align: center;
color: #999;
}
</style>
深度解析:为什么这么设计?
1. 为什么用 level 属性而不是嵌套 DOM?
有些教程会教你用 margin-left 或者嵌套 view 来做缩进。
- 错误做法: 每一层嵌套一个
view。如果层级达到 10 层,DOM 树会变得极深,小程序端渲染性能急剧下降。 - 正确做法 (本教程): 所有的节点本质上都是平级的 Flex 行,我们只是通过
padding-left: level * 30rpx这种视觉欺骗,来实现高性能的缩进效果。
2. 懒加载的艺术
一次性拉取整个 Git 仓库(可能包含几万个文件)是不可取的。
我们利用 v-if="node.isOpen"。只有当用户真正点击那个文件夹时,我们才去请求 API,才去渲染子组件。这被称为 “On-Demand Rendering” (按需渲染)。
3. 对象引用的妙用
在 handleInteraction 中,你可能会疑惑:为什么修改 targetNode.children 就能自动更新视图?
因为 Vue 3 的 reactive/ref 是深层响应的。虽然经过了递归组件的 props 传递,但内存地址指向的依然是 treeData 里的同一个对象。修改这个对象,视图自动更新。
结语
恭喜你!你现在拥有了一个可无限递归、支持懒加载、基于 Vue 3 组合式 API 的原生目录树组件。
如果你觉得这篇文章对你有帮助,别忘了 点赞 + 收藏 + 关注 yilan_n,我们下期见!
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)