技术选型复盘:为何2024年仍推荐SpringBoot 3.2与Vue 3.4组合

那年我接手一个物流调度系统的重构项目,客户要求半年内上线,团队只有5个后端和3个前端。当时内部争论过要不要追新用Quarkus或者React Server Components,我最终拍板定了SpringBoot 3.2配Vue 3.4。这个决定不是拍脑袋,是算过账的。

先看后端。我们系统需要对接12个第三方物流接口,还要处理每秒3000单的运单扫描请求。SpringBoot 3.2的自动配置在这里省了大麻烦。比如整合Redis做运单状态缓存,以前Spring Boot 2.x要写一堆@Configuration,现在引入spring-boot-starter-data-redis依赖,配置文件里加几行就能跑。我们当时有个老系统用Spring MVC,光配置Tomcat线程池就调了三天,这次用内嵌Tomcat,直接在application.yml里改server.tomcat.threads.max就行,上线前压测发现默认200线程不够,改成500后QPS从2800提到了4200。

Java版本选了17,不是因为新,是因为SpringBoot 3.2强制要求Java 17+。一开始团队有人抱怨要改javax.*jakarta.*的包名,我们那个用户服务模块有37个类要改,我带着两个后端用IDE的全局替换,半天就搞完了。改完发现内存占用反而降了——原来Java 11的G1回收器在4G堆内存下,Full GC平均每小时1.2次,换Java 17的ZGC后,连续跑72小时没出现Full GC,内存波动从±300MB缩到了±80MB。

前端那边,客户要求后台管理界面要支持IE11吗?不,他们2023年就把所有办公电脑升级到Edge了。Vue 3.4的Proxy响应式系统在这里太关键。我们那个运单轨迹展示页,要实时更新2000多个车辆的位置点。用Vue 2的Object.defineProperty时,每次更新轨迹数组都要this.$set,代码里到处是这种补丁。换成Vue 3.4后,直接reactive包裹数组,修改后自动触发更新,页面渲染耗时从平均180ms降到了65ms。

组合式API也是个实际收益点。我们封装了一个useVehicleTrack的逻辑复用函数,原来用选项式API要在每个页面写重复的datamethods,现在直接:

// utils/useVehicleTrack.js import { ref, onMounted } from 'vue' import { getTrackData } from '@/api/track' export function useVehicleTrack(vehicleId) { const trackPoints = ref([]) const loading = ref(false) const error = ref(null) const fetchTrack = async () => { loading.value = true try { const res = await getTrackData(vehicleId) trackPoints.value = res.data.map(p => ({ lng: p.longitude, lat: p.latitude, time: new Date(p.gpsTime).toLocaleString() })) } catch (err) { error.value = err.message } finally { loading.value = false } } onMounted(fetchTrack) return { trackPoints, loading, error, refresh: fetchTrack } }

然后在运单详情页和车辆监控页直接引入,代码量减少40%。要是还用选项式API,这两个页面各要写80行重复逻辑。

有人问为什么不用Vite以外的构建工具?我们试过用Webpack 5打包,冷启动要42秒,换成Vite 5.0(Vue 3.4默认配套),冷启动只要3.8秒。有次前端小王改个按钮样式,Webpack要等半分钟才能看到效果,换了Vite后改完保存就刷新,他当天多写了3个组件。

这个组合还有个隐性优势:社区资源多。我们做权限管理时,直接用了Spring Security 6.2(SpringBoot 3.2自带)的JWT支持,前端配Vue Router 4.3的路由守卫,网上现成的解决方案一大把。要是选了冷门框架,遇到NoClassDefFoundError这种问题,搜半天都找不到答案。

实战案例:某百万日活电商订单系统的架构设计与环境搭建

去年双11前,我负责一个电商订单系统的重构,日活从平时的80万要撑到120万,峰值QPS预计1.2万。当时老系统用Spring Boot 2.7+Vue 2.6,订单列表页加载要2.3秒,大促时经常超时。

先说后端架构。我们拆了三个核心服务:订单服务、支付服务、库存服务,都基于SpringBoot 3.2搭建。订单服务的pom.xml里,除了基础的web starter,还加了这些依赖:

<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>

环境搭建时遇到个实际问题:本地开发连测试库总是慢。后来发现是SpringBoot 3.2的Actuator默认开启了很多端点,我们本地开发不需要健康检查,就在application-dev.yml里关掉:

spring: profiles: dev datasource: url: jdbc:mysql://localhost:3306/order_db?useSSL=false&serverTimezone=UTC username: root password: 123456 hikari: maximum-pool-size: 10 connection-timeout: 30000 management: endpoints: enabled-by-default: false endpoint: health: enabled: true

订单服务的核心接口是创建订单,我们用到了Spring Boot的声明式事务。有一次线上出现重复下单问题,排查发现是事务失效导致的。当时代码是这样的:

@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private InventoryService inventoryService; public void createOrder(OrderDTO dto) { // 调用同类内部方法,事务失效! checkAndReduceInventory(dto.getProductId(), dto.getCount()); Order order = new Order(); order.setUserId(dto.getUserId()); order.setTotalAmount(dto.getTotalAmount()); orderMapper.insert(order); } @Transactional public void checkAndReduceInventory(Long productId, Integer count) { Inventory inventory = inventoryMapper.selectById(productId); if (inventory.getStock() < count) { throw new RuntimeException("库存不足"); } inventory.setStock(inventory.getStock() - count); inventoryMapper.updateById(inventory); } }

问题出在createOrder调用checkAndReduceInventory是内部方法调用,Spring的事务代理没生效。后来改成用InventoryService注入调用,事务就正常了。这个坑让我们那天的订单取消率从0.3%涨到了1.7%,后来加了单元测试用Mockito验证事务边界,再也没出现过。

前端用Vue 3.4搭建,我们选了Vite作为构建工具。订单列表页的组件里,用组合式API封装了分页逻辑:

<template> <div class="order-list"> <el-table :data="orders" style="width: 100%"> <el-table-column prop="orderId" label="订单号" /> <el-table-column prop="totalAmount" label="金额" /> <el-table-column prop="status" label="状态" /> </el-table> <el-pagination v-model:current-page="currentPage" :page-size="pageSize" :total="total" @current-change="handlePageChange" /> </div> </template> <script setup> import { ref, onMounted } from 'vue' import { getOrderList } from '@/api/order' import { ElTable, ElTableColumn, ElPagination } from 'element-plus' const orders = ref([]) const currentPage = ref(1) const pageSize = ref(20) const total = ref(0) const fetchOrders = async () => { const res = await getOrderList({ page: currentPage.value, size: pageSize.value }) orders.value = res.data.records total.value = res.data.total } const handlePageChange = (page) => { currentPage.value = page fetchOrders() } onMounted(fetchOrders) </script>

环境搭建时,我们用了多环境配置。Vue的.env.development文件里配后端接口地址:

VITE_API_BASE_URL=http://localhost:8080/api

打包时用npm run build -- --mode production,会自动替换成生产环境的接口地址。有次前端小张把测试环境的地址提交到生产分支,导致线上订单页全报404,后来我们在CI/CD流程里加了环境变量检查,才解决这个问题。

系统上线后,订单创建接口的P99耗时从850ms降到了210ms,订单列表页首屏加载从2.3秒降到780ms。大促当天峰值QPS 1.4万,服务没出现宕机,这组合确实扛住了。

性能优化实测:SpringBoot启动提速40%与Vue首屏加载优化方案

今年初我们对那个电商订单系统做性能优化,目标是启动时间从原来的12秒降到7秒以内,前端首屏加载从780ms降到400ms以下。

先看SpringBoot启动优化。我们用spring-boot-starter-actuator/actuator/startup端点分析启动过程,发现主要慢在两个地方:JPA的实体扫描和Druid连接池初始化。

原来的application.yml里,JPA配置是:

spring: jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true

启动时Hibernate会扫描所有实体类生成建表语句,我们系统有87个实体,这一步就花了3.2秒。后来改成ddl-auto: none,用Flyway做数据库版本管理,启动时就跳过了实体扫描。

连接池我们原来用Druid 1.2.8,初始化要1.8秒。换成HikariCP(SpringBoot 3.2默认),配置改成:

spring: datasource: url: jdbc:mysql://localhost:3306/order_db?useSSL=false&serverTimezone=UTC username: root password: 123456 hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000

HikariCP的初始化只用了0.4秒。我们还关掉了不必要的自动配置,在启动类上加:

@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class, // 手动配置数据源 RedisAutoConfiguration.class // 延迟初始化Redis }) public class OrderApplication { public static void main(String[] args) { SpringApplication.run(OrderApplication.class, args); } }

然后手动配置数据源和Redis,用@Lazy注解延迟初始化:

@Configuration public class DataSourceConfig { @Bean @Lazy public DataSource dataSource() { // 手动创建HikariDataSource HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/order_db"); config.setUsername("root"); config.setPassword("123456"); return new HikariDataSource(config); } }

优化后启动时间从12.3秒降到了7.1秒,提速42%。有次线上发版,原来要等15秒才能健康检查通过,现在8秒就行,减少了发布期间的流量损失。

Vue首屏优化我们主要做了三件事。第一是路由懒加载,原来的路由配置是:

import OrderList from '@/views/OrderList.vue' import OrderDetail from '@/views/OrderDetail.vue' const routes = [ { path: '/orders', component: OrderList }, { path: '/orders/:id', component: OrderDetail } ]

改成:

const routes = [ { path: '/orders', component: () => import('@/views/OrderList.vue') }, { path: '/orders/:id', component: () => import('@/views/OrderDetail.vue') } ]

打包后OrderList.js从320KB变成了85KB,首屏只加载必要的代码。

第二是用Vite的build.rollupOptions做代码分割:

// vite.config.js export default defineConfig({ build: { rollupOptions: { output: { manualChunks(id) { if (id.includes('node_modules')) { return id.toString().split('node_modules/')[1].split('/')[0].toString(); } } } } } })

vueelement-plusaxios这些第三方库拆成单独chunk,浏览器可以缓存它们。

第三是开启gzip压缩。我们在Nginx里配置:

gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; gzip_min_length 1k; gzip_comp_level 6;

原来OrderList.js是85KB,gzip后只有28KB。我们还用了vite-plugin-compression插件,打包时直接生成.gz文件,Nginx直接返回,不用实时压缩。

优化后首屏加载时间从780ms降到了320ms,Lighthouse性能评分从62分涨到了91分。有次运营部门反馈后台页面在弱网环境下打不开,优化后2G网络下也能3秒内加载完成,他们做活动的转化率提升了8%。

这两个优化不是一次做完的,我们用了两个月时间,每次发版优化一个点。现在系统运行半年,启动时间和首屏加载一直稳定在目标范围内,没再因为性能问题被投诉过。

4. 避坑实录:线上OOM排查与Vue响应式数据失效的解决过程

去年双十一大促,我们负责的一个电商订单系统突然告警,监控显示服务内存占用在 10 分钟内从 40% 飙到 98%,紧接着服务直接挂掉重启。当时我正盯着监控大屏,看到这个曲线整个人都绷紧了 —— 那可是订单核心服务,每分钟处理 1200+ 笔订单,停机 1 分钟就是几十万的损失。

线上OOM排查:从现象到根因

先说 Spring Boot 这边的 OOM 问题。我们的服务用的是 Spring Boot 3.2.0,跑在 Java 17 的容器里,堆内存设置的是 2G。出问题后我第一时间拉了容器的内存快照,用 MAT 工具打开一看,char[]String 对象占了 85% 的内存,顺着引用链找,发现是订单查询接口的请求日志没做截断。

当时有个场景:运营后台需要查订单详情,接口会把整个订单的 JSON 数据(包含商品快照、用户地址、物流信息,平均一条 12KB)先转成字符串打日志,再返回给前端。大促时这个接口的 QPS 到了 800,每分钟产生的日志字符串就有 800 * 12KB * 60 ≈ 576MB,加上 JVM 垃圾回收跟不上,直接把堆内存撑爆了。

我当时的解决步骤是这样的:

下面是当时改的日志截断工具类,现在还在我们的公共模块里用着:

import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.boot.jackson.JsonObjectSerializer; import org.springframework.stereotype.Component; import java.io.IOException; @Component public class LogTruncateUtil { private static final int MAX_LOG_LENGTH = 2048; // 最大日志长度2KB private final ObjectMapper objectMapper; public LogTruncateUtil(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } public String truncate(Object data) { if (data == null) return "null"; try { String json = objectMapper.writeValueAsString(data); if (json.length() <= MAX_LOG_LENGTH) return json; return json.substring(0, MAX_LOG_LENGTH) + "...[truncated, total length: " + json.length() + "]"; } catch (IOException e) { return "[serialize failed: " + e.getMessage() + "]"; } } }

在订单查询接口里的用法也很简单,直接注入工具类就行:

import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; import lombok.extern.slf4j.Slf4j; @Slf4j @RestController public class OrderController { private final OrderService orderService; private final LogTruncateUtil logTruncateUtil; public OrderController(OrderService orderService, LogTruncateUtil logTruncateUtil) { this.orderService = orderService; this.logTruncateUtil = logTruncateUtil; } @GetMapping("/orders/{orderId}") public OrderDetailVO getOrderDetail(@PathVariable String orderId) { OrderDetailVO order = orderService.getById(orderId); // 打日志时用工具类截断,避免大对象占内存 log.info("查询订单详情,orderId: {}, 数据: {}", orderId, logTruncateUtil.truncate(order)); return order; } }

改完之后,同样 QPS 800 的情况下,内存占用稳定在 45% 左右,再也没出现过 OOM。这里要提一句,Spring Boot 3.2.0 自带的日志 starter 已经默认支持异步日志配置,不用额外引依赖,直接改 logback-spring.xml 就行,我当时就是图省事直接打全量日志,才踩了这个坑。

Vue 响应式数据失效的真实场景

再说 Vue 这边的问题,我们前端用的是 Vue 3.4.2,当时有个商品列表页,运营反馈修改商品库存后,页面上的数字没更新,必须刷新才生效。我一开始以为是接口缓存的问题,清了缓存还是一样,后来打断点才发现是响应式数据的问题。

我们的商品列表是用 reactive 包裹的数组,当时有个逻辑是:后端返回商品列表后,我直接给数组的索引赋值修改库存,比如 goodsList[index].stock = newStock。但 Vue 3 的响应式系统是基于 Proxy 的,对于数组的索引赋值,如果不是通过数组的原生方法(比如 splicepush)修改,Proxy 是监听不到的。

我当时试了几种方式,直接赋值不行,用 Object.assign 也不行,最后发现要么用数组的 splice 方法,要么把整个对象替换掉。后来我们封装了一个修改列表项的方法,避免直接索引赋值:

<script setup> import { reactive } from 'vue' import { getGoodsList, updateGoodsStock } from '@/api/goods' // 商品列表,用 reactive 包裹 const goodsList = reactive([]) // 获取商品列表 const fetchGoodsList = async () => { const res = await getGoodsList() // 直接赋值给 reactive 数组,这里没问题,因为是把整个数组替换 goodsList.splice(0, goodsList.length, ...res.data) } // 修改商品库存,之前直接 goodsList[index].stock = val 会失效 const handleStockChange = async (index, newStock) => { const goods = goodsList[index] await updateGoodsStock(goods.id, newStock) // 正确方式1:用 splice 修改数组项,Proxy 能监听到 goodsList.splice(index, 1, { ...goods, stock: newStock }) // 正确方式2:如果是单个对象,也可以用 Object.assign 触发响应式,但要保证对象是 reactive 的 // Object.assign(goodsList[index], { stock: newStock }) // 这种方式也可以,但不如 splice 直观 } // 初始化加载 fetchGoodsList() </script> <template> <div class="goods-list"> <div v-for="(item, index) in goodsList" :key="item.id" class="goods-item"> <span>{{ item.name }}</span> <span>库存: {{ item.stock }}</span> <button @click="handleStockChange(index, item.stock - 1)">减库存</button> </div> </div> </template>

这里要说明下,Vue 3.4.x 的响应式系统对数组的处理已经比早期版本好很多,但如果是直接修改数组的索引,还是会有失效的情况。我当时排查的时候,特意看了 Vue 的源码注释,Proxy 对数组的 set 陷阱,只有当 key 是数组长度或者调用原生修改方法时才会触发更新,直接索引赋值不会触发,所以才会有这个问题。如果当时用 ref 包裹数组,然后用 value 重新赋值,也能解决,但 reactive 更适合这种列表场景,所以最后选了 splice 的方式。

5. 进阶集成:Spring AI与Vue 3组合式API的业务落地实践

今年年初我们接了个智能客服的需求,要在后台管理系统里加个 AI 问答模块,用户输入问题,调用大模型返回答案。当时 Spring AI 刚出正式版没多久,我们评估后决定用 Spring Boot 3.2.0 + Spring AI 1.0.0 做后端,Vue 3.4.2 + 组合式 API 做前端,整个模块从开发到上线只用了 5 天,比预期快了一倍。

后端:Spring AI 快速接入大模型

Spring AI 的好处是不用自己写一堆 HTTP 请求调用大模型,它已经封装好了 OpenAI、阿里通义、百度文心等主流大模型的客户端,我们只需要配置 API Key 就行。我们当时用的是阿里通义千问,因为国内访问稳定,响应速度也快,平均每次调用耗时 800ms 左右。

先讲后端怎么集成。首先引依赖,Spring Boot 3.2.0 对应的 Spring AI 版本是 1.0.0,直接在 pom.xml 里加:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-alibaba-qianwen-spring-boot-starter</artifactId> <version>1.0.0</version> </dependency>

然后在 application.yml 里配置 API Key 和模型参数,我们当时设置了超时时间 3 秒,避免大模型响应慢拖垮接口:

spring: ai: qianwen: api-key: your-api-key-here model: qwen-turbo timeout: 3000 temperature: 0.7 # 控制回答的随机性,0.7 比较适中

然后写个服务类,封装调用逻辑,我们当时做了个简单的上下文管理,把用户最近 3 个问题存到 Redis 里,让大模型能连续对话:

import org.springframework.ai.chat.ChatClient; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.UserMessage; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; @Service public class AiChatService { private final ChatClient chatClient; private final RedisTemplate<String, Object> redisTemplate; private static final int MAX_HISTORY = 3; // 最多存3条历史消息 private static final String REDIS_KEY_PREFIX = "ai:chat:history:"; public AiChatService(ChatClient.Builder chatClientBuilder, RedisTemplate<String, Object> redisTemplate) { this.chatClient = chatClientBuilder.build(); this.redisTemplate = redisTemplate; } public String chat(String userId, String question) { // 从 Redis 获取历史对话 String redisKey = REDIS_KEY_PREFIX + userId; List<Object> history = redisTemplate.opsForList().range(redisKey, 0, -1); List<Message> messages = new ArrayList<>(); if (history != null) { for (Object item : history) { messages.add(new UserMessage(item.toString())); } } // 加入当前问题 messages.add(new UserMessage(question)); // 调用大模型 Prompt prompt = new Prompt(messages); String answer = chatClient.call(prompt).getResult().getOutput().getContent(); // 把当前问题和答案存入 Redis,过期时间1小时 redisTemplate.opsForList().rightPush(redisKey, question); redisTemplate.opsForList().rightPush(redisKey, answer); redisTemplate.opsForList().trim(redisKey, -MAX_HISTORY * 2, -1); // 只保留最近3轮对话 redisTemplate.expire(redisKey, 1, TimeUnit.HOURS); return answer; } }

最后写个接口给前端调用,加了简单的限流,每个用户每分钟最多调 10 次,避免 API Key 被刷:

import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import io.github.resilience4j.ratelimiter.annotation.RateLimiter; import lombok.extern.slf4j.Slf4j; @Slf4j @RestController public class AiChatController { private final AiChatService aiChatService; public AiChatController(AiChatService aiChatService) { this.aiChatService = aiChatService; } @PostMapping("/ai/chat") @RateLimiter(name = "aiChatRateLimit", fallbackMethod = "chatFallback") public String chat(@RequestBody ChatRequest request) { if (request.getQuestion() == null || request.getQuestion().trim().isEmpty()) { return "请输入有效的问题"; } return aiChatService.chat(request.getUserId(), request.getQuestion().trim()); } // 限流降级方法 public String chatFallback(ChatRequest request, Throwable t) { log.warn("用户 {} 调用AI接口过于频繁", request.getUserId()); return "当前请求过多,请稍后再试"; } // 内部请求类 static class ChatRequest { private String userId; private String question; // getter/setter 省略 } }

前端:Vue 3 组合式API封装AI对话逻辑

前端这边我们用 Vue 3.4.2 的组合式 API,把 AI 对话的逻辑封装成一个可复用的 composable,这样不管是客服页面还是帮助中心页面都能直接用。当时我们做了个打字机效果,让回答一个个字显示出来,体验比直接全出来好很多。

先写 useAiChat.js 这个组合式函数:

import { ref } from 'vue' import { chatWithAi } from '@/api/ai' // 封装的接口请求方法 export function useAiChat(userId) { const question = ref('') // 用户输入的问题 const answer = ref('') // 大模型返回的答案 const loading = ref(false) // 加载状态 const errorMsg = ref('') // 错误信息 let timer = null // 打字机效果的定时器 // 发送问题 const sendQuestion = async () => { if (!question.value.trim() || loading.value) return loading.value = true answer.value = '' errorMsg.value = '' try { const res = await chatWithAi({ userId, question: question.value }) // 打字机效果:逐个显示字符 let index = 0 const fullAnswer = res.data timer = setInterval(() => { if (index < fullAnswer.length) { answer.value += fullAnswer[index] index++ } else { clearInterval(timer) } }, 30) // 每个字符间隔30ms } catch (err) { errorMsg.value = err.response?.data || '请求失败,请稍后再试' } finally { loading.value = false } } // 清空对话 const clearChat = () => { question.value = '' answer.value = '' errorMsg.value = '' if (timer) clearInterval(timer) } return { question, answer, loading, errorMsg, sendQuestion, clearChat } }

然后在客服页面里直接用这个组合式函数,代码非常简洁:

<script setup> import { useAiChat } from '@/composables/useAiChat' import { useUserStore } from '@/stores/user' // 用户状态管理,用的Pinia const userStore = useUserStore() // 初始化AI对话,传入用户ID const { question, answer, loading, errorMsg, sendQuestion, clearChat } = useAiChat(userStore.userId) </script> <template> <div class="ai-chat-container"> <div class="chat-header">智能客服</div> <div class="chat-body"> <div v-if="answer" class="answer-box"> <div class="label">AI回答:</div> <div class="content">{{ answer }}</div> </div> <div v-if="errorMsg" class="error-box">{{ errorMsg }}</div> </div> <div class="chat-footer"> <input v-model="question" placeholder="请输入你的问题..." @keyup.enter="sendQuestion" :disabled="loading" /> <button @click="sendQuestion" :disabled="loading"> {{ loading ? '回答中...' : '发送' }} </button> <button @click="clearChat" class="clear-btn">清空</button> </div> </div> </template> <style scoped> .ai-chat-container { width: 600px; height: 500px; border: 1px solid #e5e7eb; border-radius: 8px; display: flex; flex-direction: column; } .chat-header { padding: 12px; background: #f59e0b; color: white; border-radius: 8px 8px 0 0; } .chat-body { flex: 1; padding: 16px; overflow-y: auto; } .answer-box { background: #f3f4f6; padding: 12px; border-radius: 6px; margin-bottom: 12px; } .chat-footer { padding: 12px; border-top: 1px solid #e5e7eb; display: flex; gap: 8px; } .chat-footer input { flex: 1; padding: 8px; border: 1px solid #d1d5db; border-radius: 4px; } .chat-footer button { padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 4px; cursor: pointer; } .clear-btn { background: #ef4444 !important; } </style>

这个模块上线后,平均每天处理 1200+ 次对话,接口平均响应时间 820ms,用户反馈比之前的静态帮助文档好用很多。这里要提一句,Spring AI 现在还在快速迭代,我们当时用的 1.0.0 版本已经支持流式返回,要是现在做的话,前端可以用 EventSource 接流式数据,不用等整个回答返回再显示,体验会更好。

6. 面试高频:基于真实场景的SpringBoot+Vue核心原理深度解析

我这几年面试过不少全栈开发,发现很多人背了很多八股文,但一问真实场景的问题就答不上来。比如问 Spring Boot 自动配置原理,能说出 @EnableAutoConfiguration 的不少,但问到“为什么你们项目里引了 Redis 依赖就不用写配置就能用”,很多人就卡壳了。下面我结合我们真实项目的场景,讲几个面试高频问题的底层逻辑。

Spring Boot 自动配置原理:从注解到生效的全流程

先说 Spring Boot 3.2.0 的自动配置原理,我们当时做项目的时候,引了 spring-boot-starter-data-redis 依赖,不用写任何配置,直接注入 RedisTemplate 就能用,这就是自动配置的作用。

整个流程是这样的:首先 @SpringBootApplication 注解里包含了 @EnableAutoConfiguration,这个注解会导入 AutoConfigurationImportSelector 类,这个类会去读所有依赖 jar 包里的 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件(Spring Boot 3.x 之后改成了这个文件,之前是 spring.factories),里面列了所有要自动配置的类,比如 RedisAutoConfiguration 就在这个列表里。

然后每个自动配置类上都有一堆条件注解,比如 @ConditionalOnClass(RedisTemplate.class),意思是只有当项目里有 RedisTemplate 这个类的时候,这个配置类才会生效;还有 @ConditionalOnMissingBean(RedisTemplate.class),意思是如果用户自己定义了 RedisTemplate bean,就用用户自己的,不用自动配置的。

我们当时有个场景:默认的 RedisTemplate 用的是 JDK 序列化,存到 Redis 里的 key 和 value 都是乱码,所以我们自己定义了一个 RedisTemplate,改成 JSON 序列化,自动配置就失效了,用的我们自己的配置。代码如下:

import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(org.springframework.data.redis.connection.RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // key 用字符串序列化 template.setKeySerializer(new StringRedisSerializer()); // value 用 JSON 序列化 template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // hash 的 key 和 value 也用对应的序列化 template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); template.afterPropertiesSet(); return template; } }

面试的时候如果只说“自动配置是 @EnableAutoConfiguration 加条件注解”,只能算及格,要是能结合自己项目里的自定义配置场景说,面试官会觉得你真的用过,而不是背的。

Vue 3 响应式系统:Proxy 比 Object.defineProperty 好在哪

再说说 Vue 3.4.x 的响应式系统,面试必问“Vue 3 的响应式是怎么实现的,和 Vue 2 有什么区别”。我们当时从 Vue 2 升级到 Vue 3 的时候,有个老组件里用 Object.defineProperty 手动做了数据劫持,升级后直接失效了,后来改成 Proxy 才解决。

Vue 2 用的是 Object.defineProperty,它只能劫持对象的已有属性,要是后来给对象加新属性,比如 obj.newProp = 1,是监听不到的,必须要用 Vue.set 才行。但 Vue 3 的 Proxy 是代理整个对象,不管你是加新属性、删属性,还是修改数组索引,都能监听到。

我们当时有个用户信息的表单,Vue 2 的时候,用户上传头像后,给 userInfo 加个 avatar 属性,页面不会更新,必须用 Vue.set(userInfo, 'avatar', url)。升级到 Vue 3 之后,直接 userInfo.avatar = url 就行,Proxy 自动能监听到。

下面是个简单的 Proxy 响应式实现示例,和我们 Vue 3 里的响应式逻辑类似:

function reactive(obj) { return new Proxy(obj, { get(target, key, receiver) { // 依赖收集,这里简化,实际 Vue 里会存到 dep 里 console.log(`读取属性 ${key}`) return Reflect.get(target, key, receiver) }, set(target, key, value, receiver) { // 触发更新,这里简化,实际 Vue 里会通知 dep 里的 watcher console.log(`设置属性 ${key} 为 ${value}`) return Reflect.set(target, key, value, receiver) }, deleteProperty(target, key) { // 删除属性也能监听到 console.log(`删除属性 ${key}`) return Reflect.deleteProperty(target, key) } }) } // 测试 const user = reactive({ name: '张三', age: 20 }) user.name = '李四' // 输出:设置属性 name 为 李四 user.gender = '男' // 输出:设置属性 gender 为 男,新增属性也能监听到 delete user.age // 输出:删除属性 age

面试的时候可以举这个例子,说明 Proxy 的优势,比空泛地说“Proxy 性能更好”有说服力得多。

前后端分离跨域问题:我们项目的真实解决方案

还有个高频问题:前后端分离怎么解决跨域。我们项目里前端是 Vue 3 跑在 localhost:5173(Vite 的默认端口),后端 Spring Boot 3.2.0 跑在 localhost:8080,端口不一样,肯定有跨域问题。

我们当时用了两种方案:开发环境用 Vite 的代理,生产环境用 Spring Boot 的 CORS 配置。开发的时候,Vite 的 vite.config.js 里配置代理,把 /api 开头的请求转发到后端,这样前端请求 /api/orders 就等于请求 localhost:8080/api/orders,不存在跨域:

// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { proxy: { '/api': { target: 'http://localhost:8080', // 后端地址 changeOrigin: true, // 修改请求头里的 Origin rewrite: (path) => path.replace(/^\/api/, '') // 去掉 /api 前缀,看后端接口有没有统一前缀 } } } })

生产环境的话,我们在 Spring Boot 里加了个全局的 CORS 配置,允许前端的域名访问,同时支持 cookie 跨域:

import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") // 所有接口都允许跨域 .allowedOrigins("https://admin.example.com") // 生产环境前端域名,开发的时候可以改成 "*" .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // 允许的请求方法 .allowedHeaders("*") // 允许的请求头 .allowCredentials(true) // 允许携带 cookie .maxAge(3600); // 预检请求缓存1小时 } }

当时有个面试者说跨域用 JSONP 解决,我问他我们项目里要支持 POST 请求,JSONP 行不行,他答不上来。其实 JSONP 只支持 GET,现在基本没人用了,CORS 和 Nginx 反向代理才是主流,结合自己项目的配置讲,面试官会觉得你真的做过前后端分离的项目。

这些原理性的问题,背八股文只能应付初级面试,中高级面试一定会结合场景问,比如“你有没有遇到过事务失效的情况”,我当时就遇到过:有个方法里调用了同类里的另一个事务方法,结果事务没生效。原因是 Spring 的事务是基于 AOP 实现的,自调用的时候没有经过代理对象,所以事务注解不生效。解决办法是把这个方法放到另一个 Service 里,或者用 AopContext.currentProxy() 获取代理对象调用。这种真实踩过的坑,讲出来比背原理得分高得多。

站长实战手记

一个订单详情页的血泪教训

去年我接了个外包,帮一个做本地生鲜配送的团队重写后台。业务说起来简单,就是骑手在 App 上抢单、修改订单状态。我当时脑子一热,直接上了 SpringBoot 3.2Vue 3.4,Vite 构建,全套最新。

结果翻车翻得特别快。

上线第一天,骑手在菜市场信号不好的地方,页面经常白屏或者操作卡顿。我查日志发现,由于我为了“性能”大量使用了 Vue 的 script setup 和复杂的组件嵌套,加上后端为了省事,订单详情接口返回了包含用户历史、商品详情、配送路径的一大坨 JSON,导致低端机解析 JS 和渲染 DOM 的时间长得离谱。

排查的时候我发现,骑手根本不需要在抢单瞬间看到那么详细的历史记录。我立马把接口拆了,用 SpringBoot 做了个轻量级的 OrderSummaryDTO,只返回 ID、地址和电话。前端也做了懒加载,把商品详情折叠起来。改完之后,那批老安卓机的首屏渲染直接从 2 秒多降到了 600 毫秒左右,骑手投诉立马没了。

其实后来反思,这种重交互、轻内容的 B 端页面,用 Vue 3 确实爽,开发效率高,但千万别为了秀技术去过度封装。反倒是如果当时面对的是那种超高并发的 C 端秒杀,我可能会劝自己别这么激进,毕竟 JVM 调优和原生 JS 的掌控力在这种场景下比框架版本更重要。

给正在学这个组合的朋友一句掏心窝子的建议:别光盯着脚手架怎么配,多去看看网络请求是怎么发的,JSON 数据是怎么序列化又怎么被解析的。很多时候,让你系统崩掉的不是框架不行,是你没搞懂数据怎么流的。