02 ValidationChain 高频 API

1. ValidationChain 是什么

调用 body()query()param() 等函数会返回一个 ValidationChain

const chain = body("email")
  .isString()
  .trim()
  .isEmail()
  .normalizeEmail();

它同时具有两种身份:

链是可变的。向已有变量继续调用方法会修改同一个链,因此不要把一个共享链在不同路由中随意追加规则。

2. 链的执行顺序

下面的顺序不是随意的:

body("age")
  .isString()
  .bail()
  .trim()
  .isInt({ min: 18 })
  .withMessage("age 必须是大于等于 18 的整数")
  .toInt();

可以理解为:

验证原始类型
  → 失败则停止当前链
  → 清理空格
  → 验证整数文本
  → 转成 number

Sanitizer 会改变请求中的值,后续 validator 看到的是改变后的值。

3. 高频 validator

3.1 .isString()

body("name")
  .isString()
  .withMessage("name 必须是字符串");

它只检查类型,不检查字符串是否为空:

{ "name": "" }

空字符串仍然是字符串。

3.2 .notEmpty()

body("name")
  .isString()
  .bail()
  .trim()
  .notEmpty()
  .withMessage("name 不能为空");

trim() 可以防止只包含空格的字符串通过检查。

3.3 .isLength()

body("name")
  .isString()
  .bail()
  .trim()
  .isLength({ min: 1, max: 20 });

常见 options:

{ min: 1 }
{ max: 200 }
{ min: 8, max: 64 }

密码长度、数据库列长度和用户可见字符限制是不同问题。不要只因为 MySQL 是 VARCHAR(255) 就允许用户输入 255 个字符。

3.4 .isInt()

query("page")
  .isInt({ min: 1, max: 10_000 })
  .withMessage("page 必须是有效正整数")
  .toInt();

高频 options:

min
max
lt
gt
allow_leading_zeroes

.isInt() 只负责验证;需要 number 时再调用 .toInt()

3.5 .isFloat()

body("price")
  .isFloat({ min: 0 })
  .withMessage("price 必须是非负数")
  .toFloat();

金额不要因为通过了浮点校验就直接使用 JavaScript 浮点数完成精确财务计算。数据库通常使用 DECIMAL

3.6 .isBoolean().toBoolean()

query("active")
  .optional()
  .isBoolean()
  .withMessage("active 必须是布尔值")
  .toBoolean();

查询字符串中的 "false" 本来仍是字符串,转换后才是布尔值。应测试客户端可能发送的具体形式,不要凭直觉理解所有 truthy/falsy 值。

3.7 .isEmail()

body("email")
  .isString()
  .bail()
  .trim()
  .isEmail()
  .withMessage("邮箱格式不正确")
  .normalizeEmail();

格式正确不代表邮箱存在,也不代表用户拥有该邮箱。账号验证仍需要验证码或确认邮件。

3.8 .isURL().isUUID().isISO8601()

body("website").optional().isURL();
param("id").isUUID("4");
body("birthday").isISO8601({ strict: true });

日期字符串通过格式校验后,还要明确业务时区和纯日期语义。

3.9 .isArray().isObject()

body("tags")
  .isArray({ min: 1, max: 20 });

body("profile")
  .isObject({ strict: true });

数组校验只检查容器,元素还需要通配符链:

body("tags.*")
  .isString()
  .trim()
  .notEmpty();

4. 高频 sanitizer

4.1 .trim()

body("name").trim();

它会修改 req.body.name。不要对密码自动 trim(),否则会无声改变用户输入。

4.2 .toInt().toFloat().toBoolean()

query("page").isInt({ min: 1 }).toInt();

转换应该放在相应格式校验之后,避免把错误输入转换成意外值。

4.3 .normalizeEmail()

body("email")
  .isEmail()
  .normalizeEmail();

邮箱规范化规则可能影响账号身份。正式系统应确定规范化策略,并让登录、注册、唯一索引采用一致规则。

4.4 .toLowerCase().toUpperCase()

body("countryCode")
  .isLength({ min: 3, max: 3 })
  .toUpperCase();

适合具有明确大小写规范的枚举或编码。不要无条件修改用户展示名称。

4.5 .escape()

.escape() 会把 HTML 特殊字符转义。它不是 SQL 注入防护,也不是所有输出环境的通用 XSS 方案。

5. .withMessage()

body("name")
  .isLength({ min: 1, max: 20 })
  .withMessage("name 必须是 1 到 20 个字符");

消息属于它前面的 validator。不要写成:

body("name")
  .isString()
  .isLength({ min: 1, max: 20 })
  .withMessage("name 不合法");

此时 .isString() 失败仍可能产生默认英文消息。更清楚的写法是每个关键 validator 都配置消息。

多语言项目建议保存消息 key:

.withMessage("validation.wish.name.length")

最终在错误处理中间件中翻译。

6. .bail()

停止当前字段

body("email")
  .isString()
  .bail()
  .isEmail()
  .bail()
  .custom(checkEmailExists);

默认等价于:

.bail({ level: "chain" })

类型已经失败时,不再执行格式和数据库校验。

停止整个请求

.bail({ level: "request" })

这会阻止该请求后续校验链继续运行,适合接口只返回第一个错误的约定。

但是 request-level bail 会影响原本可以并行执行的链,配合 oneOf()checkExact() 时可能增加执行时间。普通表单通常更适合 chain-level bail,并一次返回多个字段错误。

7. .optional()

PATCH 接口经常使用:

body("name")
  .optional()
  .isString()
  .trim()
  .notEmpty();

默认只把 undefined 视为缺失:

.optional({ values: "undefined" })
.optional({ values: "null" })
.optional({ values: "falsy" })

values: "falsy" 会把 0false、空字符串也当成未提供,容易掩盖错误,除非业务明确需要,否则不要随意使用。

.optional() 与调用位置无关,即使放在链尾也会影响整条链。为了可读性,建议紧跟字段选择器。

8. .exists().notEmpty().optional() 的区别

API 目的
.exists() 要求字段存在
.notEmpty() 要求字符串表示不为空
.optional() 字段缺失时跳过整条链

创建接口:

body("name")
  .exists()
  .withMessage("必须提供 name")
  .bail()
  .isString()
  .bail()
  .trim()
  .notEmpty();

如果 .isString() 已经足以表达字段缺失时失败,可以不额外使用 .exists()。只有需要区分“缺失”和“类型错误”的消息时才值得拆开。

9. 一个完整列表校验

export const listWishesValidation = [
  query("page")
    .default(1)
    .isInt({ min: 1 })
    .withMessage("page 必须是正整数")
    .toInt(),

  query("size")
    .default(20)
    .isInt({ min: 1, max: 100 })
    .withMessage("size 必须是 1 到 100 的整数")
    .toInt(),

  query("keyword")
    .optional()
    .isString()
    .bail()
    .trim()
    .isLength({ max: 50 })
    .withMessage("keyword 最多 50 个字符"),
];

限制 size 不只是格式校验,也是保护数据库和服务资源。

10. 练习题

  1. 为注册接口编写用户名、邮箱、密码、年龄校验链。
  2. 对比 .optional() 三种 values 配置对 undefinednull""0 的处理。
  3. 编写一个只返回第一个错误的校验数组,再改成每个字段返回一个错误。
  4. tags 编写 1–10 个非空字符串的数组校验。
  5. 验证分页参数并转换为 number,限制最大每页数量为 100。
  6. 设计一条密码校验链,确保不会调用 .trim().escape() 修改密码。

官方参考