Code前端首页关于Code前端联系我们

Vue Router 结合 SSR 该怎么玩?从概念到实践一次讲透

terry 8小时前 阅读数 13 #Vue
文章标签 Vue Router;SSR

现在不少前端同学做项目时,碰到首屏加载慢、SEO 搞不定的问题,就会想到服务端渲染(SSR),可 Vue 项目里结合 Vue Router 做 SSR,到底该咋上手?从概念到踩坑再到优化,很多细节容易懵,这篇文章就用问答形式,把 Vue Router SSR 的关键知识点、实践步骤、避坑技巧一次性聊透,不管是刚接触还是想深入优化的同学,看完心里都能有清晰的脉络。

Vue Router SSR 到底是啥?和普通 SPA 路由有啥不一样?

首先得把概念拆明白。Vue Router 是 Vue 官方的路由管理器,负责 SPA 里组件切换、路由匹配这些事儿;而 SSR(服务端渲染) 是让 Vue 组件在服务端先渲染成 HTML,再发给浏览器,浏览器拿到后“激活”成可交互的 SPA。

那 Vue Router SSR,就是把路由系统和服务端渲染结合起来——服务端收到请求后,用 Vue Router 匹配到对应的页面组件,把组件渲染成 HTML 返回;客户端拿到 HTML 后,Vue Router 再接管,让页面变成能响应路由跳转的 SPA(这个过程叫 hydration「水合」)。

和普通 SPA 路由比,核心差异在这几点:

  • 渲染阶段:SPA 是客户端 JS 渲染,首屏得等 JS 下载执行完才出内容;SSR 是服务端先渲染好 HTML,首屏能更快展示。
  • 路由匹配时机:SPA 里路由匹配是客户端异步的(比如点击链接后 JS 处理);SSR 里服务端得 同步匹配路由(因为要在服务端就确定渲染哪个组件)。
  • 状态隔离:SPA 里路由实例是单例(整个应用共用一个 router);SSR 里每个请求都得新建 router 实例,不然不同用户请求会互相干扰(状态污染)。

为啥非要给 Vue 项目加 SSR+Vue Router?纯 SPA 不行吗?

不是不行,是场景倒逼,纯 SPA 在这两类场景下很吃亏:

SEO 需求高的场景

搜索引擎爬虫抓页面时,很多时候不会等 JS 执行完再爬内容,比如做官网、电商列表页、资讯详情页,纯 SPA 的首屏是空白 HTML(只有根节点),爬虫抓不到有效内容,排名自然上不去,SSR 能让服务端直接返回带内容的 HTML,爬虫秒抓,SEO 友好度拉满。

首屏加载速度敏感的场景

像移动端弱网环境、用户手机配置差,SPA 得等 JS 下载、解析、执行完才渲染页面,中间白屏时间长,用户体验差,SSR 直接发 HTML,浏览器拿到就能渲染出结构,哪怕 JS 还没加载完,用户也能先看到内容,感知上快很多。

复杂交互页的「首屏确定性」

比如后台管理系统首页有很多图表、数据列表,SPA 要等接口请求完、Vue 渲染完才展示,过程中可能有 Loading 动画;SSR 能把初始数据直接渲染到 HTML 里,用户打开就看到完整结构,后续交互再靠客户端 JS 接管,体验更流畅。

Vue Router 做 SSR 的核心流程和原理是啥?

得把服务端和客户端的流程拆成「渲染前→渲染中→渲染后」三个阶段,理解每个环节 Vue Router 的作用:

服务端流程(处理请求时):

  1. 接收请求:用户访问 https://xxx.com/about,服务端(Node.js 服务)接到这个请求。
  2. 创建新路由实例:因为每个请求要隔离状态,所以每次请求都 new 一个 Vue Router 实例(配置和客户端的路由规则保持一致)。
  3. 匹配路由:用 router.match(location) 找到对应的路由记录(比如匹配到 About 组件)。
  4. 渲染组件成 HTML:创建 Vue 实例(把 router 注入进去),用 vue-server-renderer 把整个应用渲染成 HTML 字符串,返回给浏览器。

客户端流程(浏览器拿到 HTML 后):

  1. hydration 水合:浏览器先展示服务端返回的 HTML(这时候是静态的),然后客户端 JS 执行,Vue Router 重新创建路由实例,和服务端的路由状态对齐,让静态 HTML 变成可交互的 SPA(比如点击链接能跳转、组件能响应数据变化)。
  2. 接管路由:之后的路由跳转(比如点击 <router-link>),就和普通 SPA 一样,由客户端 Vue Router 处理,不再走服务端。

这里有个关键:服务端和客户端的路由配置必须完全一致,不然会出现「hydration mismatch」——服务端渲染的 HTML 结构和客户端 JS 渲染的不一样,浏览器控制台会报错,页面交互也会出问题。

从零搭建 Vue Router SSR 项目,具体步骤是啥?

很多同学觉得搭建复杂,其实拆成「初始化项目→配置路由→服务端渲染逻辑→客户端 hydration→处理异步数据」这几步,思路就清晰了,下面用 手动配置 的方式讲(不用 Nuxt.js,先理解底层逻辑):

步骤 1:初始化项目结构

新建项目文件夹(vue-ssr-demo),初始化 package.json

npm init -y

安装核心依赖(Vue、Vue Router、服务端渲染工具、Node 服务框架):

npm install vue vue-router vue-server-renderer express

步骤 2:配置 Vue Router(客户端+服务端通用)

src/router.js 里写路由规则,注意服务端每次请求要新建 router 实例,所以导出一个创建 router 的函数:

import Vue from 'vue'
import Router from 'vue-router'
// 假设 Home、About 是同步组件(也可以用异步组件)
import Home from './components/Home.vue'
import About from './components/About.vue'
Vue.use(Router)
// 导出创建路由的函数,服务端每次请求都调用生成新实例
export function createRouter() {
  return new Router({
    mode: 'history', // 服务端用 history 模式更友好(避免 # 号)
    routes: [
      { path: '/', component: Home },
      { path: '/about', component: About }
    ]
  })
}

步骤 3:创建 Vue 实例(服务端+客户端通用)

src/app.js 里,创建 Vue 根实例,同样导出一个创建 App 的函数(服务端每次请求要新建 Vue 实例,避免状态污染):

import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
export function createApp() {
  const router = createRouter()
  const app = new Vue({
    router,
    render: h => h(App)
  })
  return { app, router }
}

步骤 4:服务端渲染逻辑(Node.js 服务)

新建 server.js,用 express 处理请求,结合 vue-server-renderer 渲染页面:

const express = require('express')
const Vue = require('vue')
const server = express()
const { createRenderer } = require('vue-server-renderer')
const { createApp } = require('./src/app')
// 创建渲染器(把 Vue 实例转成 HTML)
const renderer = createRenderer()
server.get('*', (req, res) => {
  // 每次请求都新建 App 和 Router
  const { app, router } = createApp()
  // 跳转到用户请求的路径(比如用户访问 /about,router 就匹配到 /about)
  router.push(req.url)
  // 渲染 Vue 实例成 HTML
  renderer.renderToString(app, (err, html) => {
    if (err) {
      res.status(500).send('Internal Server Error')
      return
    }
    // 把渲染好的 HTML 返回给浏览器
    res.send(`
      <!DOCTYPE html>
      <html>
        <head><title>Vue SSR Demo</title></head>
        <body><div id="app">${html}</div></body>
      </html>
    `)
  })
})
server.listen(3000, () => {
  console.log('Server running at http://localhost:3000')
})

步骤 5:客户端 hydration(让静态 HTML 变可交互 SPA)

新建 src/client.js,负责在浏览器中挂载 Vue 实例,完成水合:

import { createApp } from './app'
// 客户端创建 App,和服务端的结构对齐
const { app } = createApp()
// 挂载到 #app 节点(服务端返回的 HTML 里要有这个节点)
app.$mount('#app')

步骤 6:处理异步数据(路由组件需请求接口时)

上面的例子是同步组件,实际项目中很多组件需要先请求数据再渲染(About 组件要获取用户列表),这时候得让 服务端等待数据加载完成后再渲染,否则服务端返回的 HTML 是没有数据的,客户端 hydration 时再请求数据,会导致页面闪烁(服务端 HTML 没数据,客户端加载后才有)。

这里以 结合 Vuex 管理状态 为例,演示核心逻辑:

定义 Vuex Store(服务端+客户端通用)

新建 src/store.js,导出创建 Store 的函数:

import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios' // 假设用 axios 请求数据
Vue.use(Vuex)
export function createStore() {
  return new Vuex.Store({
    state: {
      users: []
    },
    mutations: {
      setUsers(state, data) {
        state.users = data
      }
    },
    actions: {
      // 异步 action,获取用户数据
      async fetchUsers({ commit }) {
        const res = await axios.get('/api/users')
        commit('setUsers', res.data)
      }
    }
  })
}

路由组件中声明「需要提前获取数据」

修改 src/router.js,给 /about 路由加 asyncData(自定义逻辑,标记该路由需要预取数据):

export function createRouter() {
  return new Router({
    routes: [
      { 
        path: '/about', 
        component: About,
        // asyncData 接收 store,触发数据请求
        asyncData(store) { 
          return store.dispatch('fetchUsers') 
        }
      }
    ]
  })
}

服务端等待数据加载完成后再渲染

修改 server.js 的请求处理逻辑,等待所有路由的 asyncData 执行完:

server.get('*', async (req, res) => { // 改成 async 函数
  const { app, router, store } = createApp() // 假设 createApp 现在返回 store
  router.push(req.url)
  // 找到匹配的路由记录
  const matchedRoutes = router.getMatchedRoutes()
  // 执行每个路由的 asyncData,等待所有 Promise 完成
  const promises = matchedRoutes.map(route => {
    return route.component.asyncData 
      ? route.component.asyncData(store) 
      : Promise.resolve()
  })
  try {
    await Promise.all(promises) // 等待所有数据请求完成
    const html = await renderer.renderToString(app)
    // 把 store 状态注入到 HTML,客户端 hydration 时复用
    res.send(`
      <!DOCTYPE html>
      <html>
        <head><title>Vue SSR Demo</title></head>
        <body>
          <div id="app">${html}</div>
          <script>
            window.__INITIAL_STATE__ = ${JSON.stringify(store.state)}
          </script>
        </body>
      </html>
    `)
  } catch (err) {
    res.status(500).send('Error')
  }
})

客户端 hydration 时复用服务端数据

修改 src/client.js,初始化 Store 时读取服务端注入的状态:

import { createApp } from './app'
import { createStore } from './store'
const store = createStore()
// 复用服务端注入的状态
if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}
const { app } = createApp(store) // 假设 createApp 现在接收 store
app.$mount('#app')

到这里,一个能处理异步数据的 Vue Router SSR 项目就跑通了!但实际开发中,还会遇到很多「坑」,得重点避坑。

Vue Router SSR 实践中最容易踩的坑是啥?咋解决?

坑 1:状态污染(服务端路由实例复用导致数据串了)

问题:服务端如果复用同一个 router 实例,用户 A 访问 /about,用户 B 接着访问 ,可能拿到的还是 /about 的路由状态,数据就串了。
解决:服务端每次请求都要 新建 router 实例(像步骤 2 里那样,用 createRouter 函数每次返回新实例)。

坑 2:Hydration Mismatch(服务端和客户端渲染的 DOM 不一样)

问题:浏览器控制台报错「Hydration completed but contains mismatches」,原因是服务端和客户端渲染的 HTML 结构、属性、文本不一致。
常见原因

  • 路由配置不一致(比如服务端用 history 模式,客户端用 hash 模式);
  • 组件里有依赖浏览器 API 的代码(windowdocument,服务端没有这些,渲染时结构不同);
  • 异步数据没处理好,服务端渲染时没数据,客户端 hydration 时有数据,导致 DOM 结构变化。
    解决
  • 确保服务端和客户端的路由配置、模式完全一致;
  • 组件里避免在 createdmounted 之外的钩子(render)中使用浏览器 API,或者用条件判断(if (process.browser) {});
  • 严格处理异步数据,服务端必须等数据加载完再渲染,客户端 hydration 时复用服务端数据。

坑 3:异步组件加载时机不对(服务端渲染时没加载完)

问题:用 () => import('./About.vue') 这种异步组件,服务端渲染时可能组件还没加载完,导致渲染的 HTML 缺失内容。
解决:服务端渲染时,要确保异步组件已经加载完成,可以用 router.onReady 等待路由就绪,或者在 webpack 打包时把异步组件改成同步加载(服务端打包和客户端打包配置不同)。

坑 4:SEO 优化没做透(只有内容,meta 标签还是静态的)

问题:服务端返回的 HTML 里 titlemeta 是固定的,不同页面需要动态设置(比如文章详情页的 title 是文章标题)。
解决

  • 在路由组件里加 metaInfo 配置(比如用 vue-meta 库),服务端渲染时把 meta 信息注入到 HTML 的 head 里;
  • 服务端渲染时,根据当前路由的 meta 信息,动态生成 titlemeta 标签。

自己搭 SSR vs 用 Nuxt.js,该咋选?

很多同学纠结是自己手动配置,还是直接用 Nuxt.js 这类框架,先看两者的核心差异:

自己搭 SSR:

  • 优势:完全自定义,想怎么改路由、怎么处理数据都可以;适合需要深度定制的项目(比如公司内部框架整合、特殊性能优化)。
  • 劣势:工作量大,要处理路由、数据、渲染、缓存等所有细节;容易踩坑,对 SSR 原理理解要求高。

用 Nuxt.js:

  • 优势:开箱即用!内置 Vue Router、Vuex、SSR 配置,还有 约定式路由pages 目录下的 .vue 文件自动生成路由),不用手动配路由;支持 <

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

热门