技术选型复盘:为何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要在每个页面写重复的data、methods,现在直接:
// 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();
}
}
}
}
}
})
把vue、element-plus、axios这些第三方库拆成单独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 垃圾回收跟不上,直接把堆内存撑爆了。
我当时的解决步骤是这样的:
- 先临时把日志级别从 DEBUG 改成 INFO,减少日志输出量,内存占用立刻降到 70%,服务先稳下来;
- 然后改代码,给订单数据加截断逻辑,超过 2KB 的部分用
... 代替,同时把日志输出改成异步,用 Logback 的 AsyncAppender 避免阻塞;
- 最后调整 JVM 参数,加上
-XX:+HeapDumpOnOutOfMemoryError,下次出问题能直接拿到快照,不用临时手动 dump。
下面是当时改的日志截断工具类,现在还在我们的公共模块里用着:
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 的,对于数组的索引赋值,如果不是通过数组的原生方法(比如 splice、push)修改,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.2 配 Vue 3.4,Vite 构建,全套最新。
结果翻车翻得特别快。
上线第一天,骑手在菜市场信号不好的地方,页面经常白屏或者操作卡顿。我查日志发现,由于我为了“性能”大量使用了 Vue 的 script setup 和复杂的组件嵌套,加上后端为了省事,订单详情接口返回了包含用户历史、商品详情、配送路径的一大坨 JSON,导致低端机解析 JS 和渲染 DOM 的时间长得离谱。
排查的时候我发现,骑手根本不需要在抢单瞬间看到那么详细的历史记录。我立马把接口拆了,用 SpringBoot 做了个轻量级的 OrderSummaryDTO,只返回 ID、地址和电话。前端也做了懒加载,把商品详情折叠起来。改完之后,那批老安卓机的首屏渲染直接从 2 秒多降到了 600 毫秒左右,骑手投诉立马没了。
其实后来反思,这种重交互、轻内容的 B 端页面,用 Vue 3 确实爽,开发效率高,但千万别为了秀技术去过度封装。反倒是如果当时面对的是那种超高并发的 C 端秒杀,我可能会劝自己别这么激进,毕竟 JVM 调优和原生 JS 的掌控力在这种场景下比框架版本更重要。
给正在学这个组合的朋友一句掏心窝子的建议:别光盯着脚手架怎么配,多去看看网络请求是怎么发的,JSON 数据是怎么序列化又怎么被解析的。很多时候,让你系统崩掉的不是框架不行,是你没搞懂数据怎么流的。