在 Vercel 上部署一个个人作品集网站,带一个真正能用的“联系我”表单:访客提交后,Vercel Function 校验内容、过滤机器人,再把留言推送到你的飞书、Slack 或 Discord 群机器人。不用买服务器,不用管数据库。
先看清成品
成品是
public/index.html静态页面加一个api/contact.js函数。本文已用 Node.js 22 实际运行 3 个测试,全部通过:字段校验(空值、邮箱格式、蜜罐字段)、非 JSON 请求返回 400、合法留言能转发到一个本地模拟的 Webhook。本文环境没有 Vercel 账号,没有真正部署,也没有接入真实的聊天机器人。
注意:Vercel 的免费 Hobby 计划仅限个人、非商业用途(官方 Hobby 页面和公平使用准则写明),商业项目需要 Pro 计划。各计划额度以官方页面为准。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| 作品集页面 | 纯 HTML,手机上也好看 |
| 联系表单 | 前端 fetch('/api/contact') |
| 后端函数 | 校验、蜜罐防机器人、转发 Webhook |
| 安全响应头 | vercel.json 统一添加 |
| 自动 HTTPS | Vercel 自带 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Vercel 账号 | Hobby 免费计划即可 | 用 GitHub 账号注册最方便 |
| Node.js | 20 或更高 | 本文使用 22.23.3 |
| Vercel CLI | npm i -g vercel |
本文核对时最新版为 65.0.0 |
| GitHub 仓库 | 可选 | 连接后每次推送自动部署 |
| 群机器人 Webhook | 飞书、Slack 或 Discord | 在对应群里添加“自定义机器人”获取地址 |
第一步:项目结构是什么样的?
portfolio/
├── public/
│ └── index.html # 作品集和表单
├── api/
│ └── contact.js # Vercel Function
├── contact.test.js
├── vercel.json
└── package.json
Vercel 官方文档说明:根目录 api/ 下的每个源文件会成为一个函数,可以导出 GET、POST 等方法,参数和返回值都是 Web 标准的 Request 和 Response。
第二步:代码怎么写?
1. package.json
{ "name": "portfolio", "private": true, "type": "module", "scripts": { "test": "node --test" } }
"type": "module" 让 .js 按 ES 模块处理,这是官方文档提到的两种方式之一。
2. api/contact.js
// 联系表单:校验输入,转发到你的通知 Webhook(飞书、Slack、Discord 等都可以)
const MAX = { name: 50, email: 100, message: 2000 };
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function validate(body) {
const errors = [];
const data = {};
for (const k of Object.keys(MAX)) {
const v = typeof body?.[k] === 'string' ? body[k].trim() : '';
if (!v) errors.push(`${k} 不能为空`);
else if (v.length > MAX[k]) errors.push(`${k} 超过 ${MAX[k]} 字`);
data[k] = v;
}
if (data.email && !EMAIL.test(data.email)) errors.push('email 格式不对');
if (body?.website) errors.push('spam'); // 蜜罐字段:真人看不到,机器人会填
return { ok: errors.length === 0, errors, data };
}
export async function POST(request) {
let body;
try {
body = await request.json();
} catch {
return Response.json({ error: '请求格式错误' }, { status: 400 });
}
const { ok, errors, data } = validate(body);
if (!ok) return Response.json({ error: errors.join(';') }, { status: 400 });
const hook = process.env.CONTACT_WEBHOOK_URL;
if (!hook) return Response.json({ error: '服务器未配置 CONTACT_WEBHOOK_URL' }, { status: 500 });
const text = `新留言\n姓名:${data.name}\n邮箱:${data.email}\n内容:${data.message}`;
const r = await fetch(hook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, msg_type: 'text', content: { text } }),
});
if (!r.ok) return Response.json({ error: '通知发送失败' }, { status: 502 });
return Response.json({ ok: true });
}
| 要点 | 说明 |
|---|---|
| 长度限制 | 防止有人塞超长内容 |
蜜罐字段 website |
页面上藏起来,真人不会填 |
| Webhook 地址放环境变量 | 不写进代码,不进 Git |
同时带 text 和 content.text |
兼容 Slack/Discord 风格和飞书风格,用哪个保留哪个即可 |
注意:Discord 的 Webhook 字段名是
content(字符串),飞书是msg_type+content.text,Slack 是text。代码同时发了几种,如果你的平台报格式错误,只保留对应的那一种。
3. public/index.html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>小林的作品集</title>
<style>
body{font-family:system-ui,sans-serif;max-width:720px;margin:40px auto;padding:0 16px;line-height:1.7}
.card{border:1px solid #ddd;border-radius:12px;padding:16px;margin:12px 0}
input,textarea{width:100%;padding:8px;margin:6px 0;box-sizing:border-box}
.hp{position:absolute;left:-9999px}
button{padding:10px 20px}
</style>
</head>
<body>
<h1>小林 · 前端开发</h1>
<p>喜欢做小而好用的网页工具。</p>
<h2>作品</h2>
<div class="card"><b>番茄钟</b>:专注 25 分钟,休息 5 分钟。</div>
<div class="card"><b>记账本</b>:离线可用的个人记账。</div>
<h2>联系我</h2>
<form id="f">
<input name="name" placeholder="你的名字" required maxlength="50">
<input name="email" type="email" placeholder="邮箱" required maxlength="100">
<textarea name="message" rows="5" placeholder="想说的话" required maxlength="2000"></textarea>
<input name="website" class="hp" tabindex="-1" autocomplete="off">
<button>发送</button>
<p id="msg"></p>
</form>
<script>
document.getElementById('f').addEventListener('submit', async (e) => {
e.preventDefault();
const msg = document.getElementById('msg');
msg.textContent = '发送中…';
const r = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(new FormData(e.target))),
});
const j = await r.json();
msg.textContent = r.ok ? '已收到,谢谢!' : '出错了:' + j.error;
if (r.ok) e.target.reset();
});
</script>
</body>
</html>
4. vercel.json
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"outputDirectory": "public",
"cleanUrls": true,
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }
]
}
]
}
cleanUrls 让 /about.html 可以写成 /about。
第三步:怎样测试和部署?
1. 本地测试
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { POST, validate } from './api/contact.js';
const req = (body) => new Request('http://x/api/contact', { method: 'POST', body: typeof body === 'string' ? body : JSON.stringify(body) });
test('校验:正常、空字段、邮箱错误、蜜罐', () => {
assert.equal(validate({ name: '小王', email: 'a@b.cn', message: '你好' }).ok, true);
assert.match(validate({ name: '', email: 'a@b.cn', message: 'x' }).errors[0], /name/);
assert.ok(validate({ name: 'a', email: 'bad', message: 'x' }).errors.includes('email 格式不对'));
assert.equal(validate({ name: 'a', email: 'a@b.cn', message: 'x', website: 'http://spam' }).ok, false);
});
test('非 JSON 返回 400', async () => {
assert.equal((await POST(req('not json'))).status, 400);
});
test('转发到 Webhook', async () => {
let got;
const srv = http.createServer((q, s) => { let d = ''; q.on('data', c => d += c); q.on('end', () => { got = JSON.parse(d); s.end('ok'); }); });
await new Promise(r => srv.listen(0, r));
process.env.CONTACT_WEBHOOK_URL = `http://127.0.0.1:${srv.address().port}/`;
const res = await POST(req({ name: '小王', email: 'a@b.cn', message: '想合作' }));
srv.close();
assert.equal(res.status, 200);
assert.match(got.text, /想合作/);
});
npm test
本文实际输出:
# pass 3
# fail 0
2. 部署
npm i -g vercel
vercel login
vercel # 第一次会询问项目名,生成预览地址
vercel env add CONTACT_WEBHOOK_URL production
vercel --prod
vercel env add 会提示你粘贴 Webhook 地址。环境变量修改后要重新部署才生效。
3. 检查
| 检查项 | 期望 |
|---|---|
| 打开网址 | 显示作品集 |
| 提交表单 | 显示“已收到,谢谢!”,群里收到消息 |
邮箱填 abc |
浏览器先拦截;绕过前端时接口返回 400 |
| 响应头 | curl -I 网址 能看到 X-Content-Type-Options: nosniff |
4. 绑定自己的域名
项目 Settings → Domains 添加域名,按提示在域名服务商添加 DNS 记录。
出问题时该怎么调?
1. 表单返回 500“未配置 CONTACT_WEBHOOK_URL”
环境变量只加到了 Preview,或者加完没有重新部署。用 vercel env ls 查看。
2. 返回 502“通知发送失败”
到项目的 Logs 查看函数日志。常见原因是 Webhook 地址失效,或者平台要求的消息格式不同。
3. 本地想完整运行
vercel dev
会在本地模拟静态文件和函数,第一次需要 vercel link 关联项目。
常见问题怎么处理?
| 现象 | 原因 | 解决方法 |
|---|---|---|
/api/contact 404 |
api 目录不在项目根目录 |
移到根目录 |
Cannot use import statement |
没有 "type": "module" |
加上或改成 .mjs |
| 收到大量垃圾留言 | 机器人 | 蜜罐之外再加验证码服务 |
| 网站要商用 | Hobby 不允许商用 | 升级 Pro |
| 改了代码线上没变 | 没推送或部署失败 | 看 Deployments 的构建日志 |
项目资源
| 用途 | 地址 |
|---|---|
| Vercel Node.js 函数 | https://vercel.com/docs/functions/runtimes/node-js |
| vercel.json 配置 | https://vercel.com/docs/project-configuration |
| vercel env 命令 | https://vercel.com/docs/cli/env |
| Vercel CLI | https://vercel.com/docs/cli |
| Hobby 计划 | https://vercel.com/docs/plans/hobby |
| Git 集成 | https://vercel.com/docs/git |
建议先跑 npm test,然后用 vercel 部署一个预览版,用测试群的 Webhook 发几条留言。
确认消息格式没问题后,再换成正式群的地址,部署正式版,绑定域名。