9个HTML5原生功能,重构老项目砍掉4万行JS

📅 发布时间:2026/10/20 13:18:55
9个HTML5原生功能,重构老项目砍掉4万行JS
删代码比写代码更容易上瘾。去年年底我接手了公司一个年久失修的管理后台光JS文件加起来就有4万行上下。业务逻辑说实话不算复杂但里面有大量重复的弹窗、折叠面板、表单校验、滚动懒加载而且全是老式写法一个页面动不动就几十个事件监听。我花了两周时间做了一件事——把这些代码一个个抠出来看它们到底在实现什么然后尽可能用HTML5自带的原生能力去替代。等重构收尾的时候代码量从4万行降到两千行左右功能一个没少页面反而更稳了。这篇文章就把我替换过程中实测最顺手的9个HTML5原生功能整理出来。每个功能我都会说清楚它能干什么、怎么用、替换了什么样的老代码以及哪些地方容易踩坑。如果你也在维护那种JS写得像毛线团一样的老项目这篇内容可以给你一条很直接的改造路线。1. 这次重构我做了什么4万行JS到底怎么砍下来的1.1 先说背景这个后台为什么会有4万行JS公司在跑的一个内部管理系统最早是外包团队做的。功能其实不算变态复杂无非就是订单管理、用户管理、内容发布这几大块。但代码风格属于典型的面向粘贴复制编程第一年写了8000行第二年上图功能没用旧代码重新写了一版第三年换个同事又改改最后就堆到了4万多行。我接手时大致扫了一遍发现真正的核心业务逻辑可能不到5000行。剩下的大部分可以归为三类第一类是重复的UI交互。项目里至少有7种弹窗实现方式5种折叠面板写法每个页面都自己从零写一套改起来想死。第二类是静态展示逻辑。有些分区导航、Tab切换其实页面加载后根本不变但代码里还是用JS去控制显隐生怕CSS乱掉。第三类是**能用JS解决的事绝不考虑HTML**。比如表单校验明明有原生能力但老代码都是用正则自己写一套弹窗打开后手动锁body滚动再把已存在的弹窗全部干掉这些在现代浏览器里都有现成方案。我给自己定的目标不是把JS全删光而是让JS只做真正需要它做的事。换句话讲凡是浏览器原生已经提供的能力就不要自己造轮子。1.2 替换思路不是硬砍而是平移刚开始我也犯过激进主义的错误想把所有JS都换成原生结果发现业务代码根本离不开JavaScript。后来我换了个思路把工作拆成两步。第一步盘点高频使用场景。把代码里出现次数最多的交互模式列出来统计每个scenario下面有多少行相关代码。统计结果让我意外折叠面板、弹窗、下拉菜单这三项加起来占了大概1.5万行这说明用原生HTML5功能替换的性价比极高。第二步给替换风险分层。像折叠面板、Tab切换这种纯UI、不涉及数据逻辑的直接换了就行像表单校验和异步提交这些半UI半业务的要仔细一点像懒加载和滚动如果场景简单就换场景复杂就保留。我当时给自己定的顺序是先切无状态的UI组件再切有交互但不依赖接口的组件最后才碰有业务依赖的部分。这样做的好处是每一步的风险都可控出了问题立刻能定位到是哪一个替换引起来的不会整个后台乱成一锅粥。2. 帮我瘦身的9个HTML5原生功能2.1 折叠面板用details直接删掉三百行老项目里的折叠面板是这么写的一个click事件、一个状态变量、一堆style.display切换多个折叠面板还得互斥一个手风琴组件写了三百多行。换成原生details标签之后我基本没写过什么交互代码。一个最简单的折叠面板是这样的details summary订单详情/summary div classdetail-body 这里放订单的详细信息列表 /div /details浏览器原生支持点击展开和收起展开时open属性会自动加上收起时自动去掉。用CSS可以控制箭头和样式details { border: 1px solid #e0e0e0; border-radius: 6px; margin-bottom: 8px; } summary { padding: 12px 16px; cursor: pointer; font-weight: 600; } .detail-body { padding: 12px 16px; border-top: 1px solid #eee; }如果你需要手风琴模式就是展开一个、收起其他的效果新版浏览器甚至可以直接用name属性实现互斥details namefaq summary问题一/summary p这是问题一的答案/p /details details namefaq summary问题二/summary p这是问题二的答案/p /details同一组的name相同浏览器就会天然互斥。不过这个特性在Chromium内核的新版本里表现很好其他浏览器要看具体情况我用的时候加了很轻量的JS兜底几十行就够了比原来三百行舒服太多。注意summary里不要塞太多块级元素部分浏览器渲染会出问题。我踩过坑summary里放了div导致点击不灵敏后来改成span就好了。2.2 模态框用dialog焦点管理都不用管弹窗是老后台的刚需确认删除、编辑详情、提示信息全是弹窗。以前的代码是这样的一个遮罩层div、一个内容层div、手动控制display、手动把焦点丢进弹窗、关掉之后手动还回到触发按钮、还得监听Esc键。一套下来通常四百行起步。原生dialog直接把这些全包了。它有两种打开方式show()是非模态的不阻塞页面其他操作showModal()是模态的自带焦点锁定、切除背景点击而且浏览器专门给了一个::backdrop伪元素用来做遮罩样式。最基本的用法dialog idconfirmDialog h3确认删除这条记录/h3 p删除后不可恢复请谨慎操作。/p form methoddialog button valuecancel取消/button button valueconfirm删除/button /form /dialog button idopenBtn打开弹窗/buttonJS只需要两行打开和关掉const dialog document.getElementById(confirmDialog); document.getElementById(openBtn).addEventListener(click, () dialog.showModal());form methoddialog这个设计很妙点里面的按钮会自动关掉弹窗而且value还能通过returnValue属性拿到业务代码里直接读取用户点了哪个按钮就行不需要再手动找按钮绑定事件。dialog.addEventListener(close, () { if (dialog.returnValue confirm) { // 执行删除逻辑 } });遮罩样式用::backdrop处理dialog::backdrop { background: rgba(0, 0, 0, 0.5); }我以前还花精力做过弹窗打开时禁止背景滚动原生showModal()已经帮你做了。这次替换把弹窗相关的几百行JS全部删了。2.3 轻量弹层用Popover API下拉菜单和气泡提示不再写监听dialog适合正式模态但很多场景我只是想要一个轻量弹层比如表格行里的更多操作、通知提醒、一个小的帮助气泡。这种弹层不需要阻塞背景不需要焦点锁死原来我也用JS写了一套显隐控制后来发现Popover API更合适。Popover API就是原生弹层机制一句话总结给元素加一个popover属性它就变成了弹层再用别的元素通过popovertarget属性来控制它。默认是隐藏的。button popovertargetactionMenu popovertargetactiontoggle更多操作/button div idactionMenu popover a href#编辑/a a href#复制/a a href#下线/a /div连JS都不需要写。popovertargetaction支持show、hide、toggle三种动作默认就是toggle所以我可以简写button popovertargetactionMenu更多操作/button这种弹层默认会以顶层浮层的方式渲染不会被父元素的overflow裁剪图层层级问题一下子解决了。而且它还有个好处点击弹层外面的区域会自动关闭按Esc也会关这俩行为在很多场景下正好是刚需。我在项目里用Popover API替换了好几处下拉菜单和帮助气泡。老代码里each菜单要处理点击外部关闭多级浮层这些边界条件现在这部分全都归浏览器管了。注意Popover API和dialog不要混用。需要模态、需要用户必须当场决策的用dialog只是临时展示、点外面就应该消失的用popover。我一开始把确认框也改成了popover结果用户没意识到必须处理这个交互语义是错的。2.4 表单校验交给浏览器正则少写90%后台系统离不开表单新增用户、修改配置、批量导入。老代码的校验方式是每个表单自己写一个validate函数然后绑定到submit事件上逐个字段判断错误信息还要自己写DOM去显示。原生表单校验其实已经很强了。先加required、pattern、min、max、maxlength这些属性浏览器就会在提交的时候自动拦截。form idregForm novalidate div label邮箱/label input typeemail nameemail required placeholder请输入邮箱 /div div label手机号/label input typetext namephone required pattern1[3-9][0-9]{9} placeholder11位手机号 /div div label年龄/label input typenumber nameage required min18 max60 /div button typesubmit提交/button /form不写任何JS直接点提交浏览器会拦截非法字符并把错误气泡弹出来。这是最基础的做法。如果你不想让原生气泡弹出来而是想要自定义提示可以给form加novalidate然后自己用checkValidity()和reportValidity()控制。const form document.getElementById(regForm); form.addEventListener(submit, (e) { e.preventDefault(); if (!form.checkValidity()) { form.reportValidity(); return; } // 校验通过可以做异步提交 });样式上还有个很好用的伪类input:user-invalid它只在用户实际输入之后才触发比:invalid页面一加载就红体验好很多input:user-invalid { border-color: #e74c3c; background: #fff5f5; }我在替换的过程中把原来每个表单里几十行的校验函数直接删掉改成requiredpattern属性再用user-invalid控制样式。单纯这个动作就删掉了大概两千行正则校验代码。当然了如果业务校验特别花哨比如两次密码必须一致还是需要自己写点JS配合setCustomValidity()来用的。2.5 FormData fetch异步提交终于清爽了后台的表单大多不是同步跳转页面老代码用的异步提交方式是手动取出每个控件的值拼成字符串或者JSON对象再交给AJAX去发。一个十多个字段的表单光收集字段的JS就有几十行。现在不用手动收集了。表单数据可以直接交给FormData去处理const form document.getElementById(regForm); form.addEventListener(submit, async (e) { e.preventDefault(); const formData new FormData(form); const res await fetch(/api/user, { method: POST, body: formData }); const data await res.json(); console.log(data); });new FormData(form)一个括号就会自动把表单里所有有name的控件收集进来文本框、下拉框、单选框、文件域全都处理好了。发给后端时fetch遇到FormData作为body会自动设置成multipart/form-data不需要手动指定Content-Type这比老代码里还要手动拼boundary舒服多了。文件上传也能直接打包上面表单里如果有个input typefile namefile同样在FormData里后端收文件就会从multipart/form-data里解析出来。注意fetch不支持上传进度事件。如果你想做那种上传时显示进度条的效果实际上还得回到XMLHttpRequest去监听progress。我当时也误以为fetch全都能干最后在传大文件时老老实实把XHR请回来了。这不算倒退选工具得按场景来。2.6 IntersectionObserver懒加载和无限滚动不用再做滚动监听老项目的懒加载通常是监听scroll事件然后在回调里对每个元素调getBoundingClientRect()算位置还要自己加throttle节流否则滚动一次可以触发几十次计算。这种代码性能差、逻辑绕在低端办公电脑上滚动页面经常卡顿。IntersectionObserver的思路完全不一样它不是边滚动边算而是让浏览器帮你观察元素和视口的相交状态发生变化时通知我一下。最简单的图片懒加载img>const io new IntersectionObserver((entries) { entries.forEach(entry { if (entry.isIntersecting) { const img entry.target; img.src img.dataset.src; io.unobserve(img); } }); }, { rootMargin: 100px }); document.querySelectorAll(img[data-src]).forEach(img io.observe(img));rootMargin: 100px的意思是元素进入离视口边界100像素的范围内时就算相交。也就是说图片快出现时就开始加载用户感觉不到延迟。无限滚动也是同样的模式底部有一个哨兵元素它一进入视口就加载下一页数据加载完后再把哨兵摆到新内容的底部。替换之前那种滚动到底部就加载的监听方法代码量少了一半而且回调频率低多了不会在用户快速滚动时反复触发。注意如果屏幕上有几百个元素都要观察可以重复用一个observer实例去observe所有元素不要再搞一个observer工厂。我之前见过有人每个图片都new一个实例页面多了之后反而性能更差。2.7 CSS:target一个选择器搞定Tab切换和分区跳转Tab切换是后台非常常见的交互。老代码的逻辑通常是点击某个Tab先给当前Tab加active类再切换面板的display。虽然不复杂但每个页面都来一遍代码冗余量也不小。现在的做法可以更暴力把Tab变成锚点链接面板的显隐直接交给:target选择器。div classtabs a href#pane-overview classtab-link概览/a a href#pane-detail classtab-link明细/a a href#pane-logs classtab-link操作日志/a /div div classtab-pane idpane-overview概览内容/div div classtab-pane idpane-detail明细内容/div div classtab-pane idpane-logs日志内容/divCSS只需要这么一段其中#pane-detail:target选中了URL hash指向的面板.tab-pane { display: none; } .tab-pane:target { display: block; }点击链接时URL的hash会变成#pane-detail匹配到的面板自然就显示了。其他面板依然保持display: none。这个方案最大的好处是状态直接体现在URL上。用户刷新页面浏览器还会自动定位到当前Tab不用像老代码那样从localStorage里找回状态。前进后退也能用体验比其他实现好不少。还有一个加分项纯CSS的scroll-margin-top配合:target可以处理锚点跳转后顶部被固定导航遮挡的问题。.tab-pane { scroll-margin-top: 80px; }不过:target的缺点是它只能用URL hash多对一触发不能实现同时选中两个Tab这种复杂逻辑。如果Tab组件涉及到权限控制或联动就需要带着JS来做了。2.8 Web Components把重复组件一次性固化成你自己的HTML标签后台管理系统的页面上有大量重复的卡头、标签、头像、状态点。老项目里每个页面的HTML定制党写法都不同CSS各写各的样式改起来得改十几个文件。我用自定义元素把它们固化成了原生似的标签。这是Web Components里的Custom Elements能力。以头像标签为例class XAvatar extends HTMLElement { connectedCallback() { const name this.getAttribute(name) || 未知; const size this.getAttribute(size) || md; this.innerHTML span classx-avatar ${size}${name.slice(0, 1)}/span; } } customElements.define(x-avatar, XAvatar);在页面上直接这样使用x-avatar name张三 sizelg/x-avatar就自动生成了张字头像。同理卡片、标签、状态点都是这么封装的。这样做的价值不只是省代码而是让团队所有人都用同一套组件规范。新人接手时不担心样式走样后端模板改起来也直观。更进一步可以用Shadow DOM把组件内部的样式和DOM隔离起来外部怎么改都不会破坏组件本身。我在封装状态标签时用了Shadow DOM内部的颜色和排版用style包裹页面上的其他CSS绝对不会漏进来。class XStatusTag extends HTMLElement { constructor() { super(); const shadow this.attachShadow({ mode: open }); shadow.innerHTML style .tag { padding: 4px 12px; border-radius: 10px; font-size: 12px; } .success { background: #e8f5e9; color: #2e7d32; } .danger { background: #fce4ec; color: #c62828; } /style span classtag ${this.getAttribute(type) || default}${this.innerText}/span ; } } customElements.define(x-status-tag, XStatusTag);用的时候特别简单x-status-tag typesuccess已上线/x-status-tag x-status-tag typedanger已下线/x-status-tag注意自定义元素的名字必须带短横线比如x-avatar这是规范强制要求的目的是避免和未来新增的原生HTML元素冲突。如果你定义一个mytab出来浏览器根本不会理你。2.9scroll-behavior: smoothscrollIntoView丝滑滚动告别JS动画老项目里的点击某个按钮后页面平滑滚动到某个区域以前得用很多JS来计算元素位置再做动画碰上页面布局变化还要重新计算。现在一行CSS就能搞定全页面的平滑滚动html { scroll-behavior: smooth; }所有a href#xxx的锚点跳转都自动变成了平滑滚动不需要写任何JS。导航菜单左侧点击右侧内容平滑滚动过去的效果就是这么简单。对于JS触发的滚动还可以用scrollIntoViewdocument.getElementById(error-message).scrollIntoView({ behavior: smooth, block: center });block: center能让目标元素滚动到视口中央表单校验失败时把错误提示滚到中间体验比默认的顶部对齐更显眼。我这次改动里还顺手把老代码里所有animate({scrollTop})这类滚动动画删掉了全部改成scroll-behavior。滚动本身是浏览器底层能力性能比JS逐帧动画稳定得多。3. 实操过程一个后台页面是这么瘦身的3.1 动手前先把必须保留JS的清单列出来我曾经见过有人一上来就把所有script标签全删了结果是页面结构在但什么都没法点了。我学乖了动手前先花半小时盘了一下哪些JS是绝对不能删的。我列了一个反向清单和接口数据交互相关的逻辑比如列表加载、详情拉取、提交保存权限控制逻辑比如按钮根据角色显示或隐藏依赖第三方图表的初始化代码极个别的老IE兼容逻辑如果是新内部系统我一般直接要求浏览器升级这个清单不是不让你删代码而是提醒你别把业务逻辑和UI交互逻辑混在一起。我删掉的4万行里绝大多数是UI交互逻辑真正在跑业务的代码我一行都没动。3.2 按风险从低到高逐块替换实操的时候我把替换顺序定成下面这样实测下来很稳。第一轮替换无状态UI组件。页面里所有普通的折叠面板、Tab切换、轻量弹层直接改用details、:target和popover。这一轮基本上就是删JS 改HTML标签零风险。我当时把全项目二十几个折叠面板全部换成details运行一天没有一条报错。第二轮替换有交互但没有数据依赖的组件。比如弹窗确认删除、表单内容的新增编辑弹框这类组件有状态但状态只存在于当前页面不依赖接口返回值。换成dialog后主要精力花在调整样式上把默认边框和背景色重置成符合设计稿的样子。第三轮动涉及老接口交互的部分。这是最需要小心的一轮。我接手的项目里有一个新增用户页面原先用AJAX提交代码里既有表单序列化又有字段校验还有失败弹窗。我先把校验切成requiredpattern再把提交切成FormDatafetch然后老老实实测了一遍成功、失败、超时三种情况确定对外行为完全一致才放心继续下一个。第四轮性能类替换。懒加载和滚动优化放最后。因为前面几轮纯删代码项目体积变小了此刻再优化渲染性能体感上更有对照。我把头像和富文本里的图片全部改为IntersectionObserver懒加载同时把全站平滑滚动改成scroll-behavior用户端没有感知到任何差异内存占用反而明显掉了。3.3 回归验证功能一个都不能少重这么一个存量系统我不可能在两周内把每个页面都点一遍所以我是分步骤验证的。先把一个核心菜单下的页面全部替换完然后把页面上每个按钮、每个Tab、每个表单都点一遍记录有没有控制台报错。然后跑一遍登录、新增、删除、导出这4条核心业务路径保证主流程没断。最后是静态代码扫描把项目里所有onclick、addEventListener过一遍看有没有漏掉的引用已经被删掉的函数。有一次就是这一步救了场我在抽干一个筛选表单的校验逻辑时忘了底导出的按钮还在引用旧的validateForm()函数结果一点导出就报错。要是没做全量引用检查估计要等到用户上报问题才发现。4. 常见问题和避坑指南4.1 浏览器兼容性到底怎么盘很多人在动手前最担心的就是兼容性。我的经验是分三步走第一步搞清楚你的用户是谁。如果是一个工作场景固定的内部后台用户用的电脑基本都是标配浏览器版本往往比较统一。这一点直接决定你能不能用原生功能。第二步逐个特性查兼容性表。dialog、popover、:user-invalid这些特性在主流现代浏览器里已经都是绿了但自定义元素的老版本Edge会有点问题。在动手前可以选一个中位数浏览器版本所有特性都要求至少在这个版本上可用。第三步给低版本浏览器留一条退路。我的做法是能用CSS降级就让CSS降级不能降级的就加一个很薄的JS兜底。比如details的互斥功能如果不支持我就写十来行代码手动关闭其他项。这种兜底成本远比一开始想象的低因为原生特性替你挡掉了95%的问题。4.2 原生组件的地雷和补救方案原生组件有一些样式上的默认行为不处理会显得很丑陋。dialog默认没有宽度会自动缩到内容宽度。我给了全局样式dialog { margin: auto; padding: 0; border: none; border-radius: 8px; }这样能适配更多布局。details的箭头是各浏览器自绘的位置和大小不一致。可以通过summary::-webkit-details-marker { display: none; }这样的代码去掉箭头换成自己的图标。popover元素默认没有阴影和边框刚开始看起来特别裸。我给它统一加了box-shadow和背景色才达到设计稿要求。pattern校验时正则默认是全字符串匹配所以不需要也不应该写^和$符号。我第一次写的时候没搞明白写了^1[3-9][0-9]{9}$结果反而匹配不了后来去掉锚点就正常了。4.3 无障碍和焦点管理别以为原生就高枕无忧这轮重构虽然把JS删了但老系统的可访问性原本就一般我不能因为改了原生功能就忽视它。有几个细节实测下来值得注意。dialog打开后焦点会锁定在弹窗内但关闭之后焦点不会自动回到触发按钮上。虽然浏览器提供了close()方法但焦点回落的逻辑还要自己补一句我当时在每个打开弹窗的按钮监听里都做了处理。details的summary天然是可聚焦和可键盘操作的它是原生button语义比老代码里的div加onclick要好得多。但前提是你的summary里别塞复杂的交互元素否则键盘导航会乱。popover在默认行为下打开后焦点不一定会移到弹层内部。如果是按钮触发的下拉菜单建议在打开后手动focus到第一个菜单项上这样键盘用户才能操作。4.4 哪些场景真的不适合裸奔不能为了删代码而删代码我这次也遇到几个硬骨头最后不得不保留JS。第一个是非常复杂的联动表单。比如选择了某个客户之后要动态刷新它的余额、联系人、历史订单这种必须要有业务JS参与原生表单校验收不了这个需求。第二个是强定制的无障碍弹窗。如果产品对准确聚焦顺序、屏幕阅读器朗读措辞有严格规范原生dialog的提示语可能不够精细需要额外手动增强。第三个是大量异步下载的数据表格。比如虚拟滚动表格需要根据可视区域计算行渲染这种场景IntersectionObserver只能帮你判断哪一段进入了视口后面的数据管理、缓存清理还得自己写。第四个是老旧的浏览器环境。如果你还在维护一个内网遍布老浏览器的系统且没有权限让用户升级浏览器那上面这些原生功能很可能不全支持强行替换就是给自己挖坑。结尾说两句这次重构最让我上瘾的不是删了4万行代码这个数字而是我重新认识了HTML本身。过去几年写了太多框架代码习惯性地把div、span当成万能积木忘了浏览器其实自带了很多更可靠、更没有依赖的解决方案。我个人最推荐的改造起点是折叠面板和Tab切换这两个模块风险最低、改动最直观花一个下午就能完成一个页面的替换晚上回家看到代码统计里那一大片红色删除线成就感直接拉满。等你熟悉了这套思路再去看那些七八年前的老项目就能很清楚地分辨出哪些JS是保护业务的关键哪些只是历史包袱。如果你也被某个存量系统的JS代码量愁到头禿不妨从这9个原生功能开始试。删掉那些本来就不需要的代码页面跑得会更快你也会更轻松。