Element Plus SSR 基础概念与环境搭建
学习目标
- 理解 SSR(服务端渲染)的基本概念和优势
- 掌握 Element Plus 在 SSR 环境下的配置方法
- 学会搭建支持 Element Plus 的 SSR 开发环境
- 了解 SSR 与 CSR 的区别和适用场景
知识点概览
1. SSR 基础概念
1.1 什么是 SSR
服务端渲染(Server-Side Rendering,SSR) 是指在服务器端将 Vue 组件渲染为 HTML 字符串,然后将其发送到浏览器,最后在客户端进行"激活"的过程。
typescript
// SSR 渲染流程示意
// 1. 服务器接收请求
// 2. 服务器执行 Vue 应用
// 3. 将组件渲染为 HTML 字符串
// 4. 发送完整的 HTML 到客户端
// 5. 客户端激活(hydration)
// 传统 CSR 流程
// 1. 服务器发送空白 HTML + JS 包
// 2. 客户端下载并执行 JS
// 3. 客户端渲染页面内容
1.2 SSR 的优势
- 更好的 SEO:搜索引擎可以直接抓取到完整的 HTML 内容
- 更快的首屏加载:用户可以更快看到页面内容
- 更好的性能指标:FCP(First Contentful Paint)和 LCP(Largest Contentful Paint)更优
- 更好的社交媒体分享:Open Graph 和 Twitter Cards 可以正确显示
1.3 SSR 的挑战
- 开发复杂度增加:需要考虑服务端和客户端的差异
- 服务器负载增加:每个请求都需要服务器渲染
- 部署复杂度:需要 Node.js 服务器环境
- 调试困难:需要同时调试服务端和客户端代码
2. Element Plus SSR 支持
2.1 Element Plus SSR 兼容性
Element Plus 从设计之初就考虑了 SSR 的兼容性:
typescript
// Element Plus SSR 特性
// ✅ 支持服务端渲染
// ✅ 支持客户端激活
// ✅ 支持按需导入
// ✅ 支持主题定制
// ✅ 支持国际化
// ⚠️ 需要特殊配置的功能:
// - 客户端特定的 API(如 window、document)
// - 异步组件加载
// - 动态主题切换
2.2 SSR 环境下的注意事项
typescript
// 1. 避免在服务端使用浏览器特定的 API
if (typeof window !== 'undefined') {
// 客户端特定代码
window.localStorage.setItem('theme', 'dark')
}
// 2. 使用 onMounted 确保代码只在客户端执行
import { onMounted } from 'vue'
onMounted(() => {
// 这里的代码只在客户端执行
console.log('客户端激活完成')
})
// 3. 条件性渲染客户端组件
const isClient = ref(false)
onMounted(() => {
isClient.value = true
})
3. Vite + Vue 3 + Element Plus SSR 环境搭建
3.1 项目初始化
bash
# 创建新项目
npm create vue@latest element-plus-ssr
cd element-plus-ssr
# 安装依赖
npm install
# 安装 Element Plus
npm install element-plus
# 安装 SSR 相关依赖
npm install express compression serve-static
npm install -D @types/express @types/compression
3.2 项目结构设计
element-plus-ssr/
├── src/
│ ├── components/ # 组件目录
│ ├── views/ # 页面组件
│ ├── router/ # 路由配置
│ ├── store/ # 状态管理
│ ├── styles/ # 样式文件
│ ├── entry-client.ts # 客户端入口
│ ├── entry-server.ts # 服务端入口
│ ├── main.ts # 通用应用创建
│ └── App.vue # 根组件
├── server.js # 开发服务器
├── prerender.js # 预渲染脚本
├── index.html # HTML 模板
├── vite.config.ts # Vite 配置
└── package.json
3.3 应用入口配置
typescript
// src/main.ts - 通用应用创建函数
import { createSSRApp } from 'vue'
import { createRouter, createWebHistory, createMemoryHistory } from 'vue-router'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import { routes } from './router'
export function createApp() {
const app = createSSRApp(App)
// 创建路由器
const router = createRouter({
history: import.meta.env.SSR
? createMemoryHistory()
: createWebHistory(),
routes
})
// 创建状态管理
const pinia = createPinia()
// 安装插件
app.use(router)
app.use(pinia)
app.use(ElementPlus)
return { app, router, pinia }
}
typescript
// src/entry-client.ts - 客户端入口
import { createApp } from './main'
const { app, router } = createApp()
// 等待路由器准备就绪
router.isReady().then(() => {
// 激活应用
app.mount('#app')
})
typescript
// src/entry-server.ts - 服务端入口
import { renderToString } from 'vue/server-renderer'
import { createApp } from './main'
export async function render(url: string) {
const { app, router } = createApp()
// 设置服务端路由位置
await router.push(url)
await router.isReady()
// 渲染应用为字符串
const html = await renderToString(app)
return html
}
3.4 Vite 配置
typescript
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
// SSR 配置
ssr: {
// 需要外部化的依赖
external: ['element-plus']
},
// 构建配置
build: {
rollupOptions: {
input: {
client: resolve(__dirname, 'src/entry-client.ts'),
server: resolve(__dirname, 'src/entry-server.ts')
},
output: {
entryFileNames: '[name].js'
}
}
},
// 开发服务器配置
server: {
middlewareMode: true
}
})
3.5 开发服务器配置
javascript
// server.js - 开发服务器
import fs from 'fs'
import path from 'path'
import express from 'express'
import compression from 'compression'
import { createServer as createViteServer } from 'vite'
const isProduction = process.env.NODE_ENV === 'production'
const port = process.env.PORT || 3000
async function createServer() {
const app = express()
// 添加 gzip 压缩
app.use(compression())
let vite
if (!isProduction) {
// 开发模式:创建 Vite 服务器
vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom'
})
app.use(vite.ssrLoadModule)
} else {
// 生产模式:使用构建后的文件
app.use('/assets', express.static(path.resolve('dist/client/assets')))
}
// 处理所有路由
app.use('*', async (req, res, next) => {
const url = req.originalUrl
try {
let template
let render
if (!isProduction) {
// 开发模式:读取模板并转换
template = fs.readFileSync(
path.resolve('index.html'),
'utf-8'
)
template = await vite.transformIndexHtml(url, template)
// 加载服务端入口
render = (await vite.ssrLoadModule('/src/entry-server.ts')).render
} else {
// 生产模式:使用构建后的文件
template = fs.readFileSync(
path.resolve('dist/client/index.html'),
'utf-8'
)
render = (await import('./dist/server/entry-server.js')).render
}
// 渲染应用
const appHtml = await render(url)
// 注入渲染后的应用 HTML
const html = template.replace('<!--ssr-outlet-->', appHtml)
res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
} catch (e) {
// 错误处理
if (!isProduction) {
vite.ssrFixStacktrace(e)
}
console.error(e)
res.status(500).end(e.message)
}
})
return { app, vite }
}
createServer().then(({ app }) => {
app.listen(port, () => {
console.log(`服务器运行在 http://localhost:${port}`)
})
})
3.6 HTML 模板
html
<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Element Plus SSR</title>
<meta name="description" content="Element Plus SSR 示例应用">
</head>
<body>
<div id="app"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client.ts"></script>
</body>
</html>
4. Element Plus 组件 SSR 示例
4.1 基础组件使用
vue
<!-- src/views/Home.vue -->
<template>
<div class="home">
<el-card class="welcome-card">
<template #header>
<div class="card-header">
<span>欢迎使用 Element Plus SSR</span>
<el-button type="primary" @click="handleClick">
点击测试
</el-button>
</div>
</template>
<el-row :gutter="20">
<el-col :span="12">
<el-form :model="form" label-width="80px">
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm">
提交
</el-button>
</el-form-item>
</el-form>
</el-col>
<el-col :span="12">
<el-table :data="tableData" style="width: 100%">
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="age" label="年龄" width="80" />
<el-table-column prop="address" label="地址" />
</el-table>
</el-col>
</el-row>
<!-- 客户端特定组件 -->
<div v-if="isClient" class="client-only">
<el-date-picker
v-model="selectedDate"
type="date"
placeholder="选择日期"
/>
</div>
</el-card>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
// 表单数据
const form = reactive({
username: '',
email: ''
})
// 表格数据
const tableData = ref([
{ name: '张三', age: 25, address: '北京市朝阳区' },
{ name: '李四', age: 30, address: '上海市浦东新区' },
{ name: '王五', age: 28, address: '广州市天河区' }
])
// 客户端状态
const isClient = ref(false)
const selectedDate = ref('')
// 客户端激活后设置状态
onMounted(() => {
isClient.value = true
})
const handleClick = () => {
if (typeof window !== 'undefined') {
ElMessage.success('按钮点击成功!')
}
}
const submitForm = () => {
console.log('提交表单:', form)
if (typeof window !== 'undefined') {
ElMessage.success('表单提交成功!')
}
}
</script>
<style scoped>
.home {
padding: 20px;
}
.welcome-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.client-only {
margin-top: 20px;
padding: 20px;
border: 1px dashed #dcdfe6;
border-radius: 4px;
}
</style>
4.2 路由配置
typescript
// src/router/index.ts
import type { RouteRecordRaw } from 'vue-router'
export const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('../views/Home.vue'),
meta: {
title: '首页',
description: 'Element Plus SSR 首页示例'
}
},
{
path: '/about',
name: 'About',
component: () => import('../views/About.vue'),
meta: {
title: '关于',
description: '关于 Element Plus SSR'
}
},
{
path: '/components',
name: 'Components',
component: () => import('../views/Components.vue'),
meta: {
title: '组件展示',
description: 'Element Plus 组件展示页面'
}
}
]
5. 构建和部署
5.1 构建脚本
json
{
"scripts": {
"dev": "node server.js",
"build": "npm run build:client && npm run build:server",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.ts --outDir dist/server",
"preview": "cross-env NODE_ENV=production node server.js",
"serve": "node server.js"
}
}
5.2 生产环境优化
javascript
// 生产环境服务器优化
if (isProduction) {
// 启用静态文件缓存
app.use('/assets', express.static(
path.resolve('dist/client/assets'),
{ maxAge: '1y' }
))
// 启用 HTML 缓存(可选)
const htmlCache = new Map()
app.use('*', async (req, res, next) => {
const url = req.originalUrl
// 检查缓存
if (htmlCache.has(url)) {
return res.status(200)
.set({ 'Content-Type': 'text/html' })
.end(htmlCache.get(url))
}
// 渲染并缓存
try {
const html = await renderPage(url)
htmlCache.set(url, html)
res.status(200)
.set({ 'Content-Type': 'text/html' })
.end(html)
} catch (error) {
next(error)
}
})
}
6. 实践练习
搭建基础 SSR 环境:
- 按照教程搭建完整的 SSR 开发环境
- 确保开发和生产模式都能正常运行
Element Plus 组件测试:
- 测试各种 Element Plus 组件在 SSR 环境下的表现
- 识别和解决客户端特定功能的问题
性能优化:
- 实现页面缓存机制
- 优化首屏加载时间
- 测量和比较 SSR 与 CSR 的性能差异
7. 学习资源
8. 作业
- 完成 Element Plus SSR 环境的搭建
- 创建一个包含多个页面的 SSR 应用
- 实现基本的 SEO 优化(meta 标签、结构化数据)
- 对比测试 SSR 和 CSR 的首屏加载性能
总结
通过第52天的学习,我们掌握了:
- SSR 基础概念:理解了服务端渲染的原理和优势
- 环境搭建:学会了搭建支持 Element Plus 的 SSR 开发环境
- 配置优化:掌握了 Vite 和 Express 的 SSR 配置方法
- 组件适配:了解了 Element Plus 组件在 SSR 环境下的注意事项
这些知识为我们后续深入学习 SSR 框架(如 Nuxt.js)和生产环境部署奠定了坚实的基础。