提到ExtJS,很多前端老手的第一反应是“老牌企业级UI框架”。它从Ext 2时代一路火到4.x,再到现在的6/7,虽然不像React、Vue那样天天上热搜,但在后台管理系统、数据中台、内部ERP这类需要大量表格、表单和树形结构的场景里,依然有一批忠实用户。这篇文章不打算照着官网文档Ctrl+C,而是从一个实际做项目的角度,把ExtJS组件怎么用、怎么搭、怎么避开日常坑讲清楚。适合刚上手ExtJS的初学者,也适合被临时派去维护老ExtJS项目的同学参考。
1. 先理解ExtJS的组件世界观
1.1 组件不是“控件”,而是一棵组件树
我在刚接触ExtJS时踩过最深的坑,就是把它当成像<input>、<button>那样的独立控件来用。其实ExtJS里的“组件”概念比HTML控件大得多:一个按钮是组件,一个表格是组件,一个窗口是组件,一个表单也是组件,甚至整个页面布局都可以是一个组件。
这些组件不是散落各处的零散元素,而是按照层级关系组织成一棵“组件树”。比如一个Ext.panel.Panel面板里可以放一个Ext.grid.Panel表格,表格里又可以放一列Ext.button.Button操作按钮。父组件负责管理子组件的创建、渲染、布局和销毁,子组件通过xtype定义自己的类型。这种树形结构和浏览器里的DOM树非常像,区别在于ExtJS在DOM之上又封装了一层组件管理逻辑。
理解这棵树很重要,因为后面所有布局、查找、通信、销毁都是基于这棵树的。拿到一个ExtJS页面,先别急着看代码逻辑,先画一下组件树:最外层是什么容器,里面分了几块,每块里又嵌套了什么。这样排查问题会快很多,尤其是处理“某个组件不显示”的时候,十有八九是组件树某个节点放错位置。
1.2 组件的生命周期:从创建到销毁
组件从创建到页面消失,经历的过程远比想象中复杂。简化来看,大概是这样一条线:
- 创建实例:通过
Ext.create或者容器items配置创建,这时组件对象已经存在,但还没有HTML。 - 初始化:调用
initComponent等方法,完成属性合并、子组件创建。 - 渲染:把组件挂到页面上,生成DOM节点,触发
render、afterrender事件。 - 运行:用户操作,数据进行各种交互。
- 销毁:调用
destroy方法,移除DOM、解绑监听、销毁子组件。
这里面最关键的一点是:组件对象存在不等于渲染完成。很多新手在launch里直接Ext.create一个组件,然后立刻去操作它的DOM,结果拿到的是null。正确做法是等afterrender事件触发后再操作,或者通过renderTo明确指定渲染位置。
我踩过的一个典型的坑是:在beforerender里访问组件的高度和宽度,拿到的全是0。因为此时还没有生成DOM,布局引擎还没有跑过。如果有类似需求,应该监听boxready事件,这时候布局已经完成,能拿到真实的宽高。
1.3 为什么要用xtype,而不是疯狂new
xtype可以理解为组件类型的字符串别名。Ext.panel.Panel对应'panel',Ext.grid.Panel对应'grid',Ext.form.field.Text对应'textfield'。在容器配置里,你可以写{ xtype: 'panel', title: '面板' },也可以写Ext.create('Ext.panel.Panel', {...})。
很多人不理解为什么要多一个xtype。直接new不好吗?不好。第一,xtype写法简洁,尤其在items数组写一堆组件时,一眼能看清组件类型。第二,容器在创建子组件时会延迟实例化,也就是说组件的new操作真正发生在容器初始化时,而不是写配置时,这样可以避免一些不必要的内存占用。第三,后续配合Ext.ComponentQuery查询时,'panel'这类xtype可以直接当选择器用,比如down('grid')查表格组件,写起来非常自然。
我在实际项目里推荐遵循一个原则:所有静态配置的组件一律用xtype写在items里,只有需要动态创建、或者需要在创建后立即调用方法时才用Ext.create。这样代码结构会清晰很多,可维护性也高。
2. 环境准备与第一个能跑的组件页面
2.1 引入ExtJS的两种方式
先说环境。ExtJS不是npm包那么简单,它自带一套样式系统和几十个JS文件。传统开发方式很简单:下载ExtJS正式包后,在页面里引入CSS和核心JS文件即可。
以ExtJS 6.x为例,目录结构大概是:
<link rel="stylesheet" href="ext/build/classic/theme-classic/resources/theme-classic-all.css"> <script src="ext/build/ext-all.js"></script>ext-all.js是打包好的完整框架核心,内容很多,加载会慢一点。如果项目很重,也可以用ext-all-debug.js方便调试,但正式环境一定记得切回压缩版。
另一种方式是使用Sencha Cmd来搭建工程,自动管理依赖编译,还能生成优化的版本。但对初学者来说,用Sencha Cmd会瞬间淹没在构建工具的各种概念里,反而不容易理解组件本身。我的建议是:第一周先直接用静态页面跑通组件,等理解组件树和布局了,再考虑工程化工具。
2.2 最小的Ext.application配置
我用得最多的入口是Ext.application,它负责初始化整个应用。一个最简单的配置长这样:
Ext.application({ name: 'DemoApp', launch: function() { Ext.create('Ext.panel.Panel', { title: '第一个ExtJS页面', width: 400, height: 200, html: '<p>Hello ExtJS</p>', renderTo: Ext.getBody() }); } });name是应用命名空间,随便取。launch会在ExtJS框架加载完成、DOM准备好之后执行。在这里创建组件最为安全。
注意那个renderTo: Ext.getBody(),它的意思是把组件直接渲染到body上。如果你不写这一点,组件只是存在于内存中,页面上什么都看不到。很多新手第一次试跑,页面白屏,多半就是漏了这一行。
2.3 页面挂载与加载顺序的细节
在真实项目中,很少会把组件直接render到body,更多是放到容器或Viewport里。但了解renderTo依然很重要,因为它帮助你理解组件的“挂载”行为。
挂载涉及一个核心概念:一个组件只能有一个父容器。你不能把一个组件同时renderTo到两个地方,也不能把它放到一个已经渲染完成的父容器里,然后又手动调用render。所以动态添加组件时,推荐用container.add(comp)再container.updateLayout(),而不是尝试手动改DOM。
如果页面加载后发现样式不对,优先检查CSS有没有引入。ExtJS组件极度依赖主题CSS,少了主题文件,组件会以最原始的HTML裸奔,看起来就像样式全部丢了。这个问题我在引入新版本主题时遇到过,升级ExtJS版本时,旧的主题CSS不能直接沿用,必须换成配套版本。
3. 日常工作绕不开的核心组件
3.1 Button:事件绑定的最小范例
按钮是最简单的组件,却是学习事件机制最好的入口。看这段代码:
Ext.create('Ext.Button', { text: '点我保存', iconCls: 'x-fa fa-save', handler: function(btn, event) { Ext.Msg.alert('提示', '你点击了保存按钮'); } });handler是点击事件的简写,适用于“只关心点击后做什么”的场景。如果除了点击,还想监听mouseover、mouseout、focus等状态,可以用listeners配置:
listeners: { click: function(btn) { console.log('clicked'); }, mouseover: function() { console.log('mouse over'); } }这里要注意一个细节:handler本质上是listeners里click的快捷方式,如果同时配置handler和listeners.click,handler会先执行,再执行listeners里的click。虽然不冲突,但混乱,我一般只选一种。
3.2 Panel:几乎所有组件的容器
如果ExtJS只能记住一个组件,我选Panel。它自带标题栏、底部工具栏、侧边工具栏,还可以嵌套各种组件。一个典型的Panel配置是这样的:
Ext.create('Ext.panel.Panel', { title: '用户管理', width: 600, height: 400, collapsible: true, tbar: [ { text: '新增', handler: function() {} }, { text: '删除', handler: function() {} } ], items: [ { xtype: 'grid', ... } ], renderTo: Ext.getBody() });tbar是顶部工具栏,bbar是底部工具栏,这两个属性在后台管理页面中太常用了。collapsible允许用户折叠面板,节省空间。
使用Panel时要注意,如果Panel的items里放了多个组件,必须设置layout,否则这些子组件会全部叠在一起,效果非常难看。关于布局在第4章专门讲。
3.3 Grid:最常打交道的表格组件
后台项目里,表格是绝对的主角。ExtJS的Grid组件功能强大,但结构也比按钮复杂。一个最简单的Grid需要两部分:数据仓库Store和列配置。
Ext.create('Ext.grid.Panel', { title: '订单列表', store: { fields: ['id', 'status', 'amount'], data: [ { id: 1, status: '已支付', amount: 199.00 }, { id: 2, status: '待发货', amount: 299.00 } ] }, columns: [ { text: '订单ID', dataIndex: 'id', width: 80 }, { text: '状态', dataIndex: 'status', flex: 1 }, { text: '金额', dataIndex: 'amount', flex: 1 } ], renderTo: Ext.getBody() });store是数据容器,fields定义字段结构,data可以直接给本地数据,也可以配置proxy从后台API读取。columns里的dataIndex必须和fields里的字段名一一对应,否则表格就是空白的。
flex属性很常用,它表示该列会“吃掉”剩余宽度。比如flex: 1让状态列和金额列自动拉伸填满表格,而ID列固定80像素。想做好后台布局,必须理解flex的伸缩逻辑,它类似于CSS的flex-grow。
Grid的难点在动态列、单元格渲染、行选中和分页。建议初学者先把本地数据跑通,再去接远程数据。远程数据往往涉及代理配置、异步加载、loading状态,变量一下子很多,容易把人绕晕。
3.4 Form:表单数据采集与回填
表单在业务系统里和Grid是双胞胎,一个负责展示,一个负责录入。ExtJS的Form支持各种输入字段,常见的组合是:
Ext.create('Ext.form.Panel', { title: '新增用户', items: [ { xtype: 'textfield', name: 'username', fieldLabel: '用户名' }, { xtype: 'combobox', name: 'role', fieldLabel: '角色', store: ['管理员', '操作员'], queryMode: 'local' }, { xtype: 'datefield', name: 'birthday', fieldLabel: '生日', format: 'Y-m-d' }, { xtype: 'numberfield', name: 'age', fieldLabel: '年龄', minValue: 1, maxValue: 120 } ], buttons: [ { text: '提交', formBind: true, handler: function(btn) { var form = btn.up('form'); var values = form.getValues(); console.log(values); }} ] });fieldLabel是表单字段左侧的文字标签,name是提交数据的键名。getValues()一次性拿到所有字段值,非常方便。
Form使用中最大的坑是“回填”。很多新手直接用form.setValues(obj),发现下拉框、日期框不显示,或者数据显示了但格式不对。正确做法是确保setValues的键名和字段name完全一致,并且日期字段要传Date对象或符合format的字符串,不能传任意格式的字符串。
3.5 Tree和Tab:侧边导航与页签组织
Tree和Tab在后台管理界面里出镜率也很高。Tree的典型用途是组织架构、权限菜单;Tab用于在一个区域内切换多个业务页面。
Ext.create('Ext.tree.Panel', { title: '菜单树', store: { type: 'tree', root: { text: '根节点', expanded: true, children: [ { text: '用户管理', children: [{ text: '新增用户' }, { text: '用户列表' }] }, { text: '订单管理' } ] } }, renderTo: Ext.getBody() });TabPanel则是一个容器,内部每个子组件对应一个Tab页:
Ext.create('Ext.tab.Panel', { items: [ { title: '订单列表', items: [/* grid */] }, { title: '统计报表', items: [/* chart */] } ] });树组件结合Store的异步加载可以做树形菜单,TabPanel结合动态添加删除可以做多页签业务。这两个组件本身不是最难,但它们和布局系统结合紧密,建议学完布局后再回来看。
4. 布局:组件摆放的核心机制
4.1 布局和容器是绑定的
ExtJS组件不会自动排列。如果你往一个Panel里塞两个子组件而不设置layout,最后看到的就是两个组件重叠或者挤在左上角,非常难看。这是因为每个容器都有一个layout配置,layout决定了子组件的排列方式。
我把ExtJS布局理解为“排版引擎”。它不关心组件具体是什么,只关心组件放在哪个位置、占多大空间。默认布局通常是auto,它不会做复杂排版。实际项目中,我强烈建议每个容器都显式指定layout,哪怕只有一个子组件,也写明layout: 'fit',这样代码意图明确,排查问题也方便。
4.2 五个最常用布局
fit:子组件自动填满父容器。适合“什么布局都不想要,只想让一个组件占满空间”的场景。border:把区域划分为上北、下南、左西、右东、中五个区域,最经典的框架布局。hbox:子组件水平排列,可用flex控制宽度比例。vbox:子组件垂直排列,可用flex控制高度比例。column:按列排,用columnWidth百分比控制宽度,比hbox更古老一些。
我平时用得最多的是border、fit和hbox/vbox。border搭页面骨架,fit填内容,hbox/vbox做局部排版。
4.3 用border布局搭建后台主界面
后台管理系统的经典布局是:顶部导航栏、左侧菜单、中间内容区。用border布局实现非常直观:
Ext.create('Ext.container.Viewport', { layout: 'border', items: [ { region: 'north', height: 60, title: '系统标题', collapseMode: 'none' }, { region: 'west', width: 220, title: '菜单', split: true }, { region: 'center', xtype: 'tabpanel', activeTab: 0 } ] });Viewport是ExtJS的根容器,它会自动占满整个窗口。region指定在border布局中的方位,north和west必须给固定宽度或高度,center不用指定,它会自动吃掉剩余空间。
这里有个容易犯的错:忘了设置layout: 'border',只在items里写了region,结果区域全部不生效。或者north和west都写了flex,导致高度分配失控。记住,border布局下,north/south固定高度,west/east固定宽度,center自适应。
4.4 flex参数:让组件学会伸缩
hbox和vbox里的flex是控制主轴方向空间分配的关键。类似CSS flex布局,给一个组件flex: 1,另一个组件flex: 2,后者占的空间是前者的两倍。
Ext.create('Ext.panel.Panel', { layout: 'hbox', items: [ { xtype: 'panel', title: 'A', flex: 1 }, { xtype: 'panel', title: 'B', flex: 2 }, { xtype: 'panel', title: 'C', width: 200 } ] });在这个例子里,A和B动态分配剩余宽度,C固定200像素。如果窗口宽度变化,A和B的宽度会跟着变化,C始终保持200。
经验之谈:尽可能用flex而不写死所有宽度。我做后台页面时,很多表格列宽和分区宽度都是用flex控制的。写死宽度在固定分辨率下因为现代显示器尺寸多样,很容易出现横向滚动条。
5. 组件通信:从父找子到兄弟互喊
5.1 用up、down、query在组件树里查找
组件之间经常需要互相调用。最直接的方式就是沿组件树查找。
component.up('selector'):向上找最近的匹配祖先。component.down('selector'):向下找第一个匹配后代。component.query('selector'):向下找所有匹配后代。component.child('selector'):只查找直接子组件。
举个例子:一个Window里有Form和Grid,点击保存按钮时,要在handler里拿到表单数据和表格选中行:
handler: function(btn) { var win = btn.up('window'); var form = win.down('form'); var grid = win.down('grid'); var values = form.getValues(); var selection = grid.getSelectionModel().getSelection(); console.log(values, selection); }这段代码的核心是:按钮通过up('window')找到自己所在的窗口,再由窗口向下查找表单和表格。只要你在items里按正常结构组织,选择器就一定能命中。
query返回数组,即使只匹配到一个,也是数组。down返回单个组件。初学时我经常把这两者记混,导致调用方法时报cannot call ... of undefined。记一句话:要找单个用down,要找多个用query。
5.2 自定义事件让组件解耦
当组件树很深时,一直用up/down会写出“组件寻亲记”,耦合度非常高。更好的方式是让组件自己抛出事件,由感兴趣的组件来监听。
ExtJS的组件天然具备事件能力,你可以用fireEvent抛出自定义事件:
Ext.define('MyApp.view.OrderGrid', { extend: 'Ext.grid.Panel', xtype: 'ordergrid', listeners: { itemdblclick: function(grid, record) { this.fireEvent('orderopen', record); } } });然后在父容器里:
items: [{ xtype: 'ordergrid', listeners: { orderopen: function(record) { // 打开订单详情窗口 } } }]这样OrderGrid不需要知道谁会处理它的事件,只负责抛出,调用方自行监听。组件之间代码耦合度明显降低,也更容易做单元测试。
5.3 兄弟组件之间传值
兄弟组件指同一个父容器下的两个组件。表面看起来它们没有直接关系,但只要有共同的父容器,就能搭桥。
我在项目里常用的做法是:父容器控制整个逻辑,监听子组件事件,更新另一个子组件。比如左侧是商品列表,右侧是商品详情,点击列表项时更新详情:
listeners: { select: function(grid, record) { var detail = grid.up('panel').down('detailpanel'); detail.updateData(record); } }从grid往上找到公共父容器,再向下找到detailpanel,调用它的更新方法。这个方法比用全局变量好在于作用域清晰,不受页面其他模块影响。
如果两个组件距离较远,跨越了很多层,那么用全局事件总线。ExtJS没有官方内置总线,但可以用一个Ext.util.Observable子类当总线,或者直接挂在Ext.app.Application上。不过过度使用全局总线会让数据流变得很难跟踪,我一般只在跨模块、非层级关系时才用。
5.4 数据层Store的联动
还有一种特殊的“通信”是Store之间的联动。比如选择了省份,城市下拉框的Store要根据省份ID重新加载。这种适合监听第一个字段的change事件,再调用第二个Store的load:
listeners: { change: function(field, newValue) { var cityCombo = field.up('form').down('[name=city]'); cityCombo.getStore().getProxy().extraParams = { provinceId: newValue }; cityCombo.getStore().reload(); } }注意getProxy().extraParams用于给请求额外参数,重新加载时会带上省份ID。这里也体现了数据层和组件的配合:组件负责展示和交互,Store负责管理和加载数据。
6. 组件的生命周期与销毁艺术
6.1 渲染前后的关键钩子
在真实项目里,经常需要在组件渲染完成后做一些外部插件初始化、DOM样式调整、数据预加载。ExtJS为组件提供了几个生命周期钩子,最常用的是afterrender和boxready。
Ext.create('Ext.panel.Panel', { title: '图表容器', html: '<div id="chartDiv"></div>', listeners: { afterrender: function(panel) { // 在这里初始化图表,因为DOM已经存在了 var div = document.getElementById('chartDiv'); // init chart }, boxready: function(panel, width, height) { console.log('组件实际宽高', width, height); } } });afterrender保证组件DOM存在,但此时不一定完成了布局计算;boxready则保证布局完成,可以拿到真实宽高。如果两者都需要,我习惯在boxready里做依赖尺寸的初始化,比如图表组件、第三方富文本编辑器。
6.2 定时器与监听器的清理
这是内存泄漏的重灾区。很多人在组件里写了setInterval定时刷新数据,或者给window绑定resize事件,组件销毁时忘记清理。
正确做法是在destroy事件或beforedestroy里释放资源:
listeners: { destroy: function() { if (this.timer) { clearInterval(this.timer); this.timer = null; } window.removeEventListener('resize', this.onResizeHandler); } }销毁组件本身会清理内部事件监听和子组件,但你自己附加出去的东西,比如全局定时器、window事件、第三方实例,ExtJS管不到,必须手动清理。
另一个常见的坑是Window的closeAction。默认closeAction: 'destroy',关闭窗口会销毁组件;但如果你把它改成'hide',窗口只是隐藏,对象还在内存中。如果反复open和close,又不做数据清理,就会出现隐藏组件越来越多,页面越来越卡。
6.3 容器销毁时孙组件的去向
当父容器销毁时,它的直接子组件会被自动销毁。但如果某个子组件的Store是外部共享的,比如多个Grid共用一个Store,Store并不会随着Grid销毁。如果Store里注册了很多监听器,或者有定时器,它就会一直留在内存里。
我的经验是:局部Store一定挂在组件内部定义,这样随组件销毁;如果确实要共享Store,必须单独管理生命周期,在页面关闭或退出登录时手动销毁Store。判断一个组件是否泄漏,可以打开浏览器DevTools的内存快照,反复开关页面,看组件类名和Store是否不断增长。
7. 常见问题与排查技巧实录
7.1 页面白屏,组件完全没显示
白屏原因有很多,我按出现概率排一下:
- 没有引入主题CSS或JS路径错误。
- 忘了写
renderTo,或者组件没有加到任何容器里。 Ext.application的launch没执行,比如JS报错。- 组件配置了
layout但子组件配置错误,导致渲染中断。
遇到白屏,第一步打开浏览器控制台,看有没有红色报错。第二步确认页面里<body>下面有没有组件的DOM节点。第三步检查Style,如果DOM节点存在但样式不对,大概率是主题CSS问题。
7.2 组件出现,但全部叠在左上角
百分之九十是因为容器没有设置layout。ExtJS不会像浏览器文档流那样自动摆正位置,必须靠布局系统。找到最外层容器,根据想要的效果设置fit、border或hbox。这个问题容易在复制代码时发生,一个不留神删了layout,整个页面就乱了。
还有个小技巧:如果某个区域子组件很多,且布局复杂,尽量不要一层层嵌套太多容器。布局嵌套越深,重算成本越高,出现调整问题的概率也越大。能用border一屏拆几个区域,就不要拆成十层小Panel。
7.3 表格数据没渲染出来
表格没有数据,先分三层排查:
第一层,Store有没有数据。控制台打印store.getCount(),如果是0,说明数据加载就有问题。本地数据看data数组;远程数据看Network请求是否返回正确。
第二层,字段是否匹配。store的fields里定义了字段名,columns里的dataIndex必须和字段名完全一致。大小写、空格都可能让列变成空白。
第三层,列宽度是否被压缩。如果columns里所有列都没设宽度,表格可能把所有列都渲染成0。至少要有一列用flex或明确给width。
7.4 事件不触发
事件不触发的原因五花八门,最常见的是这几种:
- 选择器写错。
down('grid')匹配的是xtype,如果你给组件起的xtype是mygrid,那就要写down('mygrid')。 - 监听器加错了对象。比如想监听Grid的行点击,却把listeners写在了Grid的父容器上。
- 组件还没有渲染完成就调用了bind。比如在
launch里对还没渲染的组件绑定第一次监听,而组件在后续渲染过程中可能重置了配置。 - handler函数作用域错了。用了普通function,内部的
this不再是组件,导致this.up(...)报错。在listeners里可以直接用scope: this或者用箭头函数。
我排查这类问题有个土办法:在监听器第一行加一句console.log(this, arguments),看执行到没有,this指向谁。八成问题能在这一步找到。
7.5 ExtJS常见报错速查表
| 报错信息 | 原因与处理 |
|---|---|
Cannot read property 'getStore' of undefined | 组件还没创建成功,或者选择器没有匹配到组件。检查up/down的路径。 |
Ext.container.Container: cannot insert child at index | 试图向容器添加一个已经有父组件的组件。先remove再add。 |
Unable to find theme stylesheet | 主题CSS引入失败或版本不匹配。 |
Uncaught TypeError: records[0].get is not a function | 数据不是Ext.data.Model实例,检查Store的fields定义或者数据源格式。 |
Layout run failed或boxready无限循环 | 通常是组件在boxready里修改了自身宽高,触发再次布局,形成死循环。避免在布局回调里改动自身尺寸。 |
form.getValues() is not a function | 拿到的对象不是Form组件,是其他Panel。检查up('form')是否匹配。 |
这几类报错基本覆盖了我多年维护ExtJS项目的大部分异常。遇到不认识的报错,先试着用英文关键词搜索,很多老开发者都踩过同样的坑,Stack Overflow上有大量现成答案。关键是定位问题时要有顺序:先看网络请求,再看DOM,再看Store数据,最后看事件绑定。
我自己在实际操作中最大的体会是:ExtJS的组件化思想其实非常成熟,只要把组件树、布局、生命周期这三件事琢磨透,后面写代码就是套模板。别被它庞大的API吓到,先掌握Button、Panel、Grid、Form和布局,已经能应付绝大多数后台页面了。如果遇到组件不显示或者布局乱了,不用急着改代码,先画组件树,再层层排查,通常都能快速定位问题。
最后分享一个小技巧:在做Table列配置时,尽量用flex而非固定width,在窗口大小变化时体验会好很多。如果你正在维护老版本ExtJS项目,记得升级时先替换主题CSS,这能帮你省下至少一个下午的调试时间。