记一次Vue + Vite 的 SSR 改造记

记一次Vue + Vite 的 SSR 改造记

前言

前言我就不多说了,总之就是公司有业务需求需要对公司的新闻、论坛相关的页面有SEO的需求,我们公司的技术栈是 VUE + Vite,要求是尽量快并且改造小,由于我之前在 Vue2 时代有过几次失败的SSR改造经历,所以想在这一次挑战一下自我,破除心魔,所以有了这次的改造。

SEO 和 SSR 介绍

介于可能有些人还是不了解 SEO 和 SSR,这里还是简单描述一下。

SEO : SEO就是平常经常使用到的搜索引擎比如 百度一下、必应搜索等等,当网站上线后,需要到该搜索引擎上提交网址或通过被录入的其它网站的友链,来被搜索引擎的爬取到,这样用户通过在该搜索引擎上输入关键词就可以检索到你的网站,通过优化网站本身,让网站能被好的关键词检索到并且排名靠前就叫 SEO ,其实就是拉用户进来看网站的一个手段

SSR: 其实就是由于的页面渲染方式变了,由原来的 DOM 渲染变成了现在虚拟 DOM 渲染了,也就是浏览器拿到的页面是空的,由浏览器运行 JS 后才会插入 DOM 节点,这样对于爬虫来说就由于出于性能、钱等多方面考量,爬虫不会去执行 JS 也就拿不到 JS 执行后真正的 DOM。

通常情况下可以通过直接进入网页右键查看源代码就可以看出来网页中是否有 DOM 节点判断网页是否有 SEO 方面的优化

所以想让爬虫拿到真实的 DOM,就需要提前把 DOM 渲染出来,而这个提前渲染出来并返回就叫服务端渲染(SSR),在 nodejs 出来之前想做 SSR 就比较困难了,nodejs 出现后,由于都是 js 环境所以做 SSR 就简单多了,只需要对项目做比较少的改造就可以实现。

一. 方案选择

方案的话,我们团队还是提出了几个方案,这里放出来供大家参考一下:

名称 说明 是否采用
nuxt 不需要考虑太多的方式就是nuxt,直接使用框架也比较简单,也有文档可查,不过对于我们的项目来说由于只有部分的页面需要 SEO,需要把整个项目的页面都搬过去比较困难
使用 java 后台渲染的方式 需要手动把需要 SEO 页面从 VUE 转成后台模板的方式,不过这种方式比较适合后端比较多和空闲的情况
SSG 考虑到是偏新闻和论坛类型的偏实时的网站,不太适合使用SSG这种只能定时生成的方案
自己改造 SSR 自己改造的话好处是无需代码迁移,可以定制化

主要来说还是觉得自己改造一下可能比其他方式更加简单并且后期维护也更加轻松。所以就还是自己改造。

二. 改造开始

入口改造

对于 SSR 而言,和原来的对比最主要就是代码不仅需要在浏览器中运行,还需要在 nodejs 的环境中运行了,所以需要两个入口一个给客户端,一个给到服务端。

首先从公共的 main.js

js 复制代码
import { createSSRApp, createApp as _createApp } from 'vue';
import { createRouter } from './router';
import { createPinia } from 'pinia';

export function createApp() {
  const pina = createPinia();
  const router = createRouter();
  const vm = createSSRApp(App);
  vm.use(router)
  vm.use(pina)
  return { vm, router };
}

可以看到从原来的直接创建 Vue 实例,这里需要返回一个方法,这是因为当代码在 nodejs 中运行时,需要给每一个进入的请求一个新的 Vue 实例,这样才不会相互之间交叉污染和冲突。所以对于有全局引用的变量,需要斟酌是否需要返回一个新的变量

router.js 和上面一样我们也需要改成每个请求一个新的实例

js 复制代码
import { createRouter as _createRouter, createWebHistory, createMemoryHistory } from 'vue-router';

const routes = []

export function createRouter() {
  const router = _createRouter({
    history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
    routes: routes // short for `routes: routes`
  });
  return router;
}

对于客户端入口而言,和原来几乎一致,只需要挂载到 dom 上就行

entry-client.js

js 复制代码
import { createApp } from './main';
const { vm } = createApp();
vm.mount('#app');

服务端的入口相对来说就比较复杂一点 entry-server.js

js 复制代码
import { renderToString } from 'vue/server-renderer';
import { createApp } from './main';

export async function render({ req, url, manifest }) {
  let html = ''
  let head = ''
  try {
    const { vm, router } = createApp();
    // 等待路由导航成功
    await router.push(url);
    await router.isReady();
    // 判断路由的 meta 上是否包含需要 ssr 的页面,只有需要的才会进行渲染
    if (router?.currentRoute?.value?.meta?.isSsr) {
      const ctx = {};
      // 将 vue 实例渲染成 html
      html = await renderToString(vm, ctx);
      // 获取该页面所需要的相关资源,如css、js等,这里主要是为了解决打开页面后样式没有被加载的问题
      head = renderPreloadLinks(ctx.modules, manifest);
    }
    console.log('页面渲染成功');
  } catch (err) {
    console.error('页面渲染出错!');
    console.error(err);
  }
  // dataMap 涉及到水合相关代码,后面会讲到
  return { head, html, dataMap: JSON.stringify(global.dataMap) };
}
/**
 * 获取所需要的静态资源
 */
export function renderPreloadLinks(modules, manifest) {
  if (manifest == null) {
    return '';
  }
  let links = '';
  const seen = new Set();
  modules.forEach((id) => {
    const files = manifest[id];
    if (files) {
      files.forEach((file) => {
        if (!seen.has(file)) {
          seen.add(file);
          const filename = basename(file);
          if (manifest[filename]) {
            for (const depFile of manifest[filename]) {
              links += renderPreloadLink(depFile);
              seen.add(depFile);
            }
          }
          links += renderPreloadLink(file);
        }
      });
    }
  });
  return links;
}

function renderPreloadLink(file) {
  if (file.endsWith('.js')) {
    return `<link rel="modulepreload" crossorigin href="${file}">`;
  } else if (file.endsWith('.css')) {
    return `<link rel="stylesheet" href="${file}">`;
  } else if (file.endsWith('.woff')) {
    return ` <link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`;
  } else if (file.endsWith('.woff2')) {
    return ` <link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`;
  } else if (file.endsWith('.gif')) {
    return ` <link rel="preload" href="${file}" as="image" type="image/gif">`;
  } else if (file.endsWith('.jpg') || file.endsWith('.jpeg')) {
    return ` <link rel="preload" href="${file}" as="image" type="image/jpeg">`;
  } else if (file.endsWith('.png')) {
    return ` <link rel="preload" href="${file}" as="image" type="image/png">`;
  } else {
    // TODO
    return '';
  }
}

渲染服务器改造

做完入口改造后,接下来就可以开始渲染服务器改造了,渲染服务器也分为两部分,一部分是平常用来开发测试运行的,另一部分是打包部署后运行的。

在项目根目录新建seo文件夹,并创建如下 LocalMoonSeo.js、MoonSeo.js、ServerMoonSeo.js

这里我把它们之间公共的抽离成了一个父类MoonSeo.js

js 复制代码
import express from 'express';
import artTemplate from 'art-template';

export class MoonSeo {

  constructor () {
    this.app = express()
    this.port = 3000
    this.ssrManifest = null
    this.base =  process.env.BASE || '/'
  }


  async doDrawBanner () {
    console.log('启动中...')
  }

  /**
   * 配置express
   * @param app
   * @returns {Promise<void>}
   */
  async configExpress (app) {
      // 由子类实现
  }

  /**
   * 请求渲染方法
   * @param req
   * @param res
   * @returns {Promise<void>}
   */
  async doGetRender (req, res) {
      // 由子类实现
  }


  async start () {
    await this.doDrawBanner();
    await this.configExpress(this.app);
    await this.addListener()
    await this.startExpress()
  }

  async addListener () {
    // 监听所有请求
    this.app.use('*', async (req, res) => {
      try {
        global.dataMap = {}
        const url = req.originalUrl.replace(this.base, '')
        let {template, render} =  await this.doGetRender(req, res);
        // 调用子类返回的render函数
        const model = await render({
          req,
          res,
          url,
          ssrManifest: this.ssrManifest
        })
        // 这里使用 artTemplate 来进行数据渲染
        let html = artTemplate.render(template, model)
        res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
      } catch (err) {
        res.status(500).end('服务器出现错误')
      }
    })
  }

  async startExpress () {
    // 启动服务
    this.app.listen(this.port, () => {
      console.log(`启动成功`)
      console.log(`Server started at http://localhost:${this.port}`)
    })
    // 监听延迟异常,注意这里需要捕获一些由于setTimeout、promise 等未知异常导致程序崩溃
    process.on('uncaughtException', function(err) {
      console.error('===================== 捕获未知延迟异常开始 =============================>' );
      console.error(err)
      console.error('<===================== 捕获未知延迟异常结束 =============================' );
    });
  }
}

可以看到对于服务器而言主要就是调用render方法,获取所需要的 model 后和模板进行结合返回给客户端

接下来看看 LocalMoonSeo.js 本地测试服务 和 ServerMoonSeo.js

LocalMoonSeo.js

js 复制代码
import { MoonSeo } from './MoonSeo.js';
import fs from 'node:fs/promises';
export class  LocalMoonSeo extends MoonSeo {

  constructor() {
    super();
    this.vite = null
  }


  async configExpress (app) {
    const { createServer } = await import('vite')
    this.vite = await createServer({
      server: { middlewareMode: true },
      appType: 'custom',
      mode: 'dev',
      base: this.base
    })
    app.use(this.vite.middlewares)
  }

  async doGetRender (req, res) {
    const url = req.originalUrl.replace(this.base, '')
    let template = await fs.readFile('./index.html', 'utf-8')
    template = await this.vite.transformIndexHtml(url, template)
    let render = (await this.vite.ssrLoadModule('./src/entry-server.js')).render
    return {template, render}
  }
}

ServerMoonSeo.js

js 复制代码
import { MoonSeo } from './MoonSeo.js';
import fs from 'node:fs/promises';
export class ServerMoonSeo extends MoonSeo {

  constructor() {
    super();
    this.ssrManifest = fs.readFile('./dist/client/.vite/ssr-manifest.json', 'utf-8')
  }


  async configExpress (app) {
    const compression = (await import('compression')).default
    const sirv = (await import('sirv')).default
    app.use(compression())
    app.use(this.base, sirv('./dist/client', { extensions: [] }))
  }

  async doGetRender (req, res) {
    let template = await fs.readFile('./dist/client/index.html', 'utf-8')
    let render = (await import('../dist/server/entry-server.js')).render
    return {template, render}
  }
}

这里可以参考 Vite SSR 渲染 官方文档

最后我们还需要一个入口, 我们在根目录可以新建一个server.js,就是根据不同的环境启动不同的后台服务就行

js 复制代码
import { LocalMoonSeo } from './seo/LocalMoonSeo.js';
import { ServerMoonSeo } from './seo/ServerMoonSeo.js';

const isProduction = process.env.NODE_ENV === 'production'
const moonSeo = isProduction ? new ServerMoonSeo() : new LocalMoonSeo()

moonSeo.start();

以及新建一个index.html 当作模板

html 复制代码
<!DOCTYPE html>
<html>
<head>
  <meta charset='UTF-8' />
  <meta name='viewport' content='width=device-width, initial-scale=1.0' />
  {{@ head}}
</head>

<body>
    <div id="app">{{@ html}}</div>
    <script>
        // 水合相关
        window.dataMap = {{@ dataMap}};
    </script>
    <script type="module" src="/src/entry-client.js"></script>
</body>
</html>

脚本编写和打包

最后需要改写一下package.json 就可以试试效果了

json 复制代码
"scripts": {
  "dev": "node server",
  "build": "npm run build:client && npm run build:server",
  "build:client": "vite build --ssrManifest --outDir dist/client ",
  "build:server": "vite build --ssr src/entry-server.js --outDir dist/server",
  "preview": "cross-env NODE_ENV=production node server"
}

安装完以来然后执行就行 npm install art-template expree compression sirv cross-env npm run dev

只需要在需要 SSR 的页面的路由上加上 meta: {isSsr: true} 既可看到返回的页面里面出现了渲染后的 html

数据加载和水合

对于数据加载来说,可以直接使用 Vue 的 onServerPrefetch 生命周期中请求数据

js 复制代码
onServerPrefetch(async () => {
  await fetchA();
});

对于水合而言,其实就是服务端请求过的数据,通过放到 window 中缓存起来,这时前端渲染页面时,就没有必要再次发送请求到 API 端获取数据了,这里我们通过 axios 的拦截器来实现

js 复制代码
  http.interceptors.request.use(
    (config) => {
      handlerGetSsrData(config);
      return config;
    }
  );

  http.interceptors.response.use(
    (response) => {
      let data = response.data;
      handlerSaveSsrData(response, data)
      return Promise.resolve(data);
    }
  )


function handlerSaveSsrData (response, data) {
  if (import.meta.env.SSR) {
    // 如果页面需要 ssr 则把数据放入 global 中
    global.dataMap[getSsrDataKey(response.config)] = data
  }
}

function handlerGetSsrData (config) {
  if(import.meta.env.SSR) {
    return null
  }
  if (window.dataMap && window.dataMap[getSsrDataKey(config)]) {
    // 拿到 window 上传入的请求数据
    let data = window.dataMap[getSsrDataKey(config)];
    // 通过适配器,返回缓存数据
    config.adapter = () => {
      // 前端获取完数据后清空该数据,后续请求从 API 端获取最新数据
      window.dataMap[getSsrDataKey(config)] = null
      return Promise.resolve({
        data,
        status: 200,
        statusText: '',
        headers: config.headers,
        config: {
          ...config,
          isCache: true,
        },
        request: config,
      });
    };
  }
}

function getSsrDataKey(config) {
  // 将请求 url、data、params 作为 key 返回
  return JSON.stringify({
    url: config.url,
    // 这里很奇怪,后台变成字符串了
    data: config.data == null ?  null : typeof config.data == 'string' ? config.data : JSON.stringify(config.data),
    params: config.params
  })
}

这样我们的水合就完成了

可以看到没有客户端页面没有发送新的 HTTP 请求,而是直接获取了 window.dataMap 中的数据

这里可能需要注意的是可能用户进行了登录,可能返回的还是未登录请求的页面,而且 SEO 也不需要爬取登录后页面的内容

三、性能问题 与 SSR 分离改造

经过权衡,直接使用 SSR 上线的话有比较多的一个性能缺点:

  1. 众所周知 js 是单线程的,所以每一个请求的响应都必须等待前一个请求的响应结束后才行

  2. onServerPrefetch 生命周期也是同样的,而且子组件必须要等待父组件 onServerPrefetch 响应完成。如果页面中包含请求比较多的话,等待一个页面渲染完成耗时是非常长的

  3. 客户端渲染时需要对比服务端渲染的节点,需要耗费比较多的性能

综合评估后(可能是我技术不行,解决不了这些问题)决定对于线上页面采用客户端渲染,只有爬虫爬取时才采用服务端渲染,对此需要将项目改成两个入口

  1. 将原来的index.html 修改为ssr.html,新建一个新的index.html
html 复制代码
<!DOCTYPE html>
<html>
<head>
  <meta charset='UTF-8' />
</head>
<body>
<div id='app'></div>
<script type='module' src='/src/entry-client.js'></script>
</body>
</html>
  1. 修改 vite.config.js 将入口变成两个
vite.config.js 复制代码
/// <reference types="vitest" />
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
import * as path from 'path';
// https://vitejs.dev/config/
export default ({ mode, isSsrBuild }) => {
  return defineConfig({
    // ... 其它配置
    build: {
      rollupOptions: {
        input: {
          // 这里配成两个入口
          index: path.resolve(__dirname, './index.html'),
          ssr: path.resolve(__dirname, './ssr.html')
        },
        output: {
          entryFileNames: isSsrBuild ? '[name].js' : 'assets/js/[name].[hash].js',
          chunkFileNames: 'assets/js/[name].[hash].js',
          assetFileNames: 'assets/[ext]/[name].[hash].[ext]'
        }
      }
    }
  });
};

这样打包后就会有两个 html,一个是 ssr 的html模板,一个是客户端渲染的模板。

然后修改 LocalMoonSeo.js 和 ServerMoonSeo.js 将原来引入的 index.html 全部修改为 ssr.html

js 复制代码
let template = await fs.readFile('./index.html', 'utf-8')
修改为
let template = await fs.readFile('./ssr.html', 'utf-8')

为了区分 SSR 和 客户端模式,在根目录新建 .env.ssr

js 复制代码
VITE_RENDER_MODE= ssr

修改main.js 和 LocalMoonSeo.js 中的 configExpress 将 mode 变成 ssr

js 复制代码
const vm = import.meta.env.SSR || import.meta.env.VITE_RENDER_MODE == 'ssr' ? createSSRApp(App) : _createApp(App);
js 复制代码
async configExpress (app) {
    const { createServer } = await import('vite')
    this.vite = await createServer({
        server: { middlewareMode: true },
        appType: 'custom',
        mode: 'ssr',
        base: this.base
    })
    app.use(this.vite.middlewares)
}

我们还修改一下启动脚本

package.json 复制代码
"scripts": {
  // 原生模式启动
  "dev": "vite",
  // ssr 的方式启动
  "ssr": "node server",
  "build": "npm run build:client && npm run build:server",
  "build:client": "vite build --ssrManifest --outDir dist/client ",
  "build:server": "vite build --ssr src/entry-server.js --outDir dist/server",
  "preview": "cross-env NODE_ENV=production node server"
}

日常开发还是可以使用 dev 进行启动,如果有 ssr 调试需求可以通过 ssr 启动

最后,使用 nginx 做服务器转发,通过对请求头进行判断,如果是爬虫则转发到 3000 端口

nginx.conf 复制代码
http {
    map $http_user_agent $is_crawler {
        default 0;
        ~*(Googlebot|Bingbot|Baiduspider) 1;
    }
    
    server {
        listen 80;
        location / {
            if ($is_crawler) {
                proxy_pass http://localhost:3000;
            }
            alias /dist/client/;
            index index.html index.htm;
            try_files $uri $uri/ /index.html;
        }
    }
    
}

当然这种分离的方式,其实也就不需要数据水合了

四、问题与其它

  1. 一定需要注意的是页面需要在nodejs 中运行,在 steup 中不能有 nodejs 没有的变量和方法,比如 window、localStorge 等等,此外需要注意 setTimeout、Promise 这些延时代码,尽量都放到 onMounted 中去执行,避免造成内存溢出问题。

  2. 由于使用了富文本,肯定需要防止 xss 攻击,原来是使用的 v-dompurify-html="data",但是对于后端渲染来说,指令只会执行 getSSRProps 钩子,所以只能采用原生的 v-html 来完成

js 复制代码
export async function doXssHtml (html) {
  if (html == null || html == '') {
    return
  }
  if (import.meta.env.SSR) {
    const  createDOMPurify =  (await import('dompurify')).default;
    const { JSDOM } = (await import('jsdom')).default
    const window = new JSDOM('').window;
    const DOMPurify = createDOMPurify(window)
    return DOMPurify.sanitize(html)
  } else {
    const  DOMPurify =  (await import('dompurify')).default;
    return DOMPurify.sanitize(html)
  }
}
v-html="doXssHtml(data)"

五、结尾

总的来说改造还是相对来说比较顺利的,一两周就完成了全部的改造,vite 和 vue 官网对于如何做 ssr 改造也已经做了很详细的描述,相对 Vue2 时代去做 ssr 改造容易许多了,基本按部就班的改造就可以了,目前来看使用 nodejs 来做服务器可能还有很长一段路走, 比如缺少完善的全局错误处理,js中如果有遇到异步方法被执行可能就很难监控到了,另外就是单线程性能问题对小白来说也很难去优化。大致就是这样,算是了了一桩事情

最后添一下demo地址 moonSeo github

参考文档:

  1. 前端页面秒开的关键 - 小白也能看懂的同构渲染原理和实现

  2. vite ssr

相关推荐
莹雨潇潇15 分钟前
Docker 快速入门(Ubuntu版)
java·前端·docker·容器
Jiaberrr24 分钟前
Element UI教程:如何将Radio单选框的圆框改为方框
前端·javascript·vue.js·ui·elementui
Tiffany_Ho1 小时前
【TypeScript】知识点梳理(三)
前端·typescript
安冬的码畜日常2 小时前
【D3.js in Action 3 精译_029】3.5 给 D3 条形图加注图表标签(上)
开发语言·前端·javascript·信息可视化·数据可视化·d3.js
小白学习日记3 小时前
【复习】HTML常用标签<table>
前端·html
丁总学Java3 小时前
微信小程序-npm支持-如何使用npm包
前端·微信小程序·npm·node.js
yanlele4 小时前
前瞻 - 盘点 ES2025 已经定稿的语法规范
前端·javascript·代码规范
懒羊羊大王呀4 小时前
CSS——属性值计算
前端·css
DOKE4 小时前
VSCode终端:提升命令行使用体验
前端