Java 后端如何优雅地把 List 集合返回给前端

1. 问题背景

在 Java 后端开发中,几乎每天都要面对这样的场景:Service 层查出一个 List<User>、List<OrderVO>,然后要返回给前端。看似简单的一行 return list;,实际却藏着不少坑:

  • 直接返回实体集合,字段全暴露,敏感信息(密码、手机号)跟着一起出去;
  • 返回 null 还是空数组,前端处理逻辑不一致,动不动就 Cannot read property 'length' of null;
  • 分页信息、总数、状态码没有统一结构,前端每个接口都要单独适配;
  • 字段命名风格不统一,一会儿 userName 一会儿 user_name,前端直呼受不了。

本文从「能跑」到「优雅」,一步步梳理把 List 返回给前端的几种做法和最佳实践。

2. 最朴素的做法与它的问题

先看一个最常见的写法:

java 复制代码
@GetMapping("/users")
public List<User> list() {
    return userService.list();
}

这段代码能跑,但存在几个明显问题:

  1. 实体直接外泄 :User 实体里的 password、salt、deleted 等字段会原样序列化给前端。
  2. 返回结构不统一:有的接口返回数组,有的返回对象,前端需要写多套解析逻辑。
  3. 缺少元信息:没有总数、没有分页、没有业务状态码,前端无法判断请求是否真正成功。
  4. 空集合语义模糊 :null 和 [] 在前端表现不同,容易引发空指针异常。

所以「优雅」的第一步,是不要直接把实体 List 丢给前端。

3. 优雅方案一:统一响应体 + VO 转换

3.1 定义统一响应结构

一个通用的 Result<T> 能解决结构不统一的问题:

java 复制代码
@Data
public class Result<T> implements Serializable {

    private Integer code;
    private String message;
    private T data;

    public static <T> Result<T> success(T data) {
        Result<T> r = new Result<>();
        r.setCode(200);
        r.setMessage("success");
        r.setData(data);
        return r;
    }

    public static <T> Result<T> fail(Integer code, String message) {
        Result<T> r = new Result<>();
        r.setCode(code);
        r.setMessage(message);
        return r;
    }
}

3.2 用 VO 隔离实体

不要让实体直接出现在接口返回值里,用 VO(View Object)承载前端需要的字段:

java 复制代码
@Data
public class UserVO {
    private Long id;
    private String nickname;
    private String avatar;
    // 不包含 password、salt 等敏感字段
}

3.3 Controller 层组装返回

java 复制代码
@GetMapping("/users")
public Result<List<UserVO>> list() {
    List<UserVO> voList = userService.listUsers()
            .stream()
            .map(UserConverter::toVO)
            .collect(Collectors.toList());
    return Result.success(voList);
}

这样前端拿到的永远是 { code, message, data } 结构,data 里是干净的 VO 数组,字段可控、结构统一。

4. 优雅方案二:分页场景用 PageResult

列表接口十有八九要分页,此时只返回 List 是不够的,还需要总数、页码等信息。可以定义一个分页结果对象:

java 复制代码
@Data
public class PageResult<T> {
    private Long total;
    private Integer pageNum;
    private Integer pageSize;
    private List<T> records;

    public static <T> PageResult<T> of(IPage<T> page) {
        PageResult<T> result = new PageResult<>();
        result.setTotal(page.getTotal());
        result.setPageNum((int) page.getCurrent());
        result.setPageSize((int) page.getSize());
        result.setRecords(page.getRecords());
        return result;
    }
}

Controller 中配合 MyBatis-Plus 的 Page 使用:

java 复制代码
@GetMapping("/users/page")
public Result<PageResult<UserVO>> page(
        @RequestParam(defaultValue = "1") Integer pageNum,
        @RequestParam(defaultValue = "10") Integer pageSize) {

    Page<User> page = userService.page(new Page<>(pageNum, pageSize));
    List<UserVO> voList = page.getRecords()
            .stream()
            .map(UserConverter::toVO)
            .collect(Collectors.toList());

    PageResult<UserVO> result = new PageResult<>();
    result.setTotal(page.getTotal());
    result.setPageNum(pageNum);
    result.setPageSize(pageSize);
    result.setRecords(voList);

    return Result.success(result);
}

前端拿到 total 就能渲染分页器,拿到 records 就能渲染列表,语义清晰。

5. 优雅方案三:全局统一包装,Controller 只返回 List

如果觉得每个接口都手写 Result.success(...) 太啰嗦,可以用 ResponseBodyAdvice 做全局自动包装,让 Controller 保持「只返回业务数据」的清爽写法:

java 复制代码
@RestControllerAdvice
public class ResponseWrapper implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter returnType,
                            Class<? extends HttpMessageConverter<?>> converterType) {
        // 已经手动包装过的、或标注了忽略注解的,不再重复包装
        return !returnType.hasMethodAnnotation(IgnoreWrap.class);
    }

    @Override
    public Object beforeBodyWrite(Object body,
                                  MethodParameter returnType,
                                  MediaType selectedContentType,
                                  Class<? extends HttpMessageConverter<?>> selectedConverterType,
                                  ServerHttpRequest request,
                                  ServerHttpResponse response) {
        if (body instanceof Result) {
            return body;
        }
        return Result.success(body);
    }
}

此时 Controller 可以写成:

java 复制代码
@GetMapping("/users")
public List<UserVO> list() {
    return userService.listUserVOs();
}

前端实际收到的仍然是 { code, message, data: [...] }。业务代码干净,返回结构统一,两全其美。

注意:使用 ResponseBodyAdvice 时,如果返回类型是 String,会因为 StringHttpMessageConverter 与 Jackson 的转换器冲突而报错,需要特殊处理或避免直接返回 String。

6. 空集合与 null 的处理

优雅的另一个细节是永远不要返回 null 集合 。空集合用 Collections.emptyList() 或 new ArrayList<>() 表示,前端统一按数组处理即可:

java 复制代码
public List<UserVO> listUserVOs() {
    List<User> users = userMapper.selectList(null);
    if (CollectionUtils.isEmpty(users)) {
        return Collections.emptyList();
    }
    return users.stream()
            .map(UserConverter::toVO)
            .collect(Collectors.toList());
}

配合 Jackson 的全局配置,还可以把 null 字段直接忽略,减少无用字段传输:

yaml 复制代码
spring:
  jackson:
    default-property-inclusion: non_null

7. 字段命名与序列化规范

前后端联调时,字段命名不一致是高频摩擦点。建议统一约定:

  • 后端 Java 用驼峰 userName,通过 Jackson 全局配置输出为下划线或保持驼峰,二者选其一并写进接口文档;
  • 日期统一格式化为 yyyy-MM-dd HH:mm:ss,避免前端拿到时间戳还要自己转;
  • 大整数(如雪花 ID)用 @JsonSerialize(using = ToStringSerializer.class) 转成字符串,防止 JS 精度丢失。
java 复制代码
@Data
public class UserVO {
    @JsonSerialize(using = ToStringSerializer.class)
    private Long id;

    private String userName;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private LocalDateTime createTime;
}

8. 方案对比与选型建议

方案 优点 缺点 适用场景
直接返回 List<Entity> 代码最少 字段外泄、结构不统一 仅限内部调试
Result<List<VO>> 结构统一、字段可控 每个接口需手动包装 大多数业务接口
Result<PageResult<VO>> 带分页元信息 结构稍复杂 列表分页接口
ResponseBodyAdvice 全局包装 Controller 极简 需处理特殊返回类型 中大型项目统一规范

选型建议:

  • 小项目、接口少:直接用 Result<List<VO>>,简单直接;
  • 中大型项目:上 ResponseBodyAdvice 全局包装,配合 VO 和分页对象,形成团队统一规范;
  • 无论哪种方案,实体不出 Controller、空集合不返回 null、字段命名有约定,这三条是底线。

9. 总结

把 List 优雅地返回给前端,核心不在于用了多花哨的框架,而在于三点:

  1. 隔离:用 VO 隔离实体,控制暴露字段;
  2. 统一 :用 Result / PageResult 统一响应结构,前端只写一套解析逻辑;
  3. 规范:空集合、字段命名、日期格式、大整数精度,提前约定好。

做到这三点,接口返回就从「能跑」升级为「好维护、好联调、好扩展」。

相关推荐
Linux运维技术栈2 小时前
CVE-2026-91403 高危漏洞修复:Rocky Linux 9 多版本 JDK 环境清理实操
java·linux·运维
On The Road :2 小时前
Java 多线程详解:从入门到线程安全
java·python·安全
Shaoshing2 小时前
LeetCode100 49. 字母异位词分组
java·算法
Flynt2 小时前
我把热榜那篇"禁用 axios"逐条实测了一遍:结论大体对,坑在它没讲的地方
前端·javascript·axios
划破黑暗的第一缕曙光3 小时前
[C++]:5.STL:string(2)
java·c++·redis
Csvn3 小时前
bundle优化
前端
hhhjhl3 小时前
Undici 跨端三方库仓颉适配全流程
java·开发语言·ubuntu
wangchunyu1143 小时前
JAVA JVM内存结构与运行时数据区
java·开发语言·jvm
北冥有鱼3 小时前
发版后动态路由点击报 `Failed to fetch dynamically imported module`」问题上的完整排查与修复过程
前端·vue.js