凌晨两点,滚动穿透问题第三次爆发。我盯着那套已经升级过五次的弹窗组件,突然意识到一个事实:这个后台项目里躺着的四万多行 JavaScript,有一大半是在给 HTML5 本该自己搞定的事情“补课”。那次之后,我花了三周时间做了一次大胆的删码实验——把所有能用原生能力替代的交互逻辑全部换成原生方案,最终仓库里的 JS 从四万行降到六千行出头。堪称“裸奔”的这段经历里,有 9 个原生功能给我的冲击最大,它们不仅省掉了大量代码,还让项目稳定性上了一个台阶。这事听起来像激进派前端在喊口号,但数据摆在眼前:弹窗、折叠面板、表单校验、图片懒加载、平滑滚动、浮层定位、简单富文本、交互动画,这些东西真的可以不用写一行业务 JS。这篇文章我会把每一个替换思路、代码对比、以及实际落地时踩过的坑都摊开来讲,适合那些正被各种库绑架、想在下一个项目里做减法的团队参考。1. 四万行 JS 是怎么攒出来的:一台后台的“过度封装症”1.1 为什么一个后台能积攒四万行 JS这个项目是一个典型的中后台管理系统,页面数量多、表单密集、弹窗遍地、列表交互复杂。如果只算业务逻辑,四万行并不夸张。但问题在于,里面大量代码是在模拟浏览器原生行为:自定义弹窗组件,光是遮罩、焦点管理、点击外部关闭、动画切换就占了约 5000 行;折叠面板/手风琴组件,需要自己做展开收起、互斥、动画,约 2500 行;浮层定位相关,包括下拉菜单、气泡提示、右键菜单的坐标计算,约 6000 行;表单校验模块,正则、错误状态、联动规则、校验气泡,约 9000 行;图片懒加载与滚动监听,约 1800 行;富文本区域与第三方编辑器的桥接、工具栏状态同步,约 3000 行;回到顶部、平滑滚动、锚点定位等,约 2000 行。把这些加起来,已经超过 28000 行。剩下的部分才是真正的业务逻辑、数据请求和权限控制。换句话说,我花了大量时间维护的,不是业务,而是浏览器早就提供了原生方案的“基建代码”。这个现象在早期技术选型时很难避免。团队从框架时代一路走来,习惯遇到问题先找个库或者封装一个组件,很少停下来想一想:浏览器自己能不能做?结果就是库套库、组件套组件,代码量滚雪球。1.2 触发我下决心删代码的那个凌晨真正让我决定动手的,是一次平平无奇的第三方库升级事故。那次升级之后,筛选面板的弹层在高分屏上出现了滚动穿透——用户打开筛选面板,鼠标滚轮竟然还能滚动背后的表格。排查定位整整花了两小时,最后发现我们同时叠了三层遮罩逻辑:一层来自自研组件,一层来自框架的 Modal 封装,还有一层是想修前面的 bug 临时加的补丁。当时我抱着试试看的心态,用一个原生<dialog>元素把同一个面板重写了一遍,效果竟然只有十来行代码,滚动穿透问题从根上消失。那一刻我有点恍惚:这些年我维护的到底是什么?于是我做了一个粗糙的统计:把四万行代码按“浏览器原生是否已支持”分了个类。结论很清晰——至少有 70% 的代码属于“补课式代码”,浏览器早就给了更干净的答案。1.3 重构的开始:先立规矩,别再造轮子那个周末,我给团队立了一条硬性规矩:凡是原生 HTML/CSS/浏览器 API 能覆盖的场景,一律优先使用原生;只有当原生方案明确不够用或者兼容性无法接受时,才允许引入库。新代码按这个标准写,老代码按重要程度逐步重构。规则听起来很简单,执行起来却很有力量。第一个月结束,我们删掉了大约 28000 行 JS,弹窗、折叠面板、懒加载、滚动逻辑这些高频组件全部换成原生方案。而那些被删掉的代码,其实都有对应的、更优雅的替代品。下面这 9 个原生功能,就是这次重构里让我“爽翻了”的存在。2. 9 个原生功能逐个拆解:每一段被删掉的代码都有说法2.1 dialog + showModal():手写模态框可以全军退役了先说体量最大的弹窗组件。以前我们自己维护一套 Modal,里面有遮罩层、显示隐藏逻辑、焦点锁、Esc 关闭、动画类名切换,还要处理嵌套弹窗、滚动穿透等边界。这套代码在多年迭代中已经膨胀得面目全非。换成原生dialog之后,核心调用就两行:<dialog id="userDialog" class="dlg"> <div class="dlg-header">新增用户</div> <div class="dlg-body"> <!-- 表单内容 --> </div> </dialog>const dialog = document.getElementById('userDialog'); dialog.showModal();showModal()会自动做几件我们以前手动维护的事:阻止页面其他区域交互、把焦点锁进弹窗内部、按 Tab 时焦点会在弹窗里循环而不是跑到背后、Esc 自动关闭。这些能力全部由浏览器实现,代码层面什么都不用管。遮罩样式可以用::backdrop伪元素单独控制,比如加一层半透明黑底加模糊效果:dialog::backdrop { background: rgba(0, 0, 0, 0.45); backdrop-filter: blur(2px); }如果你需要表单提交后自动关闭弹窗,form标签加一个method="dialog"就够了,连 JS 都不用写。这是一个非常容易被忽略的小技巧。实际使用中需要注意,原生dialog默认样式比较朴素,边框、内边距、圆角都要自己重置一遍。此外它在旧浏览器下的支持稍微欠缺,但考虑到现在的主流环境,这个兼容成本基本可以接受,真遇到老环境也可以上 polyfill。2.2 details/summary + name 分组:手风琴折叠面板的原生答案折叠面板是我第二想骂的组件。以前我们手写一套手风琴:点击标题给内容加 class、动画展开、还要维护互斥状态——一个面板打开时自动关闭其他面板。这部分的代码结构脆弱,稍不留神就会有展开动画卡顿。现在 HTML 原生提供了details和summary,零 JS 就能实现折叠效果:<details name="faqGroup" open> <summary>项目初始化常见问题</summary> <p>这里是折叠后的内容区域,展开收起完全由浏览器管理。</p> </details> <details name="faqGroup"> <summary>部署时需要注意什么</summary> <p>另一组内容。</p> </details>注意name属性,这是近年 HTML 新增的能力:同一name下的多个details会自动形成互斥关系,打开一个就会关闭另一个,完美替代手风琴逻辑。展开动画方面,以前需要 JS 测量高度、做过渡。现在 CSS 层面已经逐渐支持interpolate-size: allow-keywords,配合height: auto就能让原生展开过程变得平滑:details { interpolate-size: allow-keywords; } details::details-content { height: auto; transition: height 0.25s ease; }不需要 JS,不需要测量高度。当然在旧浏览器上这个动画能力可能打折,但退一步讲,没有动画的直接展开也不是不能接受,至少不会像以前那样动画写崩。2.3 popover + togglePopover():浮层、气泡和下拉菜单的新方向浮层定位是我以前最头疼的领域。下拉菜单、信息气泡、右键菜单、提示卡片,每一处都需要算坐标、监听点击外部、处理滚动跟随、管理 z-index。为了处理“点击外部关闭”,我写过无数个document.addEventListener('click', ...)监听器。原生popover属性做了一次很彻底的解放:<button popovertarget="toolbarMenu" type="button">更多操作</button> <div id="toolbarMenu" popover> <button>编辑</button> <button>删除</button> <button>导出</button> </div>popover元素默认隐藏,带该属性的元素会被浏览器自动放到“顶层绘制上下文”里,天然不被其他元素的z-index压制。点击触发按钮会弹出,点击页面其他区域会自动关闭,Esc 也能关闭。想要手动控制,可以调用popover.togglePopover()或showPopover()/hidePopover()。在样式上,可以针对.popover-open状态或:popover-open伪类写规则,实现入场动画:[id="toolbarMenu"]:popover-open { opacity: 1; transform: translateY(0); }现在还有配套的 CSS 锚定定位(Anchor Positioning)在推进中,等它铺开之后,浮层跟随触发按钮的定位问题也会彻底解决。当前的版本里,临时定位可以自己写几条 CSS,量级比以前小太多。2.4 CSS :has() 选择器:今天就删掉 300 行 JS这是一个纯 CSS 能力,但对 JS 代码量的削减效果惊人。以前要判断“某个输入框有值时显示清除按钮”“某个表单字段校验失败时显示错误提示”,常规做法是监听input事件、切换 class。:has()选择器允许 CSS 根据子元素或后代状态反向控制父元素。一个典型例子:.input-group:has(input:not(:placeholder-shown)) .clear-btn { display: inline-flex; }这段规则的意思是:当.input-group内的输入框有真实内容时,让对应的清除按钮显示出来。以前这段逻辑至少要写七八行 JS,加上防抖、事件绑定、销毁清理,换成 CSS 之后完全是声明式的。更惊艳的是:has()配合表单校验选择器。比如当用户输错邮箱时,自动显示错误提示块:.field:has(input:user-invalid) .field-error { display: block; }这里面有两个新东西::has()反向选择,:user-invalid表示“用户已经交互过且值不合法”。这两者叠加,让错误提示的显示逻辑完全从 JS 中剥离。一次改版中,我只删掉了一个密集校验页面的 300 行 JS,全部换成 CSS 规则。必须提醒一句::has()是相对较新的特性,在完全不支持的旧浏览器里,整条 CSS 规则会被忽略。稳妥做法是用@supports selector(:has(*))包裹新方案,再给旧环境留一个 class 切换的降级路径。2.5 loading="lazy" 与 解码叠加:图片加载不用管了图片懒加载以前靠什么做?最典型的方案是引入懒加载库,或者自己写IntersectionObserver监听图片进入视口,再做一系列scroll/resize监听与卸载逻辑。这套代码不复杂,但很啰嗦,而且容易在复用过程中埋雷。现在只要在img标签上写:<img src="cover.jpg" alt="封面图" loading="lazy" decoding="async">浏览器会在图片即将进入视口时自动开始加载,开发者不需要任何 JS。loading="lazy"控制懒加载行为,decoding="async"让图片解码不阻塞页面渲染,两者配合,图片性能优化基本可以躺平。有两点实操建议:首屏关键图片不要加loading="lazy",因为浏览器需要它尽快加载;对真正重要的图片可以用fetchpriority="high"提高加载优先级,比如页面头图或核心商品图。这个替换给我省掉了差不多 1800 行监听代码。以前还要考虑“图片已经进入视口后移除监听”,现在这些细节完全不用管。2.6 Constraint Validation API:表单校验的最后一层老茧表单校验模块是我删得最痛快的一块,因为它足足占了 9000 多行。老代码里有正则校验函数、错误文案管理、校验触发时机控制、错误气泡展示、字段联动,逻辑密密麻麻。HTML5 原生有一整套 Constraint Validation:required、pattern、min/max、minlength/maxlength、type="email"等等。配合checkValidity()/reportValidity()方法,可以完成绝大多数校验能力:const form = document.querySelector('#registerForm'); form.addEventListener('submit', (event) => { if (!form.checkValidity()) { event.preventDefault(); form.reportValidity(); return; } // 校验通过,继续走业务逻辑 });错误样式可以通过 CSS 的:invalid、:valid、:user-invalid选择器控制。:user-invalid特别实用:只有用户真实操作过且值不合法时才显示错误样式,避免页面一打开就是一片红。要说原生表单校验的短板,主要是复杂联动校验——比如“选了这个选项后另一个字段必填”“密码与确认密码一致”这类场景。没关系,这类逻辑只占 20%,其余 80% 都用原生搞定后,错误提示、正则匹配这些代码量已经少得可以忽略。团队现在只保留一个约 40 行的轻量校验补充函数,专门处理联动需求。2.7 scroll-behavior + scroll-snap:下面那些滚动库可以扔了滚动相关的第三方库,以前几乎是后台项目的标配:平滑滚动、锚点定位、回到顶部、滚动监听的节流处理。我甚至见过为了一个“平滑滚动到某个 tab”引入一个动画库的项目。CSS 原生方案很简单:html { scroll-behavior: smooth; }一行设置,所有锚点跳转和scrollIntoView()操作自动变成平滑过渡。再配合 JS 侧:document.querySelector('#section-2').scrollIntoView({ behavior: 'smooth', block: 'start', });比写任何第三方库都直白。横向滚动吸附更适合做页面里的卡片轮播或导航切换:.scroll-snap-list { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; } .scroll-snap-list .item { scroll-snap-align: start; }浏览器会自动把滚动停在每个卡片边缘,不需要我们计算位置、做惯性调整。这个特性起效后,列表轮播的逻辑立刻少了一套。还有个容易被忽略的scrollend事件:以前判断“用户到底滚完了没”,靠的是 scroll 监听加定时器防抖,现在可以直接监听scrollend,一次事件在手,回到顶部按钮的显示隐藏、目录高亮的更新都变得干净利落。2.8 contenteditable 与原生剪贴板:轻量富文本不用上重型编辑器内容输入这块,团队曾经陷入过“什么都想上编辑器库”的误区。一个简单的评论框,也要加载几十 KB 的富文本编辑器。后来我们做了一个分类:真正需要复杂排版、表格、图片上传的区域保留编辑器库,剩下的场景全部退回contenteditable。<div contenteditable="true" class="comment-editor"></div>contenteditable可能听起来简陋,但配合样式重置和简单工具栏,足以覆盖评论、备注、标注等轻量编辑场景。代码没有新增依赖,浏览器原生就支持编辑状态。还有剪贴板操作,以前复制文本要创建一个隐藏的textarea、聚焦、execCommand('copy'),最后删除节点,一整套操作既丑又不稳定。现在直接:async function copyText(text) { await navigator.clipboard.writeText(text); }不过要提醒,navigator.clipboard需要页面在安全上下文(HTTPS 或 localhost)中才能用。HTTP 内网环境还是要走旧的textarea回退方案,这个我在实际部署时踩过坑,后面单列一节详说。2.9 原生动画与 View Transitions:交互过渡的轻量平替以前实现“筛选条件变化后列表刷新”,常见的做法是加一个 loading 遮罩,再重新渲染列表。视觉上生硬,代码上还得写动画状态。现在document.startViewTransition()可以做出丝滑的页面内容过渡:function applyFilter(filterParams) { document.startViewTransition(() => { renderList(filterParams); }); }一行调用,浏览器会捕捉旧画面到新画面的过渡帧,自动生成淡入淡出和位移动画。列表切换筛选条件时,视觉效果一下子柔和了很多,而成本只是几行代码。更底层一点,Web Animations API(WAAPI)的element.animate()可以替代很多轻量动画库:element.animate( [ { opacity: 0, transform: 'scale(0.8)' }, { opacity: 1, transform: 'scale(1)' } ], { duration: 200, easing: 'ease-out' } );不需要引入动画库,不需要写 CSS 类,命令式动画开箱即用。当然 View Transitions 和 WAAPI 对复杂编排的支持仍然有限,遇到帧级编排的场景,专业动画库还是绕不开的。后面会细聊哪些场景被我们打了回去。3. 原生功能落地时的真实坑:样式、兼容与返工记录原生的东西用起来很爽,但落地过程远不是“零成本”。我在这次重构里踩了不少坑,攒下的经验直接影响了团队的编码规范。3.1 样式重置:原生组件默认又丑又乱原生组件的默认样式,说实话离产品需求差得远。dialog自带边框、内边距、居中样式,但不同浏览器里表现不一;details的展开小三角在各浏览器里长得不一样;popover默认没有背景色也没有内边距。我给项目加了一个 base 层,把这些原生组件的样式统一重置:dialog { border: none; border-radius: 12px; padding: 16px; max-width: min(680px, calc(100% - 32px)); } dialog::backdrop { background: rgba(0, 0, 0, 0.45); } [popover] { border: 1px solid #ddd; border-radius: 8px; padding: 8px; background: #fff; } summary { cursor: pointer; list-style: none; }这里最容易被忽略的是dialog的居中问题:它默认在视口中央,但如果页面很长,滚动后打开弹窗,它依然会跑到可视区域中央,这个行为其实是对的,很多团队却误以为出了 bug 去拼命改定位样式。summary的小三角也要处理一下:不同浏览器显示不同,想要完全隐藏,要写list-style: none加上::-webkit-details-marker { display: none; }这类兼容规则。3.2 兼容性边界与渐进增强原生功能各有各的兼容性年龄。dialog和details相对成熟;但popover、:has()、View Transitions、scrollend事件在旧浏览器里大概率翻车。我的处理方式是“渐进增强 + 按需降级”。具体套路如下:在 CSS 中用@supports做特性检测,原生能力可用时使用新的 CSS 规则,否则沿用之前的 class 切换方案;在 JS 中用if ('showPopover' in element)判断,支持就走原生,不支持就动态加载一个轻量定位库;不在项目里全局引入 polyfill,而是把兼容库放到按需加载的异步模块里。举个例子,:has()降级:.field .clear-btn { display: none; } @supports selector(:has(input:not(:placeholder-shown))) { .field:has(input:not(:placeholder-shown)) .clear-btn { display: inline-flex; } }这种做法既拥抱了原生能力,又没有把老环境用户彻底抛弃。3.3 返工记录:哪些原生功能被我们“打回原形”这一节是实话实说:不是所有原生方案都一用到底,有三块场景我们最终做了回退。第一块是复杂富文本。contenteditable在简单评论框场景够用,但一旦遇到嵌套表格、图片拖拽、样式复杂的文档编辑,它的维护成本迅速上升。我们把真正需要复杂编辑的区域换回了专业编辑器库,contenteditable只保留给轻量输入框。第二块是帧级动画编排。View Transitions 适合页面级过场,WAAPI 适合单元素动画,但项目里有个引导页需要多个步骤的精确时间线控制,最后我们还是引入了动画编排库做专业时间线。第三块是跨浏览器一致性问题。有几个老旧环境下的内部客户机还跑着旧内核浏览器,popover和 View Transitions 都不支持。我们保留了一个最小兼容层:只在检测到不支持时才加载对应的降级库。这些回退不代表原生方案失败。它们只是说明:原生功能适合解决高频、标准化的交互,而极度复杂、非标准化的业务始终值得用专业工具。我个人的判断标准是:如果这个交互“所有后台系统都有”,就先找原生方案;如果是“我们系统独有的”,再考虑专属库。4. 删掉四万行代码之后:工程变化与实践逻辑4.1 代码量、依赖与构建:瘦的不只是代码重构结束后的数字对比,比任何长篇大论都有说服力:指标重构前重构后主仓库 JS 总量4 万行左右6 千行出头npm 依赖数量150 多个60 个不到构建时间75 秒14 秒首屏 JS 体积(压缩后)420 KB约 120 KB依赖减少带来的连锁反应是:node_modules体积变小,CI 安装依赖的时间变短,锁文件冲突的频率直线下降。曾经为了修复某个传递依赖的安全公告,我们要跟着升级半套生态;现在依赖少了,这种被连带升级支配的恐惧也少了大半。4.2 该保留的自定义轮子:别为了删而删删代码不是目的,“减少没必要的补课代码”才是目的。这次重构里我们保留了四样东西,它们属于业务核心逻辑:统一的请求封装与错误上报;权限控制数据模型和组件级指令;复杂表格的状态管理(跨页选中、批量操作、行列合并);路由层和服务端渲染的配置。这些东西没有任何原生替代品,它们本身就是业务的一部分。删代码的正确判断标准是:这段 JS 是不是在替代 HTML/CSS/浏览器本来该做的事?如果是,删;如果它是在表达只有业务才懂的逻辑,留。4.3 稳定性与维护:少一层依赖,少一层风险原生能力最踏实的点在于,它们随浏览器升级而演进,不会因为某个库停止维护而被迫重构。重构完成后至今,我们没有遇到过因为浏览器升级导致的兼容故障,反而有一次基础库废弃公告让团队庆幸当初做了减法。现在代码评审时,团队多了一条不成文的规定:如果一段功能要引入新库,提交者需要在评审里写清楚“原生能力为什么不可行”。这条规定倒逼大家先查一遍平台能力,再决定要不要加依赖。执行下来,新功能的开发速度没有变慢,反而因为少了很多配置代码而更快了。当然,“原生很好”不等于“所有项目都必须裸奔”。如果你的团队服务的是企业级存量系统,用户浏览器环境又严格锁死,那渐进增强、分模块引入才是更稳的路子。我这个项目恰好属于内部可控环境,所以能做激进删码。换一个环境,同样的策略要打折扣。这次删码经历里,我最大的体会是:删代码的爽感不在“删”这个动作本身,而在写代码的人重新认识了浏览器这个平台。它远不是我们想象中那个需要各种库来补齐的工具,而是已经内置了大量交互能力的成熟平台。最后分享一个团队现在仍在用的习惯:建一个原生能力日志文档。每当谁发现一个可以替代库的原生功能,就记录一行,包括用法、兼容情况、应用场景。这个文档已经积累了好几十条,新同学写需求时先查文档再决定要不要引入依赖。这个方法比任何口头规范都管用,也让我们的代码越来越“轻”。
凌晨两点,滚动穿透问题第三次爆发。我盯着那套已经升级过五次的弹窗组件,突然意识到一个事实:这个后台项目里躺着的四万多行 JavaScript,有一大半是在给 HTML5 本该自己搞定的事情“补课”。那次之后,我花了三周时间做了一次大胆的删码实验——把所有能用原生能力替代的交互逻辑全部换成原生方案,最终仓库里的 JS 从四万行降到六千行出头。堪称“裸奔”的这段经历里,有 9 个原生功能给我的冲击最大,它们不仅省掉了大量代码,还让项目稳定性上了一个台阶。这事听起来像激进派前端在喊口号,但数据摆在眼前:弹窗、折叠面板、表单校验、图片懒加载、平滑滚动、浮层定位、简单富文本、交互动画,这些东西真的可以不用写一行业务 JS。这篇文章我会把每一个替换思路、代码对比、以及实际落地时踩过的坑都摊开来讲,适合那些正被各种库绑架、想在下一个项目里做减法的团队参考。1. 四万行 JS 是怎么攒出来的:一台后台的“过度封装症”1.1 为什么一个后台能积攒四万行 JS这个项目是一个典型的中后台管理系统,页面数量多、表单密集、弹窗遍地、列表交互复杂。如果只算业务逻辑,四万行并不夸张。但问题在于,里面大量代码是在模拟浏览器原生行为:自定义弹窗组件,光是遮罩、焦点管理、点击外部关闭、动画切换就占了约 5000 行;折叠面板/手风琴组件,需要自己做展开收起、互斥、动画,约 2500 行;浮层定位相关,包括下拉菜单、气泡提示、右键菜单的坐标计算,约 6000 行;表单校验模块,正则、错误状态、联动规则、校验气泡,约 9000 行;图片懒加载与滚动监听,约 1800 行;富文本区域与第三方编辑器的桥接、工具栏状态同步,约 3000 行;回到顶部、平滑滚动、锚点定位等,约 2000 行。把这些加起来,已经超过 28000 行。剩下的部分才是真正的业务逻辑、数据请求和权限控制。换句话说,我花了大量时间维护的,不是业务,而是浏览器早就提供了原生方案的“基建代码”。这个现象在早期技术选型时很难避免。团队从框架时代一路走来,习惯遇到问题先找个库或者封装一个组件,很少停下来想一想:浏览器自己能不能做?结果就是库套库、组件套组件,代码量滚雪球。1.2 触发我下决心删代码的那个凌晨真正让我决定动手的,是一次平平无奇的第三方库升级事故。那次升级之后,筛选面板的弹层在高分屏上出现了滚动穿透——用户打开筛选面板,鼠标滚轮竟然还能滚动背后的表格。排查定位整整花了两小时,最后发现我们同时叠了三层遮罩逻辑:一层来自自研组件,一层来自框架的 Modal 封装,还有一层是想修前面的 bug 临时加的补丁。当时我抱着试试看的心态,用一个原生<dialog>元素把同一个面板重写了一遍,效果竟然只有十来行代码,滚动穿透问题从根上消失。那一刻我有点恍惚:这些年我维护的到底是什么?于是我做了一个粗糙的统计:把四万行代码按“浏览器原生是否已支持”分了个类。结论很清晰——至少有 70% 的代码属于“补课式代码”,浏览器早就给了更干净的答案。1.3 重构的开始:先立规矩,别再造轮子那个周末,我给团队立了一条硬性规矩:凡是原生 HTML/CSS/浏览器 API 能覆盖的场景,一律优先使用原生;只有当原生方案明确不够用或者兼容性无法接受时,才允许引入库。新代码按这个标准写,老代码按重要程度逐步重构。规则听起来很简单,执行起来却很有力量。第一个月结束,我们删掉了大约 28000 行 JS,弹窗、折叠面板、懒加载、滚动逻辑这些高频组件全部换成原生方案。而那些被删掉的代码,其实都有对应的、更优雅的替代品。下面这 9 个原生功能,就是这次重构里让我“爽翻了”的存在。2. 9 个原生功能逐个拆解:每一段被删掉的代码都有说法2.1 dialog + showModal():手写模态框可以全军退役了先说体量最大的弹窗组件。以前我们自己维护一套 Modal,里面有遮罩层、显示隐藏逻辑、焦点锁、Esc 关闭、动画类名切换,还要处理嵌套弹窗、滚动穿透等边界。这套代码在多年迭代中已经膨胀得面目全非。换成原生dialog之后,核心调用就两行:<dialog id="userDialog" class="dlg"> <div class="dlg-header">新增用户</div> <div class="dlg-body"> <!-- 表单内容 --> </div> </dialog>const dialog = document.getElementById('userDialog'); dialog.showModal();showModal()会自动做几件我们以前手动维护的事:阻止页面其他区域交互、把焦点锁进弹窗内部、按 Tab 时焦点会在弹窗里循环而不是跑到背后、Esc 自动关闭。这些能力全部由浏览器实现,代码层面什么都不用管。遮罩样式可以用::backdrop伪元素单独控制,比如加一层半透明黑底加模糊效果:dialog::backdrop { background: rgba(0, 0, 0, 0.45); backdrop-filter: blur(2px); }如果你需要表单提交后自动关闭弹窗,form标签加一个method="dialog"就够了,连 JS 都不用写。这是一个非常容易被忽略的小技巧。实际使用中需要注意,原生dialog默认样式比较朴素,边框、内边距、圆角都要自己重置一遍。此外它在旧浏览器下的支持稍微欠缺,但考虑到现在的主流环境,这个兼容成本基本可以接受,真遇到老环境也可以上 polyfill。2.2 details/summary + name 分组:手风琴折叠面板的原生答案折叠面板是我第二想骂的组件。以前我们手写一套手风琴:点击标题给内容加 class、动画展开、还要维护互斥状态——一个面板打开时自动关闭其他面板。这部分的代码结构脆弱,稍不留神就会有展开动画卡顿。现在 HTML 原生提供了details和summary,零 JS 就能实现折叠效果:<details name="faqGroup" open> <summary>项目初始化常见问题</summary> <p>这里是折叠后的内容区域,展开收起完全由浏览器管理。</p> </details> <details name="faqGroup"> <summary>部署时需要注意什么</summary> <p>另一组内容。</p> </details>注意name属性,这是近年 HTML 新增的能力:同一name下的多个details会自动形成互斥关系,打开一个就会关闭另一个,完美替代手风琴逻辑。展开动画方面,以前需要 JS 测量高度、做过渡。现在 CSS 层面已经逐渐支持interpolate-size: allow-keywords,配合height: auto就能让原生展开过程变得平滑:details { interpolate-size: allow-keywords; } details::details-content { height: auto; transition: height 0.25s ease; }不需要 JS,不需要测量高度。当然在旧浏览器上这个动画能力可能打折,但退一步讲,没有动画的直接展开也不是不能接受,至少不会像以前那样动画写崩。2.3 popover + togglePopover():浮层、气泡和下拉菜单的新方向浮层定位是我以前最头疼的领域。下拉菜单、信息气泡、右键菜单、提示卡片,每一处都需要算坐标、监听点击外部、处理滚动跟随、管理 z-index。为了处理“点击外部关闭”,我写过无数个document.addEventListener('click', ...)监听器。原生popover属性做了一次很彻底的解放:<button popovertarget="toolbarMenu" type="button">更多操作</button> <div id="toolbarMenu" popover> <button>编辑</button> <button>删除</button> <button>导出</button> </div>popover元素默认隐藏,带该属性的元素会被浏览器自动放到“顶层绘制上下文”里,天然不被其他元素的z-index压制。点击触发按钮会弹出,点击页面其他区域会自动关闭,Esc 也能关闭。想要手动控制,可以调用popover.togglePopover()或showPopover()/hidePopover()。在样式上,可以针对.popover-open状态或:popover-open伪类写规则,实现入场动画:[id="toolbarMenu"]:popover-open { opacity: 1; transform: translateY(0); }现在还有配套的 CSS 锚定定位(Anchor Positioning)在推进中,等它铺开之后,浮层跟随触发按钮的定位问题也会彻底解决。当前的版本里,临时定位可以自己写几条 CSS,量级比以前小太多。2.4 CSS :has() 选择器:今天就删掉 300 行 JS这是一个纯 CSS 能力,但对 JS 代码量的削减效果惊人。以前要判断“某个输入框有值时显示清除按钮”“某个表单字段校验失败时显示错误提示”,常规做法是监听input事件、切换 class。:has()选择器允许 CSS 根据子元素或后代状态反向控制父元素。一个典型例子:.input-group:has(input:not(:placeholder-shown)) .clear-btn { display: inline-flex; }这段规则的意思是:当.input-group内的输入框有真实内容时,让对应的清除按钮显示出来。以前这段逻辑至少要写七八行 JS,加上防抖、事件绑定、销毁清理,换成 CSS 之后完全是声明式的。更惊艳的是:has()配合表单校验选择器。比如当用户输错邮箱时,自动显示错误提示块:.field:has(input:user-invalid) .field-error { display: block; }这里面有两个新东西::has()反向选择,:user-invalid表示“用户已经交互过且值不合法”。这两者叠加,让错误提示的显示逻辑完全从 JS 中剥离。一次改版中,我只删掉了一个密集校验页面的 300 行 JS,全部换成 CSS 规则。必须提醒一句::has()是相对较新的特性,在完全不支持的旧浏览器里,整条 CSS 规则会被忽略。稳妥做法是用@supports selector(:has(*))包裹新方案,再给旧环境留一个 class 切换的降级路径。2.5 loading="lazy" 与 解码叠加:图片加载不用管了图片懒加载以前靠什么做?最典型的方案是引入懒加载库,或者自己写IntersectionObserver监听图片进入视口,再做一系列scroll/resize监听与卸载逻辑。这套代码不复杂,但很啰嗦,而且容易在复用过程中埋雷。现在只要在img标签上写:<img src="cover.jpg" alt="封面图" loading="lazy" decoding="async">浏览器会在图片即将进入视口时自动开始加载,开发者不需要任何 JS。loading="lazy"控制懒加载行为,decoding="async"让图片解码不阻塞页面渲染,两者配合,图片性能优化基本可以躺平。有两点实操建议:首屏关键图片不要加loading="lazy",因为浏览器需要它尽快加载;对真正重要的图片可以用fetchpriority="high"提高加载优先级,比如页面头图或核心商品图。这个替换给我省掉了差不多 1800 行监听代码。以前还要考虑“图片已经进入视口后移除监听”,现在这些细节完全不用管。2.6 Constraint Validation API:表单校验的最后一层老茧表单校验模块是我删得最痛快的一块,因为它足足占了 9000 多行。老代码里有正则校验函数、错误文案管理、校验触发时机控制、错误气泡展示、字段联动,逻辑密密麻麻。HTML5 原生有一整套 Constraint Validation:required、pattern、min/max、minlength/maxlength、type="email"等等。配合checkValidity()/reportValidity()方法,可以完成绝大多数校验能力:const form = document.querySelector('#registerForm'); form.addEventListener('submit', (event) => { if (!form.checkValidity()) { event.preventDefault(); form.reportValidity(); return; } // 校验通过,继续走业务逻辑 });错误样式可以通过 CSS 的:invalid、:valid、:user-invalid选择器控制。:user-invalid特别实用:只有用户真实操作过且值不合法时才显示错误样式,避免页面一打开就是一片红。要说原生表单校验的短板,主要是复杂联动校验——比如“选了这个选项后另一个字段必填”“密码与确认密码一致”这类场景。没关系,这类逻辑只占 20%,其余 80% 都用原生搞定后,错误提示、正则匹配这些代码量已经少得可以忽略。团队现在只保留一个约 40 行的轻量校验补充函数,专门处理联动需求。2.7 scroll-behavior + scroll-snap:下面那些滚动库可以扔了滚动相关的第三方库,以前几乎是后台项目的标配:平滑滚动、锚点定位、回到顶部、滚动监听的节流处理。我甚至见过为了一个“平滑滚动到某个 tab”引入一个动画库的项目。CSS 原生方案很简单:html { scroll-behavior: smooth; }一行设置,所有锚点跳转和scrollIntoView()操作自动变成平滑过渡。再配合 JS 侧:document.querySelector('#section-2').scrollIntoView({ behavior: 'smooth', block: 'start', });比写任何第三方库都直白。横向滚动吸附更适合做页面里的卡片轮播或导航切换:.scroll-snap-list { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; } .scroll-snap-list .item { scroll-snap-align: start; }浏览器会自动把滚动停在每个卡片边缘,不需要我们计算位置、做惯性调整。这个特性起效后,列表轮播的逻辑立刻少了一套。还有个容易被忽略的scrollend事件:以前判断“用户到底滚完了没”,靠的是 scroll 监听加定时器防抖,现在可以直接监听scrollend,一次事件在手,回到顶部按钮的显示隐藏、目录高亮的更新都变得干净利落。2.8 contenteditable 与原生剪贴板:轻量富文本不用上重型编辑器内容输入这块,团队曾经陷入过“什么都想上编辑器库”的误区。一个简单的评论框,也要加载几十 KB 的富文本编辑器。后来我们做了一个分类:真正需要复杂排版、表格、图片上传的区域保留编辑器库,剩下的场景全部退回contenteditable。<div contenteditable="true" class="comment-editor"></div>contenteditable可能听起来简陋,但配合样式重置和简单工具栏,足以覆盖评论、备注、标注等轻量编辑场景。代码没有新增依赖,浏览器原生就支持编辑状态。还有剪贴板操作,以前复制文本要创建一个隐藏的textarea、聚焦、execCommand('copy'),最后删除节点,一整套操作既丑又不稳定。现在直接:async function copyText(text) { await navigator.clipboard.writeText(text); }不过要提醒,navigator.clipboard需要页面在安全上下文(HTTPS 或 localhost)中才能用。HTTP 内网环境还是要走旧的textarea回退方案,这个我在实际部署时踩过坑,后面单列一节详说。2.9 原生动画与 View Transitions:交互过渡的轻量平替以前实现“筛选条件变化后列表刷新”,常见的做法是加一个 loading 遮罩,再重新渲染列表。视觉上生硬,代码上还得写动画状态。现在document.startViewTransition()可以做出丝滑的页面内容过渡:function applyFilter(filterParams) { document.startViewTransition(() => { renderList(filterParams); }); }一行调用,浏览器会捕捉旧画面到新画面的过渡帧,自动生成淡入淡出和位移动画。列表切换筛选条件时,视觉效果一下子柔和了很多,而成本只是几行代码。更底层一点,Web Animations API(WAAPI)的element.animate()可以替代很多轻量动画库:element.animate( [ { opacity: 0, transform: 'scale(0.8)' }, { opacity: 1, transform: 'scale(1)' } ], { duration: 200, easing: 'ease-out' } );不需要引入动画库,不需要写 CSS 类,命令式动画开箱即用。当然 View Transitions 和 WAAPI 对复杂编排的支持仍然有限,遇到帧级编排的场景,专业动画库还是绕不开的。后面会细聊哪些场景被我们打了回去。3. 原生功能落地时的真实坑:样式、兼容与返工记录原生的东西用起来很爽,但落地过程远不是“零成本”。我在这次重构里踩了不少坑,攒下的经验直接影响了团队的编码规范。3.1 样式重置:原生组件默认又丑又乱原生组件的默认样式,说实话离产品需求差得远。dialog自带边框、内边距、居中样式,但不同浏览器里表现不一;details的展开小三角在各浏览器里长得不一样;popover默认没有背景色也没有内边距。我给项目加了一个 base 层,把这些原生组件的样式统一重置:dialog { border: none; border-radius: 12px; padding: 16px; max-width: min(680px, calc(100% - 32px)); } dialog::backdrop { background: rgba(0, 0, 0, 0.45); } [popover] { border: 1px solid #ddd; border-radius: 8px; padding: 8px; background: #fff; } summary { cursor: pointer; list-style: none; }这里最容易被忽略的是dialog的居中问题:它默认在视口中央,但如果页面很长,滚动后打开弹窗,它依然会跑到可视区域中央,这个行为其实是对的,很多团队却误以为出了 bug 去拼命改定位样式。summary的小三角也要处理一下:不同浏览器显示不同,想要完全隐藏,要写list-style: none加上::-webkit-details-marker { display: none; }这类兼容规则。3.2 兼容性边界与渐进增强原生功能各有各的兼容性年龄。dialog和details相对成熟;但popover、:has()、View Transitions、scrollend事件在旧浏览器里大概率翻车。我的处理方式是“渐进增强 + 按需降级”。具体套路如下:在 CSS 中用@supports做特性检测,原生能力可用时使用新的 CSS 规则,否则沿用之前的 class 切换方案;在 JS 中用if ('showPopover' in element)判断,支持就走原生,不支持就动态加载一个轻量定位库;不在项目里全局引入 polyfill,而是把兼容库放到按需加载的异步模块里。举个例子,:has()降级:.field .clear-btn { display: none; } @supports selector(:has(input:not(:placeholder-shown))) { .field:has(input:not(:placeholder-shown)) .clear-btn { display: inline-flex; } }这种做法既拥抱了原生能力,又没有把老环境用户彻底抛弃。3.3 返工记录:哪些原生功能被我们“打回原形”这一节是实话实说:不是所有原生方案都一用到底,有三块场景我们最终做了回退。第一块是复杂富文本。contenteditable在简单评论框场景够用,但一旦遇到嵌套表格、图片拖拽、样式复杂的文档编辑,它的维护成本迅速上升。我们把真正需要复杂编辑的区域换回了专业编辑器库,contenteditable只保留给轻量输入框。第二块是帧级动画编排。View Transitions 适合页面级过场,WAAPI 适合单元素动画,但项目里有个引导页需要多个步骤的精确时间线控制,最后我们还是引入了动画编排库做专业时间线。第三块是跨浏览器一致性问题。有几个老旧环境下的内部客户机还跑着旧内核浏览器,popover和 View Transitions 都不支持。我们保留了一个最小兼容层:只在检测到不支持时才加载对应的降级库。这些回退不代表原生方案失败。它们只是说明:原生功能适合解决高频、标准化的交互,而极度复杂、非标准化的业务始终值得用专业工具。我个人的判断标准是:如果这个交互“所有后台系统都有”,就先找原生方案;如果是“我们系统独有的”,再考虑专属库。4. 删掉四万行代码之后:工程变化与实践逻辑4.1 代码量、依赖与构建:瘦的不只是代码重构结束后的数字对比,比任何长篇大论都有说服力:指标重构前重构后主仓库 JS 总量4 万行左右6 千行出头npm 依赖数量150 多个60 个不到构建时间75 秒14 秒首屏 JS 体积(压缩后)420 KB约 120 KB依赖减少带来的连锁反应是:node_modules体积变小,CI 安装依赖的时间变短,锁文件冲突的频率直线下降。曾经为了修复某个传递依赖的安全公告,我们要跟着升级半套生态;现在依赖少了,这种被连带升级支配的恐惧也少了大半。4.2 该保留的自定义轮子:别为了删而删删代码不是目的,“减少没必要的补课代码”才是目的。这次重构里我们保留了四样东西,它们属于业务核心逻辑:统一的请求封装与错误上报;权限控制数据模型和组件级指令;复杂表格的状态管理(跨页选中、批量操作、行列合并);路由层和服务端渲染的配置。这些东西没有任何原生替代品,它们本身就是业务的一部分。删代码的正确判断标准是:这段 JS 是不是在替代 HTML/CSS/浏览器本来该做的事?如果是,删;如果它是在表达只有业务才懂的逻辑,留。4.3 稳定性与维护:少一层依赖,少一层风险原生能力最踏实的点在于,它们随浏览器升级而演进,不会因为某个库停止维护而被迫重构。重构完成后至今,我们没有遇到过因为浏览器升级导致的兼容故障,反而有一次基础库废弃公告让团队庆幸当初做了减法。现在代码评审时,团队多了一条不成文的规定:如果一段功能要引入新库,提交者需要在评审里写清楚“原生能力为什么不可行”。这条规定倒逼大家先查一遍平台能力,再决定要不要加依赖。执行下来,新功能的开发速度没有变慢,反而因为少了很多配置代码而更快了。当然,“原生很好”不等于“所有项目都必须裸奔”。如果你的团队服务的是企业级存量系统,用户浏览器环境又严格锁死,那渐进增强、分模块引入才是更稳的路子。我这个项目恰好属于内部可控环境,所以能做激进删码。换一个环境,同样的策略要打折扣。这次删码经历里,我最大的体会是:删代码的爽感不在“删”这个动作本身,而在写代码的人重新认识了浏览器这个平台。它远不是我们想象中那个需要各种库来补齐的工具,而是已经内置了大量交互能力的成熟平台。最后分享一个团队现在仍在用的习惯:建一个原生能力日志文档。每当谁发现一个可以替代库的原生功能,就记录一行,包括用法、兼容情况、应用场景。这个文档已经积累了好几十条,新同学写需求时先查文档再决定要不要引入依赖。这个方法比任何口头规范都管用,也让我们的代码越来越“轻”。