欢迎回到我们的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

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐