取舍)
做后臺管理系統(tǒng)做到第三年我發(fā)現(xiàn)自己跟樹形菜單打交道的時間幾乎跟表格一樣多。部門樹、權限樹、商品類目樹甚至地區(qū)選擇器本質上都是同一個組件——EasyUI 的 tree。而“父/子節(jié)點怎么加載”這個問題幾乎每次都要重新翻一遍文檔。jEasyUI很多工程里就這么叫實際就是 EasyUI 的 jQuery 版本的樹組件最常用的就兩種玩法一次性把整棵樹的數(shù)據丟給前端或者等到用戶展開節(jié)點時才去后端要子節(jié)點。這篇就把這兩種加載方式從數(shù)據結構、接口設計到常見坑原原本本拆一遍。不管你是剛接手老項目的新手還是已經寫了不少后臺頁面的前端只要跟樹組件打過交道這幾點應該都用得上。1. jEasyUI 樹形菜單加載的核心思路拆解1.1 兩種加載模式全量加載和懶加載怎么選網上聊樹形菜單動不動就讓你“用懶加載”但實際項目里真不是所有場景都適合懶加載。我第一次做權限樹的時候全公司一共二十多個部門用戶角色也就三五個這種數(shù)據量一次性返回根本沒問題強行做成懶加載反而要多寫好多接口和狀態(tài)判斷。反過來后來做一個電商后臺的商品類目樹一級類目下面掛著上千個末級類目如果一次性全渲染頁面直接卡成幻燈片。EasyUI 的 tree 組件剛好兩種模式都支持區(qū)別只在初始化參數(shù)data模式直接傳一個數(shù)組前端拿到的就是完整樹結構適合節(jié)點總數(shù)在幾百以內、層級固定、不需要頻繁增刪改的場景。url模式初始化時只傳一個接口地址tree 組件會先請求根節(jié)點數(shù)據之后展開某個state: closed的節(jié)點時再自動帶著該節(jié)點的 id 去請求子節(jié)點適合層級深、節(jié)點多、需要按需加載的場景。我在項目里的選擇標準很簡單節(jié)點總量超過 500或者層級深度不固定就優(yōu)先用懶加載。因為全量加載雖然代碼簡單但數(shù)據一到前端就需要遞歸構建樹、渲染 DOM數(shù)據量一大初始化和展開都會有肉眼可見的延遲。懶加載把壓力分攤到每次展開動作上體驗上反而更順。1.2 父節(jié)點和子節(jié)點背后的數(shù)據約定很多人用 tree 組件卡住不是 API 不熟而是沒搞明白它眼里的“樹”長什么樣。EasyUI 的 tree 節(jié)點就是一個普通的 JSON 對象里面有幾個保留字段id是節(jié)點唯一標識text是顯示文字state表示節(jié)點狀態(tài)值為open時節(jié)點默認展開值為closed時節(jié)點收起并且可以被再次展開。最關鍵的是children字段它保存當前節(jié)點的直接子節(jié)點數(shù)組。我見過不少后端同事會返回parentId、parentName、isParent這類自定義字段這些字段不是不能用但 tree 組件本身只認上面那幾個保留字段。所以常見的做法是后端直接返回符合樹結構的 JSON或者前端拿到扁平列表后自己轉成樹。比如下面這段就是最標準的節(jié)點數(shù)據[ { id: 1, text: 總部, state: closed, children: [ { id: 2, text: 技術部, state: open } ] } ]這里有個容易踩的細節(jié)如果某個節(jié)點明明還有子節(jié)點但你把它寫成state: open并且沒給children那樹組件就認為它是一個空的葉子節(jié)點展開箭頭都不會顯示更不會發(fā)請求。反過來如果它是一個沒有子節(jié)點的葉子節(jié)點你卻寫了state: closed前端就會一直以為它有下級每次展開都會去請求結果白折騰一趟還拿回空數(shù)組。1.3 加載父/子節(jié)點本質上是個數(shù)據關系問題別看“加載父/子節(jié)點”聽起來像是組件用法實際工作中你會發(fā)現(xiàn)問題八成出在數(shù)據組織方式上。比如數(shù)據庫里通常只存一個parent_id字段根節(jié)點的parent_id是 0 或 NULL子節(jié)點的parent_id指向父節(jié)點的 id。這種扁平結構適合存儲但不適合直接渲染樹中間必須做一次“按父找子”的組裝。所以做樹形菜單之前我建議先把數(shù)據流想清楚后端返回什么前端轉換成什么組件最終渲染成什么。最容易維護的方案是后端直接把樹結構拼好返回前端只用data參數(shù)一次性接收如果后端實在不方便返回樹結構那就只能在 JavaScirpt 里自己把扁平的pid列表轉成children嵌套結構。后面第 2 部分我會給出可以直接用的轉換函數(shù)順手把排序、空節(jié)點這些細節(jié)也一起處理掉。2. 一次性加載把扁平列表轉成樹的完整實操2.1 前后端接口格式與樹構建函數(shù)一次性加載最典型的場景是權限樹。后端從一張sys_menu表里查出所有菜單每條記錄大概長這樣[ { id: 1, pid: 0, text: 系統(tǒng)管理 }, { id: 2, pid: 1, text: 用戶管理 }, { id: 3, pid: 1, text: 角色管理 }, { id: 4, pid: 0, text: 商品管理 } ]前端拿到這串數(shù)據后不能直接塞給$(#tree).tree({ data: list })否則樹組件只會把每個對象當成平級節(jié)點根本不會形成父子關系。所以需要一個遞歸轉換函數(shù)把pid等于某個節(jié)點 id 的對象掛到它的children下。下面這段是我用了很久的工具函數(shù)順手把children為空數(shù)組的情況也處理干凈了function buildTree(list, parentId) { var result []; for (var i 0; i list.length; i) { var node list[i]; if (node.pid parentId) { var children buildTree(list, node.id); if (children.length 0) { node.children children; node.state closed; } else { node.children undefined; node.state open; } result.push(node); } } return result; } var treeData buildTree(menuList, 0); $(#menuTree).tree({ data: treeData });這里為什么要把state分開設置因為 EasyUI 渲染規(guī)則里有子節(jié)點的closed節(jié)點才會顯示可展開的箭頭沒有子節(jié)點的節(jié)點保持open就不會誤導用戶。如果你把所有節(jié)點都當成closed葉子節(jié)點旁邊也會出現(xiàn)一個加號點一下就發(fā)請求顯然不合理。這個函數(shù)已經是 O(n2) 了如果菜單量特別大可以用一個 map 先按 id 索引再遍歷一次掛 children能快不少但一般后臺菜單不超過幾百條這個簡單版本就夠了。2.2 節(jié)點排序和空 children 的心得扁平列表轉樹時很容易忽略排序問題。數(shù)據庫查詢如果不加order by同一個父節(jié)點下的子節(jié)點順序隨機生成的樹就會亂跳。我習慣在接口返回前就按sort_order排好轉換函數(shù)里保持數(shù)組原有順序即可。如果你拿到的列表沒有專門排序字段退一步按id排序也比完全不排強。還有一個常見問題是空children。有的后端很喜歡給每個節(jié)點都帶上children: []這在前端渲染時通常沒問題但如果你用node.children.length去判斷有沒有子節(jié)點就會把空數(shù)組判斷成“有子節(jié)點”導致誤顯示展開箭頭。我在上面函數(shù)里用children.length 0才賦值children就是為了避免這種歧義。經驗之談狀態(tài)判斷只看有沒有children字段不看它的值雖然空數(shù)組在嚴格意義上是 false但不同版本組件對空數(shù)組的處理并不一致干脆手動清除掉最穩(wěn)妥。2.3 默認展開層級和選中根節(jié)點一次性加載時如果整棵樹都處于closed狀態(tài)用戶進來只看到一堆根節(jié)點體驗不太好。我通常在初始化完成后按業(yè)務需要展開特定層級$(#menuTree).tree({ data: treeData, onLoadSuccess: function () { var root $(#menuTree).tree(getRoots)[0]; if (root) { $(#menuTree).tree(expandAll, root.target); } } });expandAll會把所有層級一次性展開適合層級少、節(jié)點少的場景。節(jié)點多的時候我更傾向于只展開到第二層或者直接記住用戶上次展開到哪個節(jié)點。你可以循環(huán)調用tree(expandTo, target)逐級展開或者干脆在數(shù)據里把第一層節(jié)點的state直接設為open省去 JS 操作。這個看業(yè)務沒有絕對標準但最好不要在大樹上無腦expandAllDOM 節(jié)點太多會讓瀏覽器卡頓。2.4 父子節(jié)點勾選聯(lián)動的取舍樹形菜單經常配合 checkbox 使用用于分配權限。EasyUI 默認行為是勾選父節(jié)點時自動勾選所有子節(jié)點再取消父節(jié)點時子節(jié)點也會全部取消這種級聯(lián)邏輯大多數(shù)業(yè)務都需要。但如果你做的是“部分權限獨立分配”父節(jié)點和子節(jié)點不強關聯(lián)就得把cascadeCheck設為false$(#menuTree).tree({ data: treeData, checkbox: true, cascadeCheck: false });注意設置之后勾選子節(jié)點時父節(jié)點不會自動半選你需要自己在onCheck事件里處理半選狀態(tài)否則界面上父節(jié)點一直顯示未勾選后期回顯權限時很難看。我吃過一次虧那時以為關掉級聯(lián)就萬事大吉結果權限回顯時發(fā)現(xiàn)父節(jié)點狀態(tài)不對最后是自己遍歷所有子節(jié)點只要有任意一個被勾選就把父節(jié)點設為半選才算補齊這個交互。3. 異步加載父/子節(jié)點的完整配置3.1 url 模式初始化根節(jié)點請求怎么傳參當業(yè)務規(guī)模變大全量加載撐不住時就該切到異步懶加載。懶加載的初始化方式很簡單給 tree 組件一個url它加載時會默認請求根節(jié)點數(shù)據展開某個closed子節(jié)點時會自動把該節(jié)點的 id 作為請求參數(shù)發(fā)給后端。默認參數(shù)名是id也可以通過queryParams固定附加參數(shù)。$(#categoryTree).tree({ url: /api/tree/children, queryParams: { rootFlag: 1 }, onBeforeExpand: function (node) { console.log(即將展開節(jié)點, node.id); } });這里有個容易繞暈的點如果初始化時后端根節(jié)點的父 id 不是 0而是用rootFlag或其他字段標識怎么辦你可以在后端判斷id參數(shù)是否為空。比如接口/api/tree/children第一次請求沒有id參數(shù)就默認返回所有根節(jié)點第二次請求帶id1就返回 id 為 1 的節(jié)點的直接子節(jié)點。前端并不需要專門處理根節(jié)點的參數(shù)只要后端能區(qū)分“無參”和“有參”兩種場景即可。3.2 到底要不要用 onBeforeExpand 手動加載網上很多教程會告訴你“在onBeforeExpand里發(fā)起 Ajax成功后用append方法手動把子節(jié)點加到樹上”。這個方法確實做得到但我個人不太推薦除非你有特殊需求。原因很簡單EasyUI 的 tree 組件在url模式下展開closed節(jié)點時本來就會自動發(fā)請求加載子節(jié)點你再用onBeforeExpand手動發(fā)請求等于同一件事做兩遍還可能觸發(fā)重復請求。手動append的典型場景是節(jié)點不是通過自身 id 去查而是需要拼一個復雜的自定義參數(shù)比如orgId加type。這時你可以用onBeforeExpand返回false阻止默認加載再自己發(fā) Ajax完成后調用tree(append, { parent: node.target, data: children })。$(#orgTree).tree({ url: /api/tree/children, onBeforeExpand: function (node) { if (node.type ! org) { return false; } } });這里需要重點關注一旦onBeforeExpand返回false組件的默認加載就會被取消后續(xù)不會再自動發(fā)送請求。所以如果你只是想在展開前攔一道做校驗而不是完全接管加載記得在通過校驗后讓函數(shù)返回true否則你會看到節(jié)點怎么點都打不開。這是新手最容易犯的錯之一。3.3 后端接口設計返回直接子節(jié)點還是整棵子樹配合懶加載的后端接口設計原則只有一個每次只返回直接子節(jié)點不要一次返回整棵子樹。比如點開“手機數(shù)碼”節(jié)點后端只返回“手機”“電腦”“耳機”這一層而不是把“手機”下面所有型號全部帶出來。這樣做的好處是響應快對前端渲染壓力小而且后續(xù)數(shù)據更新后用戶重新展開就能拿到最新數(shù)據。接口收到參數(shù)后大概邏輯如下GetMapping(/api/tree/children) public ListTreeNode children(Integer id) { if (id null) { // 返回根級節(jié)點 return menuService.listByParentId(0); } // 返回某個父節(jié)點下的直接子節(jié)點 return menuService.listByParentId(id); }返回的每個節(jié)點要帶上id、text以及是否還有子節(jié)點的標記state。如果該節(jié)點還有下級返回state: closed這樣前端展開它時會繼續(xù)發(fā)請求如果沒有下級就返回state: open或干脆不返回state讓組件把它當葉子節(jié)點處理。如果你希望前端渲染更快可以在 SQL 里用EXISTS查一下有沒有子節(jié)點動態(tài)決定返回的state但要注意這種寫法在高并發(fā)下可能讓數(shù)據庫壓力變大可以適當做緩存。3.4 緩存已加載節(jié)點防止展開又收起的重復請求懶加載模式下EasyUI 其實會自動記住哪些節(jié)點已經加載過了。節(jié)點一旦從closed變成open再次收起再展開正常情況下組件不會重新請求。但如果你手動改過state或者調用了tree(reload, target)就容易重復發(fā)請求。還有一種自己造成的坑有些同事為了確保數(shù)據最新在onExpand事件里調用tree(reload, node.target)結果每次展開都重新加載用戶覺得卡不說后端日志還會刷出一堆請求。如果確實需要刷新某個節(jié)點更合理的做法是提供手動刷新按鈕或者只刷新變化的節(jié)點而不是全量 reload。另外如果后端返回的節(jié)點id在多次加載后不唯一EasyUI 會認為它是同一個節(jié)點導致渲染錯亂。我遇到過的情況是后端把數(shù)據庫自增 id 和業(yè)務編碼混用同一個 node id 在不同父節(jié)點下重復樹就亂了。所以懶加載模式下id的全局唯一性一定要保證如果原表沒有唯一主鍵可以在接口里拼一個業(yè)務前綴比如org_1、menu_2甚至直接返回復合主鍵字符串。4. 常見問題與排查技巧實錄4.1 子節(jié)點加載成功父節(jié)點卻自動收起這個現(xiàn)象很詭異點開父節(jié)點子節(jié)點明明出來了界面閃一下樹又縮回去了。排查下來十有八九是數(shù)據問題。最常見的是父節(jié)點的id在加載子節(jié)點后發(fā)生變化組件定位不到原來的父節(jié)點于是把當前展開狀態(tài)重置了。比如后端返回節(jié)點時id用的是數(shù)據庫主鍵但前端某個環(huán)節(jié)把 id 替換成了別的字段就會觸發(fā)這個問題。我的排查套路是先用瀏覽器的 Network 面板看請求參數(shù)確認展開父節(jié)點時發(fā)送的id是不是當前節(jié)點的 id再用 Console 看看tree(getChildren, node.target)返回多少個子節(jié)點最后檢查接口返回的每條數(shù)據里id是否唯一。如果這幾項都正常多半是onBeforeExpand里返回了false把默認加載行為給打斷了。4.2 節(jié)點 state 為 closed但點擊展開后沒有發(fā)請求如果你初始化時用的是data模式而非url模式那么節(jié)點的state: closed只是一個顯示狀態(tài)組件根本不會自動發(fā)請求。這時候你需要自己在展開事件里用append方法塞子節(jié)點否則節(jié)點永遠是空轉。這是兩種加載模式混用時的經典問題。另一個可能性是onBeforeExpand返回了false而且沒有實現(xiàn)后續(xù)加載邏輯。這種情況在代碼里搜一下事件綁定很容易發(fā)現(xiàn)。還有個小眾原因某個版本的 jEasyUI 對url模式要求根節(jié)點必須有id如果根節(jié)點沒有 id展開時傳給后端的參數(shù)就是undefined后端按 null 處理返回了根節(jié)點數(shù)據前端匹配不上看著就像沒發(fā)請求。解決辦法是初始化時給根節(jié)點一個固定 id比如0。4.3 樹刷新后選中態(tài)和展開態(tài)丟失后臺管理系統(tǒng)里很常見用戶勾選了一些權限然后切到其他 tab 再切回來或者點了刷新按鈕整棵樹的勾選狀態(tài)全沒了。EasyUI 本身不會自動記憶這些狀態(tài)你必須自己保存。我一般用一個全局對象記錄關鍵狀態(tài)var treeState { expandedIds: [], checkedIds: [] }; $(#permTree).tree({ url: /api/perm/tree, checkbox: true, onExpand: function (node) { if (treeState.expandedIds.indexOf(node.id) -1) { treeState.expandedIds.push(node.id); } }, onCheck: function (node) { treeState.checkedIds $(#permTree).tree(getChecked); } });刷新之后在onLoadSuccess里重新展開和勾選$(#permTree).tree({ onLoadSuccess: function () { // 先展開記錄過的節(jié)點 $.each(treeState.expandedIds, function (i, id) { var node $(#permTree).tree(find, id); if (node) { $(#permTree).tree(expand, node.target); } }); // 再回顯勾選 $.each(treeState.checkedIds, function (i, node) { var target $(#permTree).tree(find, node.id); if (target) { $(#permTree).tree(check, target.target); } }); } });有一點要注意如果節(jié)點還沒有加載出來tree(find, id)是找不到的這就回到第 3 部分的懶加載問題上。所以回顯勾選時最好先把所有節(jié)點展開一遍或者干脆用全量加載模式。權限樹本身節(jié)點通常不多我一般直接用全量加載配合上面的狀態(tài)記錄刷新體驗能好很多。4.4 一次性加載大量節(jié)點導致頁面卡頓怎么優(yōu)化如果你確實需要一次性加載很多節(jié)點比如幾千個類目但又不想改造成懶加載可以先做“分層渲染”。我試過一種簡單方案只在onLoadSuccess時展開一層用戶點擊展開按鈕時才渲染下一層通過onBeforeExpand里給tree(append)動態(tài)塞children。這樣雖然數(shù)據還是全量在前端但 DOM 是逐步插入的能明顯降低首屏卡頓。還有一種更徹底的方案是改用懶加載并且后端給每個節(jié)點提前算好children數(shù)量。當children數(shù)量大于 0 時返回state: closed否則返回open。前端就完全不需要加載葉子節(jié)點的子級數(shù)據數(shù)據庫壓力也小。這都是我在商品類目這種大數(shù)據量場景下實際試過的方案最終穩(wěn)定下來的是懶加載加后端緩存。4.5 遞歸深度控制和前端參數(shù)校驗說句容易被忽略的樹接口如果不好好校驗參數(shù)可能被人惡意利用。比如傳入一個特別大的id或者接口無限遞歸查詢會把數(shù)據庫拖垮。后端最好限制查詢深度最多返回三級或五級子節(jié)點超出的部分讓前端繼續(xù)懶加載。另外所有參數(shù)都要做類型轉換防止 SQL 注入。這一點我在安全性審查時被提過一次后來就變成了團隊樹接口的默認要求id必須能轉成整數(shù)否則直接返回空數(shù)組。最后再說兩句實在的寫樹形菜單真正花時間的從來不是那幾行組件配置而是前后端對“節(jié)點”這個概念的認知是否一致。全量加載也好懶加載也罷你要先想清楚數(shù)據從哪來、怎么組裝、展開時能不能找到父節(jié)點、刷新后要不要恢復狀態(tài)。這些細節(jié)理順了jEasyUI 的樹用起來就是順手的事。我個人現(xiàn)在的習慣是普通后臺菜單默認全量加載省心商品類目、組織機構這種層級深或者數(shù)據量大的一律懶加載并且把接口收斂成一個通用的children接口。如果你也正在被樹形菜單折騰不妨先把數(shù)據格式打印出來對照著 id、pid、state 逐項檢查很多時候問題就自己暴露了。