一、为什么需要服务端渲染数据预取
想象一下你打开一个网页,白屏转圈好几秒才看到内容,这种体验肯定让人抓狂。服务端渲染(SSR)就是为了解决这个问题而生的,它让服务器先把页面内容准备好,用户一打开就能看到完整页面。但光有SSR还不够,数据预取才是真正让首屏快起来的关键。
传统客户端渲染就像点外卖:浏览器拿到空碗(HTML模板),再叫外卖(API请求),最后才能吃上饭(渲染数据)。而数据预取相当于让服务器提前把外卖准备好,用户打开页面直接开吃。
技术栈说明:本文所有示例基于Vue 3 + Vue Server Renderer + Express
// 基础SSR示例 - server.js
const express = require('express')
const { createSSRApp } = require('vue')
const { renderToString } = require('@vue/server-renderer')
const app = express()
app.get('*', async (req, res) => {
const vueApp = createSSRApp({
data() {
return { message: 'Hello SSR!' }
},
template: `<div>{{ message }}</div>`
})
const html = await renderToString(vueApp)
res.send(`<!DOCTYPE html><html><body>${html}</body></html>`)
})
app.listen(3000)
二、数据预取的三种实现方式
2.1 路由级别预取
最常用的方式,在路由跳转前就把数据准备好。Vue Router的beforeResolve钩子是绝佳的切入点。
// 路由预取示例 - router.js
router.beforeResolve(async (to, from, next) => {
// 检查目标路由是否需要预取
if (to.matched.some(record => record.meta.shouldPrefetch)) {
try {
// 并行发起所有需要的请求
await Promise.all(
to.matched.map(record => {
return record.meta.prefetch
? record.meta.prefetch({ store, route: to })
: Promise.resolve()
})
)
} catch (error) {
console.error('预取失败:', error)
}
}
next()
})
// 路由配置示例
{
path: '/product/:id',
component: ProductPage,
meta: {
shouldPrefetch: true,
prefetch: async ({ store, route }) => {
await store.dispatch('fetchProduct', route.params.id)
await store.dispatch('fetchRelatedProducts', route.params.id)
}
}
}
2.2 组件级别预取
Vue的serverPrefetch钩子让组件自己声明需要的数据:
// 组件内预取示例 - ProductComponent.vue
export default {
name: 'ProductPage',
async serverPrefetch() {
// 这里返回Promise会被SSR自动等待
return this.fetchProductData()
},
methods: {
async fetchProductData() {
const [detail, reviews] = await Promise.all([
this.$store.dispatch('fetchProduct', this.$route.params.id),
this.$store.dispatch('fetchReviews', this.$route.params.id)
])
return { detail, reviews }
}
}
}
2.3 混合预取策略
实际项目中,我们常常需要组合使用多种策略:
// 混合预取示例 - 结合store和组件
// store.js
export default {
state: () => ({
userInfo: null,
products: []
}),
actions: {
async fetchUserData({ commit }) {
const data = await api.get('/user')
commit('SET_USER', data)
},
async prefetchGlobalData() {
await Promise.all([
this.dispatch('fetchUserData'),
this.dispatch('fetchAnnouncements')
])
}
}
}
// 然后在入口文件中
if (context.isServer) {
await store.dispatch('prefetchGlobalData')
}
三、关键技术与性能优化
3.1 状态序列化与脱水
服务端获取的数据需要安全地传递到客户端:
// 序列化示例 - server.js
const serialize = require('serialize-javascript')
app.get('*', async (req, res) => {
const store = createStore()
await store.dispatch('prefetchAll')
const appHtml = await renderToString(app)
const stateScript = `<script>window.__INITIAL_STATE__=${serialize(store.state)}</script>`
res.send(`
<!DOCTYPE html>
<html>
<head><title>SSR App</title></head>
<body>
<div id="app">${appHtml}</div>
${stateScript}
</body>
</html>
`)
})
// 客户端恢复状态 - client.js
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
3.2 缓存策略
聪明的缓存能大幅提升性能:
// API缓存示例 - serverCache.js
const LRU = require('lru-cache')
const apiCache = new LRU({
max: 500, // 最大缓存数量
maxAge: 1000 * 60 * 5 // 5分钟有效期
})
async function fetchWithCache(url) {
const cached = apiCache.get(url)
if (cached) return Promise.resolve(cached)
const freshData = await api.get(url)
apiCache.set(url, freshData)
return freshData
}
3.3 代码分割与异步组件
即使SSR也要注意代码体积:
// 异步组件示例 - router.js
const ProductPage = () => ({
component: import('./views/ProductPage.vue'),
loading: LoadingComponent,
delay: 200
})
// 服务端处理 - 需要特殊配置
module.exports = {
configureWebpack: {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
}
}
}
}
}
}
四、实战中的坑与解决方案
4.1 内存泄漏问题
SSR服务长时间运行容易内存泄漏:
// 内存管理示例 - server.js
const vm = require('vm')
const sandbox = {
app: null,
store: null,
url: req.url
}
// 使用沙箱环境执行
const context = new vm.createContext(sandbox)
const script = new vm.Script(`
app = createSSRApp()
store = createStore()
`)
script.runInContext(context)
// 每次请求结束后清理引用
sandbox.app = null
sandbox.store = null
4.2 异步操作处理
SSR中所有异步必须完成才能渲染:
// 异步处理工具函数
async function renderWithTimeout(app, timeout = 5000) {
let timer
const timeoutPromise = new Promise((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`渲染超时: ${timeout}ms`))
}, timeout)
})
try {
return await Promise.race([
renderToString(app),
timeoutPromise
])
} finally {
clearTimeout(timer)
}
}
4.3 第三方库兼容性
不是所有库都支持SSR:
// 条件性引入示例 - plugin.js
if (process.server) {
// 服务端专用mock
module.exports = require('./server-mock')
} else {
// 客户端真实实现
module.exports = require('./real-implementation')
}
// 或者在组件中
export default {
mounted() {
// 只在客户端执行的代码
if (process.client) {
import('client-only-library').then(module => {
module.init()
})
}
}
}
五、应用场景与选型建议
适合SSR数据预取的场景:
- 内容展示型网站(新闻、博客)
- SEO要求高的页面
- 首屏性能要求严苛的应用
不适合的场景:
- 高度交互的管理后台
- 对服务器成本敏感的项目
- 纯静态内容网站
六、完整项目示例
最后来看一个完整的实现:
// 完整SSR服务示例 - server.js
const express = require('express')
const { createSSRApp } = require('vue')
const { renderToString } = require('@vue/server-renderer')
const serialize = require('serialize-javascript')
const createStore = require('./store')
const app = express()
app.get('*', async (req, res) => {
const store = createStore()
const router = createRouter()
const vueApp = createSSRApp({
store,
router,
render: h => h(App)
})
// 设置服务器端router位置
router.push(req.url)
try {
// 等待路由和组件预取完成
await new Promise((resolve, reject) => {
router.onReady(resolve, reject)
})
// 等待组件级预取
await Promise.all(
router.getMatchedComponents().map(Component => {
return Component.serverPrefetch
? Component.serverPrefetch({ store, route: router.currentRoute })
: Promise.resolve()
})
)
const appHtml = await renderToString(vueApp)
const state = serialize(store.state)
res.send(`
<!DOCTYPE html>
<html>
<head><title>我的SSR应用</title></head>
<body>
<div id="app">${appHtml}</div>
<script>window.__INITIAL_STATE__=${state}</script>
<script src="/client-bundle.js"></script>
</body>
</html>
`)
} catch (error) {
console.error('SSR渲染失败:', error)
res.status(500).send('服务器错误')
}
})
app.listen(3000, () => {
console.log('SSR服务已启动: http://localhost:3000')
})
七、总结与最佳实践
经过上面的探索,我们可以总结出以下经验:
- 分级预取策略:全局数据用store预取,路由级数据用router钩子,组件特有数据用serverPrefetch
- 缓存一切可缓存的:API响应、渲染结果、组件实例
- 完善的错误处理:SSR环境没有window对象,所有客户端API调用都需要保护
- 性能监控:记录首屏时间、数据预取耗时等关键指标
- 渐进式方案:可以从部分页面开始SSR,逐步扩大范围
记住,SSR不是银弹,它带来了首屏性能提升,但也增加了服务器负担。根据你的业务场景,找到平衡点才是关键。
评论