vitepress+nodejs
⏱️最后更新: 2025/07/27
- 前端项目 基于 vitepress 入门优化
- 后端项目 nodejs 项目部署
前后端项目
前端项目
先在服务器端启动后端项目,然后优化测试前端项目
目录
markdown
.
|-- .gitignore
|-- docs
| |-- .vitepress
| | |-- config.ts
| | `-- theme
| | |-- Layout.vue
| | |-- components
| | | `-- MyComponent.vue
| | `-- index.mts
| `-- index.md
|-- package-lock.json
`-- package.json
MyComponent.vue
vue
<template>
<div class="api-tester">
<h2>后端接口测试工具</h2>
<!-- 1. 健康检查接口测试 -->
<section class="test-section">
<h3>1. 健康检查接口(/api/health)</h3>
<button @click="testHealth" :disabled="loading.health">
{{ loading.health ? "测试中..." : "开始测试" }}
</button>
<div
class="result"
:class="{
success: healthResult?.status === '运行正常',
error: healthError,
}"
>
<div v-if="healthError">
<p class="error-msg">❌ 测试失败:{{ healthError.message }}</p>
<p>状态码:{{ healthError.status }}</p>
</div>
<div v-else-if="healthResult">
<p>✅ 测试成功(状态码:200)</p>
<p>服务状态:{{ healthResult.status }}</p>
<p>运行时间:{{ healthResult.uptime.toFixed(2) }} 秒</p>
<p>内存使用:</p>
<ul>
<li>RSS:{{ formatBytes(healthResult.memoryUsage.rss) }}</li>
<li>
堆总量:{{ formatBytes(healthResult.memoryUsage.heapTotal) }}
</li>
<li>
堆使用:{{ formatBytes(healthResult.memoryUsage.heapUsed) }}
</li>
<li>
外部内存:{{ formatBytes(healthResult.memoryUsage.external) }}
</li>
</ul>
<p>时间戳:{{ formatDate(healthResult.timestamp) }}</p>
</div>
</div>
</section>
<!-- 2. 个性化问候接口测试 -->
<section class="test-section">
<h3>2. 个性化问候接口(/api/greet)</h3>
<div class="input-group">
<label>姓名:</label>
<input
type="text"
v-model="greetName"
placeholder="请输入姓名(可选)"
/>
</div>
<button @click="testGreet" :disabled="loading.greet">
{{ loading.greet ? "测试中..." : "发送问候" }}
</button>
<div
class="result"
:class="{ success: greetResult?.message, error: greetError }"
>
<div v-if="greetError">
<p class="error-msg">❌ 测试失败:{{ greetError.message }}</p>
</div>
<div v-else-if="greetResult">
<p>✅ 测试成功(状态码:200)</p>
<p>{{ greetResult.message }}</p>
<p>时间戳:{{ formatDate(greetResult.timestamp) }}</p>
</div>
</div>
</section>
<!-- 3. 服务器时间接口测试 -->
<section class="test-section">
<h3>3. 服务器时间接口(/api/time)</h3>
<button @click="testServerTime" :disabled="loading.serverTime">
{{ loading.serverTime ? "测试中..." : "获取时间" }}
</button>
<div
class="result"
:class="{
success: serverTimeResult?.serverTime,
error: serverTimeError,
}"
>
<div v-if="serverTimeError">
<p class="error-msg">❌ 测试失败:{{ serverTimeError.message }}</p>
</div>
<div v-else-if="serverTimeResult">
<p>✅ 测试成功(状态码:200)</p>
<p>服务器时间:{{ formatDate(serverTimeResult.serverTime) }}</p>
<p>客户端时间:{{ formatDate(new Date()) }}</p>
<p>
时间偏差:{{ calculateTimeDiff(serverTimeResult.serverTime) }} 秒
</p>
<p>时区:{{ serverTimeResult.timezone }}</p>
</div>
</div>
</section>
</div>
</template>
<script setup>
import { ref } from "vue";
import axios from "axios";
// 输入值状态
const greetName = ref("");
const proxyTarget = ref("");
// 结果状态
const healthResult = ref(null);
const greetResult = ref(null);
const proxyResult = ref(null);
const serverTimeResult = ref(null);
// 错误状态
const healthError = ref(null);
const greetError = ref(null);
const proxyError = ref(null);
const serverTimeError = ref(null);
// 加载状态(防止重复点击)
const loading = ref({
health: false,
greet: false,
proxy: false,
serverTime: false,
});
// 基础URL(根据后端部署地址调整)
const BASE_URL = ref("/api");
const createProxyUrl = (path) => {
return `${BASE_URL.value}${path}`; // 生成的路径是/api/proxy(正确)
};
const testHealth = async () => {
resetState("/health");
loading.value.health = true;
try {
const url = `${BASE_URL.value}/health`;
console.log("健康检查请求URL:", url);
const response = await axios.get(url); // 用axios替代fetch
healthResult.value = response.data;
// 验证点检查
if (response.data.status !== "运行正常") throw new Error("服务状态异常");
if (response.data.uptime <= 0) throw new Error("运行时间无效");
} catch (err) {
healthError.value = {
message: err.message,
status: err.response?.status,
responseText: err.response?.data,
};
} finally {
loading.value.health = false;
}
};
// 个性化问候接口(testGreet)
const testGreet = async () => {
resetState("/greet");
loading.value.greet = true;
try {
// 修正:用BASE_URL.value拼接正确URL
const url = `${BASE_URL.value}/greet`;
console.log("问候接口请求URL:", url);
const response = await axios.get(url, {
params: { name: greetName.value },
});
// 验证响应类型(必须是JSON)
if (response.headers["content-type"]?.indexOf("application/json") === -1) {
throw new Error("响应类型错误,预期JSON");
}
// 验证数据结构(必须包含message)
if (!response.data || !response.data.message) {
throw new Error("响应数据缺失message字段");
}
greetResult.value = response.data;
// 验证问候信息是否包含姓名
if (!response.data.message.includes(greetName.value || "朋友")) {
throw new Error("问候信息异常");
}
} catch (err) {
greetError.value = {
message: err.message,
status: err.response?.status,
responseText: err.response?.data || "无响应内容", // 新增:显示错误响应内容,方便调试
};
} finally {
loading.value.greet = false;
}
};
// 服务器时间接口(testServerTime)
const testServerTime = async () => {
resetState("/serverTime");
loading.value.serverTime = true;
try {
// 修正:用BASE_URL.value拼接正确URL
const url = `${BASE_URL.value}/time`;
console.log("服务器时间接口请求URL:", url);
const response = await axios.get(url);
// 验证响应类型
if (response.headers["content-type"]?.indexOf("application/json") === -1) {
throw new Error("响应类型错误,预期JSON");
}
// 验证数据结构(必须包含serverTime和timezone)
if (
!response.data ||
!response.data.serverTime ||
!response.data.timezone
) {
throw new Error("响应数据缺失关键字段(serverTime/timezone)");
}
serverTimeResult.value = response.data;
// 验证服务器时间格式(ISO 8601)
if (!isValidIsoString(response.data.serverTime)) {
throw new Error("服务器时间格式无效");
}
} catch (err) {
serverTimeError.value = {
message: err.message,
status: err.response?.status,
responseText: err.response?.data || "无响应内容",
};
} finally {
loading.value.serverTime = false;
}
};
/**
* 重置状态(根据接口类型)
*/
const resetState = (type) => {
switch (type) {
case "/health":
healthResult.value = null;
healthError.value = null;
break;
case "/greet":
greetResult.value = null;
greetError.value = null;
break;
case "/proxy":
proxyResult.value = null;
proxyError.value = null;
break;
case "/serverTime":
serverTimeResult.value = null;
serverTimeError.value = null;
break;
}
};
/**
* 工具函数:格式化字节(如123456 → 120.56 KB)
*/
const formatBytes = (bytes) => {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
};
/**
* 工具函数:格式化日期(ISO → 本地时间)
*/
const formatDate = (isoString) => {
if (!isoString) return "";
return new Date(isoString).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
/**
* 工具函数:计算服务器时间与客户端时间的偏差(秒)
*/
const calculateTimeDiff = (serverTime) => {
if (!serverTime) return 0;
const serverTs = new Date(serverTime).getTime();
const clientTs = new Date().getTime();
return ((clientTs - serverTs) / 1000).toFixed(2);
};
/**
* 工具函数:验证ISO 8601格式
*/
const isValidIsoString = (str) => {
const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
return isoRegex.test(str);
};
</script>
<style scoped>
.api-tester {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: "微软雅黑", sans-serif;
}
.test-section {
margin-bottom: 40px;
padding: 20px;
border: 1px solid #eee;
border-radius: 8px;
}
.input-group {
margin-bottom: 15px;
}
.input-group label {
margin-right: 10px;
}
.input-group input {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
width: 300px;
}
button {
padding: 10px 20px;
background-color: #409eff;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
button:hover {
background-color: #3a8ee6;
}
button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.result {
margin-top: 20px;
padding: 15px;
border-radius: 4px;
line-height: 1.6;
}
.result.success {
border: 1px solid #e6f9ed;
background-color: #f0f9eb;
color: #389e0d;
}
.result.error {
border: 1px solid #fff1f0;
background-color: #fff1f0;
color: #cf1322;
}
.error-msg {
font-weight: bold;
margin-bottom: 10px;
}
.result ul {
margin: 10px 0 0 20px;
padding: 0;
}
.result li {
margin-bottom: 5px;
}
</style>
Layout.vue
vue
<script setup>
import DefaultTheme from "vitepress/theme";
const { Layout } = DefaultTheme;
</script>
<template>
<div>
<Layout />
</div>
</template>
index.mts
ts
// .vitepress/theme/index.ts(1.x 版本)
import type { Theme, EnhanceAppContext } from "vitepress";
import Layout from "./Layout.vue";
import MyComponent from "./components/MyComponent.vue";
export default {
Layout,
enhanceApp({ app, router, siteData }: EnhanceAppContext) {
const components = [{ comp: MyComponent, name: "MyComponent" }];
components.forEach(({ comp, name }) => {
// 避免重复注册
if (!app.component(name)) {
app.component(name, comp);
}
});
},
} satisfies Theme;
index.md
markdown
<MyComponent />
config.ts
ts
import { defineConfig } from "vitepress";
// https://vitepress.vuejs.org/config/app-configs
export default defineConfig({});
package.json
json
{
"name": "vitepress-project",
"private": true,
"type": "module",
"scripts": {
"dev": "vitepress dev docs",
"build": "vitepress build docs",
"serve": "vitepress serve docs"
},
"devDependencies": {
"vitepress": "1.0.0-alpha.28",
"vue": "3.2.44",
"axios": "^1.11.0"
},
"pnpm": {
"peerDependencyRules": {
"ignoreMissing": ["@algolia/client-search"]
}
},
"dependencies": {
"axios": "^1.11.0"
}
}
执行命令进行项目打包
Bash
npm run build
将打包后的 dist 拷贝到部署目录中
后端项目
目录
markdown
.
|-- package.json
`-- src
`-- index.js
index.js
js
const express = require("express");
// 创建Express应用
const app = express();
const PORT = process.env.PORT || 3001;
// 基础路由 - 欢迎信息
app.get("/", (req, res) => {
res.json({
message: "欢迎使用简单Node.js服务!",
endpoints: [
{ path: "/", description: "欢迎页面" },
{ path: "/api/greet", description: "个性化问候" },
{ path: "/api/time", description: "获取服务器时间" },
{ path: "/api/health", description: "服务健康检查" },
],
});
});
// 个性化问候API
app.get("/api/greet", (req, res) => {
const name = req.query.name || "朋友";
res.json({
message: `你好,${name}!`,
timestamp: new Date().toISOString(),
});
});
// 服务器时间API
app.get("/api/time", (req, res) => {
res.json({
serverTime: new Date().toISOString(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
});
// 健康检查API
app.get("/api/health", (req, res) => {
res.json({
status: "运行正常",
uptime: process.uptime(),
memoryUsage: process.memoryUsage(),
timestamp: new Date().toISOString(),
});
});
// 启动服务器
app.listen(PORT, () => {
console.log(`🚀 服务器已启动,运行在 http://localhost:${PORT}`);
console.log("➤ 按 Ctrl+C 停止服务");
});
// 优雅关闭处理
process.on("SIGINT", () => {
console.log("\n🛑 正在关闭服务器...");
process.exit(0);
});
package.json
json
{
"name": "simple-node-project",
"version": "1.0.0",
"description": "一个简单的Node.js项目示例",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js"
},
"engines": {
"node": ">=14.0.0"
},
"dependencies": {
"express": "^4.18.3"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}
部署准备
目录
markdown
.
|-- backend
| |-- .dockerignore
| |-- Dockerfile.node
| |-- package.json
| `-- src
| `-- index.js
|-- default.conf // nginx 配置文件
|-- dist // 前端打包的项目
| |-- 404.html
| |-- assets
| |-- hashmap.json
| `-- index.html
|-- docker-compose.yml
|-- dockerfile
default.conf
bash
server {
listen 80;
server_name 106.52.78.231;
# 前端静态文件服务
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
# 基础安全头
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
}
# 后端接口代理(关键修正)
location /api/ {
proxy_pass http://nodejs:3001; #正确:使用Docker Compose服务名(nodejs)
proxy_set_header Host $host; #传递Host头(后端需要时)
proxy_set_header X-Real-IP $remote_addr;# 传递真实IP(可选)
# CORS配置(允许前端 origin)
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' '*' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
# 处理预检请求(OPTIONS)
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
}
}
dockerfile
bash
FROM nginx:1.9.0
COPY dist/ /usr/share/nginx/html/
COPY default.conf /etc/nginx/conf.d/default.conf
Dockerfile.node
bash
FROM node:18-alpine
# 1. 创建非Root用户(appuser)
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# 2. 设置工作目录(/app)
WORKDIR /app
# 3. 复制依赖文件(package.json/package-lock.json)
COPY package*.json ./
# 4. 安装生产依赖(使用淘宝源,避免DNS问题)
RUN npm install --omit=dev --registry=https://registry.npmmirror.com
# 5. 复制代码(此时已通过.dockerignore排除冗余文件)
COPY . .
# 6. 改变工作目录所有权(赋予appuser读写权限)
RUN chown -R appuser:appgroup /app
# 7. 切换到非Root用户(安全运行)
USER appuser
# 8. 暴露端口(与docker-compose一致)
EXPOSE 3000
# 9. 启动命令(指向src/index.js)
CMD ["node", "src/index.js"]
docker-compose.yml
yml
# docker-compose.yml(部分)
services:
vueapp:
build:
context: .
dockerfile: Dockerfile
ports:
- "80:80"
restart: unless-stopped
depends_on:
- nodejs # 前端依赖后端启动
networks:
- appnet
nodejs:
build:
context: ./backend
dockerfile: Dockerfile.node
ports:
- "3001:3001" # 可选:保留端口映射(方便调试)
restart: unless-stopped
networks:
- appnet # 加入同一网络
networks:
appnet:
driver: bridge
启动服务并验证
执行命令
bash
sudo docker-compose up -d
验证服务,启动浏览器
http://ip地址
应如下所示