2.1 开发环境准备

2.1.1 Node.js环境配置

# 检查当前Node.js版本
node --version
npm --version

# 推荐版本
# Node.js: >= 16.14.0
# npm: >= 8.0.0

# 使用nvm管理Node.js版本(推荐)
# 安装nvm (Windows)
choco install nvm

# 安装nvm (macOS/Linux)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash

# 重启终端后安装Node.js
nvm install 18.17.0
nvm use 18.17.0
nvm alias default 18.17.0

2.1.2 包管理器选择

# 方案一:使用npm(默认)
npm config set registry https://registry.npmmirror.com
npm install -g npm@latest

# 方案二:使用yarn(推荐)
npm install -g yarn
yarn config set registry https://registry.npmmirror.com

# 方案三:使用pnpm(性能最佳)
npm install -g pnpm
pnpm config set registry https://registry.npmmirror.com

# 验证安装
yarn --version
pnpm --version

2.1.3 开发工具配置

// .vscode/settings.json
{
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  },
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "vetur.validation.template": false,
  "vetur.validation.script": false,
  "vetur.validation.style": false,
  "typescript.preferences.importModuleSpecifier": "relative",
  "vue.codeActions.enabled": false
}
// .vscode/extensions.json
{
  "recommendations": [
    "Vue.volar",
    "Vue.vscode-typescript-vue-plugin",
    "bradlc.vscode-tailwindcss",
    "esbenp.prettier-vscode",
    "dbaeumer.vscode-eslint",
    "formulahendry.auto-rename-tag",
    "christian-kohler.path-intellisense"
  ]
}

2.2 项目初始化方式

2.2.1 使用Vue CLI创建项目

# 安装Vue CLI
npm install -g @vue/cli

# 创建项目
vue create vue-ssr-demo

# 选择配置
? Please pick a preset: Manually select features
? Check the features needed for your project:
  ◉ Choose Vue version
  ◉ Babel
  ◉ TypeScript
  ◉ Router
  ◉ Vuex
  ◉ CSS Pre-processors
  ◉ Linter / Formatter
  ◉ Unit Testing

? Choose a version of Vue.js: 3.x
? Use class-style component syntax? No
? Use Babel alongside TypeScript? Yes
? Use history mode for router? Yes
? Pick a CSS pre-processor: Sass/SCSS (with dart-sass)
? Pick a linter / formatter config: ESLint + Prettier
? Pick additional lint features: Lint on save
? Pick a unit testing solution: Jest
? Where do you prefer placing config? In dedicated config files

2.2.2 使用Vite创建项目

# 使用Vite创建Vue项目
npm create vue@latest vue-ssr-vite

# 进入项目目录
cd vue-ssr-vite

# 安装依赖
npm install

# 启动开发服务器
npm run dev

2.2.3 手动创建项目结构

# 创建项目目录
mkdir vue-ssr-manual
cd vue-ssr-manual

# 初始化package.json
npm init -y

# 创建基本目录结构
mkdir src
mkdir src/components
mkdir src/views
mkdir src/router
mkdir src/store
mkdir build
mkdir public
mkdir server

2.3 项目结构设计

2.3.1 标准项目结构

vue-ssr-project/
├── build/                    # 构建配置
│   ├── webpack.base.js       # 基础webpack配置
│   ├── webpack.client.js     # 客户端webpack配置
│   ├── webpack.server.js     # 服务端webpack配置
│   └── setup-dev-server.js   # 开发服务器配置
├── public/                   # 静态资源
│   ├── index.html           # HTML模板
│   └── favicon.ico          # 网站图标
├── server/                   # 服务端代码
│   ├── index.js             # 服务器入口
│   └── render.js            # 渲染逻辑
├── src/                     # 源代码
│   ├── components/          # 组件
│   ├── views/              # 页面组件
│   ├── router/             # 路由配置
│   ├── store/              # 状态管理
│   ├── utils/              # 工具函数
│   ├── app.js              # 应用入口
│   ├── entry-client.js     # 客户端入口
│   ├── entry-server.js     # 服务端入口
│   └── main.js             # 主入口文件
├── dist/                   # 构建输出
│   ├── client/             # 客户端构建文件
│   └── server/             # 服务端构建文件
├── .env                    # 环境变量
├── .gitignore             # Git忽略文件
├── package.json           # 项目配置
└── README.md              # 项目说明

2.3.2 核心文件创建

package.json配置

{
  "name": "vue-ssr-demo",
  "version": "1.0.0",
  "description": "Vue SSR Demo Project",
  "scripts": {
    "dev": "node server/index.js",
    "build": "npm run build:client && npm run build:server",
    "build:client": "webpack --config build/webpack.client.js",
    "build:server": "webpack --config build/webpack.server.js",
    "start": "cross-env NODE_ENV=production node server/index.js",
    "lint": "eslint --ext .js,.vue src/",
    "lint:fix": "eslint --ext .js,.vue src/ --fix"
  },
  "dependencies": {
    "vue": "^3.3.4",
    "vue-router": "^4.2.4",
    "vuex": "^4.1.0",
    "express": "^4.18.2",
    "vue-server-renderer": "^3.3.4",
    "serialize-javascript": "^6.0.1"
  },
  "devDependencies": {
    "@babel/core": "^7.22.9",
    "@babel/preset-env": "^7.22.9",
    "babel-loader": "^9.1.3",
    "css-loader": "^6.8.1",
    "vue-loader": "^17.2.2",
    "webpack": "^5.88.2",
    "webpack-cli": "^5.1.4",
    "webpack-dev-middleware": "^6.1.1",
    "webpack-hot-middleware": "^2.25.4",
    "webpack-merge": "^5.9.0",
    "webpack-node-externals": "^3.0.0",
    "cross-env": "^7.0.3",
    "eslint": "^8.45.0",
    "eslint-plugin-vue": "^9.15.1",
    "prettier": "^3.0.0"
  }
}

应用入口文件

// src/app.js
import { createSSRApp } from 'vue'
import { createRouter } from './router'
import { createStore } from './store'
import App from './App.vue'

// 导出工厂函数,避免状态污染
export function createApp() {
  const app = createSSRApp(App)
  const router = createRouter()
  const store = createStore()
  
  app.use(router)
  app.use(store)
  
  return { app, router, store }
}

客户端入口

// src/entry-client.js
import { createApp } from './app'

const { app, router, store } = createApp()

// 等待路由准备就绪
router.isReady().then(() => {
  // 恢复服务端状态
  if (window.__INITIAL_STATE__) {
    store.replaceState(window.__INITIAL_STATE__)
  }
  
  // 挂载应用
  app.mount('#app')
})

服务端入口

// src/entry-server.js
import { createApp } from './app'

export default async function(context) {
  const { app, router, store } = createApp()
  
  // 设置路由
  await router.push(context.url)
  await router.isReady()
  
  // 预取数据
  const matchedComponents = router.currentRoute.value.matched
    .flatMap(record => Object.values(record.components || {}))
  
  await Promise.all(
    matchedComponents.map(component => {
      if (component.asyncData) {
        return component.asyncData({
          store,
          route: router.currentRoute.value
        })
      }
    })
  )
  
  // 返回应用实例和状态
  context.state = store.state
  return app
}

2.4 Webpack配置

2.4.1 基础配置

// build/webpack.base.js
const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')

module.exports = {
  mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
  output: {
    path: path.resolve(__dirname, '../dist'),
    publicPath: '/dist/',
    filename: '[name].[contenthash].js'
  },
  resolve: {
    extensions: ['.js', '.vue', '.json'],
    alias: {
      '@': path.resolve(__dirname, '../src')
    }
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader']
      },
      {
        test: /\.(png|jpg|gif|svg)$/,
        type: 'asset/resource',
        generator: {
          filename: 'images/[name].[contenthash][ext]'
        }
      }
    ]
  },
  plugins: [
    new VueLoaderPlugin()
  ]
}

2.4.2 客户端配置

// build/webpack.client.js
const { merge } = require('webpack-merge')
const baseConfig = require('./webpack.base')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

module.exports = merge(baseConfig, {
  entry: './src/entry-client.js',
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          name: 'vendor',
          test: /[\\/]node_modules[\\/]/,
          priority: 10,
          chunks: 'initial'
        }
      }
    }
  },
  plugins: [
    new VueSSRClientPlugin()
  ]
})

2.4.3 服务端配置

// build/webpack.server.js
const { merge } = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = merge(baseConfig, {
  entry: './src/entry-server.js',
  target: 'node',
  devtool: 'source-map',
  output: {
    libraryTarget: 'commonjs2'
  },
  externals: nodeExternals({
    allowlist: /\.(css|vue)$/
  }),
  plugins: [
    new VueSSRServerPlugin()
  ]
})

2.5 开发服务器配置

2.5.1 Express服务器

// server/index.js
const express = require('express')
const { createBundleRenderer } = require('vue-server-renderer')
const setupDevServer = require('../build/setup-dev-server')

const app = express()
const isProd = process.env.NODE_ENV === 'production'

let renderer
let readyPromise

if (isProd) {
  // 生产环境
  const template = require('fs').readFileSync(
    require('path').resolve(__dirname, '../public/index.html'),
    'utf-8'
  )
  const serverBundle = require('../dist/vue-ssr-server-bundle.json')
  const clientManifest = require('../dist/vue-ssr-client-manifest.json')
  
  renderer = createBundleRenderer(serverBundle, {
    template,
    clientManifest,
    runInNewContext: false
  })
} else {
  // 开发环境
  readyPromise = setupDevServer(app, (bundle, options) => {
    renderer = createBundleRenderer(bundle, options)
  })
}

// 静态资源服务
app.use('/dist', express.static('./dist'))
app.use('/public', express.static('./public'))

// 渲染函数
function render(req, res) {
  const context = {
    url: req.url,
    title: 'Vue SSR Demo'
  }
  
  renderer.renderToString(context, (err, html) => {
    if (err) {
      console.error(err)
      if (err.code === 404) {
        res.status(404).end('Page not found')
      } else {
        res.status(500).end('Internal Server Error')
      }
      return
    }
    
    res.end(html)
  })
}

// 路由处理
app.get('*', isProd ? render : (req, res) => {
  readyPromise.then(() => render(req, res))
})

const port = process.env.PORT || 3000
app.listen(port, () => {
  console.log(`Server started at http://localhost:${port}`)
})

2.5.2 开发服务器设置

// build/setup-dev-server.js
const fs = require('fs')
const path = require('path')
const webpack = require('webpack')
const webpackDevMiddleware = require('webpack-dev-middleware')
const webpackHotMiddleware = require('webpack-hot-middleware')

const clientConfig = require('./webpack.client')
const serverConfig = require('./webpack.server')

module.exports = function setupDevServer(app, callback) {
  let bundle
  let template
  let clientManifest
  
  let ready
  const readyPromise = new Promise(resolve => {
    ready = resolve
  })
  
  const update = () => {
    if (bundle && clientManifest) {
      ready()
      callback(bundle, {
        template,
        clientManifest,
        runInNewContext: false
      })
    }
  }
  
  // 读取模板
  template = fs.readFileSync(
    path.resolve(__dirname, '../public/index.html'),
    'utf-8'
  )
  
  // 客户端webpack配置
  clientConfig.entry = ['webpack-hot-middleware/client', clientConfig.entry]
  clientConfig.plugins.push(
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoEmitOnErrorsPlugin()
  )
  
  const clientCompiler = webpack(clientConfig)
  const devMiddleware = webpackDevMiddleware(clientCompiler, {
    publicPath: clientConfig.output.publicPath,
    stats: 'minimal'
  })
  
  app.use(devMiddleware)
  app.use(webpackHotMiddleware(clientCompiler, { heartbeat: 5000 }))
  
  // 监听客户端manifest
  clientCompiler.hooks.done.tap('dev-server', stats => {
    const fs = devMiddleware.context.outputFileSystem
    const readFile = file => fs.readFileSync(
      path.join(clientConfig.output.path, file),
      'utf-8'
    )
    
    clientManifest = JSON.parse(readFile('vue-ssr-client-manifest.json'))
    update()
  })
  
  // 服务端webpack配置
  const serverCompiler = webpack(serverConfig)
  serverCompiler.watch({}, (err, stats) => {
    if (err) throw err
    
    if (stats.hasErrors()) {
      console.error(stats.toString())
      return
    }
    
    bundle = JSON.parse(
      fs.readFileSync(
        path.join(serverConfig.output.path, 'vue-ssr-server-bundle.json'),
        'utf-8'
      )
    )
    update()
  })
  
  return readyPromise
}

2.6 HTML模板配置

2.6.1 基础模板

<!-- public/index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ title }}</title>
  {{{ renderResourceHints() }}}
  {{{ renderStyles() }}}
</head>
<body>
  <!--vue-ssr-outlet-->
  {{{ renderState() }}}
  {{{ renderScripts() }}}
</body>
</html>

2.6.2 模板变量说明

// 模板变量解释
const templateVariables = {
  '{{ title }}': '页面标题,从context传入',
  '{{{ renderResourceHints() }}}': '资源预加载提示',
  '{{{ renderStyles() }}}': '内联CSS样式',
  '<!--vue-ssr-outlet-->': 'Vue应用挂载点',
  '{{{ renderState() }}}': '初始状态注入',
  '{{{ renderScripts() }}}': '客户端JavaScript'
}

2.7 环境变量配置

2.7.1 环境变量文件

# .env
NODE_ENV=development
PORT=3000
API_BASE_URL=http://localhost:8080/api
REDIS_URL=redis://localhost:6379
SESSION_SECRET=your-session-secret

# .env.production
NODE_ENV=production
PORT=80
API_BASE_URL=https://api.example.com
REDIS_URL=redis://redis-server:6379
SESSION_SECRET=production-secret

2.7.2 环境变量使用

// src/config/index.js
const config = {
  development: {
    apiBaseUrl: process.env.API_BASE_URL || 'http://localhost:8080/api',
    port: process.env.PORT || 3000,
    redis: {
      url: process.env.REDIS_URL || 'redis://localhost:6379'
    }
  },
  production: {
    apiBaseUrl: process.env.API_BASE_URL,
    port: process.env.PORT || 80,
    redis: {
      url: process.env.REDIS_URL
    }
  }
}

export default config[process.env.NODE_ENV || 'development']

2.8 代码规范配置

2.8.1 ESLint配置

// .eslintrc.js
module.exports = {
  root: true,
  env: {
    node: true,
    browser: true,
    es2021: true
  },
  extends: [
    'eslint:recommended',
    '@vue/eslint-config-prettier',
    'plugin:vue/vue3-recommended'
  ],
  parserOptions: {
    ecmaVersion: 2021,
    sourceType: 'module'
  },
  rules: {
    'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
    'vue/multi-word-component-names': 'off',
    'vue/no-v-html': 'off'
  }
}

2.8.2 Prettier配置

// .prettierrc.js
module.exports = {
  semi: false,
  singleQuote: true,
  trailingComma: 'es5',
  tabWidth: 2,
  useTabs: false,
  printWidth: 80,
  bracketSpacing: true,
  arrowParens: 'avoid',
  endOfLine: 'lf'
}

2.8.3 Git配置

# .gitignore
node_modules/
dist/
.env.local
.env.*.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
.vscode/
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

2.9 项目启动与测试

2.9.1 安装依赖

# 进入项目目录
cd vue-ssr-demo

# 安装依赖
npm install
# 或
yarn install
# 或
pnpm install

2.9.2 启动开发服务器

# 启动开发服务器
npm run dev

# 访问应用
# 浏览器打开 http://localhost:3000

2.9.3 构建生产版本

# 构建生产版本
npm run build

# 启动生产服务器
npm start

2.9.4 验证SSR功能

# 使用curl验证服务端渲染
curl http://localhost:3000

# 检查返回的HTML是否包含完整内容
# 而不是空的<div id="app"></div>

2.10 常见问题解决

2.10.1 依赖安装问题

# 清除缓存
npm cache clean --force
yarn cache clean
pnpm store prune

# 删除node_modules重新安装
rm -rf node_modules
npm install

# 使用淘宝镜像
npm config set registry https://registry.npmmirror.com

2.10.2 端口占用问题

# 查看端口占用
netstat -ano | findstr :3000  # Windows
lsof -i :3000                 # macOS/Linux

# 杀死进程
taskkill /PID <PID> /F        # Windows
kill -9 <PID>                 # macOS/Linux

# 或者修改端口
set PORT=3001 && npm run dev  # Windows
PORT=3001 npm run dev         # macOS/Linux

2.10.3 热重载不工作

// 检查webpack配置
// 确保HMR插件正确配置
const webpack = require('webpack')

module.exports = {
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ]
}

// 检查客户端入口是否包含HMR代码
if (module.hot) {
  module.hot.accept()
}

2.11 本章小结

2.11.1 核心要点

  1. 环境准备:Node.js >= 16、包管理器选择、开发工具配置
  2. 项目结构:清晰的目录组织、分离客户端和服务端代码
  3. 构建配置:Webpack基础、客户端、服务端三套配置
  4. 开发服务器:Express服务器、热重载、开发中间件
  5. 代码规范:ESLint、Prettier、Git配置

2.11.2 最佳实践

  • 使用工厂函数避免状态污染
  • 合理配置Webpack优化构建性能
  • 设置完善的开发工具链
  • 建立统一的代码规范

2.11.3 下章预告

下一章我们将学习Vue SSR的核心概念和基本实现: - 同构应用原理 - 客户端激活过程 - 状态管理 - 路由配置


练习作业:

  1. 按照本章步骤搭建一个完整的Vue SSR项目
  2. 修改HTML模板,添加自定义meta标签
  3. 配置不同的环境变量,测试开发和生产环境
  4. 尝试修改Webpack配置,添加CSS预处理器支持

下一章: Vue SSR核心概念与实现