纯原生前端:零依赖实现长期可维护Web项目的完整实践

发布时间:2026/9/26 13:21:33
纯原生前端:零依赖实现长期可维护Web项目的完整实践 见过太多项目在“要不要上框架”的问题上反复拉扯之后我对一句话的印象越来越深“是的这就是纯原生”。它常常出现在代码评审的最后一页、技术分享的收尾PPT或者某个仓库的 README 末尾语气不大分量却不轻源代码是什么样子线上跑的就是什么样子。没有框架、没有打包器、没有运行时依赖页面照样能交付、能上线、能长期维护。这篇文章想认真分析这四个字背后的完整逻辑。我会从一个实际项目出发讲清楚纯原生到底意味着什么、适合什么场景、有哪些只有亲手写过才会踩到的坑。如果你正在维护一个规模可控但寿命要求很长的小系统或者单纯想摆脱越来越重的依赖树这篇文章应该能给你不少可以直接落地的结论。1. 纯原生到底在表达什么1.1 “纯原生”不是“裸写”更不是原生App很多朋友一听到“原生”两个字第一反应是移动端的 Native App觉得跟 Web 没太大关系。实际上在 Web 开发里说“纯原生”指的是不依赖框架和构建工具的常规浏览器技术路线也就是直接用 HTML、CSS、JavaScript 完成整站。但请不要把纯原生和“裸写”划等号。现代浏览器早就提供了非常完整的原生能力只是很多开发者习惯性地被框架带着走反而把这些能力忘了。比如用 ES Modules 做模块拆分不再需要打包器把每个文件揉在一起。用 Custom Elements 封装自己的组件不需要往页面里塞 Vue 实例。用 CSS Custom Properties 管理主题和设计变量不需要 Sass 的预处理层。用 Fetch API 做异步请求不需要 axios 作为必选项。用 IntersectionObserver 做懒加载和埋点不需要自己去算滚动位置。我常跟团队里的小朋友说一句话纯原生不是回到原始社会而是回到一个更接近浏览器设计初衷的位置。浏览器就是一个非常强的运行时你写进去的每一份代码都是标准语言描述的逻辑。它能做的事远比你想象的多。所以当有人拍着胸脯说“是的这就是纯原生”他真正想表达的是我没有在其他人的地基上盖楼我的房子直接落在浏览器这块土地上。1.2 为什么“不用框架”反而能走得远我从很多项目里总结出的第一个重要理由是浏览器几乎不会删除老接口。你今天写的一段fetch请求十年以后大概率还能正常运行但一个第三方框架五年以后是否还在维护没有人敢打包票。曾经很有名的工具链当年也是众星捧月现在却可能连安装都报错。选纯原生等于主动把项目寿命和第三方生态的解耦做干净了你的代码不依赖任何人的更新节奏。第二个理由是透明性。框架项目里真正决定页面行为的是几层封装之后的运行逻辑开发者想看一眼具体发生了什么往往需要打断点、翻依赖、看源码。原生项目则是一个彻底的透明盒子HTML 在index.html里交互逻辑在main.js里样式在style.css里任何人打开这个项目不需要理解框架的渲染机制就能看明白。第三个理由是包体积。这部分我在后面会专门做对比但可以先给一个直觉框架应用最典型的表现是首屏需要先下载一块运行时代码再开始渲染页面。原生应用没有这一步几乎没有无谓的网络传输。当然选纯原生不是“免费午餐”它同样有代价。下一节我想把这边的边界说得更直白一点避免出现“为了原生而原生”的情况。1.3 冷静一下纯原生不适合哪些项目任何技术方案都有适用边界。纯原生适合规模可控、生命周期长、更新频率适度的系统。反之这几类项目我不建议硬扛原生大型多人协作项目。十个人以上同时开发一个复杂业务系统类型定义、组件规范、路由约定都不可能靠口头约定来维持框架的样板化约束反而是优势。对生态库需求极高的场景。比如复杂的富文本编辑器、大型图表库、拖拽式页面设计器这些领域几代人共同维护的第三方库远比你自己写的要深。每个都自己实现成本会失控。短平快的营销活动页。团队只有两三天工期与其折磨自己手写轮播图不如用一套已经成熟的框架样板快速上线快速交付。我这种判断并不是给原生泼冷水而是希望大家把工具箱看清楚框架是重型机械原生是基础手艺。要盖高楼重型机械自然效率更高但你要修一栋能传三代的小房子基础手艺反而更可靠。理解了这一点我们在项目里选择原生才算是一个主动的、深思熟虑的判断而不是一种固执的偏好。2. 一个真实项目零依赖后台看板的设计思路2.1 项目背景为什么我坚持不引入任何框架分享一个让我彻底改变看法的真实项目。它是一套企业内部用的“库存监控看板”运行在内网环境里主要功能包括首页总览、库存明细列表、预警记录、按月统计的图表另外还有几个管理页面。规模不大一共十六个页面三个人维护每月大约两次小版本更新。这套系统最早是用一个流行框架写的用了一年以后遇到一个问题框架版本从 2 升到 3 时升级成本高得惊人当时的依赖树里二三十个包都跟版本强绑定动一个就要跟着动一片。那次经历让我开始认真考虑重写时是不是可以不引入任何框架。最终我把它重写成了纯原生版本。选择的主要理由有三个部署环境是内网静态服务器目标最好是“源文件即产物”。不需要构建机不需要 npm install把目录拷贝上服务器就能跑。页面数量有限交互形态固定本质上属于表格、表单、图表的组合原生代码完全能覆盖。团队需要长期维护而内网环境封闭支撑工具链的新文件反而不容易获取。自然是越少依赖越好。重写落地的效果比较理想也正是从那时起我真正理解了“是的这就是纯原生”背后的分量。它不只是一个技术选型更是一种对运行环境诚实评估后的决策。2.2 不跑构建照样有清晰目录结构有朋友会担心“不用打包器以后代码会不会变成一堆散沙”。实际上不会至少对于中小型项目ES Modules 已经把模块化这件事做到浏览器层面了我只需要确立一个能约束自己的目录结构。当时的目录大概长这样monitor/ ├── assets/ │ ├── css/ │ │ ├── tokens.css # 设计变量 │ │ ├── base.css # 基础重置 │ │ └── pages/ # 按页面拆分的样式 │ ├── js/ │ │ ├── main.js # 入口 │ │ ├── router.js # hash 路由 │ │ ├── utils/ # 工具函数 │ │ └── components/ # 自定义元素 │ └── data/ │ └── config.json # 菜单、接口地址等配置 ├── index.html ├── inventory.html ├── alerts.html └── stats.html入口main.js做的事情很简单加载路由模块扫描哪个页面当前应该渲染再把对应模块通过import引进来。整个项目没有封装一层“虚拟DOM”因为页面切换本来就不频繁每次整块替换内容区域完全够用。而config.json是这套系统里非常重要的一个文件。主题色、菜单项、接口地址全部放在这用fetch拉一次全局共享。这样环境迁移或者客户调整菜单改改 JSON 就能上线完全不用动 JavaScript。这个目录设计相比框架项目的优势很清楚不需要任何工具计算依赖图所有依赖关系都写在import语句里浏览器自己就能正确加载。出问题时也容易排查哪个页面坏了打开对应的组件源文件直接查。2.3 设计阶段的三个折中决定重写过程中有三件事我觉得值得单独写出来。第一用 hash 路由不做 history 路由。内网系统不需要优雅的 URL也不需要考虑 SEOhash 路由实现成本最低刷新不丢页面也不依赖服务器配置 rewrite。原生写下来只有几十行window.addEventListener(hashchange, render); function render() { const route location.hash.replace(#/, ) || dashboard; // 简单映射后加载对应模块 }第二组件用 Custom Elements但初期不开 Shadow DOM。很多教程都会教你用attachShadow开启封闭样式但在实际项目里一旦全部组件进入 Shadow DOM全局 CSS 变量、字体、公共重置的传递就会变得麻烦。我的做法是第一版全部采用 light DOM 加 scoped class让全局样式能够照常生效等某个组件确实需要硬隔离样式时再单独对它开启 Shadow DOM。这个“按需使用”的思路比一上来就全面封装来得实际。第三把配置数据从代码里抽出来。很多人习惯把接口地址写成const API xxx放在main.js里改环境就改代码。我的后期经验告诉我所有环境相关的东西都应该放到config.json里代码保持纯粹的逻辑配置保持纯粹的配置。这一点看起来很小实际维护时省了无数沟通成本。3. 核心实现我用到而且推荐的原生写法3.1 用 Custom Elements 把页面拆成可复用部件纯原生项目同样要面对“组件化”的问题。我的答案很简单用浏览器自带的 Custom Elements。举个例子我的库存看板里有一块预警卡片组件它会请求最新预警数据展示最近几条并且提供“查看全部”的入口。第一版代码是这样组织的class AlertCard extends HTMLElement { connectedCallback() { this._renderLoading(); this._fetchData(); } async _fetchData() { try { const data await getJson(/api/alerts?limit5); this._render(data); } catch (err) { this._renderError(err); } } } customElements.define(alert-card, AlertCard);使用的时候页面里就引用一行 HTMLalert-card/alert-card这个组件不依赖任何框架按模块加载自己想怎么组织就怎么组织。和框架组件相比它最让我满意的一点是组件的实例生命周期完全交给浏览器管理。某个节点从 DOM 里移除时对应的组件也跟着销毁不需要手动清理内存。到后来项目里的基本元素都按这个模式拆了预警卡片、库存表格、月度柱状图、筛选表单、分页按钮。每个组件一个文件文件内包含自己的样式和逻辑整体维护起来非常顺手。3.2 不用预处理器用 CSS 变量安排整套设计没有了 Sass / Less样式系统怎么组织我的答案是 CSS Custom Properties也就是常说的 CSS 变量。它的能力比很多人理解得要强。我的tokens.css里集中定义了一整套设计变量:root { --c-text: #1a2430; --c-text-secondary: #72808e; --c-bg: #f6f8fa; --c-card: #ffffff; --c-border: #e4e8ec; --c-primary: #2f6fed; --c-danger: #d64545; --c-warning: #e6a700; --space-xs: 4px; --space-sm: 8px; --space-md: 16px; --space-lg: 24px; --space-xl: 40px; --radius-sm: 6px; --radius-md: 10px; --radius-lg: 16px; --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); }后面所有组件写样式只关心“用什么变量”不关心具体值是多少。这样带来的好处是普通 CSS 根本无法替代的改一个主题色整个系统里所有组件同步变色需要做暗色模式时只要在:root里重新覆盖一套变量值加上一个类名就行不需要重新扫描所有样式文件。配合现代布局代码既简洁又有表现力。比如库存表格的响应式布局我常用这种写法.grid-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: var(--space-md); }不需要写媒体查询它就能在窄屏自动变成单列。CSS 本身就一直在进步如果你还停留在“不用预处理器就没法写复杂样式”的旧印象里值得重新看看当下浏览器支持的能力。3.3 请求与异步用最接近浏览器原生的方式做数据交互纯原生项目里数据请求基本就是fetch 一点点工具封装。我建议不要把fetch再包一层很厚的库因为它本身已经足够好用。实际项目中我用了一个很轻的封装export async function getJson(url, { signal } {}) { const resp await fetch(url, { headers: { Accept: application/json }, signal, }); if (!resp.ok) { throw new Error(${resp.status} ${resp.statusText}); } return resp.json(); }有几个细节值得展开。第一每个组件发起请求时都应该携带AbortController的信号尤其是页面切换很快的看板场景里旧请求如果不取消返回后可能还会尝试更新已经不在 DOM 里的节点造成无效更新甚至报错。第二遇到接口超时原生 fetch 也有一个很顺手的能力AbortSignal.timeout(8000)直接限时。轮询逻辑在原生环境里也非常直接不用依赖框架层的定时器管理class LiveStatusTile extends HTMLElement { #timer null; connectedCallback() { this.#startPolling(); } disconnectedCallback() { this.#stopPolling(); } async #startPolling() { await this.#load(); this.#timer setInterval(() this.#load(), 30000); } #stopPolling() { clearInterval(this.#timer); } }这段代码里最关键的体验是轮询开始和结束都由浏览器元素的生命周期管理组件离开页面时定时器必定会被清理不会出现内存泄漏。纯原生在这一步并不比框架差甚至在控制力上更胜一筹。4. 实操路上的坑与修复全过程4.1 自定义元素重复定义导致的异常纯原生项目最容易遇到的一个坑出现在刷新或热更新之后报错信息大致是Failed to execute define on CustomElementRegistry: the name alert-card has already been used with another constructor。这个问题的本质是模块脚本被重复执行了一次而浏览器只允许同名自定义元素定义一次。常见触发原因是浏览器缓存了旧脚本文件当你改了组件代码刷新页面时两个版本的脚本可能都会被加载。我的应对方式非常简单在任何customElements.define调用前加一个判断if (!customElements.get(alert-card)) { customElements.define(alert-card, AlertCard); }就这样一行判断省去了很多莫名其妙的报错。不要觉得重复代码不好看运行时稳定性永远比代码的“优雅感”优先。4.2 动态渲染后的事件失效原生开发时很多朋友会写这样的代码页面初始化时给按钮绑了click事件后来通过innerHTML动态替换了列表内容新按钮怎么点都没反应。原因很简单事件绑定发生在旧节点上新节点上的按钮根本没有监听器。解决这类问题我强烈推荐事件委托。与其在每个按钮上逐一绑定不如把监听器挂到稳定的祖先节点上让事件在冒泡阶段被统一处理document.addEventListener(click, (event) { const deleteBtn event.target.closest([data-actiondelete]); if (!deleteBtn) return; const id deleteBtn.dataset.id; deleteRecord(id); });委托的另一个附带好处是页面里哪怕新增了一万条动态列表也不需要一遍遍重新绑定监听器性能开销非常低。所有带>alert-card:not(:defined) { display: none; }页面加载过程中没有定义完成的组件会被暂时隐藏等定义结束后自动出现闪烁感就消掉了。另一个常见副作用是自定义元素默认是行内元素很多组件一上来没有高度内容都挤在一起。解决办法也很简单alert-card, stock-tile, filter-panel { display: block; }或者直接放在组件的样式里用:host { display: block; }声明。这两个问题看起来小但第一次遇到时都很容易绕圈子我把它们写在这里希望能帮大家少走一点弯路。4.4 常见问题速查表现象原因处理方式自定义元素重复定义报错脚本重复执行同名组件先customElements.get再define动态新增按钮点击无效事件只绑在旧节点改用事件委托监听稳定的父容器页面首帧空白闪烁组件定义晚于 HTML 解析用:not(:defined)设置隐藏占位自定义元素没有换行display: inline默认值设置:host { display: block }fetch 请求被取消时仍报错没使用AbortController捕获 error 时判断signal.aborted全局变量被其他模块覆盖window 上挂了公共状态收敛到 ES 模块内部管理避免全量挂载5. 纯原生和框架是替代更是互补5.1 体积最容易被低估的优势先看一个直观对比我就拿这次项目的实际数据来说。库存监控看板当年用框架版本时官方构建工具处理完之后静态资源加起来约 68KBgzip 后。不要小看这 68KB内网机器虽然带宽不错但每一次发版都要处理依赖关系机子性能有限时解析执行时间也很可观。纯原生版本最后的总资源量是多少HTML、CSS、JavaScript 全部加在一起gzip 后大约 22KB。少了三分之一还多而且这 22KB 里没有任何运行时冗余浏览器拿到就能执行、就能渲染。对于一个每天内网几百人同时访问的小系统来说这份减少不一定能从首屏时间上看出巨大差异但它实实在在地降低了每个页面的解析成本。如果你以后遇到一个很慢的纯原生页面问题一定不是“没有用框架”而是某个具体资源没有做优化。框架不是性能的免死金牌。5.2 从长期维护看两边的感受差异长期维护框架项目时你操心的不只是业务代码还包括依赖升级、版本兼容、构建工具更新这些周边问题。很多时候单纯想改一个按钮颜色却要先跑一遍整个构建流程确认不会影响任何依赖这种心理压力是非常真实的。纯原生项目的维护体验则完全不同。打开源文件改完上传完事。不需要构建环境不需要依赖锁文件甚至不需要一个 Node.js 环境。这让项目做到了真正的可持久。但这不意味着原生项目不需要规则。恰恰相反没有框架来替你把关结构团队必须更自觉地遵守目录约定和命名规范。我的体会是纯原生适合有一定自律性的小团队因为最大的自由度同时也意味着最大的责任。那些写在 README 里的目录约定不是摆设而是系统的骨架。5.3 什么情况下我会再做原生选择基于这段时间的经验我整理了一份给自己的决策清单供大家参考项目生命周期预期超过三年原生更好框架换代的波折会影响维护成本。页面规模 30 个以内原生完全能覆盖不需要框架帮忙管理组件树。团队三人以内原生沟通成本低不依赖框架模式统一。部署环境封闭或特殊原生最省心不需要复杂的 Node 工具链。项目会被反复阅读和教学原生代码是最好的学习资料。反过来如果上面任何一个条件不满足我大概率会直接选择框架而不是硬撑原生。框架本身没有错它解决的是大规模协作和复杂状态管理的真问题。拿原生的标准去要求大型项目那是自己为难自己。所以“是的这就是纯原生”这句话里其实没有半点瞧不上框架的意思。它只是在说对于当前这个项目浏览器自带的能力已经足够我选择不引入额外复杂度。这个判断本身是技术素养的一部分甚至比“会用某个框架”更能体现你对整个技术栈的理解。如果让我给出一条最想分享的经验那就是纯原生最大的回报不是“看起来更厉害”而是长期维护时那种踏实感。你的代码从来不依赖任何人的更新节奏它只依赖浏览器本身而浏览器是今天 Web 领域最稳定的基础设施之一。再送大家一个小技巧如果你想尝试纯原生不要一上来就重写一个大系统那样会遇到很多挫折。你可以先把自己手边一个静态页面从框架改成原生体验一下“源文件即产物”的感觉再逐步扩大范围。你会发现完成第一个原生组件的那一刻你其实已经懂得更多了。