Vue项目的SSR处理教程

219 阅读3分钟

1.vue的ssr方案

  • vue的createApp+express接管主服务器控制服务端渲染并与客户端水合(本章讲解的方案)
  • Nuxt.js(如果是新项目建议用这个)

2.涉及技术

  • vue + vite + 任意UI框架 + vue-router
  • express
  • nginx + pm2

3.步骤

  1. 将main.js文件修改为SSR激活模式,使用createSSRApp
import { createSSRApp } from 'vue'
import App from './App.vue'
import { createRouter } from './router'

// SSR requires a fresh app instance per request, therefore we export a function
// that creates a fresh app instance. If using Vuex, we'd also be creating a
// fresh store here.
export function createApp() {
  const app = createSSRApp(App)
  const router = createRouter()
  app.use(router)
  return { app ,router}
}
  1. 修改第一步使用的createRouter ,使用方法将router进行返回以便于服务端使用,注意在服务端时仅能使用createMemoryHistory模式
import { createMemoryHistory, createRouter as createRouterBase, createWebHistory } from "vue-router"
export function createRouter(){
  const isServer = typeof window === 'undefined'
  const history = isServer ? createMemoryHistory() : createWebHistory()
  return createRouterBase({
    history: history,
    routes:[
      {
        path:'/',
        name:'home',
        component: ()=>import('../components/HelloWorld.vue')
      },{
        path:'/about',
        name:'about',
        component: ()=>import('../components/About.vue')
      }
    ]
  })
}
  1. 在src目录下创建服务端和客户端入口文件entry-server.js和entry-client.js,名字可以任意区注意区分即可
// /entry-client.js
import { createApp } from './main'

const { app, router } = createApp()

router.isReady().then(() => {
  app.mount('#app')
})
// /entry-server.js
import { renderToString } from 'vue/server-renderer'
import { createApp } from './main'

export async function render(url) {
  const { app, router } = createApp()

  // 设置当前路由
  await router.push(url)
  await router.isReady()

  // 渲染 HTML
  const ctx = {}
  const html = await renderToString(app, ctx)
  const head= `meta标签用于SEO优化`
  return { html, head }
}
  1. 修改根目录下的index.html,添加占位标记来替代服务端内容
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + Vue</title>
    <!--app-head-->
  </head>
  <body>
    <div id="app"><!--app-html--></div>
    <script type="module" src="/src/entry-client.js"></script>
  </body>
</html>

  1. 根目录创建server.js文件用于项目启动
import fs from 'node:fs/promises'
import express from 'express'

  const isProduction = process.env.NODE_ENV === 'production'
const port = process.env.PORT || 5173
const base = process.env.BASE || '/'

const templateHtml = isProduction
  ? await fs.readFile('./dist/client/index.html', 'utf-8')
  : ''

const app = express()

/** @type {import('vite').ViteDevServer | undefined} */
let vite
if (!isProduction) {
  const { createServer } = await import('vite')
  vite = await createServer({
    server: { middlewareMode: true },
    appType: 'custom',
    base,
  })
  app.use(vite.middlewares)
} else {
  const compression = (await import('compression')).default
  const sirv = (await import('sirv')).default
  app.use(compression())
  app.use(base, sirv('./dist/client', { extensions: [] }))
}

app.use('*all', async (req, res) => {
  try {
    const url = req.originalUrl.replace(base, '')

    /** @type {string} */
    let template
    /** @type {import('./src/entry-server.js').render} */
    let render
    if (!isProduction) {
      template = await fs.readFile('./index.html', 'utf-8')
      template = await vite.transformIndexHtml(url, template)
      render = (await vite.ssrLoadModule('/src/entry-server.js')).render
    } else {
      template = templateHtml
      render = (await import('./dist/server/entry-server.js')).render
    }

    const rendered = await render(url)

    const html = template
      .replace(`<!--app-head-->`, rendered.head ?? '')
      .replace(`<!--app-html-->`, rendered.html ?? '')

    res.status(200).set({ 'Content-Type': 'text/html' }).send(html)
  } catch (e) {
    vite?.ssrFixStacktrace(e)
    console.log(e.stack)
    res.status(500).end(e.stack)
  }
})

app.listen(port, () => {
  console.log(`Server started at http://localhost:${port}`)
})

  1. 部署采用nginx+pm2进行管理,SSR线上部署主要解决接口跨域的问题,在开发环境的时候可以在vite.config.js文件设置代理路径,但在线上环境主要为nginx设置接口转发,所以在线上需要nginx区分前后端设置

image.png

// nginx.conf
server {
        listen 5173; # 前端服务端口
        server_name localhost;

        location ^~ /api/ {
            proxy_pass http://your-domain.com; # 后端服务地址
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location / {
            proxy_pass http://127.0.0.1:5174; # Express 服务地址,SSR服务器
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
            proxy_cache_bypass $http_upgrade;
        }
    }
// server.js
import fs from "node:fs/promises";
import os from "node:os";
import express from "express";
import compression from "compression";
import sirv from "sirv";

const port = process.env.PORT || 5174;
const base = process.env.BASE || "/";

function getServerIP() {
  const interfaces = os.networkInterfaces();
  for (const interfaceName in interfaces) {
    const iface = interfaces[interfaceName];
    for (const alias of iface) {
      if (alias.family === "IPv4" && !alias.internal) {
        return alias.address; // 返回第一个非内部的 IPv4 地址
      }
    }
  }
  return "127.0.0.1"; // 如果未找到合适的 IP,默认返回 localhost
}

const serverIP = getServerIP();

// Cached production assets
const templateHtml = await fs.readFile("./client/index.html", "utf-8");

// Create http server
const app = express();

// Add production middlewares
app.use(compression()); // 启用压缩中间件
app.use(base, sirv("./client", { extensions: [] })); // 提供静态资源服务

// Serve HTML
app.use("*all", async (req, res) => {
  console.log(req.originalUrl);

  // 检查请求路径是否以 /api 开头
  if (req.originalUrl.startsWith("/api")) {
    // 将请求交由 Nginx 或其他服务处理
    console.log("API requests are handled by Nginx");

    return res.status(404).end("API requests are handled by Nginx");
  }

  try {
    const url = req.originalUrl.replace(base, "");

    // Load the server entry module
    const { render } = await import("./server/entry-server.js");

    // Render the page
    const rendered = await render(url);

    // 不处理服务端内容渲染,直接用客户端接管,服务端仅做 SEO 优化
    const html = templateHtml.replace(`<!--app-head-->`, rendered.head ?? "");

    res.status(200).set({ "Content-Type": "text/html" }).send(html);
  } catch (e) {
    console.error(e.stack);
    res.status(500).end("Internal Server Error");
  }
});

// Start http server
app.listen(port, () => {
  console.log(`Server started at http://${serverIP}:${port}`);
});
 pm2 start ./server.js --name demo-ssr --max-memory-restart 500M
  1. 修改package.json文件启动方式
"scripts": {
    "dev": "node server",
    "build": "npm run build:client && npm run build:server",
    "build:client": "vite build --outDir dist/client",
    "build:server": "vite build --ssr src/entry-server.js --outDir dist/server",
    "preview": "cross-env NODE_ENV=production node server"
  },

4.注意

  1. 打包后需要将server.js文件放入dist目录下,其位置决定了pm2管理时运行的位置,上述pm2命令其server.js位置放置在dist/server目录下
  2. 对于不同的UI框架可能会有部分UI框架无法正常引入,会提示没有install或者没有导出函数,这个暂未处理
  3. 需要在dist目录安装依赖express、compression、sirv