项目部署是将开发完成的 Vue 应用发布到生产环境,让用户可以访问的过程。正确的部署策略可以确保应用的稳定性、安全性和高性能。
从开发到上线的完整流程
# 1. 环境变量配置
# .env.production
VITE_API_BASE_URL=https://api.example.com
VITE_APP_TITLE=生产环境
VITE_ENABLE_SENTRY=true
# 2. 构建脚本确认
# package.json
{
"scripts": {
"build": "vue-tsc && vite build",
"build:prod": "vite build --mode production",
"build:analyze": "vite build --mode production && vite-bundle-analyzer"
}
}
# 3. 依赖检查
npm audit --production
npm outdated
// vite.config.js - 生产环境构建优化
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import compression from 'vite-plugin-compression'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
vue(),
// Gzip/Brotli 压缩
compression({
algorithm: 'gzip',
ext: '.gz',
threshold: 10240
}),
// 打包分析
visualizer({
open: false,
filename: 'dist/stats.html'
})
],
build: {
target: 'es2020',
outDir: 'dist',
assetsDir: 'assets',
// 关闭 sourcemap 或只在需要时开启
sourcemap: process.env.SOURCE_MAP === 'true',
// 代码压缩
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // 移除 console
drop_debugger: true, // 移除 debugger
pure_funcs: ['console.log', 'console.info']
}
},
// 代码分割优化
rollupOptions: {
output: {
// 文件命名规则
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
// 手动拆分包
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('vue')) {
return 'vue-vendor'
}
if (id.includes('element-plus') || id.includes('ant-design')) {
return 'ui-vendor'
}
if (id.includes('lodash') || id.includes('axios')) {
return 'utils-vendor'
}
return 'vendor'
}
}
}
},
// 限制 chunk 大小警告
chunkSizeWarningLimit: 2000
}
})
# /etc/nginx/conf.d/vue-app.conf
server {
listen 80;
server_name your-domain.com www.your-domain.com;
root /var/www/vue-app/dist;
index index.html;
# Gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript
application/javascript application/xml+rss
application/json image/svg+xml;
# 静态文件缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options nosniff;
}
# HTML 文件不缓存
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# 主应用入口
location / {
try_files $uri $uri/ /index.html;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options nosniff;
}
# API 代理
location /api/ {
proxy_pass http://backend-server:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# 安全头
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.example.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.example.com;" always;
}
# 安装 Certbot
sudo apt-get update
sudo apt-get install certbot python3-certbot-nginx
# 获取证书
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
# 自动续期测试
sudo certbot renew --dry-run
# 自动续期脚本
# crontab -e
# 0 0,12 * * * /usr/bin/certbot renew --quiet
# HTTPS 配置
server {
listen 443 ssl http2;
server_name your-domain.com;
# SSL 证书路径
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# SSL 配置
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512;
ssl_prefer_server_ciphers off;
# SSL 会话缓存
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
# HSTS
add_header Strict-Transport-Security "max-age=31536000" always;
# 其他配置与 HTTP 相同
root /var/www/vue-app/dist;
# ...
}
# /etc/nginx/nginx.conf - 性能优化
user www-data;
worker_processes auto;
pid /run/nginx.pid;
events {
worker_connections 4096; # 每个worker最大连接数
multi_accept on; # 同时接受多个连接
use epoll; # 使用epoll高效I/O模型
}
http {
# 基础设置
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off; # 隐藏Nginx版本信息
# MIME类型
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Gzip压缩
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types
application/atom+xml
application/javascript
application/json
application/ld+json
application/manifest+json
application/rss+xml
application/vnd.geo+json
application/vnd.ms-fontobject
application/x-font-ttf
application/x-web-app-manifest+json
application/xhtml+xml
application/xml
font/opentype
image/bmp
image/svg+xml
image/x-icon
text/cache-manifest
text/css
text/plain
text/vcard
text/vnd.rim.location.xloc
text/vtt
text/x-component
text/x-cross-domain-policy;
# 包含站点配置
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
# 第一阶段:构建应用
FROM node:18-alpine as builder
# 设置工作目录
WORKDIR /app
# 复制 package 文件
COPY package*.json ./
COPY pnpm-lock.yaml ./
# 安装依赖(使用国内镜像加速)
RUN npm config set registry https://registry.npmmirror.com \
&& npm install -g pnpm \
&& pnpm install --frozen-lockfile
# 复制源代码
COPY . .
# 构建应用
RUN pnpm run build
# 第二阶段:运行应用
FROM nginx:alpine as production
# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 复制自定义 Nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 创建非root用户
RUN addgroup -g 1001 -S nodejs \
&& adduser -S -u 1001 -G nodejs nodejs \
&& chown -R nodejs:nodejs /usr/share/nginx/html
# 切换到非root用户
USER nodejs
# 暴露端口
EXPOSE 80
# 启动 Nginx
CMD ["nginx", "-g", "daemon off;"]
# docker-compose.yml
version: '3.8'
services:
# Vue 前端应用
frontend:
build:
context: .
dockerfile: Dockerfile
container_name: vue-app
ports:
- "8080:80"
environment:
- NODE_ENV=production
- VITE_API_URL=http://backend:3000
volumes:
- ./logs:/var/log/nginx
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- app-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
# 后端 API 服务
backend:
image: node:18-alpine
container_name: api-server
working_dir: /app
volumes:
- ./backend:/app
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://user:pass@db:5432/app
networks:
- app-network
depends_on:
- db
command: ["node", "server.js"]
# 数据库
db:
image: postgres:15-alpine
container_name: postgres-db
environment:
- POSTGRES_USER=app_user
- POSTGRES_PASSWORD=secure_password
- POSTGRES_DB=app_db
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- app-network
restart: unless-stopped
# 监控服务
prometheus:
image: prom/prometheus:latest
container_name: prometheus
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
networks:
- app-network
# 可视化监控
grafana:
image: grafana/grafana:latest
container_name: grafana
ports:
- "3001:3000"
volumes:
- grafana_data:/var/lib/grafana
networks:
- app-network
networks:
app-network:
driver: bridge
volumes:
postgres_data:
grafana_data:
#!/bin/bash
# deploy.sh - Docker 部署脚本
# 设置变量
APP_NAME="vue-app"
REGISTRY="your-registry.com"
TAG="latest"
ENVIRONMENT="production"
echo "开始构建和部署 $APP_NAME..."
# 1. 构建 Docker 镜像
docker build -t $REGISTRY/$APP_NAME:$TAG .
# 2. 推送镜像到仓库
docker push $REGISTRY/$APP_NAME:$TAG
# 3. 拉取最新镜像并部署
ssh user@server << EOF
# 拉取最新镜像
docker pull $REGISTRY/$APP_NAME:$TAG
# 停止并删除旧容器
docker stop $APP_NAME || true
docker rm $APP_NAME || true
# 运行新容器
docker run -d \\
--name $APP_NAME \\
--restart unless-stopped \\
-p 80:80 \\
-p 443:443 \\
-v /etc/letsencrypt:/etc/letsencrypt:ro \\
-v /var/log/nginx:/var/log/nginx \\
--env-file .env.$ENVIRONMENT \\
$REGISTRY/$APP_NAME:$TAG
# 清理无用镜像
docker image prune -f
echo "$APP_NAME 部署完成!"
EOF
echo "部署完成!"
# .github/workflows/deploy.yml
name: Deploy Vue App
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v3
with:
node-version: 18
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run test:unit
build-and-deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build application
run: pnpm run build
env:
VITE_API_URL: ${{ secrets.PROD_API_URL }}
VITE_APP_TITLE: ${{ secrets.APP_TITLE }}
- name: Deploy to Server
uses: appleboy/scp-action@v0.1.4
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "dist/*"
target: "/var/www/vue-app"
- name: Restart Nginx
uses: appleboy/ssh-action@v0.1.6
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
sudo systemctl restart nginx
echo "部署成功!"
# .gitlab-ci.yml
stages:
- test
- build
- deploy
variables:
NODE_VERSION: "18"
PNPM_VERSION: "8"
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
- .pnpm-store/
before_script:
- apt-get update -qq && apt-get install -y -qq curl
- curl -sL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash -
- apt-get install -y nodejs
- npm install -g pnpm@${PNPM_VERSION}
test:
stage: test
script:
- pnpm install --frozen-lockfile
- pnpm run lint
- pnpm run test:unit
artifacts:
paths:
- coverage/
build:
stage: build
script:
- pnpm run build
- ls -la dist/
artifacts:
paths:
- dist/
only:
- main
deploy_production:
stage: deploy
script:
- apt-get update && apt-get install -y openssh-client
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config'
- scp -r dist/* $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH
- ssh $DEPLOY_USER@$DEPLOY_HOST "cd $DEPLOY_PATH && sudo systemctl restart nginx"
only:
- main
when: manual
// vercel.json
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
],
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=3600" },
{ "key": "X-Frame-Options", "value": "DENY" }
]
}
]
}
# netlify.toml
[build]
command = "pnpm run build"
publish = "dist"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-XSS-Protection = "1; mode=block"
// 集成性能监控
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import * as Sentry from '@sentry/vue'
import { BrowserTracing } from '@sentry/tracing'
const app = createApp(App)
// Sentry 错误监控
if (import.meta.env.PROD) {
Sentry.init({
app,
dsn: import.meta.env.VITE_SENTRY_DSN,
integrations: [
new BrowserTracing({
routingInstrumentation: Sentry.vueRouterInstrumentation(router),
tracingOrigins: ['localhost', 'your-domain.com']
})
],
tracesSampleRate: 1.0,
environment: import.meta.env.MODE
})
}
// Web Vitals 监控
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals'
function sendToAnalytics(metric) {
const body = {
name: metric.name,
value: metric.value,
rating: metric.rating
}
// 发送到监控服务器
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' }
})
}
getCLS(sendToAnalytics)
getFID(sendToAnalytics)
getFCP(sendToAnalytics)
getLCP(sendToAnalytics)
getTTFB(sendToAnalytics)
// 性能优化配置
export default defineConfig({
build: {
rollupOptions: {
output: {
// 预加载关键资源
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
ui: ['element-plus'],
utils: ['axios', 'lodash-es']
}
}
}
},
// 预加载配置
experimental: {
renderBuiltUrl(filename, { hostId, hostType, type }) {
if (type === 'asset' && filename.endsWith('.woff2')) {
return {
runtime: `(() => {
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'font'
link.type = 'font/woff2'
link.crossorigin = 'anonymous'
link.href = '${filename}'
document.head.appendChild(link)
})()`
}
}
}
}
})
// 关键 CSS 提取
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// 服务端渲染关键 CSS
if (typeof window !== 'undefined') {
import('./styles/critical.css')
}
# Nginx 缓存配置
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
# 长期缓存静态资源
expires 1y;
add_header Cache-Control "public, immutable";
# 添加版本号防止缓存
add_header ETag "";
# 开启 Brotli 压缩
brotli_static on;
gzip_static on;
}
# 禁用 HTML 缓存
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
add_header Pragma "no-cache";
}
# CDN 配置
location /assets/ {
# 使用 CDN
add_header X-CDN "cloudflare";
# 允许跨域
add_header Access-Control-Allow-Origin "*";
# 安全头
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "DENY";
}
确保 API keys、数据库密码等不提交到版本控制
定期运行 npm audit 检查依赖安全
设置 Content-Security-Policy 头限制资源加载
设置 X-Frame-Options 头防止页面被嵌入