从面条代码到模块化开发:前端工程化重构的实战之路
2026/9/19 19:46:14 网站建设 项目流程

周末接到一个老同事的电话,说线上有个活动页面出了故障。我打开项目仓库一看,一个 order.js 文件,三千多行,十几个全局变量,函数之间互相调用,跟意大利面一样纠缠成一团。改一处要顺着调用链摸半天,最后改完还要在心里默念,千万别把别的地方带崩。这种代码,前端圈里有个非常形象的名字——面条代码。

你手上如果也有这样的项目,看这篇文章就对了。这篇文章想聊的,就是前端模块化开发这件事:面条代码到底是怎么产生的,模块化开发的核心思路是什么,以及我实际参与过的几个老项目,是怎么一步一步从一团乱麻改造成清晰可维护的结构化代码的。内容不涉及特别高深的理论,更多是我踩过的坑和摸索出来的实操经验,适合正在维护老项目的前端开发者,也适合刚入行想建立代码结构意识的新人。


1. 面条代码为什么会存在?先认清问题再谈改造

1.1 面条代码的四大典型特征

说起面条代码,我脑海里会自动浮现几个画面。这里我总结成四个特征,你对照自己手里的项目看看是不是这样。

第一个特征是一个文件从头写到尾。页面所有逻辑全部塞在几个文件里,一个 js 文件动辄几千行,HTML 里可能还埋着几百行内联脚本。这种文件什么都有,初始化、渲染、事件绑定、请求接口、处理数据,甚至字符串拼接 DOM 都堆在里面,谁进去改谁头疼。

第二个特征是全局变量满天飞。为了在函数之间共享数据,大家习惯把变量挂在全局作用域,或者往 window 对象上挂属性。demo.js 里定义let userInfo = null,order.js 里也定义let userInfo = null,两个文件引入顺序不对,就直接互相覆盖,排查起来像是在玩推理游戏。

第三个特征是函数之间隐式依赖严重。A 函数内部悄悄调用了 B 函数,而 B 函数又依赖 C 模块修改的一个全局状态。看代码时你根本不知道这个函数还牵扯了多少“看不见的手”,改一个参数,可能要连带改五六个地方。

第四个特征是复制粘贴泛滥。某个功能在 projectA 里实现了,第二天换了个页面,需求差不多,直接把代码复制过去,改改变量名就上线。时间一长,同样的逻辑散落在各个角落,修 bug 的时候要找到所有副本,漏一个就出问题。

1.2 为什么会变成这样?成因其实不只是“懒”

很多人觉得面条代码就是开发者懒、水平不行,其实不完全是这么回事。我见过很多面条代码都是在极端条件下长出来的。业务方三天一小需求五天一大需求,排期永远不够,先上线再说成了默认选择。代码能不写注释就不写,后续维护的人看着一团乱麻,也无从下手。再加上不少前端项目起点很低,最初就是一个 HTML 文件带两个 js 文件,没想到后来会膨胀成一个大应用。

还有一个很重要的原因:前端这门技术本身就经历了一段野蛮生长的时期。早期大家写页面就是往 HTML 里塞 script 标签,全局函数一把梭,后来工程化工具普及了,语法和工具升级了,但很多人的编码思维还停留在“能跑就行”的阶段。

说白了,面条代码是历史债务、业务压力和编码意识三者共同作用的结果。认清这一点,再去谈模块化改造,就不会简单粗暴地认为“重写一遍就好了”,而是会去思考怎么用合理的约束和结构,让代码在持续迭代中维持健康状态。

2. 模块化开发到底在解决什么问题?核心是边界和依赖

2.1 模块化的本质可以用三个词概括:边界、职责、依赖

很多初学者以为模块化就是把代码拆成多个文件,文件拆得越碎越“模块化”。这是个大误区。如果你只是把一个三千行的大文件拆成六个五百行的文件,但文件之间依然互相修改对方的变量、相互调用没有章法,那不过是把一大坨面条分成了几小坨,本质上还是面条。

我理解的模块化,核心是三个词:边界、职责、依赖。

边界是指每个模块要有一个清晰的对外接口,外部只知道它能干什么,不需要关心它内部怎么实现。就像一台电视机,你能看到的只有电源键、音量键和几个接口,至于内部电路怎么走,你不需要知道,也不应该去动它。

职责是指每个模块只应该负责一件相对独立的事。工具函数模块就只放工具函数,请求模块就只负责发请求,业务组件就只关心自己的渲染和交互。如果一个模块今天处理日期格式,明天又跑去做数据缓存,后天还顺手操纵了一把这个组件的状态,这个模块的职责就乱套了。

依赖是指模块与模块之间的关系必须显式、清晰、可控。一个模块如果要依赖另一个模块,应该通过 import、require 这样的方式显式引进来,而不是靠“运气”——比如正好另一个文件先执行了所以全局变量存在。依赖方向也要尽量单向,上层可以依赖下层,下层绝对不应该反过来依赖上层。

2.2 从全局函数到 ES Modules,前端模块化经历了哪几步

聊到这里,有必要把前端模块化的技术演进梳理一遍,这样你能明白现在用的方案到底解决了哪些历史问题。

最早也是最原始的做法就是 script 标签按顺序引入,所有代码共享全局作用域。这种方式的缺点是显而易见的:命名冲突、依赖关系不明确、代码执行顺序完全靠人肉保证。你永远不知道哪个全局变量被谁改了。

接着出现了闭包和立即执行函数(IIFE)技巧。开发者用闭包把变量的作用域圈起来,只把需要暴露的方法挂到全局的一个命名空间对象上。比如var MyModule = (function() { var privateVar = 1; return { get: function() { return privateVar; } }; })();这种方式解决了部分变量污染问题,但模块之间的依赖关系还是要靠 script 标签的引入顺序来保证。

再往后,Node.js 带来了 CommonJS 规范,用module.exportsrequire管理模块。这个方案在服务端非常好用,因为模块文件都在本地,同步读取没有压力。但浏览器环境不行,浏览器里没法同步加载远端文件,于是又涌现了 AMD(如 RequireJS)和 CMD(如 SeaJS)这些异步加载方案。不过这些方案现在已经不太主流了,这里不展开细讲。

真正一统天下的,是 ES6 引入的 ES Modules 规范,也就是importexport语法。它把模块化变成了语言层面的标准,支持静态分析,打包工具可以做 tree-shaking 把没用到的代码摇掉。现在你在 Vue、React 项目里天天见到的import xxx from 'xxx',就是 ES Modules 的写法。配合 Vite、Webpack、Rollup 这些打包工具,开发时模块随意拆分,构建时再统一打包优化,前端模块化终于走上了一条标准化、工程化的路。

2.3 到底该用原生 ES Modules 还是上打包器?判断依据很简单

有的读者会问,我现在写原生 HTML 页面,没有用 Vue 或 React,能用模块化吗?答案是肯定的。现代浏览器基本都原生支持 ES Modules,你可以在 script 标签上写type="module",然后在代码里正常使用 import 和 export。不过要注意一个问题:原生 ES Modules 在浏览器里会触发多次 HTTP 请求,模块文件多的时候性能会受影响,而且浏览器要求所有模块必须通过 HTTP 协议加载,直接双击打开本地 HTML 文件会报 CORS 错误。

所以我的建议是这样。如果你的项目很小,就是几个页面,每个页面逻辑不多,那直接用原生 ES Modules 足够,开发时起个本地静态服务器就行。如果项目已经有一定规模,开始涉及样式处理、图片资源、代码压缩、浏览器兼容,那就直接上 Vite 或者 Webpack 这类构建工具。不要觉得构建工具学习成本高,现在 Vite 的体验已经非常顺滑了,几分钟就能初始化一个项目。模块化开发本来就离不开工程化工具的支撑,二者是配套的。

3. 实操改造:从“意大利面”到“乐高积木”的完整步骤

3.1 改造前必须做的事:先画依赖关系图

我见过最失败的重构方式,就是拿到老代码直接开删开改,改到一半发现一个全局变量被另外三个文件引用着,只能慌忙还原,最后分支都合不进去。所以动手之前,第一件事永远是梳理现状,把依赖关系摸清楚。

我常用的方法是把每个文件的职责、暴露的全局变量、调用了哪些其他文件的函数,全部抄在一张纸或者一个文档里。比如你发现 order.js 里定义了let userInfo,然后 checkout.js 里也读了这个变量,那它们之间就有隐式依赖。再比如 formatPrice 这个函数被五个文件用到,那你就可以判断它是公共工具函数,应该抽到一个独立模块里。

画依赖关系图的目的,是找出三个关键信息。第一是哪些代码是公共的,应该被下沉到基础层。第二是哪些模块之间的依赖是“双向”的,这意味着耦合过紧,需要想办法把公共部分抽出来或者调整依赖方向。第三是哪些代码其实根本没人调用,是僵尸代码,趁改造直接删掉。

这一步看起来很费时间,但请相信我,它省下的时间远大于你花的时间。没有这张图,后面每拆一个模块都是在赌运气。

3.2 按职责分层拆模块,推荐一套可以直接套用的目录结构

梳理完现状之后,就要开始拆模块了。我个人的建议是按职责分层去拆,而不是按页面去拆。这两种思路听着差别不大,做起来效果完全不同。按页面拆容易出现一个问题:两个页面用到了同一个接口和同一套数据处理的逻辑,你拆的时候各拆各的,公共代码又被复制了一份。

我这里给出一套自己在中小型项目中反复使用过的目录结构,你可以直接抄作业:

src/ api/ // 接口请求层 user.js cart.js components/ // 通用UI组件 button/ modal/ utils/ // 工具函数 format.js storage.js store/ // 全局状态(可选) index.js views/ // 页面级组件 cart/ index.js item.js main.js

关键原则是:utils 和 api 是基础层,任何模块都可以依赖它们;components 是通用组件库,可以被不同页面复用;views 是页面层,负责把各种模块组合起来。依赖方向是自上而下的,views 依赖 components,components 不允许反过来依赖 views。

这样的分层结构,最直观的好处是你找代码有明确的方向感。格式化相关的问题,去 utils/format.js;接口改动,去 api/ 目录;购物车 UI 样式问题,去 components/cart 里找。新人接入项目,半天就能摸清代码脉络。

3.3 模块之间怎么通信?先定规则再动手

模块拆开之后,最棘手的问题就是:A 模块的数据变化了,B 模块怎么知道?这里需要提前定好通信规则,否则拆到一半会发现模块之间直接 import 来 import 去,边界又糊了。

我的通信规则优先级是这样的。第一优先是父子之间通过属性传值和事件回调。React 里就是 props 和回调函数,Vue 里就是 props 和 $emit。这种方式最直白、最可控,也是我首选的方案。第二优先是组件内部状态提升到父组件,由父组件做数据协调。如果多个兄弟组件要共享同一份数据,就找到它们共同的父级,把数据放在父级。第三优先才是引入全局状态管理工具,比如 Pinia、Redux、Zustand。很多人一上来就上状态管理库,其实很多场景根本不需要,滥用全局状态反而会让数据流变得不可追踪。

另外,模块之间如果需要通信但又不是严格的父子关系,可以用一个轻量级的事件总线(EventBus)或者自定义事件。但要注意,事件总线用多了,代码会变成“到处都是发消息的,但不知道谁会接收”,可维护性同样灾难。所以我的建议是,事件通信能不用就不用,如果非用不可,一定要把事件名集中定义在一个文件里,方便查证。

3.4 实战演示:把一团乱麻的购物车代码拆成四个模块

理论说了这么多,不如来一个真实的拆解演示。假设你现在拿到一个购物车页面,里面是类似下面这样的“面条”代码。这段代码混了数据读取、计算、字符串拼接 DOM、事件绑定、本地存储操作,全部耦合在一起。

改造前的样子大概是这样的:

// cart.js 一个典型的“面条文件” let cart = []; let totalPrice = 0; init(); function init() { cart = JSON.parse(localStorage.getItem('cart') || '[]'); renderCart(); bindEvents(); } function renderCart() { const container = document.getElementById('cart-list'); let html = ''; cart.forEach(function(item) { totalPrice += item.price * item.count; html += '<div class="cart-item">' + '<span>' + item.name + '</span>' + '<span>¥' + (item.price * item.count).toFixed(2) + '</span>' + '<button>// src/services/cartService.js const CART_KEY = 'cart'; export function getCart() { return JSON.parse(localStorage.getItem(CART_KEY) || '[]'); } export function saveCart(cart) { localStorage.setItem(CART_KEY, JSON.stringify(cart)); } export function updateItemCount(cart, id, delta) { return cart.map(function(item) { if (item.id === id) { return { ...item, count: Math.max(0, item.count + delta) }; } return item; }); } export function calcTotalPrice(cart) { return cart.reduce(function(sum, item) { return sum + item.price * item.count; }, 0); }

第二步,把金额格式化这种和业务无关的逻辑抽到 util 模块。

// src/utils/format.js export function formatPrice(price) { return '¥' + price.toFixed(2); }

第三步,把购物车项做成一个独立的 UI 模块,只接收数据并渲染,不关心数据从哪来。

// src/components/CartItem.js import { formatPrice } from '../utils/format.js'; export function createCartItem(item, handlers) { const div = document.createElement('div'); div.className = 'cart-item'; div.innerHTML = ` <span>${item.name}</span> <span>${formatPrice(item.price * item.count)}</span> <button class="btn-increase">+</button> <button class="btn-decrease">-</button> `; div.querySelector('.btn-increase').addEventListener('click', function() { handlers.onIncrease(item.id); }); div.querySelector('.btn-decrease').addEventListener('click', function() { handlers.onDecrease(item.id); }); return div; }

第四步,在页面入口处把这些模块组合起来。页面文件只负责拿到数据、调用模块、渲染结果,它不知道数据是怎么存储的,也不知道价格是怎么格式化的。

// src/views/cart/index.js import { getCart, saveCart, updateItemCount, calcTotalPrice } from '../../services/cartService.js'; import { formatPrice } from '../../utils/format.js'; import { createCartItem } from '../../components/CartItem.js'; const container = document.getElementById('cart-list'); const totalPriceEl = document.getElementById('total-price'); let cart = getCart(); function render() { container.innerHTML = ''; cart.forEach(function(item) { const el = createCartItem(item, { onIncrease: function(id) { cart = updateItemCount(cart, id, 1); saveCart(cart); render(); }, onDecrease: function(id) { cart = updateItemCount(cart, id, -1); saveCart(cart); render(); } }); container.appendChild(el); }); totalPriceEl.innerText = formatPrice(calcTotalPrice(cart)); } render();

拆完之后你会发现,每一个函数都很短,每一份职责都有明确的归属。以后改存储方式,只动 cartService;改价格展示格式,只动 format.js;改购物车项的 UI 结构,只动 CartItem 模块。各个模块之间通过显式的参数传递和回调函数通信,不再共享任何全局状态。

这才是模块化要的最终效果:单个模块坏了,你只需要修一个文件,其他文件一概不受影响。

4. 模块化改造路上躲不开的坑,我替你踩过了

4.1 循环依赖:最常见的模块化事故

循环依赖,就是 A 模块引用了 B,B 模块又引用了 A。这在小的文件拆分时非常容易出现,尤其是拆分业务模块时,把共享的状态放到了 A 模块,B 模块要读这个状态,于是 B import A,而 A 又依赖 B 的一个方法,循环依赖就产生了。

循环依赖最典型的表现有两种。一种是运行时变量为 undefined,因为 ES Modules 在编译阶段就把模块之间的关系确定了,但执行顺序上总有一个先来后到,某个模块在初始化时读取了另一个还没初始化好的模块变量,就会拿到 undefined。另一种是直接报错Cannot access 'xxx' before initialization

处理循环依赖,我的思路有三个层次。第一层是审视设计,很多循环依赖的产生是因为模块边界没划好,公共部分应该抽到第三个模块里,让 A 和 B 都去依赖这个公共模块,依赖关系立刻变成单向。第二层是在模块内部延迟引用,把import写进函数体内部而不是文件中,这样只有在函数被调用时才会真正执行引用,避免初始化时的死锁。第三层是实在绕不开时,可以考虑把共享状态提升到状态管理库中,用全局状态替代模块间的互相引用。

每次写完代码,我都会习惯性看一眼模块之间的依赖示意图,发现有圈子就立刻拆掉。这个习惯帮我避免了大量循环依赖事故。

4.2 模块粒度的把握:拆得太碎和拆得太粗都是坑

模块粒度是我见过最多人纠结的问题。有的团队把每个工具函数都单独放到一个文件里,一个 utils 目录下面挂了七八十个文件,找起来眼都花;有的团队则反向操作,一个 components/common.js 里塞了几十个“通用”组件,本质上又成了一个大杂烩。

我自己的判断标准很简单:一个模块是否值得单独存在,看它有没有“两个以上”的引用方,或者它是否承载了一段足够复杂、值得独立测试的业务逻辑。一个只被用了一次的函数,先别急着抽成公共模块,留在使用它的模块里就够了。等第二次需要用到时,再抽出来也不迟。这就是所谓的“三次法则”或者说“两次法则”:重复出现第二次,可以先眼熟;出现第三次,才值得去抽。

拆模块就像整理房间,太乱了要分类收纳,但收纳过度反而会让日常取用变得麻烦。适度是关键。

4.3 依赖方向失控:底层模块反向依赖了上层模块

这个问题在项目规模变大之后会逐渐浮出水面。你精心设计了分层结构,utils 在最底层,业务组件在上层。但某一天,业务方要求在某一个通用按钮组件里加一个埋点上报的逻辑,而埋点上报依赖一个业务层的全局配置模块。开发同学图省事,直接在按钮组件里 import 了业务配置模块,依赖方向瞬间就反了。

短期看代码能跑,长期看,这个通用按钮组件再也没法复用到其他项目了,而且一旦业务配置模块变化,所有用到这个按钮的地方都可能跟着出问题。更可怕的是,这种方式会形成“破窗效应”,一个人这么写了,后来的人都会觉得这样写没问题,分层架构很快就被击穿。

解决这个问题,我的办法是两条。第一,在代码评审时对跨层依赖保持敏感,看到 utils 或 components 里的文件 import 了业务模块,一定要追问一句:这个依赖真的必要吗?第二,必要时引入 ESLint 规则限制各目录之间的依赖方向,技术手段比口头约定可靠得多。

4.4 全局状态不是万能的,别把它当听话匣子

模块化改造过程中,很多人遇到“多模块要共享数据”的情况,第一反应就是引入 Pinia 或者 Redux,把所有共享数据都放进全局状态里。这种做法的副作用是:数据流变得不可预测。任何模块都可以修改全局状态,你不知道是谁改了这个值、什么时候改的,排查 bug 难度反而直线上升。

我自己总结的经验是,全局状态只放三类东西:第一类是用户会话信息,比如登录态、用户资料;第二类是跨模块且需要响应式更新的全局 UI 状态,比如主题、语言、侧边栏开关;第三类是多个非兄弟组件都会读取的缓存数据,比如字典数据。除此之外,一切局部数据都尽量放到组件内部或就近的模块里。能局部就不要全局,这是我一直坚持的原则。

5. 模块化之后,你的代码会发生什么肉眼可见的变化

5.1 新人上手和任务拆分的效率明显提升

代码结构化之后,最直观的变化就是新人上手速度变快了。以前一个新同事入职,看代码库可能要两周才敢动代码,因为不知道改一个全局函数会不会踩到别人埋的雷。模块化改造之后,新同学进来,我先让他看目录结构,再挑一个相对独立的页面模块去改。他只需要关注这一个模块的职责和它依赖的几个基础模块,上手的路径短了很多。

任务拆分也变得更清晰。以前一个需求可能对应着去改那一个三千行的大文件,几个人没法并行,只能排队。现在模块拆开了,接口层一个人改,UI 层一个人改,状态管理一个人改,大家各改各的模块,冲突概率也大大降低。

5.2 测试终于可以动手写了

面条代码之所以难测试,是因为一个函数依赖了太多上下文。比如你要测试那个 renderCart,它要操作 DOM、访问 localStorage、还依赖外部定义的全局变量,你根本没法把这些依赖隔离出来。

模块化之后,每个模块的输入输出都是清晰的。测试 cartService 的时候,我传一个购物车数组进去,断言输出结果是否正确就行。测试 formatPrice 就更简单了,传入数字,断言字符串是否带上了货币符号。不需要 mock DOM,不需要模拟全局变量,纯函数测试无比清爽。这也是模块化带来的一个隐藏收益:it makes your code testable。

5.3 重构和迁移时胆子变大了

模块化改造完成之前,公司说要做技术栈升级,从老项目迁移到新框架,我是不敢接的。因为代码耦合太严重,根本理不清哪些逻辑可以平移、哪些逻辑要重写。改造完成之后,分层清晰,依赖明确,迁移就变成了一件可以按模块逐个推进的事。先把 api 层挪过去,再把 utils 层挪过去,最后按页面逐步换壳。每一步都有明确的边界和验证标准,出了事能立刻定位到是哪一块。

这种“心里有底”的感觉,是模块化带给我最大的安全感。代码不再是一团需要靠意志力去维护的混合物,而是一套可以独立替换、升级、复用的组件系统。


最后再分享一个我个人的实操心得。模块化改造最忌讳的是“大爆炸式重写”。你花两周时间偷偷把整个系统的代码全部重写了一遍,最后合入的时候冲突几百个,测试全挂,业务方还催着上线,心态直接崩掉。我现在的习惯是“走一步看一步”,不从存量代码下手,而是先用模块化的思维约束增量代码,每次新增功能时按模块拆分来写。存量代码在每次修改相关功能时顺手局部重构,像一个修理工一样,今天修一个窗口,明天补一个墙角,三个月下来,整个房子已经焕然一新。这种渐进式的改造,风险小、见效快,也更容易让团队成员接受。前端模块化不是一个一蹴而就的工程,它更像一种持续演进的编码习惯,你坚持一个月,就会发现自己写的代码,已经很难再看回那个意大利面堆起来的样子了。

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

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

立即咨询