vue3+vite服务端渲染入门指南

唉~发现招聘要求上,好多都写了要会SSR,是时候入这个坑了!

1.服务端渲染

流程

采用 Node.js 部署前端服务器

  1. 浏览器请求 URL,前端服务器接收到请求后,根据不同 url,前端服务器想后端服务器请求数据。
  2. 请求完成后,前端服务器会组装一个携带了具体数据的 HTML,并返回给浏览器。
  3. 浏览器得到 HTML 后开始渲染页面,同时浏览器加载并执行 js,给页面元素绑定事件,让页面变得可交互。
  4. 当用户与浏览器页面进行交互(如跳到下个页面)时,浏览器会执行 js,向后端服务器请求数据,获取完数据后,再次执行 js,动态渲染页面。

总结:根据url获取前端服务器已经完成数据渲染的HTML,然后在浏览器里面显示出来,后续的操作依旧走浏览器,除非切换路由页面。

好处:

  1. 利于SEO
  2. 首屏加载的用户体验优化

坏处:

  1. 维护兼容node和浏览器端的代码,代码复杂度提升了。
  2. 部署上处理前端静态资源,还有要搭建node.js前端服务,考虑负载均衡,运维成本增加。

2.开发模式SSR实现

1.创建一个基础的vite+vue+ts的项目

bash 复制代码
npm create vite@latest

2.修改src/main.ts为ssr渲染

js 复制代码
import { createSSRApp } from 'vue';
import router from './router/index';
import './style.css';
import App from './App.vue';

// createApp(App).mount('#app');
export function createApp() {
  //改成ssr渲染
  const app = createSSRApp(App);
  app.use(router);
  return { app, router };
}

3.创建一个客户端入口src/entry-client.ts

js 复制代码
import { createApp } from './main';
const { app, router } = createApp();
//针对有懒加载的路由情况,需等待路由解析完
router.isReady().then(() => {
  app.mount('#app');
});

4.修改index.html文件,设置渲染替换的插槽

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 + TS</title>
    <!-- 资源文件,用于生产环境 -->
    <!--preload-links-->
  </head>
  <body>
    <div id="app">
      <!-- 渲染html结果 -->
      <!--app-html-->
    </div>
    <!-- 入口js -->
    <script type="module" src="/src/entry-client.ts"></script>
  </body>
</html>

5.创建服务端渲染入口文件src/entry-server.ts

js 复制代码
import { createApp } from './main';
import { renderToString } from 'vue/server-renderer';
export async function render(url: string) {
  const { app, router } = createApp();
  router.push(url);
  //针对有懒加载的路由情况,需等待路由解析完
  await router.isReady();

  const ctx = {};
  //renderToString将此事的根实例转换成对应的HTML字符串
  const html = await renderToString(app, ctx);

  return { html };
}

6.添加vue-router,文件路径为src/router/index.ts

js 复制代码
import { createRouter, createWebHistory, createMemoryHistory } from 'vue-router';
console.log('is SSR', import.meta.env.SSR);
const router = createRouter({
//服务端用内存历史,浏览器端用history
  history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
  routes: [
    {
      path: '/hello',
      name: 'hello',
      component: () => import('@/components/HelloWorld.vue'),
      meta: {
        title: 'HelloWorld'
      }
    },
    {
      path: '/home',
      name: 'home',
      component: () => import('@/components/Home.vue'),
      meta: {
        title: 'home'
      }
    },
    {
      path: '/error',
      name: 'error',
      component: () => import('@/components/ErrorPage.vue'),
      meta: {
        title: 'error'
      }
    }
  ]
});
//注意路由守卫只有在浏览器端可用,如果服务端用会报错
if (!import.meta.env.SSR) {
  router.beforeEach((to, from, next) => {
    if (to.matched.length > 0) {
      //有匹配路径
      next();
    } else {
      //没有匹配路径
      next('/error');
    }
  });

  router.afterEach((to) => {
    if (to.meta?.title) {
      document.title = to.meta.title;
    }
  });
}

export default router;

注意:

  • 路由守卫只有在浏览器端可用,如果服务端用会报错,因此记得要加判断。
  • 路由模式上,服务端用内存历史createMemoryHistory,浏览器端用historycreateWebHistory

7.添加express,在根目录添加server.js

创建express服务,以中间件模式创建vite应用,让express接管控制.vite的热更新HMR等都会通过express服务返回

js 复制代码
//server-config.js
//服务配置
export default {
  port: '8887',
  url: 'http://localhost:8887/'
};

//
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import serverConfig from './server-config.js';
import { createServer as createViteServer } from 'vite'; 
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function createServer() {
  
  const app = express();

  //以中间件模式创建vite应用,这将禁用vite自身的html服务逻辑,让上级服务接管控制
  const vite = await createViteServer({
    server: {
      middlewareMode: true
    },
    appType: 'custom'
  });
  //使用vite的connect实例作为中间件
  app.use(vite.middlewares);
  app.use('*', async (req, res) => {
   //返回给浏览器的html......
  });

  app.listen(serverConfig.port);
}
console.log(serverConfig.url);
createServer();

8.根据url返回对应的html

js 复制代码
 //返回给浏览器的index.html
    const url = req.originalUrl;
    console.log('url', url);
    try {
      let html;
      //1.读取index.html
      let template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8');
      //2.应用vite html转换
      template = await vite.transformIndexHtml(url, template);
      //3.加载服务器入口
      const { render } = await vite.ssrLoadModule('/src/entry-server.ts');
      //4.渲染应用的html
      const { html: appHtml } = await render(url);
      //5.注入渲染后的应用程序HTML到模板中
      html = template.replace(`<!--app-html-->`, appHtml);

      //6.返回渲染后的HTML
      res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
    } catch (error) {
      //如果捕获错误,让vite修复改堆栈,这样它可以映射回你的实际源码中
      vite.ssrFixStacktrace(error);
      console.log(error);
      res.status(500).end(error.message);
    }

流程

  1. 读取index.html作为模板
  2. 利用vite html转换模板
  3. 加载服务端渲染入口/src/entry-server.ts,得到render
  4. 传入url,执行render,渲染得到ssr的html
  5. 替换模板的html插槽
  6. 返回结果到浏览器
  7. 如果报错,则让vit处理错误,并将错误提示返回给浏览器

9.执行node server.js启动

  • 服务端打印:
  • 浏览器端打印

3.生产模式SSR实现

1.创建src/entry-server-prod.ts,用于生产环境打包服务端ssr入口

跟开发模式SSR相似,不过添加了资源文件加载的逻辑。

  • renderToString时ctx的modules里面会列出该页面需要动态加载的资源文件id。
js 复制代码
import { createApp } from './main';
import { basename } from 'node:path';
import { renderToString } from 'vue/server-renderer';
type ManifestType = {
  [prop: string]: string[];
};
type CtxType = {
  modules: string[];
};
export async function render(url: string, manifest: ManifestType) {
  const { app, router } = createApp();
  router.push(url);
  await router.isReady();
  const ctx: CtxType = {} as CtxType;
  //renderToString将此事的根实例转换成对应的HTML字符串
  const html = await renderToString(app, ctx);
  //获取首屏需要动态预加载的资源
  const preloadLinks = renderPreloadLinks(ctx.modules, manifest);
  return { html, preloadLinks };
}
  • 根据动态的模块id,遍历ssr-manifest.json资源映射表来获取具体文件路径,组装link预加载资源的字符串。
js 复制代码
//获取preload资源,组装字link符串
function renderPreloadLinks(modules: string[], manifest: ManifestType) {
  let links = '';
  const seen = new Set();
  modules.forEach((id: string) => {
    const files = manifest[id];
    if (files) {
      files.forEach((file: string) => {
        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: string) {
  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 '';
  }
}

2.打包生产环境的服务端和浏览器端文件,直接执行npm run build

json 复制代码
{
 "build:client": "vite build --outDir dist/client --ssrManifest",
    "build:server": "vite build --outDir dist/server --ssr src/entry-server-prod.ts",
      "build": "npm run build:client && npm run build:server"
}

打包后得到的文件

3.服务端去除vite,改用纯express作为服务

js 复制代码
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import serverConfig from './server-config.js';
 
async function createServer() {
  
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
  const resolve = (p) => path.resolve(__dirname, p);
  //1.读取index.html
  let template = fs.readFileSync(resolve('dist/client/index.html'), 'utf-8');
  //2.读取ssr-manifest.json资源映射表
  const mainfest = JSON.parse(fs.readFileSync(resolve('dist/client/ssr-manifest.json'), 'utf-8'));
  const app = express();
  //压缩结果文本内容
  app.use((await import('compression')).default());
  //客户端静态资源路径
  app.use(
    '/',
    (await import('serve-static')).default(resolve('dist/client'), {
      index: false
    })
  );
  app.use('*', async (req, res) => {
    //渲染index.html
    const url = req.originalUrl;
    console.log('url', url);
    try {
      const render = (await import('./dist/server/entry-server-prod.js')).render;

      //4.渲染应用的html,entry-server。
      const { html: appHtml, preloadLinks } = await render(url, mainfest);
      //5.注入渲染后的应用程序HTML到模板中
      let html = template
        .replace(`<!--preload-links-->`, preloadLinks)
        .replace(`<!--app-html-->`, appHtml);

      //6.返回渲染后的HTML
      res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
    } catch (error) {    

      console.log(error);
      res.status(500).end(error.message);
    }
  });
  app.listen(serverConfig.port);
}
console.log(serverConfig.url);
createServer();

流程

  1. 读取index.html模板和打包后的得到的ssr-manifest.json资源映射表
  2. express添加压缩结果内容和静态资源路径中间件
  3. 渲染SSR的html:
  • dist/server/entry-server-prod.js服务端渲染入口文件获取render方法
  • 传入url和manifest,执行渲染,得到html和link资源字符串
  • 将字符串对应注入到模板index.html中
  1. 将结果html返回给浏览器显示

4.执行node ./server-prod.js NODE_ENV=production启动ssr服务

效果跟开发模式SSR一致

4.文件目录

5.package.json命令配置

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

Github地址

https://github.com/xiaolidan00/vue-ssr-demo

参考:

相关推荐
.生产的驴10 分钟前
SpringBoot 消息队列RabbitMQ 消息确认机制确保消息发送成功和失败 生产者确认
java·javascript·spring boot·后端·rabbitmq·负载均衡·java-rabbitmq
布瑞泽的童话24 分钟前
无需切换平台?TuneFree如何搜罗所有你爱的音乐
前端·vue.js·后端·开源
白鹭凡36 分钟前
react 甘特图之旅
前端·react.js·甘特图
2401_8628867841 分钟前
蓝禾,汤臣倍健,三七互娱,得物,顺丰,快手,游卡,oppo,康冠科技,途游游戏,埃科光电25秋招内推
前端·c++·python·算法·游戏
书中自有妍如玉1 小时前
layui时间选择器选择周 日月季度年
前端·javascript·layui
Riesenzahn1 小时前
canvas生成图片有没有跨域问题?如果有如何解决?
前端·javascript
f8979070701 小时前
layui 可以使点击图片放大
前端·javascript·layui
小贵子的博客1 小时前
ElementUI 用span-method实现循环el-table组件的合并行功能
javascript·vue.js·elementui
明似水1 小时前
掌握 Flutter 中的 `Overlay` 和 `OverlayEntry`:弹窗管理的艺术
javascript·flutter