后台管理系统的页面,十个里有八个长得差不多:左边一列菜单,顶上一条面包屑,中间一张带搜索条件的表格,点新增弹出表单层,点详情抽屉里塞两个图表。单看一个页面没什么技术含量,量一上来就是体力和耐心的活。我这两年接的后台项目,基本都是拿 Guns 这类现成脚手架起步,然后在它的骨架上做页面制作,前端靠 jQuery 加 Bootstrap 撑着,最后用图表把数据讲清楚。这套组合听着不新,但它能扛住绝大多数中小型业务系统的交付节奏,尤其是那种三五个人、两个月上线、需求还天天变的后台项目。
这篇东西我想聊的不是"怎么做一张好看的页面",而是从一套后台脚手架到一张能交付的图表页,中间那些文档里不会写的判断和坑。内容包括:Guns 这类脚手架能替你省掉哪些活、页面制作该怎么分层拆、jQuery 和 Bootstrap 这套组合到今天还有没有必要用、图表怎么接数据才不返工、以及我踩过的那些排查起来最费时间的毛病。适合正在做后台管理系统、被模板和插件绕晕的中级前端或全栈,也适合刚接手别人项目、看不懂一堆col-md-*和$('#xxx').on()的同学。看完你至少能判断:哪些活该抄脚手架,哪些地方必须自己动手改。
1. 从一套脚手架起步:整体方案怎么定
后台系统的页面制作,最忌讳的是"从空白 HTML 开始写"。这不是审美问题,是成本问题。一个后台项目里,用户管理、角色管理、菜单管理、字典管理、日志查询,这几张页面的结构相似度能到 70%,剩下的业务页面又是从这几张变形出来的。你如果在第一张页面上花了三天从零搭布局,后面每一张都要重复一遍顶部导航、侧边栏、面包屑、权限按钮、分页样式,这是纯浪费。
1.1 Guns 这类脚手架到底替我干了什么
我先说清楚这类脚手架的价值边界。它给你的是一套已经跑通的骨架:登录鉴权、菜单权限、用户角色、字典管理、日志、代码生成器,以及配套的前端静态资源目录结构和一套模板引擎的约定。你真正拿到手的是"能跑起来的壳",而不是"能直接上线的产品"。
它省掉的活大致是这些:
| 类别 | 脚手架提供 | 我实际还要做的 |
|---|---|---|
| 权限 | 菜单树、角色分配、按钮级权限标识 | 业务页的按钮权限怎么落到具体控件上 |
| 布局 | 顶部栏、左侧菜单、内容区容器 | 内容区内部的栅格和间距要自己定 |
| 模板 | 模板引擎约定、公共片段引入 | 业务页的模板拆分粒度要自己设计 |
| 组件 | 表格插件、弹层、日期、树、上传 | 组件之间的联动逻辑全部要自己写 |
| 代码生成 | 从表结构生成增删改查页面 | 生成的页面基本都要二次改,别指望直接用 |
最后一行要重点说。代码生成器生成的页面,是"能跑通流程的最小实现",字段排布、校验规则、联动逻辑、异常提示,全都是最朴素的版本。我一般的做法是:用生成器出骨架,然后按业务把表单和表格重写一遍。生成器的作用是省掉路由、接口调用、分页参数这些机械活,不是省掉设计。
注意:不要因为脚手架带了一套权限模型就以为不用管权限。按钮级权限在前端通常只是个
>:root { --bp-narrow: 1200px; /* 侧边栏折叠临界点 */ --bp-tablet: 900px; --gap: 16px; --gap-sm: 12px; }这样后面调整起来只改一处,页面里不用出现魔法数字。
2.3 列表页与表单页的模板化写法
后台页面就两种基本型:列表页和表单页。把这两种写成模板,剩下就是填空。
列表页的固定结构是:搜索区、操作按钮区、表格区、分页区。我给这套结构定了一个标准骨架:
<div class="page-wrap"> <form class="search-bar" id="searchForm"> <div class="row g-3"> <div class="col-lg-3 col-md-4"> <input type="text" class="form-control" name="keyword" placeholder="名称/编码"> </div> <div class="col-lg-3 col-md-4"> <select class="form-select" name="status"> <option value="">全部状态</option> </select> </div> <div class="col-lg-3 col-md-4 d-flex align-items-end gap-2"> <button type="submit" class="btn btn-primary">查询</button> <button type="reset" class="btn btn-outline-secondary">重置</button> </div> </div> </form> <div class="table-toolbar"> <button class="btn btn-primary">// 打开编辑弹层,成功后刷新表格 function openEditLayer(id) { layerOpen({ title: id ? '编辑' : '新增', url: '/page/user-form', params: { id: id }, onSuccess: function (res) { // 关闭弹层、提示、刷新列表 layerClose(); toast(res.message || '保存成功'); $('#dataTable').bootstrapTable('refresh', { pageNumber: 1 }); } }); }回调里只做三件事:关闭、提示、刷新。不要在回调里写业务逻辑,业务逻辑留在弹层页面内部。这条约束能让弹层和列表的耦合度降到最低。
3. jQuery 交互层与图表实现
页面结构搭好之后,真正决定"用起来顺不顺"的是交互层。这一章讲三块:事件怎么绑才不出错、请求怎么封装才不重复、图表怎么初始化才不返工。
3.1 事件委托:动态表格不出错的写法
后台表格的数据是异步加载的,行是动态生成的。这时候如果用
$('.btn-edit').click(...)直接绑,第一次加载的行能点,翻页之后新生成的行全部失效。这是新手最常见的问题,也是我 review 代码时第一眼看的。正确写法是把事件绑在表格的静态父容器上,靠冒泡捕获:
// 错误:只能绑定当时已存在的行 $('.js-edit').on('click', function () { /* ... */ }); // 正确:绑在表格容器上,动态行也能触发 $('#dataTable').on('click', '.js-edit', function () { var row = $(this).closest('tr').data('row'); openEditLayer(row.id); });委托写法有三个连带的好处:一是所有行共用一份监听,几十行和几千行性能一样;二是重新渲染表格不用重新绑事件;三是事件统一在一个地方管理,不会出现"这个按钮绑了两次"的问题。
绑定次数重复这个坑也要说。有些插件在
onLoadSuccess回调里会执行多次,如果你在里面绑事件,就会叠加。我一般会在绑定前先解绑:$('#dataTable').off('click', '.js-edit').on('click', '.js-edit', handler);或者干脆把绑定放在页面初始化时执行一次,不放进任何回调里。哪一种都行,关键是别在会重复执行的地方绑事件。
3.2 ajax 封装与统一错误处理
后台页面里请求调用频次极高,一个列表页至少有查询、分页、删除、批量删除、导出五类请求。如果每个地方都写一遍
$.ajax配置,参数名不一致、错误处理不一致、加载状态不一致,全是隐患。我一般封一层:
function request(options) { var cfg = $.extend({ type: 'POST', dataType: 'json', showLoading: true, timeout: 15000 }, options); if (cfg.showLoading) showGlobalLoading(); return $.ajax({ url: cfg.url, type: cfg.type, data: cfg.data, dataType: cfg.dataType, timeout: cfg.timeout }).then(function (res) { // 约定:code 为 0 或 200 视为成功,其余统一提示 if (res.code === 0 || res.code === 200) { return res.data; } // 业务失败,抛出到 catch return $.Deferred().reject(res).promise(); }).always(function () { if (cfg.showLoading) hideGlobalLoading(); }); }封装之后,页面里的调用就变成一句话:
request({ url: '/api/user/page', data: params }) .then(function (data) { renderTable(data); }) .catch(function (err) { toast(err.message || '请求失败'); });这里有三个我强烈建议固定的约定:统一响应结构(
code/message/data三字段)、统一错误提示(业务错误、网络错误、超时错误分开文案)、统一加载状态(全局遮罩或者按钮 loading,二选一,不要混用)。这三件事一旦统一,后面加页面几乎不用思考。注意:超时时间别设太长。后台接口正常情况下 2 秒内返回,超过 15 秒基本可以判定出问题了。设成 60 秒的结果是用户干等一分钟才看到失败提示,体验极差。
还有一个细节是重复提交。查询按钮连点三下就会发三个请求,返回顺序还不一定,可能后发的先回,表格显示的数据和最后点的条件对不上。解决办法很简单:请求发出前把按钮禁用,
always里恢复;或者记录一个请求序号,只处理最新一次的回包。后者更稳,代码也就三行。3.3 图表初始化的四个关键动作
图表是后台页面的收尾工作,也是最容易返工的部分。我见过太多"本地好好的,上线数据一多就错位"的情况,根子都在初始化没做全。
第一个动作是容器必须有明确高度。图表的渲染引擎需要一个确定的画布尺寸,容器高度是 0 或者
auto的时候,图表要么不显示,要么显示成一条线。我的做法是给容器一个最小高度,再按视口比例给个动态值:function chartHeight(base) { var h = Math.round(window.innerHeight * 0.34); return Math.max(base || 280, Math.min(h, 460)); }这个公式的逻辑是:以视口高度的 34% 为基准,下限 280 保证小屏可用,上限 460 防止大屏上图表过高把页面撑得太长。这三档数值我调过好几轮,280 是数据点标签不重叠的最低高度,460 是一屏内能同时看到两张图表的临界值。
第二个动作是初始化只做一次。重复初始化会导致旧实例残留,表现是数据刷新后出现两层图叠在一起,或者提示框闪两下。用一个变量或者容器上的标记位控制:
var chartMap = {}; function initChart(domId, option) { var el = document.getElementById(domId); if (!el) return null; // 已有实例先销毁,避免残留 if (chartMap[domId]) { chartMap[domId].dispose(); chartMap[domId] = null; } var instance = echarts.init(el, null, { renderer: 'canvas' }); instance.setOption(option); chartMap[domId] = instance; return instance; }第三个动作是resize 要节流。侧边栏折叠、窗口拖拽都会触发 resize,如果不节流,一次拖拽能触发几十次重绘,低配机器直接卡住:
var resizeTimer = null; window.addEventListener('resize', function () { clearTimeout(resizeTimer); resizeTimer = setTimeout(function () { Object.keys(chartMap).forEach(function (k) { chartMap[k] && chartMap[k].resize(); }); }, 200); });第四个动作是异步数据回来要判断实例还在不在。用户点开页面又快速切走,请求才回来,这时候实例可能已经销毁,直接
setOption会报错。加一层判断就行。3.4 参数计算:尺寸、单位、配色与刷新
图表这块最容易出问题的不是代码,是数值。我把几个常用的计算方式整理一下,都是实际调出来的。
网格留白计算。柱状图的
grid要留出坐标轴标签的空间。纵轴标签宽度取决于数值最大位数,粗略估算:每位数字约 8px,加上单位和边距,left一般给 40 到 60。横轴标签如果是日期,bottom要给到 30 以上,否则会被截断。数据单位换算。金额类数据超过一万就要考虑换算,否则纵轴标签会变成一长串数字。逻辑是取最大值判断量级:
function formatAxisValue(max) { if (max >= 1e8) return { div: 1e8, unit: '亿' }; if (max >= 1e4) return { div: 1e4, unit: '万' }; return { div: 1, unit: '' }; }换算之后纵轴标签加单位后缀,提示框里再还原成完整数值。这里有个坑:同一组图表的数据单位和刻度必须一致,如果一个系列是"元"另一个是"万元",图看起来没问题是会误导人的。要么统一换算,要么用双轴,但双轴要慎用,超过两条轴读者基本读不出来。
配色。后台图表不需要花哨,三到五个色系够用。我的原则是同层级用同色系深浅,不同维度用对比色。分类超过六个的时候不要继续加颜色,改成横向条形图按数值排序,视觉负担小得多。
刷新频率。实时刷新的图表要设一个下限,我一般最短 5 秒。低于这个频率,用户还没看清数字就变了,而且请求压力大。如果确实需要秒级更新,改成滚动追加而不是整体重绘。
数字精度。百分比保留一位小数,金额保留两位,计数取整。这条看起来琐碎,但一个页面里数字精度不统一,观感会很不专业。
4. 常见问题与排查技巧实录
这一章是我这些年攒下来的排查清单,按出现频率排序。大部分问题的根因都很简单,难的是定位。
4.1 样式不生效的排查顺序
这是后台开发里最耗时间的一类问题,因为表现都一样——"我改了但没反应"。我的排查顺序是固定的,从外到内:
- 看请求是否 200。打开网络面板,确认那个 CSS 文件真的加载了,返回内容是不是你改后的版本。没加载的话,先查路径和构建配置,别急着调样式。
- 看是否被缓存。强刷一次,或者加个版本参数
?v=时间戳。浏览器缓存和中间层缓存都要考虑。- 看选择器权重。用开发者工具找到目标元素,看右侧样式面板,被划掉的就是被更高权重的规则覆盖了。常见原因是内联样式、
!important、或者更具体的选择器组合。- 看加载顺序。后加载的覆盖先加载的。你的自定义样式如果在框架样式之前引入,等于白写。
- 看作用域。有些脚手架会给内容区加一个命名空间类,样式如果没挂在同一个命名空间下,就选不中。
按这五步走,90% 的样式问题五分钟内能定位。剩下 10% 通常是模板里根元素结构和你以为的不一样,直接看渲染后的 DOM 就行。
提示:临时调试时用
outline: 1px solid red比改背景色好用,不会影响布局,还能看清元素边界。4.2 图表类问题速查表
图表的问题排查相对集中,整理成表格方便对照:
现象 最可能的三个原因 快速验证 图表完全不显示 容器高度为 0;初始化时元素隐藏;DOM id 写错 给容器加固定高度试一次 显示成一条细线 容器宽度为 0 或未定宽 检查父容器是否 display:none切换标签页后消失 隐藏状态下初始化,尺寸算出 0 显示后再调用 resize 刷新数据后叠了两层 重复 init 未销毁旧实例 打印实例数量确认 文字模糊 缩放或高分屏下未处理像素比 检查 canvas 尺寸与 CSS 尺寸比例 提示框被遮挡 容器 overflow:hidden裁剪把容器 overflow 改成 visible 试 数据更新了图没变 setOption 缺少合并参数 加 true参数做完全替换图例文字被截断 图例宽度不足或位置不对 调整 legend 位置或换行 最后一行那个坑我踩过不止一次:
setOption(option)默认是合并模式,新的 series 数组如果长度变了,旧的系列不会被清掉,表现就是"数据越多图越乱"。要完全替换就传setOption(option, true),但注意这个参数会把之前的配置全部冲掉,包括你另外设置的交互配置,所以要确认 option 是完整的。4.3 交互与数据类坑
表格分页跳回第一页。查询之后分页插件还保留在第五页,结果页面上什么都没有。原因是查询参数变了但页码没重置。解决办法是在查询提交时主动把页码设为 1。这行代码每个列表页都要写,别忘。
弹层里的表单校验不生效。通常是两个原因:校验插件在弹层打开之前就初始化了,找不到元素;或者弹层里的元素 id 和页面上已有元素重复了。后者很隐蔽,因为 DOM 里存在两个同名 id 时,选择器只会取第一个。
树形控件选中父节点带了子节点。这是设计层面的问题,先想清楚业务语义:选父是"只选父"还是"含所有子"。这两种在数据提交时完全不同,接口要按对应结构设计。
日期区间参数格式不统一。前端传
2024-01-01,后端收20240101,中间转换一漏就是查询报错。我的做法是在请求封装里统一处理日期字段,按约定格式序列化,页面里只传原始值。批量操作没选数据。用户没勾选就点批量删除,前端直接发了个空数组,后端要么报错要么全删。这就是必须做前端拦截的典型场景,勾选数量为 0 直接提示,别发请求。
4.4 性能与交付前自检
后台系统的性能问题通常在三个地方:请求数量、DOM 数量、内存泄漏。
请求数量上,一个页面首次加载如果超过十个接口,业务上就该考虑合并。列表页尤其明显,列表、统计、字典、权限各来一个接口,加载时间全耗在往返上。能合并的合并,不能合并的按需加载——用户点到那个标签页再请求。
DOM 数量上,表格一次性渲染五百行以上就该换成分页或者虚拟滚动。我一般把每页条数限制在 20 到 50 之间,超过 100 就是给自己找麻烦。
内存泄漏上,主要是两件事:事件监听没解绑、图表实例没销毁。单页应用里页面切走不销毁,反复进出十几次就能明显感觉到卡。虽然传统后台是整页跳转,每次刷新会清空,但如果你用了标签页式导航(很多后台都有),问题就来了。
交付前我会过一遍这张清单:
- [ ] 所有查询按钮支持回车提交
- [ ] 所有弹层关闭后列表已刷新
- [ ] 所有删除操作有二次确认
- [ ] 所有表格有"暂无数据"空状态
- [ ] 所有请求失败有提示,不是静默卡住
- [ ] 所有图表在 1366 和 1920 下都不溢出
- [ ] 所有按钮的权限标识和后端一致
- [ ] 所有日期、金额的格式在页面上统一
这张清单不复杂,但每一轮迭代都跑一遍,能省掉一大半的返工。
5. 工程化收尾与几个长期习惯
写到最后聊点工程上的事,这些不是页面制作的核心,但决定了这套东西能维护多久。
5.1 静态资源版本与缓存策略
后台项目最容易忽略的是缓存。上线之后用户说"还是旧的",八成是静态资源被缓存了。我的做法是给核心 CSS 和 JS 加版本参数,参数值跟发布批次走,改一次动一次。模板层面统一引入,不要在每个页面里手写
<script>标签,否则漏改一个就是隐患。更彻底的做法是构建时给文件名加哈希,但对用模板引擎直接引用的后台来说改造成本不低。折中方案是维护一个全局版本变量,模板里统一拼上去:
<link rel="stylesheet" href="/static/css/base.css?v=${assetVersion}"> <script src="/static/js/common/request.js?v=${assetVersion}"></script>这样发布时改一个变量,全站生效。
5.2 我自己的几个长期习惯
第一,新页面从旧页面复制,不从空白开始。找一个结构最接近的列表页或表单页,复制过来改字段。这样布局、样式、请求封装、事件绑定全都是已验证的,只改业务差异,出错概率大幅下降。
第二,每写一个交互,先在页面上手动点三遍。第一遍正常流程,第二遍异常流程(比如接口返回空),第三遍快速连点。第三遍能抓到大部分重复提交和状态错乱的问题。
第三,把调试用的代码集中在一个文件里。我习惯留一个
debug.js,里面放打印日志、模拟数据、快捷操作的函数,上线前整个文件不引入就行。散落在业务代码里的console.log早晚会忘记删。第四,公共方法写到第三个重复才抽。第一次用别抽,第二次用先复制,第三次出现相同结构再抽成函数。过早抽象比不抽象更麻烦,因为你还没搞清楚变化的维度在哪。
这套 Guns 打底、jQuery 加 Bootstrap 做页面、最后用图表收尾的做法,听起来一点不新,但它在我手上跑过好几个交付周期很紧的后台项目,每次的返工量都在可控范围内。中间当然也踩过不少坑,最典型的一次是图表在侧边栏折叠后全部错位,查了大半天才发现是容器宽度变了但没触发 resize,加了节流监听之后就好了。这种问题的价值不在于技术难度,而在于你踩过一次,下次在同一个项目里就会提前想到,在写第一张页面的时候就把容器宽度设成自适应、把 resize 监听挂上去。经验大概就是这么攒起来的。