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

参考:

相关推荐
@PHARAOH30 分钟前
WHAT - 缓存命中 Cache Hit 和缓存未命中 Cache Miss
前端·缓存
计算机学姐1 小时前
基于SpringBoot的小型民营加油站管理系统
java·vue.js·spring boot·后端·mysql·spring·tomcat
Elastic 中国社区官方博客1 小时前
JavaScript 中使用 Elasticsearch 的正确方式,第一部分
大数据·开发语言·javascript·数据库·elasticsearch·搜索引擎·全文检索
万物得其道者成1 小时前
从零开始创建一个 Next.js 项目并实现一个 TodoList 示例
开发语言·javascript·ecmascript
海天胜景1 小时前
无法加载文件 E:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本
前端·npm·node.js
MingT 明天你好!1 小时前
在vs code 中无法运行npm并报无法将“npm”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。请检查
前端·npm·node.js·visual studio code
老兵发新帖1 小时前
pnpm 与 npm 的核心区别
前端·npm·node.js
超级土豆粉1 小时前
怎么打包发布到npm?——从零到一的详细指南
前端·npm·node.js
OpenTiny社区1 小时前
TinyEngine 2.5版本正式发布:多选交互优化升级,页面预览支持热更新,性能持续跃升!
前端·低代码·开源·交互·opentiny
声声codeGrandMaster2 小时前
Django框架的前端部分使用Ajax请求一
前端·后端·python·ajax·django