为什么你写了3年前端还是搭不好一个项目

为什么你写了 3 年前端,还是搭不好一个项目?

很多前端工作到第 3 年,会进入一个很尴尬的阶段。

页面能写,接口能接,Bug 能改,组件库也用得挺熟。日常需求来了,照着现有项目往里填代码,基本没问题。

但只要换一个问题:

现在从 0 开始搭一个长期维护的项目,你会怎么组织?

很多人就开始不确定了。

用 Vite 还是 Rsbuild?目录怎么分?接口层放哪里?公共类型放哪里?登录态怎么处理?多个应用怎么共享代码?环境变量怎么管?后面如果项目变大,怎么避免越写越乱?

这时候你会发现,自己并不是不会写代码,而是缺少一种更上层的能力:项目组织能力

这篇文章不讲某个新框架,也不教你复制一套模板。我想聊的是:为什么很多人写了几年业务,仍然搭不好一个项目。

一、会写功能,不等于会搭项目

刚入行的时候,我们大部分时间都在做局部任务:

  • 写一个页面
  • 封一个组件
  • 接一个接口
  • 改一个样式
  • 修一个线上 Bug
  • 按已有模式加一个新模块

这些事情都很重要,但它们训练的是"局部实现能力"。

而从 0 搭项目,考验的是另一种能力:

  • 你能不能判断哪些东西会变,哪些东西应该稳定?
  • 你能不能把业务代码和工程配置隔开?
  • 你能不能让公共逻辑复用,而不是复制粘贴?
  • 你能不能让请求层不绑死登录方式?
  • 你能不能让新人和 AI 都看得懂这个项目的规矩?
  • 你能不能给项目留出演进空间?

很多人写了三年,还是搭不好项目,不是因为不努力,而是因为工作里很少有人系统教这些。

公司里的项目通常已经存在了。你进去的时候,目录已经定了,请求层已经封了,构建配置已经写好了,登录态也已经接好了。

你每天在这个结构里填代码,却很少被迫回答:

如果这个结构从一开始由你来设计,你会怎么做?为什么?

这就是差距。

二、搭不好项目的人,通常会踩这 5 个坑

1. 目录只按文件类型分,不按职责分

很多项目一开始都是这样:

txt 复制代码
src/
├── components/
├── utils/
├── api/
├── store/
├── views/
└── types/

看起来很标准,但项目一大就开始乱。

所有公共组件都进 components,所有工具函数都进 utils,所有接口都进 api。一开始还好,半年后谁也说不清某个文件到底归谁管。

问题不是这个目录本身错了,而是它只回答了"文件是什么类型",没有回答"它属于哪个职责边界"。

真正需要思考的是:

  • 哪些是应用层代码?
  • 哪些是跨应用共享代码?
  • 哪些是服务层?
  • 哪些是构建层?
  • 哪些是 UI 层?
  • 哪些东西应该允许业务改,哪些应该沉到底座里?

如果这个问题不先想清楚,目录迟早会变成垃圾桶。

2. 请求层越封越重,最后谁都不敢动

很多项目的请求层一开始都很简单:封一个 axios 实例,加 token,加 401 跳登录。

后来需求变多了:

  • A 后端用 code/message/data
  • B 后端用 status/msg/result
  • C 后端登录失效不是 401,而是业务码
  • 有些请求不要弹错误提示
  • 有些请求 401 不要跳登录
  • 有些项目 token 在 localStorage,有些在 cookie,有些在客户端 bridge 里

如果一开始把所有逻辑都写死在一个全局 axios 单例里,请求层很快就会变成一个巨大的隐式中心。

它知道 token 存在哪里,知道路由怎么跳,知道 UI 用什么消息组件,知道后端业务码怎么判断。

这就麻烦了。

请求层本来只应该负责"发请求"和"处理协议",结果它开始认识整个应用。

一个层知道得越多,就越难复用,也越难迁移。

3. 环境变量到处读,配置错了上线才发现

很多项目里你会看到这种代码:

ts 复制代码
const baseURL = import.meta.env.VITE_API_BASE_URL || '';

或者:

ts 复制代码
const baseURL = process.env.API_BASE_URL ?? '';

看起来很安全,实际上很危险。

环境变量如果缺了,项目不报错,而是悄悄给你一个空字符串。于是请求可能打到当前域名,或者在某个环境里表现异常。

更麻烦的是,Vite、Webpack、Rsbuild 对环境变量的注入方式并不完全一样。业务代码到处直接读 import.meta.envprocess.env,等于把业务层和构建工具绑死了。

今天你用 Vite,明天想换 Rsbuild,结果发现业务代码里到处都是构建工具的影子。

这不是技术选型问题,这是边界问题。

4. 构建配置和业务代码互相渗透

很多项目起步时只想快一点,于是所有东西都直接写进工具配置里。

Vite 配置里塞业务规则,Webpack 配置里塞页面规则,环境注入、别名、入口、HTML 模板都和具体工具绑在一起。

短期看没问题,长期看会变成锁死。

项目小的时候,Vite 很舒服。项目大了,可能你想切 Rsbuild 或 Rspack 提升构建性能;多页场景来了,可能你想换一种入口模型。

这时候如果业务代码已经认识了构建工具的脸,迁移就不是"换工具",而是"动手术"。

真正稳定的项目,不应该让业务层直接依赖构建工具细节。

5. 规范只写在 README,没人真的执行

很多团队都有规范文档,但代码还是越来越乱。

原因很简单:规范如果只写在 README 里,它就靠人肉记忆执行。

新人不一定看,老员工不一定遵守,AI 更不知道你们团队的隐性约定。

比如:

  • 组件应该薄,业务逻辑应该抽出去
  • import 顺序要统一
  • 类型不要用 any
  • 业务层不要直接读环境变量
  • 服务层不要依赖 UI 框架
  • 新页面要登记到统一入口

这些东西如果没有进入工具链、目录结构和代码约束里,时间一长就会失效。

项目腐化,很多时候不是因为没人懂规范,而是因为规范没有落到结构里。

三、搭项目的核心,不是选技术栈,而是设计边界

很多人一说搭项目,第一反应是选技术栈:

  • Vue 还是 React?
  • Vite 还是 Rsbuild?
  • Pinia 还是 Zustand?
  • pnpm 还是 npm?
  • 要不要 Monorepo?

这些问题当然重要,但它们不是第一层问题。

第一层问题应该是:

这个项目里,哪些东西应该彼此隔离?哪些东西应该统一收口?哪些东西未来一定会变?

我后来越来越觉得,一个项目底座真正重要的是 5 件事:

  1. 分层:不同职责放在不同层,不要互相污染。
  2. 依赖方向:底层不要认识上层,上层通过注入装配底层。
  3. 单一真相源:版本、入口、环境变量、页面配置,要有明确来源。
  4. 显式失败:配置缺失就报错,不要静默兜底。
  5. 演进空间:今天的工具选择,不应该堵死明天的迁移路径。

如果这 5 件事没想清楚,技术栈越先进,项目也可能越乱。

四、举个真实例子:我为什么把 LYStack 设计成这样

我最近把自己多年踩坑沉淀出来的一套 Vue3 Monorepo 底座整理成了开源项目,叫 LYStack

它不是为了证明"我又写了一个脚手架"。

恰恰相反,我不太想把它叫脚手架。脚手架解决的是"快速生成项目",而我更关心的是:

一个项目长期维护时,如何不腐化?

所以 LYStack 里有几个设计,不是为了炫技,而是为了回答上面那些边界问题。

1. 用 Monorepo 把"应用"和"公共能力"分开

LYStack 的结构大概是这样:

txt 复制代码
LYStack/
├── apps/
│   ├── example-vite/
│   ├── example-rsbuild/
│   └── example-rsbuild-mpa/
├── packages/
│   ├── build-config/
│   ├── shared/
│   ├── services/
│   └── ui/
├── pnpm-workspace.yaml
└── turbo.json

这里的重点不是"用了 Monorepo 很高级"。

重点是它把职责拆开了:

  • apps/* 放具体应用
  • packages/build-config 放构建抽象
  • packages/shared 放构建无关的共享类型、常量、环境变量读取
  • packages/services 放请求层和服务层能力
  • packages/ui 放可替换的 UI 基础能力

这样设计之后,应用不再是一个什么都往里塞的大 src

公共能力能沉淀到底层包里,多应用可以共享;应用层只负责自己的业务装配。

这就是 Monorepo 真正解决的问题:不是把多个项目塞进一个仓库,而是让共享能力有地方沉淀。

2. 构建工具被关进 adapter,业务代码不认识它

LYStack 里有一个构建工具无关的配置契约,叫 AppBuildOptions

应用只描述自己要构建什么:

ts 复制代码
export default defineViteConfig({
  kind: 'spa',
  appName: 'example-vite',
  root: resolveRoot(import.meta.url),
  entry: 'index.html',
});

切到 Rsbuild 时,应用配置长这样:

ts 复制代码
export default defineRsbuildConfig({
  kind: 'spa',
  appName: 'example-rsbuild',
  root: resolveRoot(import.meta.url),
  entry: 'src/main.ts',
});

你会发现,应用层没有直接碰 Vite 或 Rsbuild 的复杂配置。

真正的工具差异被关在 @repo/build-config/vite@repo/build-config/rsbuild 这两个 adapter 里。

这背后的思想是:

应用只描述"我要构建什么",adapter 负责翻译成"用什么工具构建"。

这样未来要换构建工具时,改的是 adapter,不是业务代码。

这就是边界。

3. 服务层用依赖反转,不把登录态写死

LYStack 没有采用"一个全局 axios 单例写死所有逻辑"的方式,而是做了一个 AxiosFactory

每个后端服务可以有自己的实例、自己的拦截器、自己的业务码处理方式。

更关键的是,services 层不直接读取 localStorage,也不直接 import router,更不绑定某个 UI 消息组件。

应用启动时,在 bootstrap() 里注入这些能力:

ts 复制代码
configureServiceAuth({
  getToken: () => localStorage.getItem(STORAGE_TOKEN_KEY) ?? '',
  onAuthenticationFailure: () => {
    // 真实项目里这里跳转登录页
    console.warn('[bootstrap] 认证失效,请接入路由跳转逻辑');
  },
});

setErrorMessenger((msg: string) => {
  console.error(`[LYStack] ${msg}`);
});

这段代码的重点不是写法,而是依赖方向。

services 层不关心 token 从哪里来,也不关心 401 后要跳哪个页面。它只暴露一个注入点,由应用层在启动时装配。

这样 services 才能被不同应用复用。

同样的请求层,可以跑在普通 Web 应用里,也可以跑在 Electron 应用里;可以接 localStorage,也可以接 cookie 或 SSO。

这就是依赖反转的价值。

4. 环境变量统一收口,缺失就直接报错

LYStack 里业务层不会到处直接读 import.meta.envprocess.env

环境变量统一从 @repo/shared/env 读取:

ts 复制代码
const apiBase = getEnv('PUBLIC_API_BASE_URL');

如果必填环境变量缺失,它不会返回空字符串,而是直接抛错。

这个设计来自一个很常见的教训:配置漏了,但代码用 ?? '' 静默兜底,结果线上请求全打错地方,排查很久才发现。

所以我现在更倾向于:

配置错了,就应该尽早炸。不要把问题留到线上。

这也是项目组织能力的一部分。

新手喜欢"先跑起来再说",但长期维护的项目更需要"错误尽早暴露"。

5. 多页入口用 PageConfig 做唯一真相源

LYStack 里 Rsbuild MPA 的页面入口不是靠扫描目录,也不是散落一堆 HTML,而是有一个 page.config.ts

ts 复制代码
export const pages = [
  {
    name: 'index',
    entry: './src/pages/index/main.ts',
    title: 'LYStack · 首页',
  },
  {
    name: 'about',
    entry: './src/pages/about/main.ts',
    title: 'LYStack · 关于',
  },
];

新增页面时,页面必须登记到这个清单里。

这叫单一真相源。

项目越大,越不能靠"大家约定一下"。入口、版本、环境、路由、权限这些东西,都应该有明确的来源。

否则项目表面能跑,内部会越来越不可控。

五、所以,3 年前端到底该补什么?

如果你已经能独立写页面、接接口、改 Bug,但一到从 0 搭项目就慌,我建议你接下来不要只补框架 API。

你真正该补的是这些问题:

1. 学会拆职责

不要只问"这个文件放哪里"。

要问:

  • 这个能力属于业务,还是基础设施?
  • 它是某个应用私有,还是多个应用共享?
  • 它依赖 UI 吗?依赖路由吗?依赖构建工具吗?
  • 它未来变化的概率高不高?

能回答这些问题,目录结构自然会清晰很多。

2. 学会看依赖方向

很多代码不是不能写,而是不该写在那个层里。

请求层可以处理协议,但不该知道页面路由。

shared 层可以放常量和工具,但不该依赖业务模块。

构建层可以处理 Vite/Rsbuild 差异,但不该泄漏到业务组件。

依赖方向一乱,项目就会越来越难改。

3. 学会建立单一真相源

版本不要散落在每个包里。

环境变量不要到处读。

页面入口不要靠大家记。

公共类型不要复制粘贴。

同一个事实,在项目里最好只有一个权威来源。

否则你后面所有维护成本,都是在为这些重复事实还债。

4. 学会显式失败

新手害怕报错,资深一点的工程师害怕不报错。

配置缺失、入口漏配、环境变量为空、adapter 不支持某种模式,这些都应该尽早暴露。

LYStack 里 Vite adapter 目前不支持 MPA,就直接抛错,而不是假装支持。

这不是功能少,而是边界诚实。

项目可以有边界,但边界必须明确。

5. 学会为未来留余地

不是所有项目一开始都需要很复杂。

但如果你明知道项目会长期维护、会多人协作、会多个应用共享能力,那从一开始就要考虑演进空间。

今天用 Vite 没问题,但不要让业务代码和 Vite 深度绑定。

今天只有一个应用没问题,但公共能力要有地方沉淀。

今天登录态很简单没问题,但请求层不要写死登录实现。

所谓架构,不是提前把所有东西做复杂,而是让关键变化有地方发生。

六、别再只问"有没有模板",要问"为什么这么组织"

我以前也很喜欢找模板。

Vue3 模板、后台管理模板、Electron 模板、Monorepo 模板,看起来都很完整。

但后来发现,只会用模板不够。

模板能帮你快速开始,但不能替你做工程判断。

真正有价值的是搞清楚:

  • 为什么它这么分层?
  • 为什么这里要注入,而不是直接 import?
  • 为什么配置要收口?
  • 为什么这个能力放 packages,而不是放 app?
  • 为什么它选择显式报错,而不是默认兜底?

当你开始追问这些问题时,你就不再只是"会写代码",而是在建立项目组织能力。

这也是我开源 LYStack 的原因。

我不希望它只是一个 clone 下来就跑的项目。我更希望它成为一个案例:我们可以借它讨论,一个真实项目从 0 到 1 时,哪些边界应该先想清楚。

七、最后

写了 3 年前端还搭不好项目,并不丢人。

因为大多数人的工作环境,本来就很少训练这件事。

你每天接触的是已经存在的项目,是局部需求,是别人定好的目录和规则。你当然会越来越擅长写功能,但不一定自然学会搭项目。

从"会写功能"到"会组织项目",中间缺的不是某个框架 API,而是一套工程判断:

  • 怎么分层
  • 怎么划边界
  • 怎么控制依赖方向
  • 怎么沉淀公共能力
  • 怎么让配置有单一真相源
  • 怎么让错误尽早暴露
  • 怎么让项目能长期演进

这些东西,才是 3-5 年前端真正需要突破的地方。

后面我会继续用 LYStack 这个开源底座做例子,拆一拆一个项目从 0 到 1 到底应该怎么组织。

下一篇我想聊:

目录结构到底怎么设计?别再把 components、utils 当垃圾桶了。

如果你也卡在"会写页面,但不会搭项目"这个阶段,可以关注这个系列。

项目地址:

相关推荐
snow@li1 小时前
Java:后端项目会有一个类似前端node_modules的目录吗 / jar包在本地仓库 ~/.m2/repository 中按版本共存
java·开发语言·前端
weedsfly1 小时前
单例模式在前端中的正确打开方式
前端·javascript·面试
PedroQue991 小时前
uni-router新推useUniEventChannel,彻底解决页面通信难题
前端·uni-app
IT_陈寒1 小时前
Vite冷启动快?我遇到了个奇怪的依赖问题
前端·人工智能·后端
2603_955279701 小时前
通过 brew upgrade cmake 升级到最新版本
前端
郝学胜_神的一滴1 小时前
系统设计 019:分片拆分与副本容灾深度解析
后端·架构
FREEDOM_X1 小时前
Linux 进程间通讯(IPC)——总结
linux·c语言·前端·嵌入式硬件·struts
WWBQU2 小时前
前端day06
前端
岁月宁静2 小时前
DeepSeek Harness 团队 招聘 :JD反推:新手如何系统掌握AI Agent开发技术?
前端·人工智能·后端