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应用的性能,包括缓存策略、代码分割等技术。