likes
comments
collection
share

从0到1使用Vite创建一个SSR模版项目

作者站长头像
站长
· 阅读数 13

本文将使用Vite+Vue3+Ts+Express+Vue Router+Pinia从0到1创建一个SSR项目。

本文所搭建的模版项目github仓库:github链接

使用Vite初始化项目模板

安装pnpm:

npm install pnpm -g

创建Vue3 + Ts模版

pnpm create vite my-vite-vue3-ts-ssr-template --template vue-ts

进入my-vite-vue3-ts-ssr-template目录执行pnpm install安装npm包,然后执行pnpm dev运行开发环境,可以看到如下页面:

从0到1使用Vite创建一个SSR模版项目

设置开发服务器

一个典型的SSR服务目录结构是这样的:

- index.html
- server.ts # main application server
- src/
  - main.ts          # 导出环境无关的(通用的)应用代码
  - entry-client.ts  # 将应用挂载到一个 DOM 元素上
  - entry-server.ts  # 使用某框架的 SSR API 渲染该应用

我们首先来实现server.ts

参照官网,我们的开发服务器使用express,首先进行安装:

pnpm install express
pnpm install @types/express -D

然后因为我们的是ts文件,服务端代码需要用到node,我们需要安装node的类型文件:

pnpm install @types/node -D

简单的开发服务器

我们在src目录下创建一个server.ts目录,输入如下代码:

import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import express from "express";

// 在ts文件里不能直接使用__dirname,所以需要使用这种方法
const __dirname = path.dirname(fileURLToPath(import.meta.url));

async function createServer() {
  const app = express();

  app.use("*", async (req, res) => {
    const html = fs.readFileSync(path.resolve(__dirname, "index.html"));
    res.status(200).set({ "Content-Type": "text/html" }).end(html);
  });

  app.listen(8900);
  console.log("Server is start at http://127.0.0.1:8900");
}

createServer();

这个服务的功能就是读取项目里的html文件然后返回。

使用ts-node启动服务

然后我们在package.json里添加启动脚本,启动脚本需要使用ts-node,先安装ts-node

pnpm install ts-node

在package.json里添加:

  "scripts": {
    "dev": "ts-node server.ts",
    "build": "vue-tsc && vite build",
    "preview": "vite preview"
  },

然后执行pnpm run dev会发现如下报错

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: 
Unknown file extension ".ts" for /Users/sunjunwei/Documents/mygithub/my-vite-vue3-ts-ssr-template/src/server.ts

这时候我们将启动命令改为如下方式:

  "scripts": {
    "dev": "node --loader ts-node/esm server.ts",
  },

然后我们执行pnpm dev可以正确启动服务,然后访问http://127.0.0.1:8900可以拿到我们的html文件。

设置占位标记

我们在index.html文件里设置占位标记<!--ssr-outlet-->,这时的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>
  </head>
  <body>
    <div id="app"><!--ssr-outlet--></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

我们要做的就是将ssr要渲染的内容填充到占位标记这里,我们更改server.ts如下:

import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import express from "express";

// 在ts文件里不能直接使用__dirname,所以需要使用这种方法
const __dirname = path.dirname(fileURLToPath(import.meta.url));

async function createServer() {
  const app = express();

  app.use("*", async (req, res) => {
    const template = fs
      .readFileSync(path.resolve(__dirname, "index.html"))
      .toString();
    const html = template.replace(
      "<!--ssr-outlet-->",
      "<h1>这是填充的内容</h1>"
    );
    res.status(200).set({ "Content-Type": "text/html" }).end(html);
  });

  app.listen(8900);
  console.log("Server is start at http://127.0.0.1:8900");
}

createServer();

这时候重启服务看到页面输出如下:

从0到1使用Vite创建一个SSR模版项目

这就是SSR的基本原理,在此基础上我们来扩展功能。

使用Vite服务

我们在server.ts里加上vite提供的服务:

import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import express from "express";

import { createServer as createViteServer } from "vite";

// 在ts文件里不能直接使用__dirname,所以需要使用这种方法
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 实例作为中间件
  // 如果你使用了自己的 express 路由(express.Router()),你应该使用 router.use
  app.use(vite.middlewares);

  app.use("*", async (req, res, next) => {
    const url = req.originalUrl;

    try {
      // 1. 读取 index.html
      let template = fs.readFileSync(
        path.resolve(__dirname, "index.html"),
        "utf-8"
      );

      // 2. 应用 Vite HTML 转换。这将会注入 Vite HMR 客户端,
      //    同时也会从 Vite 插件应用 HTML 转换。
      //    例如:@vitejs/plugin-react 中的 global preambles
      template = await vite.transformIndexHtml(url, template);

      // 3. 加载服务器入口。vite.ssrLoadModule 将自动转换
      //    你的 ESM 源码使之可以在 Node.js 中运行!无需打包
      //    并提供类似 HMR 的根据情况随时失效。
      const { render } = await vite.ssrLoadModule("/src/entry-server.ts");

      // 4. 渲染应用的 HTML。这假设 entry-server.js 导出的 `render`
      //    函数调用了适当的 SSR 框架 API。
      //    例如 ReactDOMServer.renderToString()
      const { renderedHtml } = await render(url);

      // 5. 注入渲染后的应用程序 HTML 到模板中。
      const html = template.replace(`<!--ssr-outlet-->`, renderedHtml);

      // 6. 返回渲染后的 HTML。
      res.status(200).set({ "Content-Type": "text/html" }).end(html);
    } catch (e) {
      // 如果捕获到了一个错误,让 Vite 来修复该堆栈,这样它就可以映射回
      // 你的实际源码中。
      vite.ssrFixStacktrace(e as Error);
      next(e);
    }
  });

  app.listen(8900);
  console.log("Server is start at http://127.0.0.1:8900");
}

createServer();

主要的功能就是获取html模版,然后获取一个render函数,这个render函数可以根据url渲染出页面内容,然后填充到占位标记里面,再将完整的html返回,下面我们来实现render函数。

更改main.ts

首先我们将main.ts更改如下:

import { createSSRApp } from "vue";
import App from "./App.vue";

export const createApp = () => {
  const app = createSSRApp(App);
  return { app };
};

main.ts是服务端和客户端共用的代码。

然后创建客户端入口文件:

import { createApp } from "./main";
import "./style.css";

const { app } = createApp();

app.mount("#app");

创建服务端入口文件

import { createApp } from "./main";
import { renderToString } from "@vue/server-renderer";

export const render = async (url: string) => {
  const { app } = createApp();

  // 注入vue ssr中的上下文对象
  const renderCtx: { modules?: string[] } = {};

  const renderedHtml = await renderToString(app, renderCtx);

  return { renderedHtml };
};

我们需要安装@vue/server-renderer, @vue/server-renderer可以帮助我们把单文件组件转化为字符串。

pnpm install @vue/server-renderer

index.html里我们将

<script type="module" src="/src/main.ts"></script>

改为

<script type="module" src="/src/entry-client.ts"></script>

这时候再启动服务,我们可以看到返回的HTML是通过SSR渲染的得到的

从0到1使用Vite创建一个SSR模版项目

生产环境打包

上面我们运行的SSR服务是针对开发环境的,如果要针对生产环境,还需要做下面的事情:

1、正常生成一个客户端构建;

2、再生成一个 SSR 构建,使其通过 import() 直接加载,这样便无需再使用 Vite 的 ssrLoadModule

在package.json里添加如下脚本:

  "scripts": {
    "build": "pnpm build:client && pnpm build:server",
    "build:client": "vite build --outDir dist/client",
    "build:server": "vite build --ssr src/entry-server.ts --outDir dist/server"
  },

我们执行pnpm run build可以打包出浏览器和服务器需要的生产环境的构建,打包出的目录如下:

dist
├─server
|   ├─entry-server.js
|   └vite.svg
├─client
|   ├─index.html
|   ├─vite.svg
|   ├─assets
|   |   ├─index-89e3b089.js
|   |   ├─index-94b61395.css
|   |   └vue-5532db34.svg

server目录下的entry-server.js文件就是以src/entry-server.ts为入口文件打包出来的资源,vite.svg是放在public下的资源,默认也会打包进来

client目录下是我们的index.html入口文件,assets下的静态资源是通过src/entry-client.ts构建出来的,vite.svg是public目录下的资源。

生产环境启动脚本

接下来我们需要开发生产环境的node服务,我们将原先的server.ts更改为server-dev.ts,然后新建一个server-prod.ts,启动脚本更改为如下:

  "scripts": {
    "dev": "node --loader ts-node/esm server-dev.ts",
    "prod": "node --loader ts-node/esm server-prod.ts",
  },

生产环境server-prod.ts

然后我们编写server-prod.ts如下:

import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import express from "express";
// @ts-ignore
import { render } from "./dist/server/entry-server.js";

// 在ts文件里不能直接使用__dirname,所以需要使用这种方法
const __dirname = path.dirname(fileURLToPath(import.meta.url));

async function createServer() {
  const app = express();

  const resolve = (p: string) => path.resolve(__dirname, p);
  // 模版使用打包好的
  const template = fs.readFileSync(
    resolve("./dist/client/index.html"),
    "utf-8"
  );

  // 请求静态资源
  app.use(
    "/assets",
    express.static(resolve("./dist/client/assets"), {
      maxAge: "1000h", // 设置缓存时间
    })
  );

  app.use("*", async (req, res) => {
    const url = req.originalUrl;

    try {
      const { renderedHtml } = await render(url);

      // 5. 注入渲染后的应用程序 HTML 到模板中。
      const html = template.replace(`<!--ssr-outlet-->`, renderedHtml);

      // 6. 返回渲染后的 HTML。
      res.status(200).set({ "Content-Type": "text/html" }).end(html);
    } catch (e) {
      console.log((e as Error).stack);
      res.status(500).end((e as Error).stack);
    }
  });

  app.listen(8900);
  console.log("Server is start at http://127.0.0.1:8900");
}

createServer();

和开发环境主要的区别就是我们需要的template和render函数从构建好的dist目录获取,然后针对静态资源使用express.static中间件返回对应的文件就可以了。

使用pnpm run prod后我们可以看到资源请求正常

从0到1使用Vite创建一个SSR模版项目

但是还有一个小问题,vite.logo没有加载出来,我们在App.vue里引用该图片的代码如下:

    <a href="https://vitejs.dev" target="_blank">
      <img src="/vite.svg" class="logo" alt="Vite logo" />
    </a>

这意思是请求根目录下的vite.svg图片,这个图片是在public目录下的,的确会被打包到根目录,但是我们服务器的设置返回静态资源的代码如下:

  // 请求静态资源
  app.use(
    "/assets",
    express.static(resolve("./dist/client/assets"), {
      maxAge: "1000h", // 设置缓存时间
    })
  );

我们认为静态资源都是放在assets目录下的,也就是说请求链接的前缀需要是/assets才会走到这个静态资源服务器。

要处理这个问题有两个方法,一个是在静态资源服务器这里针对直接打包到根目录的资源做处理,让/vite.logo可以正常返回,第二个是更改我们的js代码,不要在js里请求public里的资源。

这里推荐第二种,因为官方文档也说了不要在js里引用public里的资源。

所以我们只需要将vite.logo拷贝到src/assets目录下,然后将App.vue里的代码改下:

    <a href="https://vitejs.dev" target="_blank">
      <img src="./assets/vite.svg" class="logo" alt="Vite logo" />
    </a>

然后再重新打包

pnpm run build

再重启下服务就好了

pnpm run prod

集成Vue Router

然后我们来集成路由功能

创建路由组件

首先我们在src/views下新建两个路由组件

src/view/Home.vue

<template>
  <div>This is Home Page</div>
</template>

<script setup lang="ts"></script>

src/view/About.vue

<template>
  <div>This is About Page</div>
</template>

<script setup lang="ts"></script>

创建路由函数

然后我们创建src/router/index.ts

import {
  createRouter as _createRouter,
  createWebHistory,
  createMemoryHistory,
} from "vue-router";
import type { RouteRecordRaw } from "vue-router";

const routes: RouteRecordRaw[] = [
  {
    path: "/",
    redirect: () => {
      return { name: "Home" };
    },
  },
  {
    path: "/home",
    name: "Home",
    component: () => import("../views/Home.vue"),
  },
  {
    path: "/about",
    name: "About",
    component: () => import("../views/About.vue"),
  },
];

export function createRouter() {
  return _createRouter({
    // use appropriate history implementation for server/client
    // import.meta.env.SSR is injected by Vite.
    history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
    routes,
  });
}

在main.ts添加路由

在main.ts添加路由如下

import { createSSRApp } from "vue";
import App from "./App.vue";
import { createRouter } from "./router";

export const createApp = () => {
  const app = createSSRApp(App);
  const router = createRouter();
  app.use(router);
  return { app, router };
};

在客户端入口文件添加路由

在client-entry.ts文件添加路由

import { createApp } from "./main";
import "./style.css";

const { app, router } = createApp();

// wait until router is ready before mounting to ensure hydration match
router.isReady().then(() => {
  app.mount("#app");

  console.log("hydrated");
});

主要是增加了router.isReady()功能,在路由准备好之后再进行组件挂载,这样可以保证客户端挂载的组件和服务端渲染后的得到的组件是匹配的。

在服务端入口文件添加路由

在server-entry.ts添加路由

import { createApp } from "./main";
import { renderToString } from "@vue/server-renderer";

export const render = async (url: string) => {
  const { app, router } = createApp();

  // set the router to the desired URL before rendering
  await router.push(url);
  await router.isReady();

  // 注入vue ssr中的上下文对象
  const renderCtx: { modules?: string[] } = {};

  const renderedHtml = await renderToString(app, renderCtx);

  return { renderedHtml };
};

主要是使用router.push(url)切换路由以及router.isReady()等待切换完成。

执行开发环境

然后我们执行pnpm run dev可以看到项目正常启动,页面如下

从0到1使用Vite创建一个SSR模版项目

执行生产环境

我们执行

pnpm run build
pnpm run prod

可以看到页面功能也是正常的,同时请求的资源都是打包后的资源

从0到1使用Vite创建一个SSR模版项目

集成Pinia

接下来我们来集成Pinia,首先我们需要安装pinia

pnpm install pinia

mian.ts增加pinia

代码如下:

import { createSSRApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import { createRouter } from "./router";

export const createApp = () => {
  const app = createSSRApp(App);
  const router = createRouter();
  app.use(router);
  const pinia = createPinia();
  app.use(pinia);
  return { app, router, pinia };
};

创建pinia目录

我们创建src/pinia/index.ts,代码如下

import { ref } from "vue";
import type { Ref } from "vue";
import { defineStore } from "pinia";

// 当前选择的钱包地址
export const useCountStore = defineStore("count", () => {
  const count: Ref<number> = ref(0);
  const increaseCount = () => {
    count.value = count.value + 1;
  };

  return { count, increaseCount };
});

使用pinia

然后我们就可以使用pinia了,我们在Home.vue里使用Pinia,代码如下:

// Home.vue
<template>
  <div>This is Home Page</div>
  <span>Count from Pinia:</span>
  <button @click="increaseCount">{{ count }}</button>
</template>

<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useCountStore } from "../pinia";
const countStore = useCountStore();
const { count } = storeToRefs(countStore);
const { increaseCount } = countStore;
</script>

执行pnpm run dev页面一切正常,点击按钮pinia里的数据会更新。

从0到1使用Vite创建一个SSR模版项目

重新打包后生产环境也是正常的。

设置Pinia初始状态

服务端渲染Pinia里的数据可能是服务端帮我们拉取好了的,客户端不需要自己再去拉取一遍这个数据,客户端需要使用服务端渲染好的状态作为初始状态。

比如我们更改一下src/pinia/index.ts

import { ref } from "vue";
import type { Ref } from "vue";
import { defineStore } from "pinia";

// 当前选择的钱包地址
export const useCountStore = defineStore("count", () => {
  const count: Ref<number> = ref(0);
  const increaseCount = () => {
    count.value = count.value + 1;
  };

  // 简单模拟服务端数据拉取逻辑
  if (import.meta.env.SSR) {
    count.value = 3;
  }

  return { count, increaseCount };
});

这里简单模拟了一下服务端数据拉取的逻辑

  // 简单模拟服务端数据拉取逻辑
  if (import.meta.env.SSR) {
    count.value = 3;
  }

会将count的初始状态设置为3,这时我们看页面返回可以看到服务端渲染返回的html里count是3,可是我们页面展示的还是0

从0到1使用Vite创建一个SSR模版项目

同时控制台还会提示客户端激活后的数据和服务端返回的不一致

从0到1使用Vite创建一个SSR模版项目

所以我们需要用服务端的数据作为客户端激活后的初始数据

设置Pinia初始数据

首先我们在index.html增加

    <script>
      window.__INITIAL_STATE__ = `<!--pinia-state-->`;
    </script>

index.html代码如下:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/assets/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + Vue + TS</title>
  </head>
  <body>
    <div id="app"><!--ssr-outlet--></div>
    <script>
      window.__INITIAL_STATE__ = `<!--pinia-state-->`;
    </script>
    <script type="module" src="/src/entry-client.ts"></script>
  </body>
</html>

更改服务端入口文件

更改entry-server.ts如下

import { createApp } from "./main";
import { renderToString } from "@vue/server-renderer";

export const render = async (url: string) => {
  const { app, router, pinia } = createApp();

  // set the router to the desired URL before rendering
  await router.push(url);
  await router.isReady();

  // 注入vue ssr中的上下文对象
  const renderCtx: { modules?: string[] } = {};

  const renderedHtml = await renderToString(app, renderCtx);

  const state = JSON.stringify(pinia.state.value);

  return { renderedHtml, state };
};

主要代码为:

  const state = JSON.stringify(pinia.state.value);

更改server-dev.ts

然后我们在server-dev.ts里引入:

      const { renderedHtml, state } = await render(url);

      // 5. 注入渲染后的应用程序 HTML 到模板中。
      const html = template
        .replace(`<!--ssr-outlet-->`, renderedHtml)
        .replace(`<!--pinia-state-->`, state);

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

server-dev.ts完整代码如下:

import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import express from "express";

import { createServer as createViteServer } from "vite";

// 在ts文件里不能直接使用__dirname,所以需要使用这种方法
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 实例作为中间件
  // 如果你使用了自己的 express 路由(express.Router()),你应该使用 router.use
  app.use(vite.middlewares);

  app.use("*", async (req, res, next) => {
    const url = req.originalUrl;

    try {
      // 1. 读取 index.html
      let template = fs.readFileSync(
        path.resolve(__dirname, "index.html"),
        "utf-8"
      );

      // 2. 应用 Vite HTML 转换。这将会注入 Vite HMR 客户端,
      //    同时也会从 Vite 插件应用 HTML 转换。
      //    例如:@vitejs/plugin-react 中的 global preambles
      template = await vite.transformIndexHtml(url, template);

      // 3. 加载服务器入口。vite.ssrLoadModule 将自动转换
      //    你的 ESM 源码使之可以在 Node.js 中运行!无需打包
      //    并提供类似 HMR 的根据情况随时失效。
      const { render } = await vite.ssrLoadModule("/src/entry-server.ts");

      // 4. 渲染应用的 HTML。这假设 entry-server.js 导出的 `render`
      //    函数调用了适当的 SSR 框架 API。
      //    例如 ReactDOMServer.renderToString()
      const { renderedHtml, state } = await render(url);

      // 5. 注入渲染后的应用程序 HTML 到模板中。
      const html = template
        .replace(`<!--ssr-outlet-->`, renderedHtml)
        .replace(`<!--pinia-state-->`, state);

      // 6. 返回渲染后的 HTML。
      res.status(200).set({ "Content-Type": "text/html" }).end(html);
    } catch (e) {
      // 如果捕获到了一个错误,让 Vite 来修复该堆栈,这样它就可以映射回
      // 你的实际源码中。
      vite.ssrFixStacktrace(e as Error);
      next(e);
    }
  });

  app.listen(8900);
  console.log("Server is start at http://127.0.0.1:8900");
}

createServer();

更改server-prod.ts

server-prod.ts也和server-dev.ts做一样的更改,完整代码如下:

import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import express from "express";
// @ts-ignore
import { render } from "./dist/server/entry-server.js";

// 在ts文件里不能直接使用__dirname,所以需要使用这种方法
const __dirname = path.dirname(fileURLToPath(import.meta.url));

async function createServer() {
  const app = express();

  const resolve = (p: string) => path.resolve(__dirname, p);
  // 模版使用打包好的
  const template = fs.readFileSync(
    resolve("./dist/client/index.html"),
    "utf-8"
  );

  // 请求静态资源
  app.use(
    "/assets",
    express.static(resolve("./dist/client/assets"), {
      maxAge: "1000h", // 设置缓存时间
    })
  );

  app.use("*", async (req, res) => {
    const url = req.originalUrl;

    try {
      const { renderedHtml, state } = await render(url);

      // 5. 注入渲染后的应用程序 HTML 到模板中。
      const html = template
        .replace(`<!--ssr-outlet-->`, renderedHtml)
        .replace(`<!--pinia-state-->`, state);

      // 6. 返回渲染后的 HTML。
      res.status(200).set({ "Content-Type": "text/html" }).end(html);
    } catch (e) {
      console.log((e as Error).stack);
      res.status(500).end((e as Error).stack);
    }
  });

  app.listen(8900);
  console.log("Server is start at http://127.0.0.1:8900");
}

createServer();

更改客户端入口文件

然后我们需要更改entry-client.ts

import { createApp } from "./main";
import "./style.css";

const { app, router, pinia } = createApp();

// 初始化 pinia
// 注意:__INITIAL_STATE__需要在 src/shims-global.d.ts中定义
if (window.__INITIAL_STATE__) {
  pinia.state.value = JSON.parse(window.__INITIAL_STATE__);
}

// wait until router is ready before mounting to ensure hydration match
router.isReady().then(() => {
  app.mount("#app");

  console.log("hydrated");
});

这时候我们再重新运行服务,客户端的初始状态就和服务端到一样了

从0到1使用Vite创建一个SSR模版项目

一个完整的SSR服务基本就搭建完成了,后面还有一个数据预取逻辑,有时间再更新下