从零搭建全栈小应用:Express + Vue 3 + SQLite 实战

项目实战 · 约 22 分钟 · 更新于

GoToMarry 全栈架构图

图示是项目架构概览,真实产品截图可在后续接入线上演示后替换;本文重点展示可复用的工程设计。

我以前做了一个叫 GoToMarry 的备婚管理系统,给新郎新娘用 PIN 码登录、管理备婚任务和预算。这篇教程把它的核心思路压缩成一个最小可跑的全栈小应用:一个 PIN 登录 + 任务清单。你照着做完,就能掌握「前端 + 后端 + 数据库 + 登录鉴权 + 联调」一整套,之后再扩展预算、婚期等功能就有底了。

我们最终做出这样一个小应用:浏览器里输入 PIN 码登录,登录后看到任务列表,还能新增任务,数据存在后端的 SQLite 里。技术栈是 Vue 3 + Vite 做前端,Node.js + Express + SQLite 做后端,登录用 PIN + JWT。

第 1 步:装 Node.js

前后端都靠 Node.js 跑,先装它。去官网 nodejs.org 下载 LTS 版本(写这篇时是 20.x 或 22.x,选 LTS 最稳)。Windows / macOS 都有一键安装包,一路下一步即可。装完打开终端(Windows 用 PowerShell,macOS 用终端)输入:

node -v
npm -v

能打印出版本号就说明装好了。npm 是 Node 的包管理器,装 Node 时会自带,后面装第三方库全靠它。

小提示:建议每个项目放在单独的文件夹里。本教程我们在一个叫 gotomarry-demo 的文件夹下,分别建 backend 和 frontend 两个子文件夹。

第 2 步:搭后端(Express + SQLite)

后端负责存数据、校验登录、提供接口。我们用 Express(一个轻量 Web 框架)和 SQLite(一个单文件数据库,不用单独装数据库服务)。

先建后端项目并装依赖:

mkdir -p gotomarry-demo/backend
cd gotomarry-demo/backend
npm init -y
npm install express better-sqlite3 jsonwebtoken cors

四个包分别是:express 做 Web 服务、better-sqlite3 操作 SQLite 数据库、jsonwebtoken 生成和校验登录凭证、cors 处理跨域(跨域的概念第 4 步解释)。better-sqlite3 是原生模块,安装时通常会自动下载预编译二进制,多数情况能直接成功;若报错提示缺编译工具,Windows 装「windows-build-tools」、macOS 装 Xcode Command Line Tools 即可。

接着在 backend 目录下新建 server.js,完整内容如下:

const express = require('express');
const Database = require('better-sqlite3');
const jwt = require('jsonwebtoken');
const cors = require('cors');

const app = express();
app.use(cors());
app.use(express.json());

// 配置项:通过环境变量注入,方便个性化(不设也有默认值)
const PIN_CODE = process.env.PIN_CODE || '123456';
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
const PORT = process.env.PORT || 3000;

// 初始化 SQLite,建 tasks 表
const db = new Database('data.db');
db.exec(`
  CREATE TABLE IF NOT EXISTS tasks (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    done INTEGER DEFAULT 0,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP
  )
`);

// 表为空时插一条示例数据
const count = db.prepare('SELECT COUNT(*) AS c FROM tasks').get();
if (count.c === 0) {
  db.prepare("INSERT INTO tasks (title) VALUES (?)").run('买婚戒');
}

// JWT 校验中间件:从请求头取 token 并验证
// JWT(JSON Web Token)是一种登录后发给用户的「电子通行证」,
// 之后每次请求带上它,服务端就知道你是谁,不用每次重输密码。
function auth(req, res, next) {
  const header = req.headers.authorization || '';
  const token = header.startsWith('Bearer ') ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: '未登录' });
  try {
    req.user = jwt.verify(token, JWT_SECRET);
    next();
  } catch {
    res.status(401).json({ error: 'token 无效' });
  }
}

// PIN 码登录:校验通过就签发一个 7 天有效的 JWT
app.post('/api/login', (req, res) => {
  const { pin } = req.body || {};
  if (pin !== PIN_CODE) return res.status(401).json({ error: 'PIN 码错误' });
  const token = jwt.sign({ role: 'couple' }, JWT_SECRET, { expiresIn: '7d' });
  res.json({ token });
});

// 获取任务列表(需登录)
app.get('/api/tasks', auth, (req, res) => {
  const rows = db.prepare('SELECT * FROM tasks ORDER BY id DESC').all();
  res.json(rows);
});

// 新增任务(需登录)
app.post('/api/tasks', auth, (req, res) => {
  const { title } = req.body || {};
  if (!title) return res.status(400).json({ error: 'title 不能为空' });
  const info = db.prepare('INSERT INTO tasks (title) VALUES (?)').run(title);
  const created = db.prepare('SELECT * FROM tasks WHERE id = ?').get(info.lastInsertRowid);
  res.json(created);
});

app.listen(PORT, () => console.log(`后端跑起来了: http://localhost:${PORT}`));

启动后端:

node server.js

看到 后端跑起来了: http://localhost:3000 就成功了。另开一个终端用 curl 验证三个接口(PIN 默认是 123456):

# 1. 登录,拿到 token
curl -s -X POST http://localhost:3000/api/login \
  -H "Content-Type: application/json" \
  -d '{"pin":"123456"}'

# 2. 用 token 取任务列表(把 <TOKEN> 换成上一步返回的 token 值)
curl -s http://localhost:3000/api/tasks \
  -H "Authorization: Bearer <TOKEN>"

# 3. 新增一条任务
curl -s -X POST http://localhost:3000/api/tasks \
  -H "Authorization: Bearer <TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"title":"订酒店"}'

第 1 步会返回类似 {"token":"eyJhbGci..."},第 2 步能看到 [{"id":1,"title":"买婚戒",...}],第 3 步会返回你刚新增的那条任务。后端到这里就通了。

第 3 步:搭前端(Vue 3 + Vite)

回到项目根目录,用官方脚手架起一个 Vue 3 项目:

cd gotomarry-demo
npm create vue@latest frontend

脚手架会问几个问题,新手建议这样选:TypeScript No、JSX No、Vue Router No、Pinia No、Vitest No、ESLint No、Prettier No,得到最简单的模板。然后进目录装依赖和 axios(用来发 HTTP 请求调后端接口):

cd frontend
npm install
npm install axios

把 src/App.vue 整个替换成下面这份,它实现了「PIN 登录 → 存 token → 拉任务列表 → 新增任务并刷新」:

<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'

// 指向后端 3000 端口
const api = axios.create({ baseURL: 'http://localhost:3000/api' })

const pin = ref('')
const token = ref(localStorage.getItem('token') || '')
const tasks = ref([])
const newTitle = ref('')
const error = ref('')

async function login() {
  error.value = ''
  try {
    const { data } = await api.post('/login', { pin: pin.value })
    token.value = data.token
    localStorage.setItem('token', data.token)
    await loadTasks()
  } catch (e) {
    error.value = '登录失败:' + (e.response?.data?.error || e.message)
  }
}

async function loadTasks() {
  error.value = ''
  try {
    const { data } = await api.get('/tasks', {
      headers: { Authorization: 'Bearer ' + token.value }
    })
    tasks.value = data
  } catch (e) {
    error.value = '加载失败:' + (e.response?.data?.error || e.message)
  }
}

async function addTask() {
  error.value = ''
  if (!newTitle.value.trim()) return
  try {
    const { data } = await api.post('/tasks', { title: newTitle.value }, {
      headers: { Authorization: 'Bearer ' + token.value }
    })
    tasks.value.unshift(data)
    newTitle.value = ''
  } catch (e) {
    error.value = '新增失败:' + (e.response?.data?.error || e.message)
  }
}

onMounted(() => {
  if (token.value) loadTasks()
})
</script>

<template>
  <div class="app">
    <h1>备婚任务清单</h1>
    <p v-if="error" class="error">{{ error }}</p>

    <div v-if="!token">
      <input v-model="pin" type="password" placeholder="输入 PIN 码" @keyup.enter="login" />
      <button @click="login">登录</button>
    </div>

    <div v-else>
      <div class="add">
        <input v-model="newTitle" placeholder="新增一条任务" @keyup.enter="addTask" />
        <button @click="addTask">新增</button>
      </div>
      <ul>
        <li v-for="t in tasks" :key="t.id">
          {{ t.title }}
          <span class="meta">{{ t.created_at }}</span>
        </li>
      </ul>
    </div>
  </div>
</template>

<style scoped>
.app { max-width: 480px; margin: 40px auto; font-family: sans-serif; }
.error { color: #e53935; }
.add { display: flex; gap: 8px; margin-bottom: 16px; }
input { flex: 1; padding: 8px; }
button { padding: 8px 16px; }
li { padding: 8px 0; border-bottom: 1px solid #eee; }
.meta { color: #999; font-size: 12px; margin-left: 8px; }
</style>

启动前端开发服务器:

npm run dev

浏览器打开它提示的地址(一般是 http://localhost:5173)。

第 4 步:前后端联调

跨域是指浏览器出于安全,默认不允许网页去访问跟它不同源(不同端口也算不同源)的接口。我们前端在 5173、后端在 3000,属于跨域。解决方式有两种:

  1. 后端开 cors(我们在 server.js 里已经写了 app.use(cors())),最省事,本教程就靠它。
  2. 前端用 Vite 的 proxy 把请求转发到后端,让浏览器以为同源。如果你以后不想暴露后端端口,可以这样配 vite.config.js:
// vite.config.js(可选写法,本教程不强制)
export default defineConfig({
  server: { proxy: { '/api': 'http://localhost:3000' } }
})

用 proxy 时,前端 baseURL 写成 /api 即可,请求会被 Vite 转发给后端。

确认后端还在跑(终端 1),前端也在跑(终端 2)。打开 http://localhost:5173:输入 PIN 123456 → 点登录 → 应看到任务列表里那条「买婚戒」 → 在输入框打「订酒店」点新增 → 列表里立刻多出一条。这一整套跑通,就说明前后端联调成功。

第 5 步(可选):用 Docker 打包

本地能跑了,部署到服务器时通常用 Docker 把它打包成镜像再运行。这里只讲思路和关键点,不展开完整配置。

多阶段构建是指在同一个 Dockerfile 里分几步:先用一个大而全的镜像把代码编译好,再把编译产物拷进一个小镜像运行,这样最终镜像又小又干净。对这个项目大致分三块:

  • 前端构建阶段:用 node 镜像,npm ci && npm run build,产出 dist/ 静态文件。
  • 后端阶段:用 node 镜像,装 better-sqlite3 等依赖,拷入 server.js,启动 node server.js。
  • Nginx 反代阶段:Nginx 是一个高性能 Web 服务器,这里用它托管前端 dist/ 静态文件,同时把 /api 开头的请求反向代理(转发)给后端容器。也就是说浏览器只跟 Nginx 对话,由 Nginx 决定静态文件直接返回、接口请求转给后端。

再用 Docker Compose 编排:写一个 docker-compose.yml,定义 frontend(nginx + dist)和 backend(node)两个服务,让它们在同一个网络里互通;敏感信息如 PIN_CODE、JWT_SECRET 通过 Compose 的 environment 注入,而不是写死在代码里。这样换一对新人、换个婚期预算,只改环境变量、重新部署即可,这正是 GoToMarry 做个性化的方式。

小结

我们从零走了一遍:装 Node → 用 Express + SQLite 写了带 PIN/JWT 登录的后端 → 用 Vue 3 + Vite 写了登录和任务页面 → 解决跨域跑通联调 → 了解了 Docker 多阶段打包思路。掌握这套骨架后,你可以继续加:任务打勾完成、预算收支记账、按婚期倒计时等——GoToMarry 本体也就是在这个骨架上长出来的。

项目架构:为什么拆成前端、API 和数据层

GoToMarry 不是把所有逻辑塞进一个页面,而是按职责拆成三层:

Vue 3 / Vite
    │ HTTP + JSON
    ▼
Express API ── JWT middleware ── route/controller
    │
    ├── service:任务、预算、宾客、座位等业务规则
    ├── repository:统一数据库访问
    ▼
SQLite + Docker volume

这样拆分有三个实际收益:

  1. 前端可以独立迭代:页面只关心状态和交互,不直接拼 SQL。
  2. 接口可以被多个端复用:Web 页面、后续小程序或移动端都可以调用同一组 API。
  3. 数据持久化边界明确:容器可以重建,但 SQLite 数据放在 volume 中,不随镜像删除。

一次请求的完整数据流

以“新增备婚任务”为例,请求链路是:

  1. Vue 表单先做空值和长度校验。
  2. Axios 将 token 放入 Authorization: Bearer <token>。
  3. Express 中间件验证 JWT 签名、过期时间和角色。
  4. Controller 解析 DTO,Service 检查业务规则。
  5. SQLite 使用参数绑定写入任务,避免字符串拼接带来的注入风险。
  6. API 返回统一 JSON,Pinia 更新前端状态,页面只刷新必要列表。

建议统一响应结构,而不是每个接口返回不同格式:

{
  "code": 0,
  "message": "ok",
  "data": { "id": 12, "title": "预订酒店", "done": false }
}

数据库设计与一致性

项目中的任务、预算、宾客、座位、礼金和相册数据应按领域拆表,公共字段统一使用:

  • id:整数主键,避免业务字段作为主键。
  • created_at / updated_at:便于审计和排序。
  • deleted_at:需要恢复能力时使用软删除。
  • 外键字段统一加索引,避免列表页随着数据增多变慢。

涉及预算和礼金的写操作必须使用事务。例如“新增礼金记录 + 更新礼金汇总”要么同时成功,要么同时回滚,不能出现明细已经写入但汇总数字没有变化的中间状态。

鉴权与安全边界

原教程里的 cors() 和默认密钥只适合本地演示,正式部署需要收紧:

  • PIN_CODE、JWT_SECRET 只通过环境变量注入,不提交到 Git。
  • 生产环境随机生成至少 32 字节的 JWT secret。
  • CORS 只允许正式前端域名,不使用 *。
  • JWT 设置合理过期时间,前端收到 401 后清理 token 并回到登录页。
  • 生产环境使用 HTTPS,避免 PIN 和 token 在网络中明文传输。
  • 所有写接口做长度、类型、范围校验,错误响应不要返回堆栈。
  • 上传图片限制 MIME、大小和数量,文件名不能直接使用用户输入。

Docker 生产化配置

推荐使用三个容器职责:

services:
  web:
    image: nginx:alpine
    depends_on: [api]
    ports: ['80:80']
  api:
    build: ./backend
    environment:
      PIN_CODE: ${PIN_CODE}
      JWT_SECRET: ${JWT_SECRET}
    volumes:
      - app-data:/app/data
  # SQLite 文件只挂载到 api,web 不直接访问数据库
volumes:
  app-data:

真正使用时要补充:健康检查、日志输出、数据库备份和镜像版本号。更新流程建议是“构建新镜像 → 健康检查 → 切换容器”,不要直接在运行容器里修改代码。

测试清单

层次必测内容
单元测试PIN 校验、任务状态转换、金额计算、日期倒计时
API 测试登录、401、参数校验、增删改查、重复提交
数据库测试事务回滚、并发写入、备份恢复
E2E 测试登录 → 新增任务 → 刷新页面 → 数据仍存在
安全测试错误 token、过期 token、超长输入、非法文件上传

复盘:从 Demo 到可维护产品还要补什么

当前项目已经覆盖了一个完整全栈应用的主链路,但如果继续产品化,我会优先补:

  1. API 版本号,例如 /api/v1/tasks。
  2. OpenAPI 文档,让前后端接口可自动校验。
  3. 数据库迁移工具,而不是只依赖启动时 CREATE TABLE IF NOT EXISTS。
  4. 操作日志和错误追踪,方便定位线上问题。
  5. 图片对象存储和缩略图处理,避免 SQLite 文件无限增长。
  6. 定时备份和恢复演练,验证备份真正可用。
阳熙

Java / 保险业务系统开发 · 无锡

← 返回文章列表

评论

还没有评论,来留第一条吧。

首次评论会做人机校验