核心优势对比表:
特性 | CSR | SSR |
---|---|---|
首屏时间 | 依赖JS下载执行(慢) | 立即展示HTML(快) |
SEO支持 | 需动态渲染(可能不被抓取) | 完整HTML(SEO友好) |
服务器负载 | 低(纯静态资源) | 高(需实时渲染) |
开发复杂度 | 低 | 高(需同构代码) |
用户体验 | 首次加载白屏 | 快速首屏+渐进增强 |
src/
├── components/ # 通用组件
├── router/ # 路由(需同构)
├── store/ # Vuex store(需同构)
├── App.vue # 根组件
├── app.js # 通用入口
├── entry-client.js # 客户端入口
└── entry-server.js # 服务端入口
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'
export function createApp () {
const router = createRouter()
const store = createStore()
const app = new Vue({
router,
store,
render: h => h(App)
})
return { app, router, store }
}
import { createApp } from './app'
const { app, router, store } = createApp()
// 客户端混合(Hydration)
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
router.onReady(() => {
app.$mount('#app')
})
import { createApp } from './app'
export default context => {
return new Promise((resolve, reject) => {
const { app, router, store } = createApp()
router.push(context.url)
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
if (!matchedComponents.length) {
return reject({ code: 404 })
}
// 执行组件数据预取
Promise.all(matchedComponents.map(Component => {
if (Component.asyncData) {
return Component.asyncData({ store, route: router.currentRoute })
}
})).then(() => {
context.state = store.state
resolve(app)
}).catch(reject)
}, reject)
})
}
const Vue = require('vue')
const renderer = require('vue-server-renderer').createRenderer({
template: require('fs').readFileSync('./index.template.html', 'utf-8')
})
// HTML模板示例
<!-- index.template.html -->
<html>
<head>
<!-- 使用双花括号插值 -->
<title>{{ title }}</title>
</head>
<body>
<!--vue-ssr-outlet-->
</body>
</html>
// 服务端处理逻辑
server.get('*', async (req, res) => {
const context = {
url: req.url,
title: 'SSR Demo'
}
try {
const app = await createApp(context)
const html = await renderer.renderToString(app, context)
res.send(html)
} catch (err) {
if (err.code === 404) {
res.status(404).end('Page not found')
} else {
res.status(500).end('Internal Server Error')
}
}
})
// 组件定义
export default {
name: 'PostList',
asyncData({ store, route }) {
return store.dispatch('fetchPosts', {
page: route.query.page || 1
})
},
computed: {
posts() {
return this.$store.state.posts
}
}
}
// 在服务端渲染完成后
context.state = store.state
// 模板中的状态注入
<script>
window.__INITIAL_STATE__ = {{{ state }}}
</script>
// 注意:需要序列化处理防止XSS
const serialize = require('serialize-javascript')
const state = serialize(context.state, { isJSON: true })
// 客户端初始化
const store = new Vuex.Store({
state: window.__INITIAL_STATE__
})
const app = new Vue({
store,
router,
render: h => h(App)
})
// Hydration核心过程
app.$mount('#app', true) // 第二个参数为hydrating标志
// Vue内部处理逻辑(简化版)
function mountComponent() {
if (vm.$vnode == null) {
// 服务端渲染模式
vm.$el = document.getElementById('app')
vm.$el.__vue__ = vm
return
}
// ...正常挂载逻辑
}
const LRU = require('lru-cache')
const componentCache = new LRU({
max: 1000,
maxAge: 1000 * 60 * 15 // 15分钟
})
// 服务端渲染配置
const renderer = createBundleRenderer(serverBundle, {
cache: {
get: key => componentCache.get(key),
set: (key, val) => componentCache.set(key, val)
}
})
// 组件声明缓存
export default {
name: 'HeavyComponent',
serverCacheKey: props => props.id, // 自定义缓存key
props: ['id']
}
// 服务端流式处理
server.get('*', (req, res) => {
const context = { url: req.url }
renderer.renderToStream(context)
.on('error', err => {
handleError(err)
})
.on('end', () => {
console.log('Render complete')
})
.pipe(res)
})
// 客户端渐进式混合
let hasHydrated = false
function hydrateWhenVisible() {
if (hasHydrated) return
if (elementIsVisible(appContainer)) {
app.$mount()
hasHydrated = true
} else {
requestIdleFrame(hydrateWhenVisible)
}
}
// 全局错误处理组件
Vue.component('ErrorBoundary', {
data: () => ({ error: null }),
errorCaptured(err, vm, info) {
this.error = err
// 服务端错误处理
if (process.server) {
server.handleError(err)
}
return false
},
render(h) {
return this.error ? h('div', 'Error occurred') : this.$slots.default[0]
}
})
// 查看服务端生成的VNode树
const renderer = createBundleRenderer(serverBundle, {
runInNewContext: false,
template,
clientManifest,
inject: false,
shouldPrefetch: () => false,
shouldPreload: (file, type) => {
console.log(`Preload: ${file} (${type})`)
return false
}
})
// 使用VS Code调试配置
{
"type": "node",
"request": "launch",
"name": "Debug SSR",
"runtimeExecutable": "node",
"runtimeArgs": [
"--inspect",
"./server.js"
],
"port": 9229
}
// webpack.server.config.js
module.exports = {
target: 'node',
entry: './src/entry-server.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'server-bundle.js',
libraryTarget: 'commonjs2'
},
externals: nodeExternals(),
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader'
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
}
]
}
}
// webpack.client.config.js
module.exports = {
entry: './src/entry-client.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'client-bundle.js',
publicPath: '/dist/'
},
plugins: [
new VueSSRClientPlugin()
]
}
const express = require('express')
const { createBundleRenderer } = require('vue-server-renderer')
const serverBundle = require('./dist/server-bundle.json')
const clientManifest = require('./dist/client-manifest.json')
const template = require('fs').readFileSync('./index.template.html', 'utf-8')
const renderer = createBundleRenderer(serverBundle, {
runInNewContext: false,
template,
clientManifest
})
const app = express()
app.use('/dist', express.static('dist'))
app.get('*', (req, res) => {
const context = { url: req.url }
renderer.renderToString(context, (err, html) => {
if (err) {
if (err.code === 404) {
res.status(404).end('Not found')
} else {
res.status(500).end('Internal Error')
}
} else {
res.end(html)
}
})
})
app.listen(8080)
最佳实践建议:
vue-server-renderer
的clientManifest
优化资源加载serialize-javascript
防止XSS攻击process.server
标识区分运行环境vue-meta
管理服务端头部信息// 使用vue-meta示例
import VueMeta from 'vue-meta'
Vue.use(VueMeta, {
ssrAppId: 'ssr-app-id',
attribute: 'data-vue-meta',
tagIDKeyName: 'vmid'
})
// 服务端渲染时注入
const meta = app.$meta()
context.meta = meta
// 在模板中使用{{{ meta.inject().title.text() }}}