
接私活做后台管理系统的人大概都遇到过这种场面甲方把需求发过来功能列了满满一页但你问他要设计稿他回你一句你先搭出来我看着改。真到这一步从零手写一套后台布局就是给自己找罪受——侧边栏、多标签页、面包屑、弹层、数据表格、分页这些东西没有一个是业务逻辑但每一个都要花时间调。我这两年手上的中小型后台项目基本都是靠LayuiAdmin起盘子的快的时候半天就能把整套 UI 界面骨架立起来剩下的时间全砸在接口对接上。这篇就把我拿它做项目的完整路子拆开讲——它到底适合什么场景、源码拿到手先看哪几个文件、菜单权限和表格怎么打通、以及那几个让我加班到凌晨的坑。如果你是要快速交付后台的独立开发者或者刚开始接触后台界面开发的前端下面这些内容应该能直接抄。1. layuiAdmin 的真实定位它到底适合谁用1.1 从 layui 到 layuiAdmin中间隔着一层成品骨架很多人第一次听到这个名字会有点懵以为它跟 layui 是两个东西。其实关系很简单layui 是一套底层的 UI 组件库提供表格、表单、弹层、日期选择这些零件LayuiAdmin 是官方在这套零件基础上拼出来的一套后台管理成品骨架你拿到手就已经有登录页、主框架、多标签导航、侧边栏折叠、消息面板这些现成的东西。打个比方layui 是乐高积木LayuiAdmin 是照着说明书拼好的一辆成品车你只需要换个颜色、加两个轮子。这个定位决定了它的使用哲学不要重新造布局只在它给的插槽里塞业务页面。我见过有同事拿到源码第一件事是把整个框架拆了重写结果三天时间花了整整两天在调侧边栏的过渡动画上业务页面反而没做完。真不值得。这套模板的布局层是经过大量项目验证的你唯一要花心思的是 views 目录下的业务页。需要提前说清楚一点官方现在的重心已经转向了新的技术方案LayuiAdmin 属于那种存量项目极多、但不会再有大的功能更新的状态。这不是坏事——意味着它的 API 已经冻结你今天写的代码三年后还是同样的写法不会因为框架升级导致项目要重构。对于外包和内部系统这类交付即稳定的场景稳定比新潮重要得多。1.2 iframe 版和单页版选错了要返工这是我认为最容易踩的第一个坑而且是那种选错了要推翻重来的坑。LayuiAdmin 提供两种骨架版本技术形态页面切换方式适合场景主要代价iframe 版主框架 内嵌子页面切换标签加载独立 HTML多人协作、页面差异大、老项目改造跨页面通信麻烦、内存占用略高单页版纯前端路由 SPA局部 DOM 替换交互连贯、页面风格统一所有页面变量共存容易互相干扰怎么选我给个特别实在的判断标准看你团队里写页面的人有几个。iframe 版的每个业务页都是一个独立 HTML 文件你、你同事、甚至外包给别人的页面彼此之间零干扰谁写的页面出问题就改那一个文件。单页版所有视图共享同一个 window 作用域变量名撞车、全局监听没解绑、闭包里的定时器没清都会变成这个页面一开另一个页面就挂了的诡异现象。我自己的习惯是中后台、内部系统、多人协作的一律 iframe 版页面数量少于十个、交互联动多、只有一个人维护的小工具考虑单页版。绝大多数外包项目属于前者所以后面我讲的操作细节默认都是 iframe 版的形态。1.3 它真正替你省掉的到底是哪些活把话说透一点LayuiAdmin 的价值不在好看而在省事。它省掉的主要是这三块框架级布局顶部栏、侧边导航、多标签页、面包屑、内容区滚动条这些是每个后台都要有、但跟业务毫无关系的东西。权限呈现层菜单按角色动态渲染、按钮级权限控制的基础结构它已经给了数据流的框架你只要接上后端返回的菜单树。常用交互模板弹层表单、右侧滑出详情、表格行内操作、批量删除确认这些在后台里高频到几乎每个模块都要用一遍。真正需要你自己写的只剩列表页 表单页 接口对接这三件事。我实测过一个标准的增删改查模块熟练之后四十分钟能出一个完整可用的版本这在从零搭建的项目里是不敢想的。2. 源码到手的第一小时目录、入口和加载链路2.1 目录结构对照着看别急着改代码拿到源码先别动手把目录扫一遍心里有个地图。以 iframe 版为例核心目录大致是这样├── index.html // 主框架入口整个后台的壳 ├── config.js // 模块路径配置注册自定义模块 ├── layui/ // 框架本体css fonts 各模块 js ├── modules/ // 自己写的扩展模块 ├── views/ // 所有业务页面按模块分文件夹 │ ├── app/ // 内容管理类页面 │ ├── user/ // 用户、权限类页面 │ └── set/ // 系统设置类页面 ├── json/ // 本地演示数据菜单、用户信息等 └── style/ // 自定义样式覆盖这个结构里有两个地方必须记住views 是你 90% 时间待的地方modules 是你安放公共逻辑的地方。我见过有人把公共的表格配置写成一段长长的代码在十几个页面里复制粘贴后来要改一个分页参数改了半小时还漏了两个页面。这种东西就应该抽到 modules 里后面第 5 节我会给具体做法。另外提醒一句layui/目录不要随意改动尤其是里面的fonts文件夹图标字体和 css 里的相对路径是绑死的挪个位置整个界面的图标就会变成方块。2.2 index.html 里那几行脚本每一行都有作用主框架的入口文件看着简单但加载顺序是有讲究的。典型的写法大致是这样!-- 1. 先引入框架本体 -- script srclayui/layui.js/script !-- 2. 再引入配置注册模块路径 -- script srcconfig.js/script !-- 3. 最后才是业务代码 -- script layui.use([admin, layer], function () { var admin layui.admin; var layer layui.layer; // 这行通常用来拿登录用户信息 admin.req({ url: /api/current/user, done: function (res) { if (res.code ! 0) { location.href login.html; } } }); }); /script这个顺序不能乱。layui.js是地基config.js决定了后面layui.use能不能找到你自定义的模块业务代码必须放在最后。我曾经为了图省事把 config 的内容直接内联到layui.use后面结果自定义模块永远加载不到控制台只给一个很含糊的 404。记住配置必须在业务代码之前执行完。还有admin.req这个方法值得单独说。它不是原生的 ajax 封装而是在 ajax 基础上加了统一的 loading 遮罩、错误提示、以及和 layer 弹层的联动。用它之后你会发现处理请求中禁止重复点击这种琐碎需求代码量能少一半。2.3 config.js 的 base 路径本地能跑、上线白屏的常客这是我在第一个项目上栽过的跟头。config.js里通常有这么一段layui.config({ base: modules/ // 自定义模块的根路径 }).extend({ common: common, // 对应 modules/common.js api: api });本地用编辑器直接打开时modules/是相对当前 HTML 的路径一切正常。一旦部署到服务器如果你的页面是通过某种带路径前缀的方式访问的这个相对路径就会解析错表现是页面框架正常显示但所有自定义模块的功能全部失效控制台一片红。我的处理方式是用绝对路径或根路径layui.config({ base: /static/admin/modules/ // 从站点根开始写死 });另一个隐形的坑是用file://协议直接双击打开 HTML 调试。这种情况下所有 ajax 请求都会被浏览器拦截表现是菜单加载不出来、表格永远显示数据加载中。这个不是代码问题是协议限制必须起一个本地静态服务器。我自己习惯用一个最简的命令行静态服务跑起来改一次刷新一次比什么热更新都快。3. 后台的三根支柱菜单权限、数据表格、弹层交互后台管理系统说到底就是这三件事在反复循环。把这三块打通剩下的都是体力活。3.1 菜单从写死到接口下发中间只差一次数据替换初始源码里的菜单是在一个 JSON 文件里写死的长这样{ code: 0, msg: , data: [ { title: 设备台账, icon: layui-icon-app, href: views/device/list.html, children: [] }, { title: 系统设置, icon: layui-icon-set, children: [ { title: 用户管理, icon: layui-icon-username, href: views/user/list.html } ] } ] }接入真实权限只需要把读取这个 JSON 的地方换成接口调用数据结构保持一致即可。后端返回菜单树的时候有几个点必须提前和后端对齐不然一定返工href必须是相对于主框架的路径不是相对当前页面的路径。很多人在这里写错导致点击菜单后内容区空白。叶子节点的children给空数组还是不给要统一。有的模板靠children.length判断是否渲染成可点击项字段缺失会直接报错。同一角色的菜单顺序最好后端排序后返回。前端再排一次没意义还容易和权限配置界面的顺序不一致。我踩过的具体坑是菜单缓存。有些模板会把菜单存在本地存储里改完权限后不刷新缓存前端看到的还是旧菜单运维那边一脸懵。解决办法是在退出登录和角色变更的地方强制清一次缓存这个动作一定要加不然排查起来非常反直觉。3.2 table 模块一个 render 撑起整个列表页数据表格是后台里出现频率最高的组件也是 LayuiAdmin 里配置项最多、最容易配错的地方。一个典型且实用的渲染配置是这样layui.use([table, layer], function () { var table layui.table; var layer layui.layer; table.render({ elem: #deviceTable, id: deviceTable, // 关键给了 id 才能 reload url: /api/device/page, method: post, toolbar: #tableToolbar, // 顶部的批量操作按钮 defaultToolbar: [filter, exports], page: { limit: 20, limits: [20, 50, 100] }, height: full-150, // 撑满剩余高度减去顶部区域 cols: [[ { type: checkbox, fixed: left }, { field: sn, title: 设备编号, width: 170, sort: true }, { field: name, title: 设备名称, minWidth: 200 }, { field: status, title: 状态, width: 110, templet: #statusTpl }, { field: updateTime, title: 更新时间, width: 180, sort: true }, { fixed: right, title: 操作, width: 170, align: center, toolbar: #rowBar } ]], request: { pageName: pageNum, // 和后端约定的页码参数名 limitName: pageSize }, parseData: function (res) { return { code: res.code 0 ? 0 : 1, msg: res.msg, count: res.data.total, data: res.data.rows }; }, done: function (res, curr, count) { // 渲染完成后做点什么比如恢复勾选状态 } }); // 行内操作 table.on(tool(deviceTable), function (obj) { if (obj.event del) { layer.confirm(确认删除该设备, function (idx) { obj.del(); // 先从界面移除再发请求 layer.close(idx); }); } }); // 搜索重载 $(#btnSearch).on(click, function () { table.reload(deviceTable, { page: { curr: 1 }, // 搜索必须回到第一页 where: { keyword: $(#kw).val() } }); }); });这里面有几个细节值得单独拎出来讲。第一id字段千万不能省。不写id的时候table.reload会默认用elem的 id 去找表格看着好像也能用但一旦你的表格元素 id 和你想 reload 的标识对不上就会出现调了重载但数据没变的玄学问题。统一显式写id永远不亏。第二搜索重载一定要带上page: { curr: 1 }。这是个特别典型的逻辑漏洞用户翻到第 5 页输入关键词搜索如果页码不重置请求发出去的是第 5 页的关键词搜索结果数据不够的话直接显示空白。测试同学第一次提这个 bug 的时候我盯着代码看了十分钟才反应过来。第三parseData是前后端约定不一致时的救星。后端返回的字段名千奇百怪有的用total/rows有的用count/list有的把状态码放在status里。与其让后端改不如在parseData里做一层翻译成本最低。我现在的习惯是每个项目的表格统一走一层parseData这样以后换后端或者接口风格变了只改一个地方。第四templet比在done里手写 DOM 靠谱得多。状态列要显示彩色标签、操作列要显示不同按钮都应该用模板去渲染而不是在done回调里遍历所有行去innerHTML。后者在分页切换时会重复执行、性能很差还容易丢事件绑定。3.3 layer 与 admin 模块iframe 模式下最不一样的地方如果说表格是配置问题那弹层就是模式差异问题这也是 iframe 版和单页版体感差别最大的地方。在普通页面里layer.open弹出的层挂在当前文档上。但在 iframe 版的后台里业务页面是被嵌在内容区的 iframe 里的。这意味着如果你直接调用layer.open弹层只能在那个 iframe 的区域里显示超出部分会被裁掉——尤其是layer.load()这种全局遮罩看着就像只在内容区转圈顶部导航和侧边栏还能点体验很割裂。解决思路是需要覆盖整个浏览器的弹层用父窗口的 layer。// 在 iframe 子页面里让弹层铺满整个浏览器 var parentLayer parent.layui.layer; parentLayer.open({ type: 2, title: 编辑设备, content: views/device/edit.html, area: [720px, 560px], maxmin: true, end: function () { // 关闭后刷新父窗口里的表格 parent.layui.table.reload(deviceTable); } });除了 layerLayuiAdmin 里的admin模块还有一批很实用的方法属于用一次就回不去的那种方法作用典型使用场景admin.req带 loading 和错误提示的请求所有接口调用admin.popup通用弹层封装新增、编辑弹窗admin.popupRight从右侧滑出的面板详情查看、消息列表admin.putTempData跨页面传临时数据列表页传参给编辑页admin.getTempData读取临时数据编辑页读取上一页的参数admin.events.on监听框架级事件侧边栏折叠、标签页切换putTempData/getTempData这对组合我强烈推荐用起来。它是 iframe 模式下最干净的传参方式——列表页跳到编辑页时把当前行的完整数据扔进去编辑页读出来直接用不用拼一长串 URL 参数也不用让编辑页再请求一次详情接口。缺点是刷新页面数据就没了所以只适合传一次性的上下文。4. 踩坑记录那几个让我加班到凌晨的问题前面讲的是怎么用这一节讲的是我用崩过的经历。这几个问题的共同特点是现象看起来毫无道理原因其实很简单但排查链路很长。4.1 表格高度full-150在侧边栏折叠后失效现象很诡异页面第一次打开表格高度完美撑满点一下侧边栏折叠按钮内容区变宽了但表格还是原来的宽度和高度右边空出一大块底部出现双滚动条。刷新一下又好了。排查过程是这样的先怀疑是样式问题翻了一遍自定义 css没发现异常然后怀疑是表格容器高度没变用开发者工具量了一下容器确实变宽了但表格内部那个滚动容器的宽度没跟着变最后才反应过来——表格的宽高是在渲染那一刻计算出来的固定值容器尺寸变了它不会自己重算。而full-150这种写法的本质是取父容器高度减去 150 像素这个计算只在渲染时执行一次。侧边栏折叠是个 CSS 过渡动画动画结束后容器尺寸变了表格却不知道。修复方式就是监听侧边栏折叠事件然后手动触发重算// 监听框架的侧边栏折叠事件事件名以实际模板为准 layui.admin.events.on(sideSpread, function () { layui.table.resize(deviceTable); });如果这个事件在你的版本里名字不一样还有个更保险的做法直接监听窗口尺寸变化做一次防抖后统一重算所有表格。var resizeTimer null; window.addEventListener(resize, function () { clearTimeout(resizeTimer); resizeTimer setTimeout(function () { layui.table.resize(deviceTable); }, 200); });这里要注意侧边栏折叠是 CSS 动画resize事件不一定会在动画结束后立刻触发所以延迟时间要给够200 毫秒是我实测比较稳的值。给太短会在动画中途重算表格高度算出来是错的。提示这个问题在单页版里同样存在只是表现稍微轻一点。凡是用了百分比高度或减去固定值的表格都要考虑容器尺寸变化后的重算。4.2 弹层里的表单提交后列表没刷新这个坑我踩了不止一次。场景是列表页点编辑弹出编辑表单填完保存接口返回成功弹层关闭——但列表里那一行的数据还是旧的用户以为没保存成功就又点了一次。根本原因是弹层和列表页是两个独立的作用域弹层里的保存逻辑不知道外面有个表格需要刷新。这个问题在 iframe 模式下会更明显因为编辑页可能是一个独立的 HTML。我后来固定用三种方案按场景选方案一end回调刷新。用layer.open的end回调弹层关闭后统一刷新表格。优点是简单缺点是无论有没有保存成功都会刷。layer.open({ type: 2, content: edit.html, end: function () { layui.table.reload(deviceTable); } });方案二行内局部更新。数据量小、只改一两个字段的时候用obj.update()直接改当前行不重新请求接口体验最顺滑。table.on(tool(deviceTable), function (obj) { if (obj.event toggle) { // 局部更新当前行界面立刻变化 obj.update({ status: obj.data.status 1 ? 0 : 1 }); } });方案三父窗口显式调用。编辑页保存成功后直接调用父窗口的刷新方法最精确。// 编辑页里保存成功后 parent.layui.table.reload(deviceTable);我现在的默认选择是方案一加方案三的组合end回调兜底保证一定会刷同时保存成功后主动调一次让用户感觉更快。听起来有点浪费但两个请求换来的是绝对不会忘记刷新很值。4.3 动态插入 DOM 后表单控件全部失灵这个问题的现象是页面上原本好好的下拉框你用 js 动态拼了一段 HTML 插进去之后新插入的下拉框点开没有任何反应或者样式变成浏览器默认样式。原因在于 layui 的表单控件不是原生元素而是在页面加载时把原生select隐藏掉、另外渲染了一套自定义结构。动态插入的原生元素没有走这个渲染流程自然就没有那套交互样式。修复就一行// 动态插入 DOM 之后 $(#formContainer).append(html); layui.form.render(); // 重新渲染整个表单如果只想渲染某一类控件可以指定类型form.render(select)。我一开始偷懒每次都全局 render页面元素一多就会有一瞬间的闪烁后来改成按类型渲染就没这个问题了。同类的问题还有表格里的复选框——动态更新行数据后选中状态可能会错乱需要重新同步一次。这类框架接管了原生元素的设计核心记忆点就一个只要 DOM 变了就重新渲染一次。4.4 版本混用和图标字体 404最后一个坑比较偏运维但排查起来最费劲。图标全是方块。打开控制台会看到字体文件加载失败。原因是layui/css里引用字体用的是相对路径一旦你做了资源打包、CDN 分发或者把 css 挪到了别的目录这个相对关系就断了。解决办法是保证 css 和 fonts 目录的相对位置不变或者在部署时把字体文件按原路径结构复制过去。模块报错layui is not defined或者某个模块的方法不存在。基本都是版本混用导致的。有的项目里同时存在两份 layui一份是模板自带的一份是某个页面自己引的或者是在已经模块化的环境里又引了一次全量包。我现在的做法是全站只允许一处引入 layui其余页面一律走模块加载发现第二处引入就删掉。本地正常、部署后白屏。除了前面说的 base 路径问题还有一种情况是大小写。某些服务器环境对文件名大小写敏感本地在 Windows 上写Views/Device/List.html也能跑上线到区分大小写的环境就 404。这个坑没有技术含量但每年都能坑到人建议在代码里统一用全小写加连字符的命名。5. 跑通之后性能取舍与长期维护的几条经验能跑起来只是及格线。真正决定这套东西好不好用的是后面这些不写也不会报错、但迟早要还的账。5.1 表格数据量上来之后的两个实际选择小数据量的时候怎么配都行一旦单页要显示几百上千条记录问题就会集中爆发。我实测下来有两个结论第一每页条数不要盲目放大。默认 20 条是个很合理的值因为表格的每一行都要经过模板渲染、事件绑定行数翻倍渲染耗时基本是线性增长的。真要一屏看更多正确做法是把行高压缩、把不重要的列隐藏而不是把每页调到 200 条。第二慎用前端全量加载 前端分页。有些页面数据是相对固定的字典表图省事一次性全拉下来在前端分页。数据量小于 500 条的时候体验确实好切换页码零延迟。但一旦超过这个量级首次加载的等待时间会很难看而且用户改了一条数据后要重新拉全量才能保证一致。我的判断线是超过 300 条就必须走后端分页。另外提一个容易被忽略的点表格的done回调里不要做重活。这个回调在每次渲染、每次分页、每次排序后都会执行里面如果放了遍历全表的逻辑翻页会明显卡顿。需要做计算的话放到parseData里那个阶段的数据结构更干净。5.2 把重复的 CRUD 抽成公共模块后台项目里最典型的重复劳动就是一个模块的列表页逻辑在十个页面里长得几乎一样渲染表格、绑定搜索、绑定新增、绑定批量删除、绑定行内操作。如果每个页面都复制一遍后面要加一个导出时带上当前筛选条件的需求你就要改十个地方。我的做法是在modules/目录下建一个crud.js把通用逻辑包一层// modules/crud.js layui.define([table, layer], function (exports) { var table layui.table; var layer layui.layer; var crud { // 通用的列表页初始化 initList: function (options) { table.render({ elem: options.elem, id: options.id, url: options.url, method: post, page: { limit: 20, limits: [20, 50, 100] }, height: full-150, cols: options.cols, parseData: crud.parseData, request: { pageName: pageNum, limitName: pageSize } }); // 统一绑定搜索 if (options.searchBtn) { $(options.searchBtn).on(click, function () { var where {}; $(options.searchForm [name]).each(function () { where[$(this).attr(name)] $(this).val(); }); table.reload(options.id, { page: { curr: 1 }, where: where }); }); } }, // 统一的响应结构翻译 parseData: function (res) { return { code: res.code 0 ? 0 : 1, msg: res.msg, count: res.data ? res.data.total : 0, data: res.data ? res.data.rows : [] }; } }; exports(crud, crud); });然后在页面里这么用layui.use([crud], function () { var crud layui.crud; crud.initList({ elem: #deviceTable, id: deviceTable, url: /api/device/page, searchBtn: #btnSearch, searchForm: #searchForm, cols: [[ /* 各页面自己定义列 */ ]] }); });这样改造之后一个列表页的代码从一百多行降到二十行左右而且新同事接手时看一眼调用参数就知道这个页面在干什么不需要通读实现。抽公共模块的唯一成本是一开始要多花半小时设计参数结构但这个投入在一个有三个以上模块的项目里第一周就能赚回来。需要提醒的是不要过度抽象。我见过有人把整个 CRUD包括新增编辑弹窗、表单校验、提交全部塞进一个通用方法参数有二十多个最后维护起来比复制粘贴还痛苦。我的建议是只抽那些十个页面里九个都一样的部分剩下的差异留给各页面自己写。5.3 什么时候该考虑换掉它说到这里可能有人会问现在前端 UI 框架这么多为什么还要用这套。我的看法比较务实看项目生命周期和团队构成。如果是一个长期迭代、多人协作、未来还要持续加功能的平台型产品用更现代的组件化方案是更合适的。组件化方案在状态管理、代码复用、类型提示这些方面的优势是模板型方案给不了的。但如果是下面这几类场景我依然会选 LayuiAdmin交付周期极短的外包项目两三周要上线没时间搭脚手架。内部管理系统用户量固定对视觉要求不高稳定压倒一切。老项目改造原有系统就是这套东西推翻重来的成本远高于在现有基础上加功能。前后端分离不彻底的项目后端同学也要顺手改改页面模板型的写法学习成本最低。还有一类场景值得单独说用 AI 生成界面稿再落地。现在很多人习惯先让模型出一版 UI 稿再照着还原。这种流程下LayuiAdmin 反而有优势——它的组件结构是固定的你只要告诉模型用表格渲染列表、用弹层做编辑生成出来的代码基本能直接用不需要反复调组件结构。反倒是自由度极高的现代框架生成出来的实现方式五花八门你得先把它拉回统一的风格。注意不管选哪套方案有一个原则不变——先把骨架跑通再写业务。我见过太多人一开始就在纠结配色和圆角结果交付前一晚才开始对接接口。最后分享一个我在多个项目里都验证过的小习惯新项目起盘的第一天先做一个完整闭环——列表、新增、编辑、删除只做一个最简单的模块把它从数据库一路打通到界面。这个闭环跑通之后剩下的模块本质上都是复制和微调工期会变得非常可预测。我一般会在半天之内完成这个动作做完之后心里就有底了这个项目能不能按期交付第一天就看得出来。前面几节里提到的那些坑比如表格高度、弹层刷新、动态渲染全都会在这个闭环里现形早发现比上线前一天发现强太多。