戰(zhàn):表單樹形選擇與懶加載避坑指南)
簡介這是一份面向ExtJS前端開發(fā)者的自定義組件源碼核心是ComboTree下拉樹插件用于解決下拉框中展示層級數(shù)據(jù)的選擇需求如組織結(jié)構(gòu)、地區(qū)分類等場景。資源包共1個(gè)文件為js腳本壓縮包約2KB體量輕巧便于直接引入項(xiàng)目或?qū)φ臻喿x。插件通過繼承Ext.form.field.ComboBox并重寫onRender、onSelect、loadData等方法將傳統(tǒng)下拉列表擴(kuò)展為可展開折疊的樹形結(jié)構(gòu)涉及TreeStore與TreeModel的數(shù)據(jù)組織、XTemplate節(jié)點(diǎn)渲染、展開折疊事件處理以及默認(rèn)展開級別、多選策略等配置項(xiàng)與公共API設(shè)計(jì)。已有182人學(xué)習(xí)適合希望深入理解ExtJS組件系統(tǒng)、數(shù)據(jù)綁定機(jī)制與自定義組件擴(kuò)展思路的中高級開發(fā)者參考可幫助快速掌握樹形下拉選擇的實(shí)現(xiàn)要點(diǎn)并復(fù)用到企業(yè)級Web應(yīng)用中。1. 從一次表單卡頓說起這個(gè) Ext 下拉樹插件到底能干什么去年幫一個(gè)做后臺(tái)管理系統(tǒng)的朋友看問題他們有個(gè)組織架構(gòu)選擇器用的是 ExtJS 的 ComboBox 嵌套 TreePanel 的方案數(shù)據(jù)量一上來點(diǎn)開下拉框要等兩三秒選完節(jié)點(diǎn)回填還偶爾丟值。翻代碼發(fā)現(xiàn)是每次展開都重新構(gòu)建樹、沒有做節(jié)點(diǎn)緩存父子節(jié)點(diǎn)聯(lián)動(dòng)也是手寫的遞歸。這類需求其實(shí)很典型表單里要選一個(gè)樹形結(jié)構(gòu)中的節(jié)點(diǎn)比如部門、分類、區(qū)域、菜單權(quán)限用普通下拉框表達(dá)不了層級用獨(dú)立彈窗又太重。ComboTree 就是干這個(gè)的——把 Ext 的 Tree 塞進(jìn) ComboBox 的觸發(fā)框里點(diǎn)一下彈出樹選完自動(dòng)回填顯示值。這份「Ext 下拉樹插件_ComboTree_xz」是一份可直接嵌入 ExtJS 項(xiàng)目的源碼工具包適合正在維護(hù) Ext 老系統(tǒng)、又不想重寫整個(gè)前端棧的開發(fā)者。它解決的不是炫技問題而是「表單里怎么優(yōu)雅地選一個(gè)樹節(jié)點(diǎn)」這個(gè)具體痛點(diǎn)。2. 拆開 ComboTree它繼承了什么、擴(kuò)展了什么2.1 從 Ext.form.field.ComboBox 到樹形選擇器ExtJS 原生的 ComboBox 支持 store、displayField、valueField 這套配置但它的下拉列表本質(zhì)是一個(gè) BoundList渲染的是平鋪的 item。ComboTree 的思路不是重寫一個(gè)組件而是繼承 ComboBox把它的 picker 替換成 TreePanel。這樣做的好處是表單字段的 API 完全保留——getValue、setValue、reset、isValid、markInvalid 這些方法不用重新實(shí)現(xiàn)校驗(yàn)邏輯、表單提交時(shí)的取值方式也不變。換句話說你在 FormPanel 里原來怎么用 combo換成 ComboTree 之后大部分代碼不用動(dòng)。關(guān)鍵改動(dòng)集中在三處。第一重寫 getPicker 或者用 createPicker 鉤子返回一個(gè) Ext.tree.Panel 實(shí)例而不是默認(rèn)的 BoundList。第二處理 picker 的顯示與隱藏時(shí)機(jī)——ComboBox 默認(rèn)在 blur 時(shí)收起但樹節(jié)點(diǎn)展開、勾選這些交互會(huì)觸發(fā) blur需要額外判斷焦點(diǎn)是否真的離開了組件。第三選中節(jié)點(diǎn)后要把節(jié)點(diǎn)的顯示文本寫回輸入框同時(shí)把節(jié)點(diǎn)的 id 或自定義字段作為 value 存起來這一步通常通過監(jiān)聽 tree 的 itemclick 或 checkchange 事件完成。我一般會(huì)先確認(rèn)項(xiàng)目里 Ext 的版本因?yàn)?4.x、5.x、6.x 在 picker 的創(chuàng)建流程和事件名上有差異。這份源碼包如果沒標(biāo)注版本最穩(wěn)妥的做法是先在隔離頁面里跑一個(gè)最小實(shí)例確認(rèn)能彈出樹、能回填值再往業(yè)務(wù)表單里嵌。2.2 源碼包的結(jié)構(gòu)與關(guān)鍵文件拿到一個(gè)插件包我習(xí)慣先看目錄再動(dòng)手。典型的 ComboTree 源碼包大致是這幾類文件文件/目錄作用關(guān)注點(diǎn)ComboTree.js組件主文件繼承關(guān)系、picker 創(chuàng)建邏輯ComboTree.css樣式下拉樹寬度、滾動(dòng)條、層級縮進(jìn)examples/示例頁最快驗(yàn)證能否跑通resources/圖標(biāo)、箭頭圖路徑是否要按項(xiàng)目調(diào)整README 或注釋頭用法說明配置項(xiàng)、事件、依賴版本主文件里我會(huì)重點(diǎn)看幾個(gè)地方extend指向的是哪個(gè)類alias注冊的 xtype 是什么決定了你在 items 里怎么寫initComponent里對 tree 的 store 是怎么接的以及有沒有對onBlur、collapse做特殊處理。這些決定了你能不能把它和自己的數(shù)據(jù)源、表單校驗(yàn)順暢對接。提示如果源碼里用了Ext.create(Ext.tree.Panel, {...})這種硬編碼而你的項(xiàng)目做了按需加載或用了自定義的類加載器可能需要把依賴顯式聲明到requires里否則會(huì)出現(xiàn)「樹彈不出來但也不報(bào)錯(cuò)」的玄學(xué)現(xiàn)象。2.3 最小可運(yùn)行示例先跑通再談集成不要一上來就往業(yè)務(wù)代碼里塞。新建一個(gè)空白 HTML引入 Ext 的 css 和 js再引入 ComboTree 的源碼寫一個(gè)最小實(shí)例Ext.onReady(function () { // 構(gòu)造一個(gè)簡單的樹形 store真實(shí)項(xiàng)目里通常換成 TreeStore proxy var treeStore Ext.create(Ext.data.TreeStore, { root: { expanded: true, children: [ { id: dept-1, text: 技術(shù)中心, leaf: false, children: [ { id: dept-1-1, text: 前端組, leaf: true }, { id: dept-1-2, text: 后端組, leaf: true } ]}, { id: dept-2, text: 產(chǎn)品中心, leaf: false, children: [ { id: dept-2-1, text: 設(shè)計(jì)組, leaf: true } ]} ] } }); Ext.create(Ext.form.Panel, { renderTo: Ext.getBody(), width: 400, bodyPadding: 20, items: [{ // xtype 以源碼里 alias 注冊的為準(zhǔn)常見為 combotree xtype: combotree, fieldLabel: 所屬部門, name: deptId, displayField: text, valueField: id, store: treeStore, // 只允許選葉子節(jié)點(diǎn)避免選到中間層級導(dǎo)致業(yè)務(wù)歧義 allowOnlyLeaf: true, editable: false, queryMode: local }] }); });這段代碼的邏輯說明treeStore用靜態(tài)數(shù)據(jù)模擬后端返回的層級結(jié)構(gòu)真實(shí)場景換成帶proxy的 TreeStore 即可。xtype是插件注冊的別名如果跑起來報(bào)「無法識別的 xtype」說明 alias 不是這個(gè)值去主文件里搜a(bǔ)lias確認(rèn)。allowOnlyLeaf是這類插件常見的配置項(xiàng)用來限制只能選葉子節(jié)點(diǎn)——很多業(yè)務(wù)里選一個(gè)中間部門是沒有意義的這個(gè)參數(shù)能省掉一層校驗(yàn)。editable: false防止用戶手輸不存在的節(jié)點(diǎn)文本queryMode: local表示數(shù)據(jù)已在本地不需要遠(yuǎn)程過濾。參數(shù)怎么改displayField和valueField必須和你的樹節(jié)點(diǎn)數(shù)據(jù)字段對應(yīng)后端返回name/code就改成name/code。如果樹數(shù)據(jù)量大queryMode保持 local 但要做懶加載把leaf: false的節(jié)點(diǎn)配上children為空讓 TreeStore 在展開時(shí)再去請求。3. 接進(jìn)真實(shí)表單數(shù)據(jù)源、回填與校驗(yàn)怎么處理3.1 異步 TreeStore 與節(jié)點(diǎn)懶加載靜態(tài)數(shù)據(jù)跑通之后下一步是把 store 換成異步的。Ext 的 TreeStore 支持proxy配置展開未加載的節(jié)點(diǎn)時(shí)自動(dòng)發(fā)請求。常見做法是var asyncTreeStore Ext.create(Ext.data.TreeStore, { proxy: { type: ajax, url: /api/dept/tree, reader: { type: json, // 后端返回的根節(jié)點(diǎn)數(shù)組字段名按實(shí)際接口調(diào)整 rootProperty: data } }, // 根節(jié)點(diǎn)默認(rèn)展開觸發(fā)第一次請求 root: { id: root, text: 全部部門, expanded: true }, // 關(guān)鍵讓未加載的節(jié)點(diǎn)顯示為可展開點(diǎn)擊時(shí)才請求子節(jié)點(diǎn) lazyFill: true });邏輯說明lazyFill: true是懶加載的核心它讓沒有children的節(jié)點(diǎn)仍然顯示展開箭頭用戶點(diǎn)擊時(shí)才通過 proxy 拉取子節(jié)點(diǎn)。rootProperty要和后端返回結(jié)構(gòu)對齊如果后端直接返回?cái)?shù)組就寫rootProperty: 或者去掉 reader 配置。參數(shù)上url通常需要帶上父節(jié)點(diǎn) idExt 默認(rèn)會(huì)把節(jié)點(diǎn) id 作為參數(shù)傳過去后端按node參數(shù)接收即可。這里有個(gè)容易翻車的地方如果后端一次性返回整棵樹就不要開懶加載否則會(huì)出現(xiàn)「展開后子節(jié)點(diǎn)重復(fù)」或者「明明有數(shù)據(jù)卻顯示為空」。判斷標(biāo)準(zhǔn)很簡單——接口是否支持按父節(jié)點(diǎn)查詢。支持就懶加載不支持就一次性加載后本地展開。3.2 選中回填與表單取值選中節(jié)點(diǎn)后插件要把文本寫進(jìn)輸入框、把值存進(jìn)表單字段。大部分 ComboTree 實(shí)現(xiàn)會(huì)監(jiān)聽樹的itemclick事件然后調(diào)用setValue。但實(shí)際項(xiàng)目里經(jīng)常遇到兩個(gè)問題一是編輯態(tài)回填時(shí)只給了 value 沒給文本輸入框顯示的是 id 而不是名稱二是多選場景下值的拼接格式不對。編輯態(tài)回填的常見做法是在表單加載數(shù)據(jù)后手動(dòng)根據(jù) value 去樹里找對應(yīng)節(jié)點(diǎn)把文本補(bǔ)上。如果樹是懶加載的節(jié)點(diǎn)可能還沒加載出來這時(shí)候要么讓后端在詳情接口里直接返回節(jié)點(diǎn)名稱要么在 setValue 之前先展開到目標(biāo)節(jié)點(diǎn)。我一般傾向于讓后端返回名稱前端少做一次樹遍歷穩(wěn)定得多。取值方面單選時(shí)getValue()返回節(jié)點(diǎn) id多選時(shí)返回?cái)?shù)組或逗號拼接的字符串取決于插件實(shí)現(xiàn)。提交前最好在表單的beforeaction或者提交回調(diào)里打印一次getValue()的結(jié)果確認(rèn)格式和接口約定一致。這個(gè)動(dòng)作花不了兩分鐘但能省掉后端聯(lián)調(diào)時(shí)來回扯皮的時(shí)間。3.3 表單校驗(yàn)與只讀態(tài)處理ComboTree 繼承了 ComboBox所以allowBlank: false、vtype這些校驗(yàn)配置理論上都能用。但要注意如果用戶展開了樹但沒點(diǎn)任何節(jié)點(diǎn)就收起字段值仍然是空的校驗(yàn)會(huì)正常攔截。真正容易出問題的是「值有但文本為空」的狀態(tài)——比如通過setValue只設(shè)了 id輸入框看起來是空的用戶以為沒選實(shí)際上表單提交時(shí)又帶上了值。這種不一致會(huì)讓測試同學(xué)提 bug。處理辦法是在setValue之后同步設(shè)置原始值setRawValue或者重寫setValue讓它在設(shè)值的同時(shí)去 store 里查文本。只讀態(tài)則通過readOnly: true控制但要注意只讀時(shí)下拉箭頭是否還允許點(diǎn)擊——有些實(shí)現(xiàn)只禁用了輸入框箭頭還能彈出樹這在「查看詳情」頁面里是不合適的需要額外配置hideTrigger或者禁用整個(gè)組件。注意如果表單里有多個(gè) ComboTree且它們共用同一個(gè) TreeStore 實(shí)例選中一個(gè)節(jié)點(diǎn)可能會(huì)影響另一個(gè)的展開狀態(tài)。穩(wěn)妥做法是每個(gè)字段獨(dú)立創(chuàng)建 store或者確認(rèn)插件內(nèi)部對 store 做了隔離。4. 避坑與排查那些讓我加班到深夜的細(xì)節(jié)4.1 下拉樹彈不出來控制臺(tái)也不報(bào)錯(cuò)現(xiàn)象點(diǎn)擊下拉箭頭沒反應(yīng)或者樹閃一下就消失控制臺(tái)干凈得像什么都沒發(fā)生。原因最常見的是 picker 創(chuàng)建后被立即隱藏。ComboBox 的 blur 邏輯和樹的點(diǎn)擊事件沖突點(diǎn)擊樹節(jié)點(diǎn)時(shí)組件先觸發(fā)了 blur把 picker 收起來了。另一個(gè)可能是 z-index 被頁面其他浮層蓋住視覺上「沒彈出來」。解決在主文件里找onBlur或collapse相關(guān)的處理看有沒有對焦點(diǎn)做延遲判斷。如果是 z-index 問題給 picker 加一個(gè)足夠大的zIndex配置或者在 css 里提高.x-tree-panel的層級。我一般會(huì)先在瀏覽器里選中 picker 的 dom 節(jié)點(diǎn)看它的 display 和 z-index 實(shí)際值比盲猜快。4.2 編輯態(tài)回填只顯示 id 不顯示名稱現(xiàn)象新增沒問題編輯時(shí)輸入框里是一串?dāng)?shù)字或編碼不是節(jié)點(diǎn)名稱。原因setValue只設(shè)置了 valueField 對應(yīng)的值沒有同步 rawValue。樹節(jié)點(diǎn)在懶加載模式下可能還沒加載到目標(biāo)節(jié)點(diǎn)插件找不到對應(yīng)文本。解決優(yōu)先讓后端詳情接口返回節(jié)點(diǎn)名稱前端在表單賦值時(shí)同時(shí)調(diào)用setValue(id)和setRawValue(name)。如果只能拿到 id就在 store 的load事件里做一次查找找到后補(bǔ)設(shè) rawValue。懶加載場景下可以先用一個(gè)臨時(shí)文本占位等節(jié)點(diǎn)加載后再更新。4.3 多選時(shí)值格式與后端對不上現(xiàn)象前端多選了三項(xiàng)提交上去后端只收到一項(xiàng)或者收到一個(gè)帶方括號的字符串。原因不同 ComboTree 實(shí)現(xiàn)對多選值的處理不一樣有的返回?cái)?shù)組有的返回逗號拼接字符串有的在getSubmitValue里做了額外處理。解決在表單提交前打印getValue()和getSubmitValue()兩個(gè)結(jié)果確認(rèn)實(shí)際格式。如果和接口約定不符重寫getSubmitValue做轉(zhuǎn)換。別在提交后才去抓包猜前端能打印的就在前端解決。4.4 樹節(jié)點(diǎn)展開后重復(fù)加載或數(shù)據(jù)錯(cuò)亂現(xiàn)象同一個(gè)節(jié)點(diǎn)展開兩次子節(jié)點(diǎn)出現(xiàn)兩份或者展開 A 節(jié)點(diǎn)卻顯示了 B 節(jié)點(diǎn)的子級。原因懶加載時(shí) proxy 的請求參數(shù)沒有正確攜帶父節(jié)點(diǎn)標(biāo)識或者后端緩存了錯(cuò)誤的響應(yīng)。也有可能是 store 被多個(gè)組件共用加載狀態(tài)互相干擾。解決檢查請求 URL 里是否帶了node參數(shù)后端是否按這個(gè)參數(shù)過濾。store 共用問題就拆成獨(dú)立實(shí)例。如果后端返回的數(shù)據(jù)里id有重復(fù)也會(huì)導(dǎo)致樹結(jié)構(gòu)錯(cuò)亂這個(gè)要在接口層排查。4.5 樣式錯(cuò)位下拉樹寬度和輸入框?qū)Σ积R現(xiàn)象樹彈出來后比輸入框?qū)捄芏嗷蛘芏酀L動(dòng)條位置奇怪層級縮進(jìn)看不清楚。原因picker 的寬度默認(rèn)可能取了 body 寬度或者固定值沒有跟隨組件寬度。css 文件沒引入或者被項(xiàng)目樣式覆蓋。解決給組件配置matchFieldWidth: true如果插件支持或者在 css 里顯式設(shè)置 picker 的min-width和max-width。層級縮進(jìn)靠的是 tree 的indent配置默認(rèn)值在不同 Ext 版本里不一樣按視覺需要調(diào)整。5. 進(jìn)階把 ComboTree 用得更穩(wěn)的幾個(gè)習(xí)慣5.1 封裝一層業(yè)務(wù)組件別在頁面里裸用直接在頁面 items 里寫xtype: combotree能跑但項(xiàng)目里一旦有十幾個(gè)地方用到配置就會(huì)散得到處都是。我的習(xí)慣是再包一層把常用的配置固化下來Ext.define(App.form.field.DeptSelector, { extend: Ext.form.field.ComboBox, // 實(shí)際應(yīng)繼承 ComboTree 的類 alias: widget.deptselector, // 固定業(yè)務(wù)語義只選葉子、不可編輯、必填 allowOnlyLeaf: true, editable: false, allowBlank: false, fieldLabel: 所屬部門, // 統(tǒng)一在這里創(chuàng)建獨(dú)立的 TreeStore避免共用 initComponent: function () { this.store Ext.create(Ext.data.TreeStore, { proxy: { type: ajax, url: /api/dept/tree }, root: { id: root, text: 全部部門, expanded: true }, lazyFill: true }); this.callParent(arguments); } });這樣頁面里只寫xtype: deptselector字段名、校驗(yàn)、數(shù)據(jù)源都在一處維護(hù)。改接口地址或者調(diào)整交互時(shí)只動(dòng)一個(gè)文件。參數(shù)上allowBlank按業(yè)務(wù)定如果部門是必填就設(shè) falsefieldLabel可以暴露成配置項(xiàng)讓調(diào)用方覆蓋。5.2 用最小用例做回歸別靠記憶Ext 這類老框架的插件最怕的是「上次能用這次不能用」。我的做法是在項(xiàng)目里留一個(gè)dev/combotree-test.html里面放三個(gè)用例靜態(tài)數(shù)據(jù)單選、異步懶加載單選、多選回填。每次升級 Ext 版本或者改動(dòng)插件源碼后先跑這三個(gè)用例通過了再往業(yè)務(wù)里合。這個(gè)頁面不參與打包純粹是開發(fā)期的后悔藥。5.3 版本與依賴的確認(rèn)清單在把這份源碼包引入項(xiàng)目之前我會(huì)過一遍這幾個(gè)點(diǎn)Ext 版本是 4 還是 5/6插件的繼承鏈?zhǔn)欠衿ヅ漤?xiàng)目是否用了 Sencha Cmd 或自定義加載器需不需要在requires里聲明依賴css 文件是否已引入路徑對不對示例頁里的 xtype 和配置項(xiàng)是否和主文件一致。這幾項(xiàng)確認(rèn)完基本能排除八成「跑不起來」的問題。從那以后我每次拿到這類 Ext 插件包都強(qiáng)制先跑最小用例、再確認(rèn)版本依賴、最后才動(dòng)業(yè)務(wù)代碼。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取