前端视角:NestJS 和 Spring Boot,我靠对比学习啃下两个后端框架

Spring Boot 在 Java 技术栈里的地位我早有耳闻——但凡跟后端同事聊两句,十有八九项目跑的就是它。一个前端写多了业务,总会好奇:这个”霸主”到底长什么样。我是带着这个好奇去学的,顺便想把自己对后端的理解往前推一截,以后看业务、和后端协作时心里更有底。

我没直接啃 Java,而是先学了 NestJS,因为它是 TypeScript,前端上手快。学完发现它和 Spring Boot 长得像得离谱——同样是装饰器满天飞,同样有个容器帮你管对象。于是我反过来用 NestJS 当跳板摸 Spring Boot,一个没写过 Java 的人,靠着这条路径也算摸出了它的大致骨架。

这篇记一下”对比学习”这套方法,以及这两个框架到底哪里相通、哪里只是看起来像。

先说方法:从已知映射未知

我不是第一次用这套方法。我是 Vue 技术栈出身,学 React 的时候,就是从 Vue 映射过去的:ref/reactive 对应 useStatecomputed 对应 useMemowatch 对应 useEffect。映射建立之后,新框架的大部分就不用从零学了,剩下真正陌生的部分才是要花时间死磕的。

对比学习的前提是:新旧两样东西在解决同一类问题。NestJS 和 Spring Boot 都是后端框架,都要解决”请求进来 → 路由 → 业务逻辑 → 数据访问 → 响应出去”这条链路。问题一样,解法就难免趋同,这给映射提供了土壤。

但也不是什么都能这么学。比如 Angular,我完全没碰过,手里没有任何老底可以映射,真要学也只能从零硬啃。能靠对比省力的,只是那些”我这边正好有对应经验”的部分——这次算我运气好,Nest 和 Spring 恰好是这种情况。

AI 让建映射变快了

以前建映射要自己两边翻文档,慢。现在我把已掌握的那个框架丢给 agent,让它当翻译:

  • “我是前端,熟 NestJS。请用 NestJS 的概念给我讲 Spring Boot 的依赖注入,每条都给出 NestJS 里的对应物。”
  • “写一段 Spring Boot 代码,故意用 NestJS 的习惯写错,让我找问题。”
  • “我理解了这个注入方式,请当面试官追问我三个会暴露理解偏差的问题。”

agent 建的映射表只是草稿,不能照单全收。它经常把”形似”当成”神似”——比如会把两边某个注解直接划等号,但细看生命周期、作用域这些细节,其实不是一回事。这种地方得自己动手写一遍代码才会露馅。

AI 加速的是”建立对照”这一步,不是”判断对照对不对”。判断还在人。

两个框架真正相通的地方

下面这些是我摸下来对应得最实的部分。说实话,最先让我”啊,原来是这个意思”的,是横切关注这一块——大概是因为它跟前端的中间件、拦截逻辑离得最近,一下就有画面感,反倒是 IoC 容器那套,我是回头才慢慢理清楚的。

横切关注:拦截器、守卫、管道 vs 过滤器、切面

列个自己整理的对照表:

关注点 NestJS Spring Boot
鉴权 Guard Filter / Spring Security
请求/响应转换 Interceptor Interceptor
参数校验/转换 Pipe @Valid + Converter
异常处理 Exception Filter @ControllerAdvice
横切日志 Interceptor 切面(@Aspect

两边的思路是一样的:不侵入业务代码,把”所有请求都要走一遍”的逻辑(鉴权、日志、参数校验)抽出来集中写,而不是散在每个业务方法里到处复制粘贴。前端写多了拦截逻辑(比如 axios 的请求/响应拦截器)对这套一点都不陌生,只是后端把它做得更彻底、更成体系。

理解这一层之后,后端同事说”加个切面统一处理”,我脑子里大概能对上号:NestJS 里我会写个 Interceptor,Spring 里他写个 @Aspect,落在请求链路上的位置差不多。

IoC 容器:东西放进盒子,要用时拿出来

NestJS:

1
2
3
4
5
6
7
8
9
10
@Injectable()
class UserService {
findAll() { return [...] }
}

@Module({
providers: [UserService],
controllers: [UserController],
})
export class AppModule {}

Spring Boot:

1
2
3
4
5
6
7
@Service
public class UserService {
public List<...> findAll() { return ...; }
}

@SpringBootApplication
public class App { ... }

@Injectable()@Service 干的是同一件事:告诉容器”这是个可被注入的东西”,写业务代码的人不用自己去 new,用的时候容器会把实例递过来。NestJS 靠 @Module 里显式列一份 providers 清单,Spring 靠扫描注解自动找——一个手动登记,一个自动收集,但目的都是”把能用的东西丢进一个池子里管起来”。这块概念我是先在 Nest 里对着代码跑通一遍,再回头看 Spring 才算真的理清楚,一开始光看文字说明是没懂的。

装饰器:声明式地把元数据钉在代码上

NestJS 的控制器:

1
2
3
4
5
@Controller('users')
export class UserController {
@Get(':id')
findOne(@Param('id') id: string) { ... }
}

Spring Boot:

1
2
3
4
5
6
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping("/{id}")
public User findOne(@PathVariable String id) { ... }
}

对照几乎是逐行的:用一个标记说”这是个路由”,用另一个标记说”这个参数从 URL 里取”,框架在运行时读出来帮你接线,你不用自己写解析逻辑。

我查资料的时候看到说 NestJS 这套装饰器风格是从 Angular 那边搬过来的——Angular 我没用过,具体渊源说不上来,但单看这种”用标记描述意图,让框架做机械活”的思路,跟 Vue 里 <script setup> 的一些编译期魔法、React 的高阶组件其实是同一个路数,前端看到不会太陌生。

生命周期钩子

NestJS 的 OnModuleInitOnApplicationBootstrap;Spring 的 @PostConstruct。都是”对象创建完/应用启动完”这类时机的回调。前端类比:Vue 的 mounted、React 的 useEffect,同一种”框架告诉我到点了”的机制,这个映射基本不用怎么想就能对上。

哪里只是看起来像

对比学习最容易翻车的地方,是只看到像、看不到不像。我刻意记了几条,免得自己回头拿 NestJS 的经验去套 Spring 出问题:

运行时不一样。 NestJS 跑在 Node 上,单线程,异步靠 async/await。Spring 跑在 JVM 上,是多线程模型。这意味着 NestJS 里一个请求处理阻塞了,会连累后面排队的请求;Spring 那边一个请求占一个线程,互相干扰小一些。写拦截逻辑的时候这个差异会直接咬人——NestJS 的拦截器里如果塞了同步的重活,容易拖慢整个服务。

依赖注入的实现方式不一样。 NestJS 靠 TypeScript 的装饰器 + 元数据,没标 @Injectable() 的东西是注入不进去的;Spring 靠 Java 的反射机制,能拿到的运行时信息更多。这个差异导致两边”动态注入一组东西”的写法不太一样,具体细节我也还在慢慢摸。

生态成熟度不一样。 Spring Boot 更像”全家桶”,数据层、安全、多环境配置基本都有官方现成的方案,跟着官方文档走大概率不会错。NestJS 更像”核心 + 自己拼”,数据库要自己在 TypeORM/Prisma 里选,鉴权要自己接 Passport——这点上反而更像前端的习惯,什么都得自己挑生态。Spring 官方给的答案多,NestJS 的自由度更高,两种风格没有绝对的好坏,适应成本不一样而已。

对比学习不是省力,是省时间

对比学习给我的不是”少学”,而是”学得快”。Spring Boot 那些核心概念本来就要学,但因为 NestJS 里我已经摸过一遍类似的东西,又是熟悉的 TypeScript,所以能借这条路径把 Spring 的骨架搭得快一些。真正要花时间死磕的,是那些映射不上、或者一开始映射错了的部分——而这部分往往才是真正值得学的。

AI agent 在这中间扮演的是”快速出题人 + 对照表初稿生成器”的角色。它能让我把两边的代码放在一起跑一遍看效果,省了不少来回翻文档的时间。但它给的所有对照我都得自己动手验一遍,因为它经常分不清”形似”和”神似”,草稿信不得,得自己较真。

后端同事再讲架构,我至少能接上一部分话了——不是因为我会写 Java 了,是因为我手里多了一张不算精确但能用的映射图。前端去啃一个陌生的后端框架,最大的收获不是多会一门语言,是多少看清了”一个请求在服务端到底经历了什么”。这张图反过来对我写前端也有点用:知道后端大概是 Controller 接请求、Service 写业务、Repository 管数据这么一路传下去之后,前端这边怎么组织接口调用层,多少更有谱一点。

给一个 Vue2 老项目做 Webpack 到 Vite 迁移

接手过老项目的人都懂那种感受:改一行代码,等热更新等到手停在键盘上发呆。我负责迁移的这个项目是一个跑了多年的广告投放前端工程,Vue2 + Webpack,模块一年年长出来,启动和热更新的等待时间也一年年长出来。

迁移到 Vite 不是为了追新。真实动机就两个:开发时的等待成本,和 Webpack 配置本身的维护成本——那份配置文件经手过很多人,loader 和 plugin 叠了一层又一层,没人敢动。

这篇记录迁移过程中真正花时间的几个地方。不是教程,更像一份”如果重来一遍,我会先看哪里”的清单。

Vue2 项目跑 Vite,第一件事是插件层

Vite 官方模板只认 Vue3。Vue2 项目要跑起来,得先解决 SFC 编译:

  • Vue 2.7+:用官方的 @vitejs/plugin-vue2
  • Vue 2.6 及以下:用社区的 vite-plugin-vue2

这一步看起来只是装个插件,但有个隐蔽的前提:template 编译器版本必须和运行时版本严格一致。Vue2 的 vue-template-compilervue 是成对的,版本对不上启动时会直接报错——报错信息倒是清楚,真正麻烦的是有些老项目锁版本的方式很随意,package.json 里写的和 lock 文件里装的不是一回事,先对齐版本再动手。

另外两个连带问题:

  • 项目里如果有 JSX 写法(Vue2 时代不少高阶组件这么写),要额外配 Vue2 的 JSX 插件,不配的话报错位置会出现在很奇怪的地方。
  • Element UI 的按需加载原来靠 babel-plugin-component,Vite 下这条路不通。我的做法是先全量引入把项目跑通,再换 unplugin-vue-components 的 resolver 做按需——迁移期一次只解决一类问题,不要把”能跑”和”跑得好”混在一个阶段。

环境变量:最容易”看起来没问题”的部分

Webpack 时代的习惯是 process.env.VUE_APP_XXX,Vite 的口径是 import.meta.env.VITE_XXX。改配置文件只要十分钟,真正的工作量在业务代码里。

我踩的最深的一个坑:老代码里埋点开关是用 process.env.VUE_APP_ENV 判断的,散落在多个业务文件里。迁移后构建能通过,页面也能打开——但 process 在浏览器里根本不存在,某些分支一执行就是 process is not defined。而且这种错误只在特定操作路径下触发,冒烟测试根本测不出来。

处理方式分两步:

  1. 过渡期先用 defineprocess.env.VUE_APP_* 整体映射掉,保证任何遗漏的读取点都不会炸;
  2. 再把散落的读取点逐步收敛到一个统一的 env 模块,业务代码只从这个模块拿配置。

这个坑给我留下的习惯是:迁移前先全局搜一遍 process.env,把所有出现位置列成清单。清单比记忆可靠。

require.context 和动态 require

Webpack 对模块加载很宽容:require()、运行时拼路径、require.context 批量导入,都能跑。Vite 基于 ESM,要求导入路径静态可分析——这是两种构建哲学的根本差异,也是迁移中真正要”改代码”而不是”改配置”的部分。

分三类处理:

  • require.context 批量导入(路由、store 模块、全局组件注册最常见):换成 import.meta.glob。注意它是编译期展开的,路径规则要写死,不能运行时拼。
  • 普通 require():能改的直接改成静态 import 或动态 import()
  • 运行时拼路径的 require(比如按接口返回的名字加载图片):这类没有银弹,要么用 import.meta.glob 预先建立映射表,要么把资源挪到 public 目录走 URL 引用。哪种合适取决于资源数量和更新频率。

第三方 CJS 依赖大部分靠 Vite 的依赖预构建就能吃掉,个别顽固的再用 alias 指到 ESM 版本或单独配置。我的经验是:不要一上来就给每个报错找插件,先分清是”项目代码要改”还是”依赖要兼容”,前者占的比例比想象中大。

静态资源和别名

这部分琐碎但不难,列几个必查项:

  • resolve.alias:把 Webpack 的别名逐条搬过来,注意 CSS 里 ~@/assets 这种带 ~ 前缀的写法 Vite 不认,要全局清理。
  • base:对应 Webpack 的 publicPath,部署在子路径或走 CDN 的项目,构建后资源 404 十有八九是它。
  • 写死的 /static 路径:老项目常见,统一梳理成 import 引用或挪进 public

最不能省的一步:回归

构建工具迁移最怕的不是报错,是”本地能跑、线上白屏”。开发环境 Vite 用 esbuild 做依赖预构建,生产构建走 Rollup——两条链路,某些依赖开发能跑、build 就挂是真实会发生的,所以 vite build 加产物预览必须纳入验证,不能看 dev server 正常就收工。

我当时的回归清单按业务主链路列:登录、菜单权限、核心业务页面、接口代理、静态资源、埋点上报、异常监控。埋点和监控最容易被漏——它们不影响页面显示,坏了没人第一时间发现,但对业务是暗伤。

迁移完,值不值?

说实话,我当时没有保留严格的启动/HMR 耗时对比数据,所以给不出”提升了 X%”这种数字——这也是复盘时最大的遗憾:下次做这类迁移,第一步应该是记基准数据,启动耗时、HMR 耗时、build 耗时、产物体积,前后各测一轮,收益才有据可查。

能确定的是体感:本地启动从”起身倒水”变成”眨眼”,热更新基本无感,配置从一份没人敢碰的遗产变成一份看得懂的声明。对一个还要继续维护多年的老项目,这笔账是划算的。

最后一点心得:老项目迁移的难度不在 Vite 本身,在于你得先弄清楚这个项目这些年积累了哪些”隐性约定”——哪些环境变量在哪些地方被读、哪些资源路径是写死的、哪些依赖是 CJS。迁移工具链的过程,本质上是把这些隐性约定一条条显性化的过程。工具换完,项目也顺便被重新理解了一遍。