级联下拉框常见的流程是:选中一个主分类,把它发给后端,后端返回两组子分类,再分别更新两个 select。关键不只是 AJAX 能发出去,还要保证前后端理解同一份数据,并避免旧请求覆盖新选择。

先把请求和响应约定写清楚

本文沿用两组列表的响应形式,约定:

{
  "middleCategory": "A"
}

响应的第一组为 minorCategory,第二组为 subCategory:

[
  [{"categoryCd": "A1", "categoryNm": "子分类一"}],
  []
]

JSON.stringify 把 JavaScript 值序列化为文本。contentType 表示发送正文的媒体类型,dataType 表示 jQuery 对响应的解析预期,两者职责不同。jQuery AJAX

前端:清空旧值,并只接受最后一次选择的结果

以下示例用于已有 jQuery 1.7+ 的页面,页面包含三个具名 select、#categoryStatus 状态区域,以及服务端按当前会话渲染的 _csrf_header、_csrf 两个 meta。运行环境应支持示例使用的 JavaScript 语法。

$(function () {
    var sequence = 0;
    var pending = null;
    var parent = $("select[name='middleCategory']");
    var minor = $("select[name='minorCategory']");
    var sub = $("select[name='subCategory']");
    var status = $("#categoryStatus");

    function validGroup(items) {
        return Array.isArray(items) && items.every(function (item) {
            return item && typeof item.categoryCd === "string"
                && typeof item.categoryNm === "string";
        });
    }

    function fill(select, items) {
        select.empty();
        items.forEach(function (item) {
            select.append(new Option(item.categoryNm, item.categoryCd));
        });
    }

    parent.on("change.categories", function () {
        var current = ++sequence;
        if (pending) pending.abort();
        minor.add(sub).empty().prop("disabled", true);
        status.text("");

        var value = parent.val();
        if (!value) {
            minor.add(sub).prop("disabled", false);
            pending = null;
            return;
        }

        var header = $("meta[name='_csrf_header']").attr("content");
        var token = $("meta[name='_csrf']").attr("content");
        if (!header || !token) {
            status.text("页面缺少请求防护信息,请重新加载");
            pending = null;
            return;
        }
        var headers = {};
        headers[header] = token;

        pending = $.ajax({
            url: "/minorCategory/search",
            type: "POST",
            contentType: "application/json; charset=UTF-8",
            dataType: "json",
            headers: headers,
            data: JSON.stringify({ middleCategory: value })
        }).done(function (data) {
            if (current !== sequence) return;
            if (!Array.isArray(data) || data.length !== 2
                    || !validGroup(data[0]) || !validGroup(data[1])) {
                status.text("分类响应格式不符合约定");
                return;
            }
            fill(minor, data[0]);
            fill(sub, data[1]);
        }).fail(function (xhr, reason) {
            if (current !== sequence || reason === "abort") return;
            status.text("分类加载失败,请稍后重试");
        }).always(function () {
            if (current !== sequence) return;
            minor.add(sub).prop("disabled", false);
            pending = null;
        });
    });
});

先递增序号,再取消旧请求;即使取消来不及,返回回调也只处理当前序号。清空旧选项意味着空结果不会继续显示上一次的数据。new Option 将名称作为文本设置,避免拼接 HTML 字符串解释特殊字符。

这里假定服务端启用了匹配的 CSRF 防护。头名称和令牌由服务端配置决定,不应硬编码真实令牌,也不应因请求失败关闭防护;CSRF 不能替代身份认证和数据访问授权。

后端:专用请求对象,不直接绑定整张业务实体

下面是 Spring MVC 5.x 风格的控制器示例。CategoryLookup 由项目提供并注册为受控查询服务,返回两组 CategoryOption;示例不包含真实数据库实现。

import java.util.Arrays;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

@RestController
@RequestMapping("/minorCategory")
public class CategoryController {
    private final CategoryLookup lookup;

    public CategoryController(CategoryLookup lookup) {
        this.lookup = lookup;
    }

    @PostMapping(value = "/search",
        consumes = "application/json", produces = "application/json")
    public List<List<CategoryOption>> search(
            @RequestBody CategoryRequest request) {
        String code = request.getMiddleCategory();
        if (code == null || code.trim().isEmpty() || code.length() > 64) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
        }
        return Arrays.asList(lookup.findMinor(code), lookup.findSub(code));
    }

    public interface CategoryLookup {
        List<CategoryOption> findMinor(String code);
        List<CategoryOption> findSub(String code);
    }

    public static class CategoryRequest {
        private String middleCategory;
        public String getMiddleCategory() { return middleCategory; }
        public void setMiddleCategory(String value) { middleCategory = value; }
    }

    public static class CategoryOption {
        private final String categoryCd;
        private final String categoryNm;
        public CategoryOption(String code, String name) {
            categoryCd = code;
            categoryNm = name;
        }
        public String getCategoryCd() { return categoryCd; }
        public String getCategoryNm() { return categoryNm; }
    }
}

lookup 要执行参数校验后的授权查询,返回空集合而不是随意混用 null;服务端还应限制结果规模。输入 DTO 只包含当前查询需要的字段,不直接把整张产品实体暴露为可绑定对象。

@RequestBody 通过已配置的消息转换器读取请求体,@RestController 使返回值按响应体处理;还需具备对应 JSON 转换依赖。Spring MVC 请求体转换

两组列表的顺序也是接口契约

data[0] 与 data[1] 依赖固定顺序。以后可改成带名称的对象字段提高可读性,但必须前后端一起调整,不能只看“仍然返回合法 JSON”。

验收应覆盖空数组、无效响应、请求失败和快速连续切换,分别观察请求正文、响应结构以及最后显示的选择结果。