第一次用Layui做后台管理系统时,很多人都会陷入同一种纠结:文档很全、示例很多,但真正把页面攒起来以后,总觉得代码东一块西一块,表格数据渲染、弹窗回调、表单校验各自为政。Layui的定位是“写后端的人也能快速上手的前端UI方案”,它不依赖Vue、React那种工程化体系,打开页面引入两个文件就能跑起来,这也是它在传统项目和维护型项目里依然保有大量用户的原因。这篇文章不打算复述文档里的基础API,而是聊聊真正在项目里用Layui时会遇到的取舍、坑和习惯——从项目结构怎么组织,到表格怎么处理复杂列,再到弹窗关闭后怎么正确刷新列表,都是我重复踩过之后沉淀下来的经验,希望对正在接手Layui项目的朋友有参考价值。
1. 项目整体设计思路:先管好结构,再谈组件
1.1 模块化加载,但别把模块变量铺得到处都是
Layui的模块机制是异步的,一切操作都要放在layui.use()回调里执行。很多项目最开始的代码都长这样:
layui.use(['table', 'form'], function () { var table = layui.table; // 表格渲染逻辑 // 表单监听逻辑 // 弹窗逻辑 // 全堆在这里 });页面少的时候还能忍,一旦页面多起来,这个回调函数会膨胀到几百行。更好的做法是:把layui.use()当作一个入口,内部只做初始化调度,具体的渲染逻辑拆到独立函数里,再通过一个init()方法串起来。
layui.use(['table', 'form', 'layer'], function () { var table = layui.table; var form = layui.form; var layer = layui.layer; init(); function init() { renderTable(); bindSearch(); bindToolbar(); } function renderTable() { // 表格渲染配置 } function bindSearch() { form.on('submit(searchForm)', function (data) { // 查询逻辑 }); } });这样做的核心收益是:每个函数的职责单一,新增一个操作按钮时,只需要加一个监听函数,不需要在原来的大回调里翻找。另外一个容易被忽略的细节是,模块变量table、form、layer虽然定义在回调外层,但回调内部实际上是一个闭包作用域,不会污染全局。要严禁把var table挂到window上,否则多个页面共用时会互相覆盖,排查起来很痛苦。
1.2 页面分层与公共方法封装
Layui项目的后端接口返回格式如果每个接口都不一样,前端就要为每个页面单独写parseData适配,这属于典型的自找麻烦。Layui的table模块默认约定返回格式:
{ "code": 0, "msg": "", "count": 100, "data": [{}, {}] }如果后端能统一成这个结构,前端所有表格页面都可以省掉parseData这个配置项。我见过很多团队因为某个历史接口返回了{ retCode: 200, list: [] },就要在每个表格里写一遍字段映射,等到换接口时再全部重改。后端改格式的成本往往比前端适配低得多,接口联调时应该把“统一响应体”放在第一优先级去谈。
公共方法封装这块,我建议至少抽出一层request.js来做统一封装。Layui自带的$.ajax可以直接用,但最好把loading状态管理、错误提示、token注入统一放进去。比如:
function request(url, data, options) { var loadingIndex = layer.load(2); return $.ajax({ url: url, data: data, dataType: 'json', headers: { 'X-Token': getToken() }, complete: function () { layer.close(loadingIndex); }, error: function () { layer.msg('网络异常,请重试', { icon: 2 }); } }); }项目里所有页面都用这个request发请求,而不是越过它直接$.ajax。这样做最大的好处是,某天登录失效机制从“返回状态码”改成“HTTP 401拦截”,只需要改request.js一个文件,而不是满项目去搜$.ajax。
1.3 版本选型与资源管理
Layui目前存在2.x和3.x两个大版本,资源管理上有一点需要特别注意:它们不能共存,也不建议在同一个项目里混用CDN域名。我遇到过最混乱的情况是一个老系统里的部分页面引用本地2.6.8,部分页面引用CDN上的2.8.3,结果表格渲染行为和默认字体全都出现了细微差异,最后不得不统一到同一个版本。
让人省心的做法是:固定在项目公共目录放一套layui.js和layui.css,或者干脆使用某个CDN的固定版本号路径,确保所有页面都是从同一个入口加载。3.x的layui命名空间已经模块化了更多功能,但2.x的存量插件生态相对成熟,如果是从老项目升级,不要一次性全量切换。我建议先切核心的table、form、layer、laydate,验证没问题后再逐步替换其他模块。
2. 核心组件实战拆解:表格、表单、弹层
2.1 table组件:接口、列、重载之间的配合
table是Layui后台项目的主力组件,也是坑最多的组件。先看一个最基础的渲染配置:
table.render({ elem: '#userTable', url: '/api/user/list', page: true, cols: [[ { field: 'name', title: '姓名' }, { field: 'role', title: '角色' }, { field: 'status', title: '状态', templet: function (d) { return d.status === 1 ? '启用' : '禁用'; } }, { title: '操作', templet: '#userBar' } ]] });这里要重点说明templet的两种用法。一种是函数形式,直接根据当前行数据返回字符串;另一种是模板选择器形式,指向页面里用<script type="text/html">定义的模板。我在实际项目里更推荐函数形式,尤其是列内逻辑简单、只是做状态映射或时间格式化时,函数形式更直观,不容易把模板ID写错。如果说列表里要放按钮并绑定点击事件,那就得配合table.on('tool')来监听,不能直接在templet里写onclick事件,因为Layui的表格重载会重新渲染DOM,行内事件绑定的函数一旦不在全局作用域里就很容易丢失。
table.reload是另一个高频操作。很多人在搜索时会写:
table.reload('userTable', { where: { keyword: $('#keyword').val() } });这是标准做法。但要注意,reload的where参数是“覆盖式”替换,不是“追加式”合并。如果表格初始时有意传入where: { type: 'admin' },重载时只给了keyword,那type就会被丢掉。解决方案是初始化时把这些固定参数存起来,重载时手动合并:
var tableOptions = { type: 'admin' }; table.reload('userTable', { where: $.extend({}, tableOptions, { keyword: keyword }) });分页参数方面,Layui默认会把当前页(默认page)和每页条数(默认limit)用GET方式拼到请求里,如果你的后端常用pageNum和pageSize,可以这样适配:
table.render({ request: { pageName: 'pageNum', limitName: 'pageSize' } });很多老后端不认page和limit,却坚持让前端改字段名,其实在Layui里这就是一行配置的事,和后端沟通时可以直接说前端支持自定义请求参数名。
2.2 form组件:验证规则与动态渲染
form组件在Layui里负责所有input、select、textarea、checkbox、switch的样式和事件。它最容易被忽略的点是:只要是通过JS往容器里新增或修改表单项,就必须重新调用form.render(),否则Layui的样式和事件不会自动绑定到那些动态生成的DOM上。
我举个例子。一个编辑弹窗里,角色下拉框需要根据用户类型动态加载选项:
$.ajax({ url: '/api/role/list', success: function (roles) { var html = ''; roles.forEach(function (item) { html += '<option value="' + item.id + '">' + item.name + '</option>'; }); $('#roleSelect').html(html); form.render('select'); // 这行必须加 } });如果不加form.render('select'),下拉框在页面上会显示成Layui的灰色占位样式,点击没有任何反应。很多人这时候第一反应是“是不是接口数据没返回”,其实接口数据早回来了,只是Layui没有重新扫描DOM而已。
表单验证方面,Layui的lay-verify可以做大部分基础校验:
<input name="username" lay-verify="required" placeholder="请输入用户名" /> <input id="phone" name="phone" lay-verify="phone" placeholder="请输入手机号" /> <textarea name="remark" lay-verify="required" placeholder="请输入备注"></textarea>自定义验证规则通过form.verify扩展:
form.verify({ username: function (value, item) { if (value.length < 2) { return '用户名至少2个字符'; } if (value.length > 20) { return '用户名不能超过20个字符'; } }, phone: function (value, item) { var reg = /^1[3-9]\d{9}$/; if (value && !reg.test(value)) { return '请输入正确的手机号'; } } });注意form.verify里的自定义规则,与lay-verify中的内置规则是等价的,如果值非空时还想做“必填”之外的二次校验,就在同一个函数里判断。一个容易忽略的点是:lay-verify的校验只会对name字段生效,如果你在<select>上设置了lay-verify="required",但下拉框没有初始选中项,Layui会把空字符串视为校验不通过,这是符合预期的,不需要额外处理。
2.3 layer组件:弹层类型选择与回调设计
layer是Layui里使用频率最高的组件。处理后台弹窗时,首先要分清两种打开方式:
type: 1:弹层内容是页面里的DOM元素,适合表单在同一个页面内编辑的场景。type: 2:弹层内容是独立HTML页面(iframe),适合打开一个完整子页面进行复杂编辑。
我的分界标准是:如果在弹窗里只需要改几个字段,就用type: 1,直接把页面里隐藏的form元素通过content参数塞进弹层;如果弹窗里需要“再次调用table/list渲染、有独立的操作步骤”,就用type: 2,每个子页面维护自己的逻辑。
type: 1的典型写法:
var editIndex; $('#editBtn').on('click', function () { editIndex = layer.open({ type: 1, title: '编辑用户', area: ['600px', '400px'], content: $('#editForm'), btn: ['保存', '取消'], yes: function (index, layero) { // 这里要手动提交表单 $('#editForm').submit(); }, end: function () { // 弹层关闭后再统一刷新表格 table.reload('userTable'); } }); });一定要把editIndex保存到变量里,因为layer.close(editIndex)和layer.closeAll()是有明确语义差异的。layer.close(editIndex)只关闭当前弹层,不会误伤正在显示的loading图标或者msg提示;layer.closeAll()会把页面上所有layer层全部关掉,包括你调试时打开的loading。很多人在“保存成功后关闭弹窗”时顺手用layer.closeAll(),结果loading层也被关了,看起来就像接口还没返回就提前关闭了,容易误导排查方向。
弹窗关闭后刷新表格也是一门学问。直接在yes回调里table.reload是最常见的写法,但它存在一个问题:如果用户点右上角的“X”关闭弹窗,yes回调根本不会执行,表格也不会刷新。更稳妥的做法是在end回调里刷新,因为无论用户通过哪种方式关闭弹层,end都会触发。刷新时如果搜索条件还在,就带上搜索参数一起重载:
end: function () { table.reload('userTable', { where: { keyword: $('#keyword').val() || '' } }); }这样“提交后刷新”和“直接关闭后刷新”两种用户路径都能覆盖到,不需要在保存成功和关闭事件里各写一遍重载逻辑。
3. 从零到可用:一个后台管理模块的完整实操
3.1 模拟场景与页面骨架
假设现在要做一个“用户管理”模块,页面包含搜索区、表格区、新增/编辑弹窗三个部分。这是后台系统里最常见的模块形态,也最能体现Layui各类组件的配合方式。
页面骨架大致如下:
<div class="layui-card"> <div class="layui-card-body"> <form class="layui-form" id="searchForm"> <div class="layui-inline"> <input name="keyword" placeholder="用户名/手机号" class="layui-input" /> </div> <button type="submit" class="layui-btn" lay-submit lay-filter="searchFilter"> <i class="layui-icon layui-icon-search"></i> 搜索 </button> </form> </div> </div> <div class="layui-card"> <div class="layui-card-body"> <table id="userTable" lay-filter="userTable"></table> </div> </div> <script type="text/html" id="userBar"> <button class="layui-btn layui-btn-xs" lay-event="edit">编辑</button> <button class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</button> </script>这里有个很容易犯的错误是把<script type="text/html">模板写在表格元素之前。Layui的表格模板解析是在table.render执行时进行的,模板必须在此之前存在于页面DOM中。虽然绝大多数情况下模板写在后面也能拿到,因为table.render是在layui.use回调里执行的,但为了整齐和避免歧义,我习惯把模板统一放在表格标签之后。
3.2 表格初始化与工具栏监听
初始化表格:
function renderTable() { table.render({ elem: '#userTable', url: '/api/user/list', page: true, limit: 10, limits: [10, 20, 50], cols: [[ { field: 'id', title: 'ID', width: 80, sort: true }, { field: 'name', title: '用户名' }, { field: 'phone', title: '手机号' }, { field: 'roleName', title: '角色' }, { field: 'status', title: '状态', templet: function (d) { return d.status === 1 ? '<span class="layui-badge layui-bg-green">启用</span>' : '<span class="layui-badge layui-bg-gray">禁用</span>'; } }, { fixed: 'right', title: '操作', toolbar: '#userBar', width: 160 } ]] }); }sort: true表示列头可排序,Layui会默认把排序字段和排序方向作为参数传给后端。有个细节要注意:用户点击排序后,Layui不会自动携带你render时配置的where参数,而是以排序参数为准。如果你在排序的同时还需要保留搜索关键词,就得在table.on('sort')事件里手动合并参数并重载表格。
工具栏操作监听:
table.on('tool(userTable)', function (obj) { var data = obj.data; if (obj.event === 'edit') { openEdit(data); } else if (obj.event === 'del') { layer.confirm('确定删除用户 ' + data.name + ' 吗?', function (index) { request('/api/user/delete', { id: data.id }, { method: 'POST' }).done(function (res) { layer.close(index); layer.msg('删除成功', { icon: 1 }); table.reload('userTable'); }); }); } });obj.del()也可以用来删除当前行,它内部会调用table.reload并默认移除行数据,看起来很方便,但它的实现是本地删除,并不会通知后端。必须在接口成功返回后再调用obj.del()或table.reload,否则一旦后端保存失败,界面上数据却已经消失,那才是真正的数据事故。
3.3 新增/编辑弹窗与提交
新增和编辑尽量复用同一个弹窗页面或DOM结构,区别只是打开时是否回填数据。我的做法是:
function openEdit(data) { var data = data || {}; editIndex = layer.open({ type: 1, title: data.id ? '编辑用户' : '新增用户', area: ['600px', '420px'], content: $('#editForm'), success: function () { $('#editForm')[0].reset(); form.val('editForm', { id: data.id, name: data.name || '', phone: data.phone || '', roleId: data.roleId || '', status: data.status === 0 ? 0 : 1 }); form.render(); } }); }form.val('editForm', {...})是Layui为表单赋值的便捷方法,它能正确处理Layui渲染后的下拉框、单选、复选、开关等组件。这里踩过最多次的坑是:先调用了$('#editForm')[0].reset(),再通过form.val赋值,如果某些下拉框是动态加载的,赋值前必须确保option已经渲染完成,否则选中的值会被Layui吞掉。
提交逻辑统一走form.on('submit(editSubmit)'),并在校验通过后再发起保存请求:
form.on('submit(editSubmit)', function (data) { var loadingIndex = layer.load(2); request('/api/user/save', data.field, { method: 'POST' }) .done(function (res) { layer.close(loadingIndex); layer.close(editIndex); layer.msg('保存成功', { icon: 1 }); table.reload('userTable'); }) .fail(function () { layer.close(loadingIndex); }); return false; });这里的data.field是Layui表单自动收集的字段集合,包含所有带name属性的表单项。如果碰上空字符串不想传给后端,可以在提交前做一次清洗,比如:
var field = $.extend({}, data.field); Object.keys(field).forEach(function (key) { if (field[key] === '') { delete field[key]; } });3.4 与后端接口对接的约定
接口对接这步,最理想的状态是“前端约定格式,后端遵守”。我在团队里常用的一套规范是:
- 列表接口统一用
GET /api/resource/list,返回{ code: 0, count: 100, data: [...] }; - 新增/更新统一用
POST /api/resource/save,提交{ id: 1, ... },返回{ code: 0, msg: '保存成功' }; - 删除统一用
POST /api/resource/delete,提交{ id: 1 }; - 所有非
code: 0的返回都视为失败,前端弹msg提示。
这套约定建立起来以后,前端每个页面的“保存成功”“删除成功”写法可以完全一致,不需要每个接口单独判断返回码。如果某个接口返回的数据结构不一致,第一反应应该是让后端调整,而不是前端在parseData里写分支适配——分支写多了,接口一换全是坑。
套一层“接口层”其实是后期维护的关键。我见过不少Layui项目直接在每个页面的layui.use回调里写$.ajax,等后端路径一改,几十个页面一起改,改到怀疑人生。如果说项目已经有多个模块,我强烈建议统一封装:
var api = { getUserList: function (params) { return request('/api/user/list', params); }, saveUser: function (data) { return request('/api/user/save', data, { method: 'POST' }); }, deleteUser: function (id) { return request('/api/user/delete', { id: id }, { method: 'POST' }); } };页面里只调用api.getUserList,后端路径再变化时只改api.js一个文件,这种收益会随着页面数量增长被无限放大。
4. 项目后期的高频问题与排查技巧
4.1 表格渲染不出来,先按三层排查
遇到表格数据不显示时,先不要马上怀疑是Layui配置写错了。按下面的顺序排查,命中率高很多:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 表格区域空白,没有表头 | layui.js或layui.css加载失败 | 打开浏览器控制台看Network,确认资源是否404;右键“查看网页源代码”确认引用路径 |
| 表头正常,表格无数据 | 接口返回的code非0,或data字段名不匹配 | 打开控制台看接口返回,确认返回的JSON是否满足{code, msg, count, data} |
| 表格有数据但分页不刷新 | page: false或分页参数名不一致 | 检查页面配置是否漏了page: true;检查request.pageName和request.limitName |
| 操作列按钮不触发 | 按钮没写lay-event,或table.on('tool')选择器写错 | 确认按钮标签上有lay-event;确认table.on('tool(表名)')里的名字和elem的lay-filter对齐 |
另外有个新手容易忽略的点:table.render的elem选择器对应的是<table>元素的ID,而lay-filter是用来绑定table.on事件的。两者都是tableDOM上的属性,但不能混淆,写错后表现是表头出来了,但所有事件监听都失效。
4.2 动态表单项不生效:缺少form.render()
这个问题在Layui项目里出现的频率极高。核心原理是:Layui的select、radio、checkbox、switch组件在页面初始化时会被“渲染”成自己的一套DOM结构和事件体系,动态往里塞的DOM节点根本没有经过这一流程,所以显示的是原生样式,事件也不响应。
判断是否是这个问题的“金标准”是:看下拉框是否能正常展开下拉层。如果下拉框外观是浏览器原生样式,基本就是没调用form.render()。对应方案是:动态新增表单元素后,根据元素类型调用form.render('select')、form.render('radio')或form.render()(全量重渲染)。
什么时候需要全量渲染?如果一次性塞入了多个不同类型的表单项(比如一个工具栏区域既有下拉框又有单选框),直接无参调用form.render()最省事。但要注意性能问题:如果页面上表单项特别多,比如有几百个输入框的大表单,调用无参form.render()可能会造成一段时间卡顿,此时按类型精准渲染会好一些。
4.3 弹窗关闭后父页面没有刷新
这个问题的本质是回调时机选错了。yes(确认按钮)、end(关闭动画结束)、cancel(右上角X)三个回调的触发条件完全不同。最简单的经验是:
- 所有“关闭弹窗”的场景,都在
end里做收尾操作; - 在
yes里只做“提交前数据处理/请求发送”,不要直接操作表格状态; - 关闭层本身用
layer.close(index),不要用layer.closeAll()。
如果弹窗是type: 2的iframe,父页面还需要刷新表格,可以在iframe子页面里调用父页面的方法:
// 父页面 window.refreshUserTable = function () { table.reload('userTable'); }; // iframe子页面保存成功后 window.parent.refreshUserTable();这里我踩过的一个坑是:直接写parent.table.reload,出来的是undefined,因为iframe子页面的parent虽然是父页面,但table变量是父页面layui.use回调内部的局部变量,并没有暴露到全局。需要手动把要复用的方法挂到window上才能跨页面调用,这个“挂出来”的动作在项目里一定要有约定,最好统一用一个命名空间,防止方法名互相覆盖。
4.4 排序、分页参数对不上的处理
Layui把排序参数命名为field和order,分页参数默认是page和limit。有些后端框架(尤其是Spring Data系的)要求传sort=name,asc这样的字符串。适配方式是在table.on('sort')里拦截:
table.on('sort(userTable)', function (obj) { table.reload('userTable', { initSort: obj, where: { sort: obj.field + ',' + (obj.type === 'asc' ? 'asc' : 'desc') } }); });这里有个小细节:重载时带上initSort: obj,是为了让排序后的表头箭头保持正确状态。如果不带,重载后表格会回到“未排序”的样式,看起来像是排序状态丢了。
5. 性能优化与长期维护的避坑经验
5.1 重复渲染表格导致的页面卡顿
每个页面只调用一次table.render,这个原则在简单页面里不会出问题。但一旦在弹出的独立子页面里也包含表格,而子页面又频繁打开关闭,就要留意是不是每次打开都重复初始化了同一个表格。
解决思路是:在打开iframe弹窗时,可以使用layer.open的success回调判断子页面是否已经初始化过表格。若是子页面里要固定渲染的表格,就放到页面级的初始化里;若表格依赖父页面带过来的参数,则在打开弹窗前通过动态传参方式,在子页面里判断参数变化后再决定是否重载。
页面卡顿还有一个常见来源是:表格列配置里写了巨长的templet函数,每一行都做大量字符串拼接和DOM操作。templet在渲染时是逐行执行的,行数一旦过千,性能差异非常明显。优化方向很简单:把模板里静态字符串提取为常量,或者使用Layui自带的模板语法${}减少字符串拼接。控制台一条条打log排接口数据时,尽量避免在templet里打console.log,因为每行都会触发一次打印。
5.2 组件按需加载与依赖分析
layui.use一次性引入全部模块是一个常见的坏习惯,尤其是项目引用了大量不常用模块(如carousel、flow)却只为了用其中的一个小功能时,网络开销和维护重点都会增加负担。按需引入的正确姿势是只写当前页面真正用到的模块:
layui.use(['table', 'layer'], function () { var table = layui.table; var layer = layui.layer; });这里还有一个“隐性依赖”问题需要注意:table模块内部依赖laytpl、laypage等,这些不用手动引入,Layui会自动处理。如果手动重复引入核心模块,反而可能引起重复注册的问题。
Layui各组件之间也会互相调用。最常见的场景是:在table.on('tool')里弹layer.confirm,这在模块层面看起来只是layer.confirm的调用,其实传入的“确认/取消”回调内部又会操作table变量。建议在layui.use回调里先把table、layer、form都声明出来,即使当前页面暂时不用,也比以后在某个角落突然要用时找不到变量好。
5.3 存量项目升级与局部改造策略
很多团队手上的Layui项目是几年前的存量系统,升级3.x并不是一个“改版本号”那么简单的事。三个最常见的差异点:
- 表单验证提示样式变了,部分自定义CSS可能不兼容;
laydate的日期格式配置和弹层定位方式有所调整;- 模块化子系统拆分方式不同,旧插件可能需要重写。
我的建议是:不要一次大规模升级。先评估系统里用了哪些第三方Layui插件,这类插件往往是版本兼容的“重灾区”。如果插件依赖的是某个具体 2.x 版本的内部实现,升级后大概率会“带病运行”。
更稳妥的节奏是:优先升级layer、table、form三个核心模块,验证基础功能;确认没问题后再升级laydate、upload等场景类模块;第三方插件放到最后,甚至可以考虑用原生按钮或自研组件替换掉那些长期不维护的插件。升级过程中保留一个旧的完整资源包作为回滚通道,切换后至少跑一周核心流程,没有异常再清理旧文件。
5.4 团队协作中的代码规范建议
最后聊聊多人协作时容易乱的地方。Layui项目最大的特点就是“一个人写起来很快,两个人一起写就会打架”。主要原因是全局变量、表单项name、表格lay-filter容易重名。
我订过几条约定,执行下来效果不错:
- 所有表格的
lay-filter用“模块名+Table”命名,如userTable、orderTable; - 表单的
name属性后端字段保持一一对应,不在页面上做二次映射; - 每个页面的核心代码只写一个入口函数
init(),其余辅助函数命名前缀用模块名; - 公共JS统一放到
common/目录,不允许在页面里重复定义同名函数。
刚开始执行时会觉得“多打了几个字符”,但当某天同事告诉你他改了api.js里一个路径,而全项目几十个页面都跟着生效时,你会明白这些规范不是束缚,而是后人接手项目时最重要的导航图。
写在最后
Layui的价值一直很清晰:让不擅长前端工程化的人也能快速构建出有模有样的后台界面。但“能用”和“好用”之间差距很大,这个差距主要藏在项目结构是否清晰、组件使用是否遵循约定、回调和渲染时机是否拿捏准确等细节里。我在实际项目中最大的体会是:不要急着铺页面,先花半小时把公共请求、统一返回格式、模块变量隔离这三件事定下来,后面的开发速度会快很多。如果能在项目初期就遵守“公共方法只写一次、动态渲染必须刷新、弹窗收尾统一走end回调”这几条经验,Layui项目的维护体验其实可以很舒服。也希望上面这些踩坑经历能帮你少走一些弯路。