尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

前端模块化的本质是逻辑边界划分,而非文件拆分

发布时间:2026/9/28 14:42:17

资讯中心
01
ARTICLE

前端模块化的本质是逻辑边界划分,而非文件拆分

前端模块化的本质是逻辑边界划分,而非文件拆分
很多前端同学一听到模块化第一反应就是拆文件。拆组件、拆工具函数、拆请求封装、拆路由配置恨不得把所有能分家的东西都分家。我也曾经是这种思路的坚定拥护者最高峰把一个 2000 多行的页面拆成了 14 个文件。拆完之后我以为自己很专业结果下一轮迭代需要调整订单状态流转逻辑我在 14 个文件之间来回跳了整整一个下午最后才把 6 处互相牵制的 state 改对还顺手引入了两处隐性的循环依赖。那天晚上我把这 14 个文件重新摊开看了一遍突然意识到一个很难接受的事实文件确实多了模块边界却没有变清晰甚至还变模糊了。这篇文章是这个系列的第 4 部分第 1 讲主题落在思想层。我们不讲某个具体库也不给一段能直接抄的配置而是把模块化这件事重新拆开看看它到底在解决什么问题。尤其想讲清楚一个核心观点模块化的本质不是文件的物理切分而是系统在逻辑上的边界划分。边界划对了哪怕文件数量不变整个代码库也会变得好改、好测、好交底边界划错了文件拆得越多系统反而越碎、越乱、越难维护。这篇文章适合三种人想从业务开发往架构方向往前走一步的前端工程师被老项目几十个模块之间循环依赖搞得头疼的人以及带团队时发现大家都爱抽象但没人管边界的技术负责人。看完之后你不会多出一个脚手架生成工具但你会多一把衡量模块设计质量的尺子。1. 模块化的本质从来不是文件拆得细1.1 一次失败的完美拆分复盘先回顾一下我那个把页面拆成 14 个文件的经历。当时的理由听起来很正当单一职责、组件复用、代码可读性。我把请求逻辑单独抽成 service 文件把状态管理拆成 store 文件把 UI 拆成卡片、弹窗、表单、筛选栏把公共常量抽成 config。表面上确实职责分明但问题出在职责之间的连接关系上。订单状态流转这个功能跨越了 4 个文件页面上调用了useOrderFlow这个 hook 内部又引入了orderStatusStorestore 里的某些枚举常量来自order/config而order/config里又有一处计算逻辑依赖组件传入的 props。我改状态流转规则的时候实际上是在跨四个文件追踪一条逻辑链路。拆文件之后文件数量变多了但逻辑链路长度一点没变短。这就暴露了一个关键问题物理文件拆过了逻辑模块依然纠缠在一起。真正的模块边界应该画在逻辑链路停下来的地方而不是画在代码看起来太多的地方。1.2 文件和模块差在哪一个是物理一个是逻辑文件系统层面的拆分解决的是编辑体验问题。一个文件太长滚动和搜索都不方便我们就拆一拆这只是工具层面的整理。逻辑模块层面的拆分解决的是变化隔离问题。一处需求改动是否只需要动这个模块内部的东西而不影响其他模块。这两件事经常被混为一谈。很多人以为把文件拆细了就是在做模块化。但实际上一个文件可能包含多个逻辑模块也可能一个逻辑模块散落在几十个文件里。模块这个词从一开始描述的就不是源代码文件而是责任的归属。一个模块代表一个责任闭环它有自己的输入输出、自己的内部实现、自己的对外契约。判断模块边界是否清晰有一个特别直白的检验方法如果需求变化了一个模块需要改代码其他模块是否可以完全不知道这件事。如果改订单状态流转逻辑用户头像组件、商品列表组件、公共按钮组件全部要跟着动那不管文件拆得多么漂亮模块边界都是不存在的。1.3 信息隐藏原则早就说透了这件事1972 年David Parnas 发表了一篇对软件设计影响深远的论文《On the Criteria To Be Used in Decomposing Systems into Modules》。论文的核心观点是系统拆分成模块时不应该依据处理流程顺序而应该依据哪些设计决策最可能发生变化来进行隔离。每个模块负责隐藏一个设计决策其他模块不关心这个决策是怎么实现的。这个思想后来被归纳为信息隐藏原则。放到前端场景里很好理解一张订单状态标签组件外部只需要知道status是数字 2 还是 3至于 2 怎么映射成已支付、3 怎么映射成已发货全部藏在组件内部。如果订单状态从双态改成多态只要对外输入的status语义不变所有使用方都不需要感知变化。信息隐藏才是模块化真正的起点。之所以要定义边界就是为了让某个具体设计决策被限制在一个范围内即使将来推翻重来爆炸半径也只在一个模块的围墙之内。文件拆分如果和服务于这个目标就是好拆分如果只是机械地把代码按数量均分那只是给重构制造了更多烟雾弹。1.4 高内聚低耦合先搞清高和低的判断标准面试常说高内聚低耦合但很多人理解的高内聚是文件里只放同类的东西低耦合是文件之间不要互相引用。这个理解太表面了。内聚和耦合不是独立的两个指标内聚决定了耦合的形态。一个模块把订单数据的结构定义和订单状态的流转规则放在一起这是高内聚因为这两件事总是同时变化。另一个模块如果对订单状态产生了依赖比如用自己定义的字符串PAID去比对订单状态那么将来订单状态从PAID改成数字 2 时两个模块都要改。这就是耦合的真实来源。判断内聚高不高最简单的办法是看变更频率的一致性。一组代码如果因为同一个业务原因而发生变化它们天然属于同一个模块。一组代码如果总是因为各种不同的原因被改动把它们放在同一个文件里反而更符合内聚的定义——因为它们都是被同类需求触发的。反过来如果一组代码虽然属于同一个文件但变更原因五花八门那这个文件内部也需要重新划界。提示模块化设计中常见的误区是先画文件夹结构再往里塞代码。真正的做法是先凭业务经验猜测哪些东西会一起变然后把它们圈进同一个边界最后才用文件夹去落实这个边界。2. 边界思维的核心是管住依赖和契约2.1 依赖方向上层的免疫力下层的稳定性有了边界接下来就要面对边界之间的通道依赖关系。边界思维最核心的训练就是时刻问自己一句——谁可以依赖谁很多前端项目最后变成一个大泥球根本原因不是没有模块而是依赖方向彻底失控。业务页面依赖公共组件公共组件又反过来依赖某个业务页面里的工具函数A 模块 import B 模块B 模块又 import A 模块里的某个常量。这种相互依赖一旦绕成环模块边界等同于不存在因为修改任何一个点都可能触发连锁反应。画依赖图的时候理想的模型是分层有向无环图。越贴近业务变化的地方越往上越接近稳定基础设施的地方越在下层。上层依赖下层下层绝不反向依赖上层。业务组件可以依赖通用组件库通用组件库不能依赖业务组件领域业务模块可以依赖平台工具模块平台工具模块不能反过来依赖业务模块。这个方向性的本质是稳定性分配。下层的基础设施通常变化频率低、被复用面广所以它必须保持稳定上层的业务模块变化频率高、影响范围小所以它可以任意演化。如果一个底层工具函数被十几个业务模块共用那它每一次改动都会引发一场波及全项目的小型地震这种依赖关系本身就是设计失败的信号。2.2 契约模块之间的法律条文边界只靠依赖方向还不够还需要契约。契约是模块对外暴露的能力和约束它回答的是你能干什么、不能干什么、干完之后留下什么结果。前端模块的契约往往被忽视。大家更加习惯反正都在同一个仓库里可以直接看源码所以模块之间经常绕开接口直接访问对方的内部数据结构。举个例子订单模块的 store 里有一个refundStatus字段结算模块想判断这笔订单可否退款居然直接读订单 store 的这个字段还隐式地依赖了它的取值语义。将来订单模块规范改名结算模块就莫名挂掉。边界思维要求把每个模块当成一个小型 npm 包来对待。对外只留下明确的入口内部状态和实现细节不暴露。哪怕是同一个仓库模块之间也应该像服务之间一样通过 API 通信。这个 API 可能是纯函数可能是 React Hook可能是自定义事件重要的是它必须被刻意设计过而不是反正都能看到源码顺手 import 一下。契约还有一个额外好处评审代码的时候有抓手。两个模块的接口一旦明确review 的焦点就集中在接口设计是否合理、依赖方向是否正确而不是陷入这个文件该放哪一层的琐碎争论。2.3 内聚的组织方式按业务域而不是按类型传统前端项目的目录结构通常是按类型组织的components放所有组件hooks放所有自定义 Hookapi放所有请求store放所有状态。这种组织方式对小型项目很友好但随着项目长大它会彻底模糊模块边界。举例来说一个订单模块和一个用户模块都可能需要自己的列表组件、自己的请求封装、自己的状态管理。如果全部按类型堆放那么想找订单相关的全部代码要在components目录里找订单卡片、在hooks目录里找订单请求、在store目录里找订单状态。订单的业务规则被分散到了四个平行目录改动一次订单需求就要横跨四个区域搜索。按业务域组织目录则完全不同。features/orders下面订单的组件、状态、请求、工具函数全部放一起它们天然形成一个高内聚的模块。用户模块同理。这样一来两个模块之间的边界从目录结构上就能看出大概依赖检查也有了更直观的对象。按业务域组织还有一个心理学层面的好处新同学接手订单需求时只需要打开features/orders这个目录其余 90% 的代码都可以当作黑盒忽略。这种可进入性对团队协作的效率提升非常明显。2.4 康威定律在提醒我们边界也是团队边界康威定律讲的是设计系统的组织其产生的设计等价于组织之间的沟通结构。翻译成前端语境模块边界大概率会沿着团队分工这道线长出来。如果订单需求由一个小组负责用户需求由另一个小组负责那么订单和用户这两个模块的边界自然也会出现在代码里。这件事反过来也成立如果你想在代码里画出一条业务边界但团队实际分工完全不是这样那这条边界大概率活不过三个月。组件也许能强行隔离但需求的频繁跨越会让开发者的习惯性 import 绕过边界最终把模块重新糊在一起。我见过有些团队引进了很漂亮的模块化方案但代码写了一个月就又乱了原因就是十个人都在同一个代码仓里同时修改所有模块。边界没有人守护就会被人情和习惯侵蚀。所以做边界设计时不要只看代码还要看组织。要么让团队结构适配代码边界要么在代码边界上设置强制的 CI 检查二选一总能守住一个。3. 实操用边界思维做一个订单模块的重构3.1 先把变化点和稳定点找出来边界思维落地第一步不是画目录而是做分析。我会先列一张表这个系统最近半年哪些需求你最频繁改动哪些代码几乎从上线起就没动过把需求按业务主题归组之后订单状态流转、退款规则、优惠计算通常都会出现在高频变更名单里这些就是模块最应该包裹的变化点。相反请求实例配置、基础 UI 组件、用户鉴权往往稳定得多它们可以放在更底层的位置让变化频繁的业务模块依赖它们。接着要对单个业务领域比如订单做一次内部梳理。列出订单领域里有几张页面、几个关键操作、几类核心数据。然后问自己这些页面、操作、数据之间是不是共享同一套业务规则如果共享那它们天然属于同一个模块如果不共享那它们之间应该有一条边界。我拿过一个线上电商项目做过这样的梳理。当时发现订单详情页居然直接 import 了用户模块的useUserLevel工具用它来判断这个用户是否享受会员价。但订单本身已经缓存了会员等级快照根本没有必要再去倒查用户模块。这个跨越边界的依赖被清理之后用户模块的改动不再波及订单详情页订单模块的可测试性瞬间提升。3.2 从类型命名法切换到特性化目录梳理完变化点和稳定点就可以动手调整目录结构了。纯前端的目录调整不需要引入任何新技术只是改变摆放方式。下面是我常用的一种落地模板。重构前典型的前端仓库长这样src/ api/ order.ts user.ts components/ OrderCard.tsx UserProfile.tsx hooks/ useOrders.ts useUser.ts store/ orderStore.ts userStore.ts这个结构的问题在于订单相关代码分布在api、components、hooks、store四个目录里模块边界不存在有的只是按代码类型划分的抽屉。重构后我把它改成特性化目录src/ features/ orders/ domain/ order.ts order-status.ts refund.ts api/ order-api.ts components/ order-card.tsx order-detail.tsx hooks/ use-orders.ts use-order-status.ts store/ order-store.ts users/ domain/ user.ts user-permission.ts api/ user-api.ts components/ user-profile.tsx hooks/ use-user.ts store/ user-store.ts shared/ ui/ button.tsx modal.tsx utils/ format.ts request.tsshared目录放的是真正被多个业务模块共享的纯基础设施。注意一个细节重构后的目录把按类型的抽屉全部下放到了features/orders内部。也就是说类型编码不再出现在顶层而是出现在业务模块内部这样订单的组件、状态、请求都待在自己家里。使用这种结构之后最明显的变化是改动一次订单需求的时间显著缩短。状态流转、接口字段、UI 呈现三件事在同一个目录树里就能全部找齐不需要再跨目录全局搜索。3.3 依赖倒置把核心业务放在依赖图的中央目录调整只能管得了文件摆放管不住运行时依赖。真正让模块解耦的是依赖倒置。看一个很常见的前端反模式订单服务直接依赖支付平台的具体实现。// 反模式订单模块知道了所有支付平台的具体实现 class OrderService { payWithAlipay(amount: number) { // 支付宝支付逻辑 } payWithWechat(amount: number) { // 微信支付逻辑 } }将来要接入新的支付渠道比如银行卡支付就必须改动OrderService订单模块的边界就被击穿了。依赖倒置的做法是不让订单模块直接依赖支付平台的某个具体实现而是定义订单模块自己关心的支付契约// 订单模块只认接口不认具体平台 interface PaymentGateway { pay(orderId: string, amount: number): PromisePaymentResult } class OrderService { constructor(private readonly paymentGateway: PaymentGateway) {} async checkout(orderId: string, amount: number) { return this.paymentGateway.pay(orderId, amount) } }支付平台的具体实现被放到订单模块之外运行时通过依赖注入交给订单模块。订单模块因此只依赖一个抽象契约新增任何支付渠道都不需要改OrderService。放在更大的角度看这是把订单支付这个业务流放在了核心位置把支付宝怎么实现和微信怎么实现这两种会各自变化的技术决策推到了边界之外。前端场景里的依赖倒置不一定用类、接口那一套组合式函数、插槽模式、组件注入都是同类思路。关键不在于用哪种语法而在于谁拥有契约。订单模块定义了我需要一个支付能力的契约支付实现方反过来适配这个契约从而保证业务核心不被周边实现牵着走。3.4 我用什么指标校验边界拆得好不好重构完还需要验证边界是否真的有效。纯靠感觉不够我会跑几个硬指标。第一个指标是循环依赖。前端工程里 cycle import 往往是隐性的ESM 让编译期不会立刻出错但运行时和热更新会越来越怪异。我用 ESLint 的import/no-cycle规则做持续检查把 dependency graph 里的环都标红。一个环都不允许存在这是我给模块边界设的第一条红线。第二个指标是扇出扇入。一个模块被太多个模块依赖说明它承担了过重的公共责任一个模块依赖了太多其他模块说明它内部聚合了过多跨领域的逻辑。这两种情况都要警觉但不是一刀切地改成每个模块只能被一个模块依赖。更合理的判断标准是看依赖对象是否稳定被依赖的模块越稳定被很多地方依赖就越安全。第三个指标是修改波及面。这个没法自动化完全覆盖但我常用 git log 辅助分析随便挑一个业务模块比如订单模块的文件看看最近两个月它的提交中和用户模块、商品模块的提交重叠了多少次。如果订单模块提交总是同步出现在另外两三个模块中说明边界很可能划错了位置——真正的公共变化点没有被识别出来。最后一个偏工程化的指标是构建缓存命中率。在 monorepo 构建系统里模块边界越清晰单个模块的输入变更越不会触发其他模块的重新构建。缓存命中率高不只是性能优势更说明模块之间实际改动隔离效果好。这个指标虽然没那么精确但每次构建系统都帮你做了一次模块边界体检非常值得长期观察。4. 常见边界问题与排查实录4.1 典型问题速查表边界思维落地过程中我遇到的坑基本上能分成下面几类。整理成速查表方便对号入座。症状具体表现根因处理思路循环依赖webpack 报警运行时变量 undefined两个模块共享了本应下沉的公共逻辑找到公共逻辑下沉到 shared 或新建中间层模块改一处带崩三处改订单状态登录框样式异常公共状态被多处业务模块绕过接口直改收敛 store 对外入口只允许通过 action 更新目录很漂亮依赖很混乱文件结构按 feature 分了实际 import 满天飞边界只在文件系统层面没有契约约束给模块设计对外 API进行依赖方向检查抽象层层叠没人敢改每加一个需求就要新增一个 Hook/组件层级过度追求可扩展性边界拆得比需求细砍掉未被需求验证的抽象回归内聚原则模块之间同步改代码支付模块改了订单模块的测试马上挂订单逻辑依赖了支付模块内部实现引入依赖倒置让订单模块依赖契约而非实现这张表在团队设计评审时非常实用。每次新代码提交触发边界问题我会先查它落在哪一行再对症处理。大部分边界问题在两三个迭代周期内就能明显收敛。4.2 抽象泄漏的识别与修复抽象泄漏是边界思维里最隐蔽的问题。明明模块 A 对外声称我封装了订单状态你只需要传 status但使用方为了显示某些文案会偷偷去读模块 A 内部才应该知道的字段。举个例子订单模块对外返回refundStatus: 1页面组件收到后自己写了一段if (refundStatus 1 amount 99)的判断这里amount 99就是订单模块的内部规则泄漏到了页面层。抽象一旦泄漏边界就成了摆设。因为模块的生命力来自它隐藏了复杂度一旦内部细节被使用方摸清哪怕你不改接口使用方也可能因为读到了不该知道的细节而写出依赖内部实现的代码。等到模块内部真的改变使用方就集体爆掉。我在实际项目中修抽象泄漏手段是接口补全惩罚机制。先看页面为什么需要读内部字段因为缺一个真正对外的能力那就把是否可以退款封装成订单模块的一个独立接口页面只需要消费布尔结果。然后通过 code review 和 lint 规则禁止业务代码直接 import 模块内部路径只允许走模块的 index 入口。坚持两个版本之后泄漏点基本就被清干净了。4.3 边界拆得太碎也是过度设计边界思维容易被误解成边界越多越好。真不是。每一条边界都是一笔成本接口设计成本、测试成本、调用方学习成本、模块之间通信开销。如果一条边界没有对应到真实的独立变化点那它就是纯粹的负担。我见过一个项目团队把 button 都抽成了基础组件、风格组件、业务组件三层模块边界每层还配了文档和版本策略。结果三个月后基础组件的改动频率和业务组件一样高三层边界的维护成本却翻了几倍。这说明那条边界切错了地方按钮本身变化频繁不值得被稳定边界包裹。判断边界是否拆过的标准是删掉它系统会变差吗。如果删掉一条边界只是让代码从 5 个文件变成 2 个文件但改同一个需求依然只需要动同一个区域那这条边界就不必要。真正的模块边界应该让变化的影响范围明显收缩而不只是让 code review 界面看着更清爽。4.4 一场回归测试之后对边界的反思有一次做一次涉及订单、支付、会员三个模块的版本发布回归测试把全仓库的用例都跑了一遍最后一共挂了 30 多个 case。我定位后发现根因竟然是订单模块的公共工具函数把枚举从string改成了number支付模块和会员模块全都直接引用过这个枚举。当时所有人都很沮丧模块化做得这么细怎么还是炸成一片冷静下来后我复盘了这次事故的真正原因订单模块虽然边界画得很清楚但这个枚举被定义为公共枚举放进了shared目录而shared就像是公共厕所谁都能进。改动方只改了订单形态却没意识到自己在修改公共契约。从那次之后我定下一条死规矩任何被shared共享的东西必须在接口层面拥有版本语义和变更通知。公共模块不能默默改所有变更都要在 PR 描述里标注影响面。这条规矩救了我们很多次以后凡是共享模块改动回归测试都会特别聚焦那些没有业务原因但依赖了共享实现的调用方。5. 我在工程实战中沉淀的几个边界思维习惯5.1 先承认模块化是为了应对变化而不是为了好看很多模块化设计失败的案例毛病都出在设计师把漂亮的抽象当成了目标。实际上模块化是为未来变化服务的如果你的业务场景压根没有那种变化需求模块化方案做得再花哨也是负债。所以我现在做设计时有一个自检问题如果未来一年内这个需求完全不变我这个模块边界设计是不是还值得如果值得说明它提供了确定性的价值如果不值得说明我只是在玩概念。这一点听起来很简单但能帮团队过滤掉一半以上的过度设计。5.2 每个模块都要有一句一句话职责说明给模块写职责说明是我对抗边界模糊最有效的土办法。每个模块的 README 第一行必须写清楚这个模块负责什么、不负责什么。写不出来说明边界是糊涂的写出来发现有两行说明一个模块承担了两种不同的变化责任。我用订单模块举个例子一句话职责是管理订单全生命周期的状态与流转规则不负责具体支付渠道的实现、不负责配送物流的跟踪。明确不负责什么特别重要它是边界思维的精华——只有知道边界外有什么才知道墙该筑在哪里。5.3 把模块的对外接口当 API 来维护前端仓库内部模块的对外入口我会当成对外 API 一样维护。该有的命名语义、默认参数、返回值类型、错误处理方式一项都不能省。模块之间的调用必须通过这个入口不允许跨目录直达内部文件。实现这个约束不需要很复杂的工具。最简单的是在模块根目录建一个index.ts只导出对外接口再配一条 lint 规则禁止 import 模块深层路径。养成这个习惯后模块内部的调整自由度会大幅提升因为你把内部实现可以改这个自由度牢牢锁在了模块的围墙里。5.4 定期做架构巡检而非一次性重构模块边界不是被设计出来就能永远保持的。团队成员的流动、需求的变更、赶工期时的临时 hack都在不断侵蚀边界。我自己的做法是每个迭代末尾安排一次小型架构巡检不看业务需求完成度只看代码结构本身。巡检动作很轻用工具生成一份当前模块依赖关系图人工扫一遍找明显的环、泄漏和方向错误随机抽两个模块对照它们的一句话职责说明看实际代码是否越界再检查一下新提交的 PR 是否遵守模块入口约束。每个迭代花半天到一天就能保证边界问题不积压成山。我个人的感受是模块化的难点从来不是技术实现而是能不能持续用边界思维审视自己每天的提交。文件拆分只要一次操作边界守护却是一场持久战。真正有价值的模块化是在代码里画出了一张经得起时间检验的责任地图。地图画得对后期每一次需求迭代、人员交接、性能治理都会因此受益。这一篇聊的是思想层面的模块化边界思维。下一篇我们可以继续聊怎么把这个思想落到 React 生态的具体技术上比如组件层面的模块边界、状态管理层面的边界划分以及自动化工具链如何帮你守住这些边界。搞懂了边界在哪再谈具体的拆法和工具才算真正有根。希望这篇对你有用也欢迎你把实战中遇到的模块边界问题拿出来一起碰。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。