云端部署

在 Vercel 上部署一个个人作品集网站,带一个真正能用的“联系我”表单

在 Vercel 上部署一个个人作品集网站,带一个真正能用的“联系我”表单:访客提交后,Vercel Function 校验内容、过滤机器人,再把留言推送到你的飞书、Slack 或 Discord 群机器人。不用买服务器,不用管数据库。

BINBHO · 图文教程

在 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 发几条留言。

确认消息格式没问题后,再换成正式群的地址,部署正式版,绑定域名。