插件ComboTree源碼解析與實(shí)戰(zhàn)開(kāi)發(fā)指南)
簡(jiǎn)介這是一份面向ExtJS前端開(kāi)發(fā)者的自定義組件源碼核心是ComboTree_xz.js用于把傳統(tǒng)下拉框與樹(shù)形結(jié)構(gòu)結(jié)合實(shí)現(xiàn)可展開(kāi)、折疊并選擇層級(jí)數(shù)據(jù)的選擇控件適用于組織結(jié)構(gòu)、地區(qū)分類等需要展示嵌套關(guān)系的業(yè)務(wù)場(chǎng)景。壓縮包內(nèi)共1個(gè)文件為js腳本整體約2KB體量輕巧便于直接引入項(xiàng)目或作為組件擴(kuò)展的學(xué)習(xí)范例。資源圍繞組件定義、樹(shù)形數(shù)據(jù)結(jié)構(gòu)、模板渲染、事件處理、配置項(xiàng)與API等關(guān)鍵環(huán)節(jié)展開(kāi)展示了如何繼承Ext.form.field.ComboBox并重寫(xiě)onRender、onSelect、loadData等方法配合Ext.data.TreeStore與XTemplate完成節(jié)點(diǎn)渲染與交互。已有182人學(xué)習(xí)適合希望深入理解ExtJS組件系統(tǒng)、數(shù)據(jù)綁定機(jī)制并提升企業(yè)級(jí)Web應(yīng)用開(kāi)發(fā)能力的前端工程師參考借鑒。1. 下拉樹(shù)插件一個(gè)被低估的表單交互組件做過(guò)后臺(tái)管理系統(tǒng)的開(kāi)發(fā)者大概都有過(guò)這種體驗(yàn)表單里要選一個(gè)部門、選一個(gè)分類、選一個(gè)區(qū)域數(shù)據(jù)本身是樹(shù)形結(jié)構(gòu)但原生 select 只能平鋪用起來(lái)別扭得很。有人用級(jí)聯(lián)選擇器湊合有人干脆拆成兩個(gè)下拉框聯(lián)動(dòng)代碼寫(xiě)得又臭又長(zhǎng)。ComboTree 這個(gè) Ext 下拉樹(shù)插件就是沖著這個(gè)場(chǎng)景來(lái)的——它把樹(shù)形結(jié)構(gòu)和下拉框揉在一起點(diǎn)開(kāi)是一個(gè)可展開(kāi)的樹(shù)選中后回填到輸入框里既保留了樹(shù)的層級(jí)關(guān)系又有下拉框的緊湊形態(tài)。這份源碼適合正在用 Ext 框架做企業(yè)級(jí)后臺(tái)的開(kāi)發(fā)者尤其是那些被表單樹(shù)形選擇折磨過(guò)的人。拿到手可以直接跑也可以按自己的數(shù)據(jù)結(jié)構(gòu)改配置。2. 技術(shù)選型與目錄結(jié)構(gòu)Ext 體系下的插件設(shè)計(jì)思路2.1 為什么是 Ext 而不是自己造輪子Ext 這套框架在國(guó)內(nèi)企業(yè)后臺(tái)里存活了很長(zhǎng)時(shí)間原因很實(shí)際組件庫(kù)全、表格和表單能力成熟、主題統(tǒng)一。ComboTree 沒(méi)有脫離 Ext 的組件體系另起爐灶而是繼承自Ext.form.field.ComboBox把下拉面板里的列表替換成樹(shù)。這樣做的好處是所有 ComboBox 已有的配置項(xiàng)——值映射、校驗(yàn)、只讀、禁用——都能直接復(fù)用不需要重新發(fā)明一遍。常見(jiàn)做法是繼承Ext.form.field.Picker因?yàn)?Picker 本身就是下拉面板的基類ComboBox 也是從它來(lái)的。繼承 Picker 比繼承 ComboBox 更輕少了很多列表相關(guān)的冗余邏輯。源碼里如果看到extend: Ext.form.field.Picker說(shuō)明作者走的是這條更干凈的路。2.2 目錄結(jié)構(gòu)長(zhǎng)什么樣一份典型的 Ext 插件源碼包目錄不會(huì)太復(fù)雜但每個(gè)文件都有明確分工ComboTree/ ├── src/ │ ├── ComboTree.js # 插件主類繼承 Picker │ └── ComboTree.css # 下拉樹(shù)面板的樣式覆蓋 ├── examples/ │ ├── index.html # 演示頁(yè)面入口 │ ├── app.js # 演示用的 Ext 應(yīng)用啟動(dòng)邏輯 │ └── data/ │ └── tree-data.json # 模擬的樹(shù)形數(shù)據(jù) ├── resources/ │ └── images/ # 展開(kāi)收起圖標(biāo)、選中態(tài)圖標(biāo) ├── build/ # 打包后的壓縮版本 │ └── ComboTree-all.js └── README.mdsrc下是核心examples是能直接打開(kāi)的演示build是給生產(chǎn)環(huán)境用的。如果你只是想在項(xiàng)目里用把build/ComboTree-all.js和ComboTree.css引進(jìn)去就行如果要改源碼就盯src下的兩個(gè)文件。2.3 核心類結(jié)構(gòu)拆解插件主類通常包含這幾個(gè)關(guān)鍵部分Ext.define(Ext.ux.ComboTree, { extend: Ext.form.field.Picker, alias: widget.combotree, // 樹(shù)形數(shù)據(jù)源可以是嵌套數(shù)組或扁平數(shù)組 store: null, // 顯示字段對(duì)應(yīng)樹(shù)節(jié)點(diǎn)的 text displayField: text, // 值字段對(duì)應(yīng)樹(shù)節(jié)點(diǎn)的 id valueField: id, // 是否允許選擇非葉子節(jié)點(diǎn) allowParentSelect: true, // 下拉面板寬度默認(rèn)跟輸入框一致 pickerWidth: null, // 選中后輸入框顯示的內(nèi)容默認(rèn)取 displayField displayTpl: {text}, // 創(chuàng)建下拉面板返回一個(gè) TreePanel createPicker: function() { var me this; return Ext.create(Ext.tree.Panel, { floating: true, hidden: true, focusOnToFront: false, shadow: false, ownerCt: me, store: me.store, rootVisible: false, listeners: { itemclick: me.onTreeItemClick, scope: me } }); }, // 樹(shù)節(jié)點(diǎn)點(diǎn)擊后的回填邏輯 onTreeItemClick: function(view, record) { var me this; if (!me.allowParentSelect record.isLeaf() false) { return; } me.setValue(record.get(me.valueField)); me.setRawValue(record.get(me.displayField)); me.collapse(); me.fireEvent(select, me, record); } });這段代碼是整個(gè)插件的骨架。createPicker負(fù)責(zé)把下拉面板造出來(lái)返回的是一個(gè)Ext.tree.Panel而不是 ComboBox 默認(rèn)的 BoundList。onTreeItemClick處理點(diǎn)擊事件先判斷是否允許選父節(jié)點(diǎn)然后通過(guò)setValue和setRawValue分別設(shè)置值和顯示文本最后收起面板并拋出select事件供外部監(jiān)聽(tīng)。參數(shù)說(shuō)明幾個(gè)容易踩坑的地方rootVisible一般設(shè)為false因?yàn)橄吕瓨?shù)不需要顯示根節(jié)點(diǎn)否則會(huì)多一層無(wú)意義的展開(kāi)focusOnToFront設(shè)為false是為了避免面板搶焦點(diǎn)導(dǎo)致輸入框失焦allowParentSelect這個(gè)配置很關(guān)鍵有些業(yè)務(wù)場(chǎng)景只允許選葉子節(jié)點(diǎn)比如選到“部門”這一層就不能再往下選了這時(shí)候把它設(shè)成false就行。2.4 數(shù)據(jù)源的兩種接法樹(shù)形數(shù)據(jù)源有兩種常見(jiàn)格式插件一般都要支持// 格式一嵌套結(jié)構(gòu)children 里放子節(jié)點(diǎn) var nestedData [{ id: 1, text: 技術(shù)部, children: [{ id: 1-1, text: 前端組, leaf: true }, { id: 1-2, text: 后端組, leaf: true }] }]; // 格式二扁平結(jié)構(gòu)用 parentId 關(guān)聯(lián) var flatData [ { id: 1, text: 技術(shù)部, parentId: null }, { id: 1-1, text: 前端組, parentId: 1, leaf: true }, { id: 1-2, text: 后端組, parentId: 1, leaf: true } ];嵌套結(jié)構(gòu)直接丟給Ext.data.TreeStore就能用扁平結(jié)構(gòu)需要先轉(zhuǎn)成樹(shù)或者用TreeStore的parentIdProperty配置自動(dòng)組裝。我一般會(huì)在store配置里加一個(gè)parentIdProperty: parentId這樣后端返回扁平數(shù)組也能直接渲染省得在前后端之間來(lái)回轉(zhuǎn)換。3. 跑起來(lái)從零搭建一個(gè)可運(yùn)行的演示頁(yè)面3.1 引入依賴和插件文件Ext 項(xiàng)目的入口永遠(yuǎn)是一個(gè) HTML 頁(yè)面。新建一個(gè)index.html按順序引入 Ext 的樣式、Ext 核心庫(kù)、插件源碼!DOCTYPE html html head meta charsetutf-8 titleComboTree 演示/title !-- Ext 框架樣式 -- link relstylesheet hrefext/resources/ext-all.css !-- 插件樣式 -- link relstylesheet hrefsrc/ComboTree.css !-- Ext 核心庫(kù) -- script srcext/ext-all.js/script !-- 插件源碼 -- script srcsrc/ComboTree.js/script /head body div idform-container/div script srcapp.js/script /body /html順序不能亂Ext 核心庫(kù)必須在插件之前加載因?yàn)椴寮顴xt.define依賴 Ext 的類系統(tǒng)。樣式文件放在最前面避免頁(yè)面渲染時(shí)閃一下無(wú)樣式的狀態(tài)。3.2 初始化一個(gè)帶下拉樹(shù)的表單app.js里做三件事定義數(shù)據(jù)、創(chuàng)建 store、把 ComboTree 放進(jìn)表單。Ext.onReady(function() { // 1. 定義樹(shù)形數(shù)據(jù) var treeData [{ id: 1, text: 華東區(qū), expanded: true, children: [{ id: 1-1, text: 上海, leaf: true }, { id: 1-2, text: 杭州, leaf: true }] }, { id: 2, text: 華南區(qū), children: [{ id: 2-1, text: 廣州, leaf: true }, { id: 2-2, text: 深圳, leaf: true }] }]; // 2. 創(chuàng)建 TreeStore var treeStore Ext.create(Ext.data.TreeStore, { root: { expanded: true, children: treeData } }); // 3. 創(chuàng)建表單放入 ComboTree Ext.create(Ext.form.Panel, { renderTo: form-container, title: 區(qū)域選擇, width: 400, bodyPadding: 20, items: [{ xtype: combotree, fieldLabel: 所屬區(qū)域, name: regionId, store: treeStore, displayField: text, valueField: id, allowParentSelect: false, pickerWidth: 300, listeners: { select: function(combo, record) { console.log(選中的節(jié)點(diǎn), record.get(text)); } } }], buttons: [{ text: 提交, handler: function() { var form this.up(form).getForm(); console.log(form.getValues()); } }] }); });xtype: combotree能生效是因?yàn)椴寮惱锒x了alias: widget.combotree。allowParentSelect: false讓“華東區(qū)”“華南區(qū)”這種父節(jié)點(diǎn)不可選只能選到具體的城市。pickerWidth設(shè)成 300 是為了讓下拉面板比輸入框?qū)捯稽c(diǎn)樹(shù)形結(jié)構(gòu)縮進(jìn)后文字不容易被截?cái)唷?.3 驗(yàn)證運(yùn)行結(jié)果打開(kāi)瀏覽器控制臺(tái)如果看到表單渲染出來(lái)點(diǎn)擊下拉箭頭能展開(kāi)樹(shù)點(diǎn)擊“上?!焙筝斎肟蝻@示“上?!笨刂婆_(tái)打印出選中的節(jié)點(diǎn)說(shuō)明整條鏈路是通的。如果下拉面板彈不出來(lái)先檢查createPicker有沒(méi)有返回正確的組件如果樹(shù)節(jié)點(diǎn)點(diǎn)不動(dòng)看itemclick事件有沒(méi)有綁定上如果值回填了但顯示不對(duì)檢查displayField和valueField是否跟數(shù)據(jù)里的字段名對(duì)得上。4. 避坑與排查下拉樹(shù)最容易翻車的幾個(gè)地方4.1 下拉面板位置偏移或?qū)挾犬惓,F(xiàn)象點(diǎn)擊輸入框后樹(shù)面板彈出來(lái)了但位置偏到屏幕左上角或者寬度只有幾個(gè)像素。原因Ext 的 Picker 在alignPicker階段會(huì)讀取輸入框的寬度和位置如果輸入框還沒(méi)完成布局比如放在hidden的容器里拿到的尺寸就是 0。解決確保 ComboTree 在可見(jiàn)容器里初始化或者手動(dòng)調(diào)一次me.alignPicker()。如果寬度還是不對(duì)顯式設(shè)置pickerWidth別依賴自動(dòng)計(jì)算。4.2 選中父節(jié)點(diǎn)后值能取到但顯示為空現(xiàn)象allowParentSelect: true時(shí)選了父節(jié)點(diǎn)getValue()能拿到 id但輸入框里什么都不顯示。原因setRawValue用的字段跟數(shù)據(jù)里的字段名不一致或者父節(jié)點(diǎn)的displayField字段是空的。解決檢查數(shù)據(jù)里每個(gè)節(jié)點(diǎn)是否都有text字段父節(jié)點(diǎn)也不例外。如果后端返回的字段叫name就把displayField改成name別硬套默認(rèn)值。4.3 樹(shù)數(shù)據(jù)量大時(shí)下拉卡頓現(xiàn)象樹(shù)節(jié)點(diǎn)超過(guò)幾百個(gè)點(diǎn)開(kāi)下拉要等一兩秒才渲染出來(lái)。原因Ext.tree.Panel默認(rèn)一次性渲染所有可見(jiàn)節(jié)點(diǎn)數(shù)據(jù)量大時(shí) DOM 操作很重。解決給 TreeStore 開(kāi)啟bufferedRenderer或者把rootVisible設(shè)為false減少一層渲染。更徹底的做法是讓后端分頁(yè)返回子節(jié)點(diǎn)點(diǎn)開(kāi)父節(jié)點(diǎn)時(shí)再異步加載。4.4 表單重置后下拉樹(shù)的值沒(méi)清掉現(xiàn)象調(diào)用form.reset()后輸入框清空了但再次點(diǎn)開(kāi)下拉之前選中的節(jié)點(diǎn)還是高亮狀態(tài)。原因reset只重置了表單字段的值沒(méi)有清除樹(shù)面板里的選中狀態(tài)。解決在 ComboTree 里重寫(xiě)reset方法除了調(diào)用父類邏輯還要拿到 picker 里的 selection model執(zhí)行deselectAll()。4.5 在 Ext 6 版本里報(bào)createPicker未定義現(xiàn)象升級(jí) Ext 版本后插件直接報(bào)錯(cuò)說(shuō)createPicker不是函數(shù)。原因Ext 6 之后 Picker 的 API 有調(diào)整部分方法簽名變了。解決對(duì)照當(dāng)前 Ext 版本的Ext.form.field.Picker文檔確認(rèn)createPicker的返回值要求。常見(jiàn)做法是返回一個(gè)帶floating: true的組件如果新版要求返回Ext.Component的子類把 TreePanel 包一層就行。5. 二次開(kāi)發(fā)把下拉樹(shù)改成你要的樣子5.1 支持多選和標(biāo)簽回填默認(rèn)的 ComboTree 是單選但業(yè)務(wù)里經(jīng)常需要多選比如給一個(gè)角色分配多個(gè)區(qū)域權(quán)限。改造思路是給 TreePanel 加上multiSelect: true然后在onTreeItemClick里收集所有選中節(jié)點(diǎn)用分隔符拼成字符串回填到輸入框。onTreeItemClick: function(view, record) { var me this; if (!me.multiSelect) { // 單選邏輯不變 me.setValue(record.get(me.valueField)); me.setRawValue(record.get(me.displayField)); me.collapse(); return; } // 多選收集當(dāng)前所有選中節(jié)點(diǎn) var selected view.getSelectionModel().getSelection(); var values [], texts []; Ext.each(selected, function(item) { values.push(item.get(me.valueField)); texts.push(item.get(me.displayField)); }); me.setValue(values.join(,)); me.setRawValue(texts.join(, )); // 多選時(shí)不自動(dòng)收起讓用戶繼續(xù)點(diǎn) }注意多選模式下不要調(diào)collapse()否則點(diǎn)一個(gè)就收起來(lái)根本沒(méi)法連續(xù)選。另外setValue傳的是逗號(hào)拼接的字符串后端接收時(shí)按逗號(hào)拆開(kāi)就行。如果后端要求數(shù)組把values.join(,)改成values即可。5.2 異步加載子節(jié)點(diǎn)樹(shù)數(shù)據(jù)量大的時(shí)候一次性加載全量數(shù)據(jù)不現(xiàn)實(shí)。改造 TreeStore讓它在展開(kāi)節(jié)點(diǎn)時(shí)再去請(qǐng)求子節(jié)點(diǎn)var asyncStore Ext.create(Ext.data.TreeStore, { proxy: { type: ajax, url: /api/region/children, reader: { type: json, rootProperty: data } }, root: { id: root, text: 全部區(qū)域, expanded: false }, lazyFill: true });后端接口接收node參數(shù)當(dāng)前展開(kāi)的節(jié)點(diǎn) id返回該節(jié)點(diǎn)的直接子節(jié)點(diǎn)列表。lazyFill: true讓 TreeStore 在節(jié)點(diǎn)展開(kāi)時(shí)才發(fā)請(qǐng)求而不是初始化時(shí)就把整棵樹(shù)拉下來(lái)。這個(gè)改造對(duì)后端有要求接口要支持按 parentId 查詢返回的數(shù)據(jù)里每個(gè)節(jié)點(diǎn)要帶leaf字段標(biāo)識(shí)是否還有下級(jí)。5.3 自定義節(jié)點(diǎn)圖標(biāo)和選中樣式Ext 的樹(shù)節(jié)點(diǎn)圖標(biāo)通過(guò)iconCls控制可以在數(shù)據(jù)里給每個(gè)節(jié)點(diǎn)指定不同的 class{ id: 1-1, text: 上海, leaf: true, iconCls: icon-city // 對(duì)應(yīng) CSS 里的 .icon-city }然后在ComboTree.css里定義.icon-city { background-image: url(../resources/images/city.png); background-repeat: no-repeat; }選中態(tài)的樣式覆蓋要注意優(yōu)先級(jí)Ext 默認(rèn)的.x-grid-item-selected權(quán)重比較高自定義樣式最好加上.x-treepanel前綴或者用!important兜底。我一般會(huì)在 CSS 里寫(xiě).x-treepanel .x-grid-item-selected { background-color: #e6f7ff; }這樣只影響下拉樹(shù)面板不會(huì)污染其他表格組件。5.4 一個(gè)驗(yàn)證改造是否生效的小技巧改完源碼后別急著刷新整個(gè)頁(yè)面。在瀏覽器控制臺(tái)里拿到 ComboTree 的實(shí)例直接調(diào)方法驗(yàn)證// 假設(shè)表單里 combotree 的 name 是 regionId var combo Ext.ComponentQuery.query(combotree[nameregionId])[0]; // 手動(dòng)設(shè)置值看顯示是否正確 combo.setValue(1-1); // 展開(kāi)下拉看樹(shù)面板是否正常 combo.expand(); // 打印當(dāng)前選中項(xiàng) console.log(combo.getPicker().getSelectionModel().getSelection());這樣能快速定位是數(shù)據(jù)問(wèn)題、渲染問(wèn)題還是事件綁定問(wèn)題。從那以后我每次改 Ext 插件都強(qiáng)制走一遍「控制臺(tái)拿實(shí)例 → 手動(dòng)調(diào)方法 → 看返回值」的流程比反復(fù)刷新頁(yè)面猜問(wèn)題快得多。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取