Vue SSR 服务端渲染
约 967 字大约 3 分钟
VueSSR服务端渲染Nuxt
2024-08-13
概述
服务端渲染 (Server-Side Rendering) 是将 Vue 组件在服务端渲染为 HTML 字符串,然后发送到浏览器。
CSR vs SSR
| 特性 | CSR | SSR |
|---|---|---|
| 首屏加载 | 需下载 JS 后渲染 | 直接渲染 HTML |
| SEO | 需额外处理 | 原生支持 |
| 服务端压力 | 低 | 高 |
| 用户交互 | 等待 JS 加载 | 首屏即可交互 |
| 开发复杂度 | 低 | 高 |
Vue SSR 基础
核心概念
┌─────────────┐ HTTP 请求 ┌─────────────┐
│ 浏览器 │ ◀─── HTML ─────── │ 服务器 │
└─────────────┘ └─────────────┘
│ │
│ ▶ JS Bundle │ ▶ 运行 Vue
│ │ ▶ 渲染 HTML
▼ ▼
┌─────────────┐ ┌─────────────┐
│ 激活客户端 │ ─── 水合 (Hydrate) ─▶│ Vue 组件 │
└─────────────┘ └─────────────┘简单 SSR 实现
// server.js
const Vue = require('vue');
const server = require('express');
const renderer = require('vue-server-renderer').createRenderer();
const app = server();
app.get('*', (req, res) => {
const app = new Vue({
data: {
url: req.url,
},
template: `<div>当前路径: {{ url }}</div>`,
});
renderer.renderToString(app, (err, html) => {
if (err) {
res.status(500).end('Internal Server Error');
return;
}
res.end(`
<!DOCTYPE html>
<html lang="zh-CN">
<head><title>Vue SSR</title></head>
<body>
<div id="app">${html}</div>
<script src="/client.bundle.js"></script>
</body>
</html>
`);
});
});
app.listen(8080);Vue SSR 核心 API
createSSRApp
// entry-server.js
import { createSSRApp } from 'vue';
import App from './App.vue';
export default function() {
const app = createSSRApp(App);
return { app };
}激活 (Hydration)
<!-- client-entry.html -->
<!DOCTYPE html>
<html>
<body>
<div id="app"><!--app-html--></div>
<script src="/client.js"></script>
</body>
</html>// entry-client.js
import { createSSRApp } from 'vue';
import App from './App.vue';
createSSRApp(App).mount('#app');路由与代码分割
路由处理
// router.js
import { createRouter, createWebHistory } from 'vue-router';
export function createApp() {
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: Home },
{ path: '/about', component: About },
],
});
return { app, router };
}// server.js
import { createApp } from './app';
import { renderToString } from 'vue/server-renderer';
app.use(async (req, res) => {
const { app, router } = createApp();
await router.push(req.url);
await router.isReady();
const html = await renderToString(app);
res.end(`<!DOCTYPE html><html><body><div id="app">${html}</div></body></html>`);
});懒加载组件
// 路由懒加载
const routes = [
{ path: '/', component: () => import('./views/Home.vue') },
{ path: '/about', component: () => import('./views/About.vue') },
];数据预取 (Data Fetching)
服务端数据获取
// 方式一:setup 中使用
import { ref, onMounted } from 'vue';
export default {
setup() {
const data = ref(null);
// 服务端和客户端都会执行
onMounted(async () => {
const res = await fetch('/api/data');
data.value = await res.json();
});
return { data };
},
};
// 方式二:useFetch (Vue 3.3+)
import { useFetch } from '@vueuse/core';
export default {
setup() {
const { data } = useFetch('/api/data').json();
return { data };
},
};状态管理预取
// store/index.js
import { createStore } from 'vuex';
export function createStoreInstance() {
return new vuex.Store({
state: {
items: [],
},
mutations: {
setItems(state, items) {
state.items = items;
},
},
actions: {
async fetchItems({ commit }) {
const res = await fetch('/api/items');
commit('setItems', await res.json());
},
},
});
}// server-entry.js
import { createApp } from './app';
import { createStoreInstance } from './store';
export default function(context) {
const store = createStoreInstance();
const app = createApp({ store });
// 服务端预取数据
return new Promise((resolve, reject) => {
store.dispatch('fetchItems').then(() => {
context.state = store.state;
resolve(app);
}).catch(reject);
});
}Nuxt.js
Nuxt.js 是最流行的 Vue SSR 框架,简化了 Vue SSR 的开发体验。
目录结构
├── nuxt.config.ts
├── app.vue
├── pages/
│ ├── index.vue
│ └── about.vue
├── components/
│ └── MyComponent.vue
├── layouts/
│ └── default.vue
├── composables/
├── plugins/
├── server/
│ └── api/
└── public/页面示例
<!-- pages/index.vue -->
<template>
<div>
<h1>{{ title }}</h1>
<ul>
<li v-for="post in posts" :key="post.id">
{{ post.title }}
</li>
</ul>
</div>
</template>
<script setup>
const { data: posts } = await useFetch('/api/posts');
const title = '博客列表';
</script>API 路由
// server/api/posts.get.ts
export default defineEventHandler(async (event) => {
return [
{ id: 1, title: '第一篇文章' },
{ id: 2, title: '第二篇文章' },
];
});Nuxt 配置
// nuxt.config.ts
export default defineNuxtConfig({
ssr: true,
app: {
head: {
title: 'My Nuxt App',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
],
},
},
modules: [
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
],
routeRules: {
'/api/**': { cache: { maxAge: 60 } },
'/about': { prerender: true },
},
});部署
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]性能优化
首屏优化策略
| 策略 | 说明 |
|---|---|
| SSR | 首屏直出 HTML |
| 预渲染 | 静态页面构建时生成 |
| SSG | 静态站点生成 |
| ISR | 增量静态再生成 |
流式渲染
import { renderToNodeStream } from 'vue/server-renderer';
app.get('*', (req, res) => {
const { app, router } = createApp();
router.push(req.url);
res.write('<!DOCTYPE html>');
router.onReady(() => {
const stream = renderToNodeStream(app);
stream.pipe(res, { end: false });
stream.on('end', () => {
res.write('<script src="/client.js"></script>');
res.end();
});
});
});常见问题
1. window/document 未定义
// 检查环境
if (typeof window !== 'undefined') {
// 浏览器环境代码
}2. 水合不匹配
- 确保服务端和客户端渲染结果一致
- 避免在 created 钩子中进行 DOM 操作
- 使用
v-if配合客户端判断
3. 第三方库兼容
// 使用 vite-plugin-node-polyfills
import { nodeBuffer } from 'vite-plugin-node-polyfills';
export default {
vite: {
plugins: [nodeBuffer()],
},
};