理想中的校驗
校驗就是對輸入條件的約束,避免無效的輸入引起異常。Web 系統的使用者輸入主要為編輯與提交各類表單,一方面校驗要做在編輯表單欄位與提交的時候,另一方面接收表單的介面也要做足校驗行為,通過前後端共同控制輸入條件。然而,當前端比後端校驗嚴格時,會直接提高使用者編輯資訊的門檻。反之,當後端比前端校驗嚴格時,會讓辛苦填寫的表單仍無法順利提交。這兩種情況都會嚴重打擊使用者的信心,其中的關鍵在於校驗規則的前後端一致。
選擇校驗模組
基於上述思考,值得期待的校驗模組應該具備以下特點:
- 邏輯可以跨端複用
- 精巧,包大小有限
- 語義清晰
- 功能全面
- 足夠穩定
綜合比較之後,選擇 yup 作為校驗模組,現在以上一章已完成的工程 host1-tech/nodejs-server-examples - 01-api-and-layering 著手改造,在工程根目錄安裝 yup:
$ yarn add yup # 本地安裝 yup
# ...
info Direct dependencies
└─ yup@0.29.1
# ...
加上後端校驗
悉心的讀者會發現當前的店鋪管理功能對輸入是沒有限制的,比如設定店鋪名為空也會提交成功。現在加上後端校驗彌補這一不足:
$ mkdir src/moulds # 新建 src/moulds 目錄存放校驗 schema
$ tree -L 2 -I node_modules # 展示除了 node_modules 之外的目錄內容結構
.
├── Dockerfile
├── package.json
├── public
│ ├── index.html
│ └── index.js
├── src
│ ├── controllers
│ ├── moulds
│ ├── server.js
│ └── services
└── yarn.lock
// src/moulds/ShopForm.js
const Yup = require('yup');
exports.createShopFormSchema = () =>
Yup.object({
name: Yup.string()
.required('店鋪名不能為空')
.min(3, '店鋪名至少 3 個字元')
.max(20, '店鋪名不可超過 20 字'),
});
// src/controllers/shop.js
const { Router } = require('express');
const shopService = require('../services/shop');
+const { createShopFormSchema } = require('../moulds/ShopForm');
class ShopController {
// ...
put = async (req, res) => {
const { shopId } = req.params;
const { name } = req.query;
+
+ try {
+ await createShopFormSchema().validate({ name });
+ } catch (e) {
+ res.status(400).send({ success: false, message: e.message });
+ return;
+ }
+
const shopInfo = await this.shopService.modify({
id: shopId,
values: { name },
});
if (shopInfo) {
res.send({ success: true, data: shopInfo });
} else {
res.status(404).send({ success: false, data: null });
}
};
// ...
}
module.exports = async () => {
const c = new ShopController();
return await c.init();
};
這樣一來,不規範的輸入就被有效的阻止了,效果如下:
加上前端校驗
現在前端也加上校驗為使用者有效提供錯誤資訊,先借助 rollup 將 yup 搬上瀏覽器:
$ yarn add -D rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs rollup-plugin-terser # 本地安裝 rollup 及其外掛
# ...
info Direct dependencies
├─ @rollup/plugin-commonjs@14.0.0
├─ @rollup/plugin-node-resolve@8.4.0
├─ rollup-plugin-terser@6.1.0
└─ rollup@2.22.2
# ...
// package.json
{
"name": "02-validate",
"version": "1.0.0",
"scripts": {
- "start": "node src/server.js"
+ "start": "node src/server.js",
+ "build:yup": "rollup node_modules/yup -o src/moulds/yup.js -p @rollup/plugin-node-resolve,@rollup/plugin-commonjs,rollup-plugin-terser -f umd -n 'yup'"
}
// ...
}
$ yarn build:yup
# ...
created src/moulds/yup.js in 1.9s
然後補充前端校驗邏輯:
// src/server.js
const express = require('express');
const { resolve } = require('path');
const { promisify } = require('util');
const initControllers = require('./controllers');
const server = express();
const port = parseInt(process.env.PORT || '9000');
const publicDir = resolve('public');
+const mouldsDir = resolve('src/moulds');
async function bootstrap() {
server.use(express.static(publicDir));
+ server.use('/moulds', express.static(mouldsDir));
server.use(await initControllers());
await promisify(server.listen.bind(server, port))();
console.log(`> Started on port ${port}`);
}
bootstrap();
// public/glue.js
import './moulds/yup.js';
window.require = (k) => window[k];
window.exports = window.moulds = {};
/* public/index.css */
.error {
color: red;
font-size: 14px;
}
<!-- public/index.html -->
<html>
<head>
<meta charset="utf-8" />
+ <link rel="stylesheet" href="./index.css" />
</head>
<body>
<div id="root"></div>
<script type="module">
+ import './glue.js';
import { refreshShopList, bindShopInfoEvents } from './index.js';
async function bootstrap() {
await refreshShopList();
await bindShopInfoEvents();
}
bootstrap();
</script>
</body>
</html>
// public/index.js
+import './moulds/ShopForm.js';
+const { createShopFormSchema } = window.moulds;
+
export async function refreshShopList() {
const res = await fetch('/api/shop');
const { data: shopList } = await res.json();
const htmlItems = shopList.map(
({ id, name }) => `
<li data-shop-id="${id}">
<div data-type="text">${name}</div>
<input type="text" placeholder="輸入新的店鋪名稱" />
<a href="#" data-type="modify">確認修改</a>
<a href="#" data-type="remove">刪除店鋪</a>
+ <div class="error"></div>
</li>`
);
document.querySelector('#root').innerHTML = `
<h1>店鋪列表:</h1>
<ul class="shop-list">${htmlItems.join('')}</ul>`;
}
// ...
export async function modifyShopInfo(e) {
const shopId = e.target.parentElement.dataset.shopId;
const name = e.target.parentElement.querySelector('input').value;
+
+ try {
+ await createShopFormSchema().validate({ name });
+ } catch ({ message }) {
+ console.log(message);
+ e.target.parentElement.querySelector('.error').innerHTML = message;
+ return;
+ }
+
await fetch(`/api/shop/${shopId}?name=${encodeURIComponent(name)}`, {
method: 'PUT',
});
await refreshShopList();
}
看一下效果:
本章原始碼
host1-tech/nodejs-server-examples - 02-validate
更多閱讀
從零搭建 Node.js 企業級 Web 伺服器(零):靜態服務
從零搭建 Node.js 企業級 Web 伺服器(一):介面與分層
從零搭建 Node.js 企業級 Web 伺服器(二):校驗