Express服务器基础配置
基础服务器设置
// server/index.js
import express from 'express'
import { fileURLToPath } from 'url'
import { dirname, resolve } from 'path'
import { createServer as createViteServer } from 'vite'
const __dirname = dirname(fileURLToPath(import.meta.url))
const isProduction = process.env.NODE_ENV === 'production'
async function createServer() {
const app = express()
let vite
if (!isProduction) {
// 开发环境:创建Vite服务器
vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom'
})
app.use(vite.ssrFixStacktrace)
app.use(vite.middlewares)
} else {
// 生产环境:使用构建后的文件
app.use('/assets', express.static(resolve(__dirname, '../dist/client/assets')))
}
// SSR路由处理
app.use('*', async (req, res, next) => {
const url = req.originalUrl
try {
let template, render
if (!isProduction) {
// 开发环境
template = await vite.ssrLoadModule('/index.html')
render = (await vite.ssrLoadModule('/src/entry-server.js')).render
} else {
// 生产环境
template = await fs.readFile(
resolve(__dirname, '../dist/client/index.html'),
'utf-8'
)
render = (await import('../dist/server/entry-server.js')).render
}
const context = {
req,
res
}
const app = await render(url, context)
const html = await renderToString(app)
// 处理重定向
if (context.url) {
return res.redirect(301, context.url)
}
// 处理状态码
if (context.statusCode) {
res.status(context.statusCode)
}
const finalHtml = template
.replace('<!--ssr-outlet-->', html)
.replace(
'<!--ssr-state-->',
`<script>window.__INITIAL_STATE__=${JSON.stringify(context.state)}</script>`
)
res.setHeader('Content-Type', 'text/html')
res.end(finalHtml)
} catch (error) {
if (!isProduction) {
vite.ssrFixStacktrace(error)
}
next(error)
}
})
return app
}
const port = process.env.PORT || 3000
createServer().then(app => {
app.listen(port, () => {
console.log(`服务器运行在 http://localhost:${port}`)
})
})
开发环境服务器
// server/dev-server.js
import express from 'express'
import { createServer as createViteServer } from 'vite'
import { renderToString } from '@vue/server-renderer'
async function createDevServer() {
const app = express()
// 创建Vite服务器
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
optimizeDeps: {
include: ['vue', 'vue-router', 'vuex']
}
})
app.use(vite.ssrFixStacktrace)
app.use(vite.middlewares)
app.use('*', async (req, res, next) => {
const url = req.originalUrl
try {
// 1. 读取index.html模板
let template = await vite.transformIndexHtml(url, `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vue SSR</title>
</head>
<body>
<div id="app"><!--ssr-outlet--></div>
<!--ssr-state-->
</body>
</html>
`)
// 2. 加载服务端入口
const { render } = await vite.ssrLoadModule('/src/entry-server.js')
// 3. 渲染应用
const context = { req, res }
const app = await render(url, context)
const html = await renderToString(app)
// 4. 注入渲染的HTML
const finalHtml = template
.replace('<!--ssr-outlet-->', html)
.replace(
'<!--ssr-state-->',
context.state
? `<script>window.__INITIAL_STATE__=${JSON.stringify(context.state)}</script>`
: ''
)
res.setHeader('Content-Type', 'text/html')
res.end(finalHtml)
} catch (error) {
vite.ssrFixStacktrace(error)
console.error(error)
res.status(500).end(error.message)
}
})
return app
}
const port = 3000
createDevServer().then(app => {
app.listen(port, () => {
console.log(`开发服务器运行在 http://localhost:${port}`)
})
})
生产环境服务器
// server/prod-server.js
import express from 'express'
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, resolve } from 'path'
import { renderToString } from '@vue/server-renderer'
const __dirname = dirname(fileURLToPath(import.meta.url))
async function createProdServer() {
const app = express()
// 静态文件服务
app.use('/assets', express.static(resolve(__dirname, '../dist/client/assets'), {
maxAge: '1y',
etag: true,
lastModified: true
}))
// 读取构建后的文件
const template = readFileSync(
resolve(__dirname, '../dist/client/index.html'),
'utf-8'
)
const { render } = await import('../dist/server/entry-server.js')
app.use('*', async (req, res, next) => {
const url = req.originalUrl
try {
const context = { req, res }
const app = await render(url, context)
// 处理重定向
if (context.url) {
return res.redirect(301, context.url)
}
// 处理错误状态码
if (context.statusCode) {
res.status(context.statusCode)
}
const html = await renderToString(app)
const finalHtml = template
.replace('<!--ssr-outlet-->', html)
.replace(
'<!--ssr-state-->',
context.state
? `<script>window.__INITIAL_STATE__=${JSON.stringify(context.state)}</script>`
: ''
)
res.setHeader('Content-Type', 'text/html')
res.setHeader('Cache-Control', 'public, max-age=300') // 5分钟缓存
res.end(finalHtml)
} catch (error) {
console.error('SSR渲染错误:', error)
res.status(500).end('服务器内部错误')
}
})
return app
}
const port = process.env.PORT || 3000
createProdServer().then(app => {
app.listen(port, () => {
console.log(`生产服务器运行在 http://localhost:${port}`)
})
})
中间件配置
基础中间件
// server/middleware/index.js
import compression from 'compression'
import helmet from 'helmet'
import cors from 'cors'
import morgan from 'morgan'
export function setupMiddleware(app) {
// 安全头
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
fontSrc: ["'self'", "https:"],
connectSrc: ["'self'", "https:"],
},
},
}))
// GZIP压缩
app.use(compression())
// CORS
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
credentials: true
}))
// 请求日志
app.use(morgan('combined'))
// 解析JSON
app.use(express.json({ limit: '10mb' }))
app.use(express.urlencoded({ extended: true, limit: '10mb' }))
}
错误处理中间件
// server/middleware/error.js
export function errorHandler(err, req, res, next) {
console.error('服务器错误:', err)
// 开发环境返回详细错误信息
if (process.env.NODE_ENV === 'development') {
res.status(500).json({
error: err.message,
stack: err.stack
})
} else {
// 生产环境返回通用错误信息
res.status(500).json({
error: '服务器内部错误'
})
}
}
export function notFoundHandler(req, res) {
res.status(404).json({
error: '页面未找到',
path: req.originalUrl
})
}
缓存中间件
// server/middleware/cache.js
import NodeCache from 'node-cache'
const cache = new NodeCache({
stdTTL: 300, // 5分钟默认缓存
checkperiod: 60 // 每分钟检查过期
})
export function cacheMiddleware(duration = 300) {
return (req, res, next) => {
// 只缓存GET请求
if (req.method !== 'GET') {
return next()
}
const key = req.originalUrl
const cachedResponse = cache.get(key)
if (cachedResponse) {
console.log('缓存命中:', key)
res.setHeader('X-Cache', 'HIT')
return res.send(cachedResponse)
}
// 重写res.send方法以缓存响应
const originalSend = res.send
res.send = function(body) {
cache.set(key, body, duration)
res.setHeader('X-Cache', 'MISS')
originalSend.call(this, body)
}
next()
}
}
API路由配置
API路由设置
// server/api/index.js
import express from 'express'
import postsRouter from './posts.js'
import usersRouter from './users.js'
import authRouter from './auth.js'
const router = express.Router()
// API路由
router.use('/posts', postsRouter)
router.use('/users', usersRouter)
router.use('/auth', authRouter)
// API健康检查
router.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime()
})
})
export default router
Posts API
// server/api/posts.js
import express from 'express'
import { authenticateToken } from '../middleware/auth.js'
const router = express.Router()
// 模拟数据
let posts = [
{
id: 1,
title: 'Vue SSR入门指南',
content: 'Vue SSR是一个强大的服务端渲染解决方案...',
author: 'Vue开发者',
category: 'Vue.js',
tags: ['Vue', 'SSR', '教程'],
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01')
},
{
id: 2,
title: 'Express服务器配置',
content: 'Express是Node.js最流行的Web框架...',
author: 'Node开发者',
category: 'Node.js',
tags: ['Express', 'Node.js', '服务器'],
createdAt: new Date('2024-01-02'),
updatedAt: new Date('2024-01-02')
}
]
// 获取文章列表
router.get('/', (req, res) => {
const { page = 1, limit = 10, category } = req.query
let filteredPosts = posts
if (category) {
filteredPosts = posts.filter(post => post.category === category)
}
const startIndex = (page - 1) * limit
const endIndex = startIndex + parseInt(limit)
const paginatedPosts = filteredPosts.slice(startIndex, endIndex)
res.json({
posts: paginatedPosts,
page: parseInt(page),
limit: parseInt(limit),
total: filteredPosts.length,
totalPages: Math.ceil(filteredPosts.length / limit)
})
})
// 获取单篇文章
router.get('/:id', (req, res) => {
const post = posts.find(p => p.id === parseInt(req.params.id))
if (!post) {
return res.status(404).json({ error: '文章未找到' })
}
res.json(post)
})
// 创建文章(需要认证)
router.post('/', authenticateToken, (req, res) => {
const { title, content, category, tags } = req.body
if (!title || !content) {
return res.status(400).json({ error: '标题和内容不能为空' })
}
const newPost = {
id: posts.length + 1,
title,
content,
author: req.user.name,
category: category || '未分类',
tags: tags || [],
createdAt: new Date(),
updatedAt: new Date()
}
posts.push(newPost)
res.status(201).json(newPost)
})
// 更新文章(需要认证)
router.put('/:id', authenticateToken, (req, res) => {
const postIndex = posts.findIndex(p => p.id === parseInt(req.params.id))
if (postIndex === -1) {
return res.status(404).json({ error: '文章未找到' })
}
const { title, content, category, tags } = req.body
posts[postIndex] = {
...posts[postIndex],
title: title || posts[postIndex].title,
content: content || posts[postIndex].content,
category: category || posts[postIndex].category,
tags: tags || posts[postIndex].tags,
updatedAt: new Date()
}
res.json(posts[postIndex])
})
// 删除文章(需要认证)
router.delete('/:id', authenticateToken, (req, res) => {
const postIndex = posts.findIndex(p => p.id === parseInt(req.params.id))
if (postIndex === -1) {
return res.status(404).json({ error: '文章未找到' })
}
posts.splice(postIndex, 1)
res.status(204).send()
})
export default router
认证中间件
JWT认证
// server/middleware/auth.js
import jwt from 'jsonwebtoken'
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'
export function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization']
const token = authHeader && authHeader.split(' ')[1]
if (!token) {
return res.status(401).json({ error: '访问令牌缺失' })
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: '无效的访问令牌' })
}
req.user = user
next()
})
}
export function generateToken(user) {
return jwt.sign(
{
id: user.id,
name: user.name,
email: user.email
},
JWT_SECRET,
{ expiresIn: '24h' }
)
}
性能优化
响应压缩
// server/middleware/compression.js
import compression from 'compression'
export const compressionMiddleware = compression({
filter: (req, res) => {
// 不压缩已经压缩的响应
if (req.headers['x-no-compression']) {
return false
}
// 使用compression的默认过滤器
return compression.filter(req, res)
},
level: 6, // 压缩级别 (1-9)
threshold: 1024, // 只压缩大于1KB的响应
// 压缩算法优先级
chunkSize: 1024,
windowBits: 15,
memLevel: 8
})
静态资源优化
// server/middleware/static.js
import express from 'express'
import { resolve } from 'path'
export function setupStaticFiles(app, __dirname) {
// 静态资源配置
app.use('/assets', express.static(
resolve(__dirname, '../dist/client/assets'),
{
maxAge: '1y', // 1年缓存
etag: true,
lastModified: true,
setHeaders: (res, path) => {
// 为不同类型的文件设置不同的缓存策略
if (path.endsWith('.js') || path.endsWith('.css')) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
} else if (path.endsWith('.html')) {
res.setHeader('Cache-Control', 'public, max-age=300')
}
}
}
))
// 图片资源
app.use('/images', express.static(
resolve(__dirname, '../public/images'),
{
maxAge: '30d',
etag: true
}
))
}
监控和日志
请求日志
// server/middleware/logging.js
import morgan from 'morgan'
import fs from 'fs'
import path from 'path'
// 创建日志目录
const logDir = path.join(process.cwd(), 'logs')
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir)
}
// 访问日志流
const accessLogStream = fs.createWriteStream(
path.join(logDir, 'access.log'),
{ flags: 'a' }
)
// 错误日志流
const errorLogStream = fs.createWriteStream(
path.join(logDir, 'error.log'),
{ flags: 'a' }
)
export const accessLogger = morgan('combined', {
stream: accessLogStream
})
export const consoleLogger = morgan('dev')
export function logError(error, req) {
const errorLog = {
timestamp: new Date().toISOString(),
error: error.message,
stack: error.stack,
url: req?.originalUrl,
method: req?.method,
ip: req?.ip,
userAgent: req?.get('User-Agent')
}
errorLogStream.write(JSON.stringify(errorLog) + '\n')
}
下一步
在下一章节中,我们将学习如何优化Vue SSR应用的性能,包括缓存策略、代码分割等技术。