1. 基础静态树形菜单:使用 HTML 和 CSS 实现一个可折叠/展开的树。
  2. 引入 jQuery 和事件处理:使用 jQuery 来处理点击事件,实现动态的折叠/展开效果。
  3. 从服务器动态加载数据:这是核心部分,我们将通过 AJAX 从后端 API 获取数据,并动态生成树形菜单。
  4. 完整代码示例:提供一个完整的、可运行的 HTML 文件,包含所有功能。
  5. 总结与扩展:讨论如何进行功能扩展,如添加复选框、图标等。

最终效果预览

我们将创建一个类似下面这样的树形菜单,节点可以点击展开/折叠,并且子节点是动态加载的。

根节点
├─ 子节点 1
│  ├─ 孙节点 1.1
│  └─ 孙节点 1.2
├─ 子节点 2
└─ 子节点 3 (点击后,从这里开始加载它的子节点)

第 1 步:基础静态树形菜单

我们用 HTML 和 CSS 创建一个静态的树形结构,这是所有后续功能的基础。

HTML 结构

我们将使用嵌套的 <ul><li> 列表来表示树形层级,为了方便 jQuery 选择,我们给每个可点击的节点添加一个 data-node-id 属性。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">jQuery 动态树形菜单</title>
    <style>
        /* 基础样式 */
        body {
            font-family: Arial, sans-serif;
            margin: 20px;
        }
        ul {
            list-style-type: none; /* 移除默认列表项符号 */
            padding-left: 20px; /* 为子列表添加缩进 */
        }
        li {
            margin: 5px 0;
        }
        .node-content {
            cursor: pointer;
            padding: 5px;
            border-radius: 4px;
        }
        .node-content:hover {
            background-color: #f0f0f0;
        }
        .node-children {
            display: none; /* 默认隐藏子节点 */
        }
        .node-children.expanded {
            display: block; /* 展开时显示 */
        }
        .has-children::before {
            content: '▶ '; /* 可折叠节点前的箭头 */
            display: inline-block;
            transition: transform 0.2s;
        }
        .has-children.expanded::before {
            transform: rotate(90deg); /* 展开时旋转箭头 */
        }
    </style>
</head>
<body>
    <h1>jQuery 动态树形菜单</h1>
    <ul id="tree-root">
        <li>
            <div class="node-content has-children" data-node-id="1">
                根节点
            </div>
            <ul class="node-children">
                <li>
                    <div class="node-content has-children" data-node-id="2">
                        子节点 1
                    </div>
                    <ul class="node-children">
                        <li><div class="node-content" data-node-id="4">孙节点 1.1</div></li>
                        <li><div class="node-content" data-node-id="5">孙节点 1.2</div></li>
                    </ul>
                </li>
                <li>
                    <div class="node-content has-children" data-node-id="3">
                        子节点 2 (动态加载)
                    </div>
                    <ul class="node-children">
                        <!-- 子节点将通过 AJAX 动态插入这里 -->
                    </ul>
                </li>
            </ul>
        </li>
    </ul>
    <!-- 引入 jQuery 库 -->
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</body>
</html>

代码解释:

  • HTML: 使用嵌套 <ul> 表示层级关系。.node-content 是每个节点的可点击区域。.node-children 是其子节点的容器,默认 display: none
  • CSS:
    • list-style-type: nonepadding-left 创建了树的层级视觉效果。
    • .node-children.expanded 类通过 display: block 来控制子节点的显示与隐藏。
    • .has-children::before 使用 CSS 伪元素添加了一个小箭头,.expanded 类会旋转这个箭头,提供视觉反馈。

第 2 步:引入 jQuery 并实现交互

我们使用 jQuery 来为节点添加点击事件,切换 .expanded 类,实现动态的折叠和展开。

JavaScript 代码

在 HTML 文件的底部 <script> 标签内添加以下代码:

$(document).ready(function() {
    // 为所有有子节点的节点添加点击事件
    $('.node-content.has-children').on('click', function(e) {
        e.stopPropagation(); // 阻止事件冒泡,避免不必要的触发
        // 1. 切换当前节点的 'expanded' 类
        $(this).toggleClass('expanded');
        // 2. 切换其子节点的显示/隐藏
        $(this).siblings('.node-children').toggleClass('expanded');
    });
});

代码解释:

  • $(document).ready(function() { ... });: 确保在 DOM 完全加载后再执行我们的代码。
  • $('.node-content.has-children').on('click', function(e) { ... });: 为所有带有 has-children 类的节点绑定点击事件。
  • e.stopPropagation(): 这是一个好习惯,它防止点击事件被父元素重复捕获。
  • $(this).toggleClass('expanded'): 切换被点击节点自身的 expanded 类,这会改变箭头的方向。
  • $(this).siblings('.node-children').toggleClass('expanded'): 找到被点击节点的兄弟元素中 classnode-children<ul>,并切换其 expanded 类,从而控制子列表的显示和隐藏。

你可以直接打开这个 HTML 文件,点击 "根节点" 和 "子节点 1",它们应该可以正常折叠和展开了。


第 3 步:从服务器动态加载数据

这是本教程的核心,我们将实现一个功能:当用户点击一个节点("子节点 2")时,才去服务器请求它的子节点数据,并动态插入到 DOM 中。

模拟后端 API

在实际开发中,后端会提供一个 API 接口,根据节点 ID 返回其子节点数据,这里我们用一个简单的 JavaScript 函数来模拟这个 API 的返回。

// 模拟从后端 API 获取数据
function fetchChildNodes(parentNodeId) {
    console.log(`正在为节点 ${parentNodeId} 获取子节点...`);
    // 模拟网络延迟
    return new Promise((resolve) => {
        setTimeout(() => {
            // 模拟的数据库
            const mockData = {
                '3': [ // 节点ID为3的子节点
                    { id: '6', name: '动态子节点 3.1' },
                    { id: '7', name: '动态子节点 3.2' },
                    { id: '8', name: '动态子节点 3.3 (可继续展开)' }
                ],
                '8': [ // 节点ID为8的子节点
                    { id: '9', name: '动态孙节点 8.1' },
                    { id: '10', name: '动态孙节点 8.2' }
                ]
            };
            // 返回数据或空数组
            resolve(mockData[parentNodeId] || []);
        }, 800); // 模拟 800ms 的网络延迟
    });
}

修改 jQuery 代码以支持动态加载

我们需要修改之前的点击事件处理函数,增加动态加载的逻辑。

$(document).ready(function() {
    // 为所有有子节点的节点添加点击事件
    $(document).on('click', '.node-content.has-children', function(e) {
        e.stopPropagation();
        const $this = $(this);
        const $childrenContainer = $this.siblings('.node-children');
        const nodeId = $this.data('node-id');
        // 检查是否已经加载过子节点
        // 我们可以通过检查子容器是否为空来判断
        if ($childrenContainer.children().length > 0) {
            // 如果已经加载,则直接切换显示/隐藏
            $this.toggleClass('expanded');
            $childrenContainer.toggleClass('expanded');
        } else {
            // 如果未加载,则先显示加载中提示
            $childrenContainer.html('<li>加载中...</li>').addClass('expanded');
            // 调用模拟的API获取数据
            fetchChildNodes(nodeId).then(data => {
                // 清空 "加载中..." 提示
                $childrenContainer.empty();
                if (data.length > 0) {
                    // 遍历返回的数据,并生成HTML
                    data.forEach(child => {
                        // 判断子节点是否还有子节点(这里我们简单判断,实际应由后端返回)
                        const hasChildren = child.id === '8'; // 模拟节点8有子节点
                        const childLi = $(`<li><div class="node-content ${hasChildren ? 'has-children' : ''}" data-node-id="${child.id}">${child.name}</div></li>`);
                        $childrenContainer.append(childLi);
                    });
                    // 展开父节点
                    $this.addClass('expanded');
                    // 递归绑定事件:为新添加的节点也绑定点击事件
                    // 这样可以实现无限层级的动态加载
                    bindTreeEvents();
                } else {
                    // 如果没有子节点,移除 has-children 类,防止用户再次点击
                    $this.removeClass('has-children');
                }
            });
        }
    });
    // 将事件绑定逻辑抽离出来,方便递归调用
    function bindTreeEvents() {
        $('.node-content.has-children').off('click').on('click', function(e) {
            // 这里的事件处理逻辑和上面的大同小异
            // 为了代码清晰,我们可以调用一个统一的处理函数
            handleNodeClick($(this));
        });
    }
    // 统一的节点点击处理函数
    function handleNodeClick($node) {
        e.stopPropagation();
        const $childrenContainer = $node.siblings('.node-children');
        const nodeId = $node.data('node-id');
        // ... (这里把上面 else 块里的逻辑放进来)
        // 为了简化,我们直接在原来的事件处理函数里写逻辑
        // 在实际项目中,抽离成函数是更好的做法
    }
});

代码解释:

  1. 事件委托: 我们使用 $(document).on('click', '.node-content.has-children', ...) 而不是直接 $('.node-content.has-children').on(...),这样做的好处是,即使后来通过 AJAX 动态添加了新的 .node-content.has-children 元素,它们也能自动获得点击事件,无需重新绑定。
  2. 检查是否已加载: if ($childrenContainer.children().length > 0) 是一个简单的判断,如果子容器里已经有内容了,说明数据已经加载过,我们只需要切换显示状态即可。
  3. 动态加载流程:
    • 如果未加载,先在子容器里显示 "加载中...",并立即展开它,给用户反馈。
    • 调用 fetchChildNodes(nodeId) 获取数据。
    • 数据返回后,清空 "加载中..." 提示。
    • 遍历 data 数组,使用 jQuery 的 和 .html()/.append() 方法动态创建 <li><div> 节点,并插入到 DOM 中。
    • 为新添加的节点添加 has-children 类(如果它确实有子节点)。
    • 我们调用一个 bindTreeEvents()(这个概念在完整代码中会优化),确保新节点也能响应点击事件。

第 4 步:完整可运行的代码示例

将以上所有部分整合在一起,你将得到一个完整的、功能齐全的动态树形菜单。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">jQuery 动态树形菜单 - 完整示例</title>
    <style>
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            margin: 20px;
            background-color: #f9f9f9;
        }
        h1 {
            color: #333;
        }
        #tree-root {
            list-style-type: none;
            padding-left: 0;
            background-color: #fff;
            border: 1px solid #ddd;
            border-radius: 5px;
            box-shadow: 0 2px 5px rgba(0,0,0,0.1);
        }
        ul {
            list-style-type: none;
            padding-left: 25px;
            border-left: 1px dashed #ccc;
        }
        li {
            margin: 2px 0;
            position: relative;
        }
        .node-content {
            cursor: pointer;
            padding: 8px 10px;
            border-radius: 4px;
            user-select: none; /* 防止文本被选中 */
            transition: background-color 0.2s;
        }
        .node-content:hover {
            background-color: #e9e9e9;
        }
        .node-children {
            display: none;
        }
        .node-children.expanded {
            display: block;
        }
        .has-children::before {
            content: '▶ ';
            display: inline-block;
            margin-right: 5px;
            font-size: 0.8em;
            color: #666;
            transition: transform 0.2s ease;
        }
        .has-children.expanded::before {
            transform: rotate(90deg);
        }
        .loading {
            color: #888;
            font-style: italic;
            padding-left: 20px;
        }
    </style>
</head>
<body>
    <h1>jQuery 动态树形菜单</h1>
    <p>点击带有箭头的节点来展开/折叠,子节点将在点击时动态加载。</p>
    <ul id="tree-root">
        <li>
            <div class="node-content has-children" data-node-id="1">
                根节点
            </div>
            <ul class="node-children">
                <li>
                    <div class="node-content has-children" data-node-id="2">
                        子节点 1 (静态)
                    </div>
                    <ul class="node-children">
                        <li><div class="node-content" data-node-id="4">孙节点 1.1</div></li>
                        <li><div class="node-content" data-node-id="5">孙节点 1.2</div></li>
                    </ul>
                </li>
                <li>
                    <div class="node-content has-children" data-node-id="3">
                        子节点 2 (动态加载)
                    </div>
                    <ul class="node-children">
                        <!-- 子节点将通过 AJAX 动态插入这里 -->
                    </ul>
                </li>
            </ul>
        </li>
    </ul>
    <!-- 引入 jQuery 库 -->
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <script>
    $(document).ready(function() {
        // 模拟从后端 API 获取数据
        function fetchChildNodes(parentNodeId) {
            console.log(`正在为节点 ${parentNodeId} 获取子节点...`);
            return new Promise((resolve) => {
                setTimeout(() => {
                    const mockData = {
                        '3': [
                            { id: '6', name: '动态子节点 3.1' },
                            { id: '7', name: '动态子节点 3.2' },
                            { id: '8', name: '动态子节点 3.3 (可继续展开)' }
                        ],
                        '8': [
                            { id: '9', name: '动态孙节点 8.1' },
                            { id: '10', name: '动态孙节点 8.2' }
                        ]
                    };
                    resolve(mockData[parentNodeId] || []);
                }, 800);
            });
        }
        // 统一的节点点击处理逻辑
        function handleNodeClick($node) {
            const $childrenContainer = $node.siblings('.node-children');
            const nodeId = $node.data('node-id');
            // 如果已经加载过子节点,则直接切换显示状态
            if ($childrenContainer.data('loaded')) {
                $node.toggleClass('expanded');
                $childrenContainer.toggleClass('expanded');
            } else {
                // 显示加载中提示
                $childrenContainer.html('<li class="loading">加载中...</li>').addClass('expanded');
                // 调用API获取数据
                fetchChildNodes(nodeId).then(data => {
                    $childrenContainer.empty(); // 清空加载提示
                    if (data.length > 0) {
                        data.forEach(child => {
                            // 判断子节点是否还有子节点
                            const hasChildren = child.id === '8';
                            const childLi = $(`<li><div class="node-content ${hasChildren ? 'has-children' : ''}" data-node-id="${child.id}">${child.name}</div></li>`);
                            $childrenContainer.append(childLi);
                        });
                        $node.addClass('expanded'); // 加载成功后,保持展开状态
                        $childrenContainer.data('loaded', true); // 标记为已加载
                    } else {
                        // 如果没有子节点,移除 has-children 类,防止用户再次点击
                        $node.removeClass('has-children');
                    }
                });
            }
        }
        // 使用事件委托为所有 .node-content.has-children 绑定点击事件
        // 这样动态添加的节点也能自动获得事件
        $(document).on('click', '.node-content.has-children', function(e) {
            e.stopPropagation();
            handleNodeClick($(this));
        });
    });
    </script>
</body>
</html>

完整代码的优化点:

  • data('loaded'): 我们使用 jQuery 的 .data() 方法来标记一个节点的子数据是否已经加载过,这比检查子元素数量更可靠。
  • 事件委托: 在 $(document) 上使用 .on(),确保所有动态生成的节点都能正确响应点击。
  • 统一的 handleNodeClick 函数: 将核心逻辑封装,使代码更清晰、更易于维护。

第 5 步:总结与扩展

我们成功地从零开始,构建了一个功能完善的 jQuery 动态树形菜单,核心步骤如下:

  1. 结构先行: 用语义化的 HTML (<ul>, <li>) 搭建树的基本结构。
  2. 样式美化: 用 CSS 控制树的层级、折叠/展开状态和交互效果。
  3. 静态交互: 用 jQuery 实现基本的点击折叠/展开。
  4. 动态加载: 结合 AJAX (或 Promise 模拟) 和 DOM 操作,实现按需加载子节点数据。
  5. 事件优化: 使用事件委托,确保动态内容也能正确绑定事件。

扩展功能

你可以基于这个基础,轻松地添加更多功能:

  1. 添加复选框:

    • 在 HTML 的 .node-content 中添加 <input type="checkbox">
    • 使用 CSS 调整复选框和文本的布局。
    • 在 jQuery 中监听 change 事件,获取选中状态,并可以递归地处理子节点的选中状态(父节点选中则所有子节点自动选中)。
  2. 添加图标:

    • 使用图标库(如 Font Awesome)。
    • 根据节点是否有子节点、是否展开等状态,动态切换不同的图标类名,fa-folderfa-folder-open
  3. 异步加载失败处理:

    • fetchChildNodes.then() 后面添加 .catch()Promisecatch 块,当请求失败时,在 $childrenContainer 中显示“加载失败,请重试”的提示,并提供一个重试按钮。
  4. 搜索与高亮:

    • 添加一个搜索框。
    • 遍历整个树,将匹配的节点高亮显示(改变背景色),并自动展开其所有父节点以便用户看到。

希望这份详细的教程能帮助你掌握 jQuery 动态树形菜单的实现!