☰
纯原生前端:零依赖实现长期可维护Web项目的完整实践
2026/9/26 13:21:22 网站建设 项目流程

见过太多项目在“要不要上框架”的问题上反复拉扯之后,我对一句话的印象越来越深:“是的,这就是纯原生”。它常常出现在代码评审的最后一页、技术分享的收尾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,也不需要考虑 SEO,hash 路由实现成本最低,刷新不丢页面,也不依赖服务器配置 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?limit=5'); this._render(data); } catch (err) { this._renderError(err); } } } customElements.define('alert-card', AlertCard);

使用的时候,页面里就引用一行 HTML:

<alert-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-action="delete"]'); 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 体积:最容易被低估的优势

先看一个直观对比,我就拿这次项目的实际数据来说。库存监控看板当年用框架版本时,官方构建工具处理完之后,静态资源加起来约 68KB(gzip 后)。不要小看这 68KB,内网机器虽然带宽不错,但每一次发版都要处理依赖关系,机子性能有限时,解析执行时间也很可观。

纯原生版本最后的总资源量是多少?HTML、CSS、JavaScript 全部加在一起,gzip 后大约 22KB。少了三分之一还多,而且这 22KB 里没有任何运行时冗余,浏览器拿到就能执行、就能渲染。

对于一个每天内网几百人同时访问的小系统来说,这份减少不一定能从首屏时间上看出巨大差异,但它实实在在地降低了每个页面的解析成本。如果你以后遇到一个很慢的纯原生页面,问题一定不是“没有用框架”,而是某个具体资源没有做优化。框架不是性能的免死金牌。

5.2 从长期维护看两边的感受差异

长期维护框架项目时,你操心的不只是业务代码,还包括依赖升级、版本兼容、构建工具更新这些周边问题。很多时候,单纯想改一个按钮颜色,却要先跑一遍整个构建流程,确认不会影响任何依赖,这种心理压力是非常真实的。

纯原生项目的维护体验则完全不同。打开源文件,改完上传,完事。不需要构建环境,不需要依赖锁文件,甚至不需要一个 Node.js 环境。这让项目做到了真正的可持久。

但这不意味着原生项目不需要规则。恰恰相反,没有框架来替你把关结构,团队必须更自觉地遵守目录约定和命名规范。我的体会是,纯原生适合有一定自律性的小团队,因为最大的自由度同时也意味着最大的责任。那些写在 README 里的目录约定,不是摆设,而是系统的骨架。

5.3 什么情况下我会再做原生选择

基于这段时间的经验,我整理了一份给自己的决策清单,供大家参考:

  • 项目生命周期预期超过三年:原生更好,框架换代的波折会影响维护成本。
  • 页面规模 30 个以内:原生完全能覆盖,不需要框架帮忙管理组件树。
  • 团队三人以内:原生沟通成本低,不依赖框架模式统一。
  • 部署环境封闭或特殊:原生最省心,不需要复杂的 Node 工具链。
  • 项目会被反复阅读和教学:原生代码是最好的学习资料。

反过来,如果上面任何一个条件不满足,我大概率会直接选择框架,而不是硬撑原生。框架本身没有错,它解决的是大规模协作和复杂状态管理的真问题。拿原生的标准去要求大型项目,那是自己为难自己。

所以“是的,这就是纯原生”这句话里,其实没有半点瞧不上框架的意思。它只是在说:对于当前这个项目,浏览器自带的能力已经足够,我选择不引入额外复杂度。这个判断本身,是技术素养的一部分,甚至比“会用某个框架”更能体现你对整个技术栈的理解。

如果让我给出一条最想分享的经验,那就是:纯原生最大的回报不是“看起来更厉害”,而是长期维护时那种踏实感。你的代码从来不依赖任何人的更新节奏,它只依赖浏览器本身,而浏览器是今天 Web 领域最稳定的基础设施之一。

再送大家一个小技巧:如果你想尝试纯原生,不要一上来就重写一个大系统,那样会遇到很多挫折。你可以先把自己手边一个静态页面从框架改成原生,体验一下“源文件即产物”的感觉,再逐步扩大范围。你会发现,完成第一个原生组件的那一刻,你其实已经懂得更多了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询