vite + vue3 + ts + SSR 使用 Vite 内置 SSR 功能
搭建 vue3 ssr,有多种方式:
- 使用
Vite 内置 SSR 功能,它 Vite 的底层 SSR 功能。 - 使用
vite-ssr插件。基于Vite 内置 SSR 功能,简化了服的务器端渲染。只适用于 vue3。 - 使用
vite-plugin-ssr插件。基于Vite 内置 SSR 功能,提供比 Vite 的底层 SSR 更流畅的体验。适用于 vue3、vue2。 - 使用
ssr插件。基于Vite 内置 SSR 功能。适用于 vue3、vue2。
安装 vue3
cd /var/web/wwwnpm init vue@latest
项目名称:exampleSSR
cd /var/web/www exampleSSRnpm install
安装 axios
cd /var/web/www exampleSSRnpm install axios
至此,已经安装 vite + vue3 + TypeScript + vue-router + pinia + axios。还有 vitest 单元测试框架、cypress 端到端测试、ESLint 语法规则检测工具、Prettier 代码格式化工具。
局部安装 express
Express基于 NodeJs 平台,快速、简洁的 Web 应用框架,为 Web 和移动应用程序提供一组强大的功能。中文:https://www.expressjs.com.cn
cd /var/web/www/exampleSSR # 在项目中,安装 Expressnpm install express -S # 在项目中,安装 TypeScript 依赖npm install @types/express -D
node 无法直接运行 ts 文件,需要使用 ts-node。Express 是基于 node 环境的,所以还需要安装 node TypeScript 依赖。
cd /var/web/www/exampleSSR # 在项目中,安装 ts-nodenpm install ts-node -D # 在项目中,安装 TypeScript 依赖npm install @types/node -D
查看已安装
npm listnpm list expressnpm list ts-node
配置格式化
项目根目录下,tsconfig.json文件中,新增compilerOptions选项:
"compilerOptions": {
"types": ["node", "jsdom", "vite/client", "element-plus/global"],
"target": "esnext",
"module": "esnext",
"lib": ["dom", "esnext"],
"strict": true
},
在项目根目录下,修改.prettierrc.json文件
{
"semi": true,
"singleQuote": true,
"printWidth": 200,
"endOfLine": "lf"
}
在项目根目录下,修改.eslintrc.cjs文件,新增rules选项:
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution');
module.exports = {
root: true,
extends: ['plugin:vue/vue3-essential', 'eslint:recommended', '@vue/eslint-config-typescript', '@vue/eslint-config-prettier'],
overrides: [
{
files: ['cypress/e2e/**.{cy,spec}.{js,ts,jsx,tsx}'],
extends: ['plugin:cypress/recommended'],
},
],
parserOptions: {
ecmaVersion: 'latest',
},
rules: {
'prettier/prettier': [
'warn',
{
semi: true,
singleQuote: true,
printWidth: 200,
endOfLine: 'lf',
},
],
},
};
配置完毕后,需要重启 vscode 编辑器。然后运行以下命令,来格式化文件。
npm run lintnpm run dev
使用 Vite 内置 SSR 功能
SSR(Server-Side Rendering):服务器端渲染。Vite 为服务端渲染(SSR)提供了内置支持,一个底层 API,是为制作库和框架准备的。
SSR 文件结构
一个典型的 SSR 应用应该有如下的源文件结构:
- index.html - server.ts # 服务端启动文件 - src/ - main.ts # 导出环境无关的(通用的)应用代码 - entry-client.ts # 客户端入口,应用挂载元素。将应用挂载到一个 DOM 元素上 - entry-server.ts # 服务端入口,处理服务端逻辑和静态资源。使用某框架的 SSR API 渲染该应用
创建三个文件:
touch server.ts src/entry-client.ts src/entry-server.ts
Node 运行 ESM 模式
在服务端运行 node,需要开启 esm 模式,另外,需要 ts-node,用node --loader ts-node/esm来执行 TS 文件。官方文档:package.json文件中,必须设置"type":"module",实现运行esm模式。tsconfig.json文件中,必须设置"module":"ESNext"。注意:这里是node 16+版本。
# package.json "name": "exampleSSR", "version": "0.0.0", "type ": "module", "scripts": { "dev ": "node --loader ts-node/esm server.ts", // 运行 server.ts 文件 }
# tsconfig.json
"compilerOptions": {
"strict": true,
"target": "esnext",
"module": "esnext",
"lib": ["esnext", "dom", "dom.iterable", "scripthost"],
"types": ["node", "jsdom", "vite/client", "element-plus/global"]
},
"ts-node": {
"esm ": true,
"compilerOptions": {
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop ": true,
}
},
index.html
index.html将需要引用entry-client.ts,以实现客户端(在浏览器中)动态交互。另外,占位标记<!--app-html-->,服务端渲染时替换为输出的 HTML 内容。静态资源占位符(js 、css 文件):<!--preload-links-->。占位标记可以根据个人喜好,随意起名称。
<!DOCTYPE html>
<html lang="zh-hans">
<head>
<meta charset="UTF-8" />
<meta name="applicable-device" content="pc,mobile" />
<meta name="renderer" content="webkit|ie-comp|ie-stand" />
<meta name="force-rendering" content="webkit" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=no" />
<link rel="icon" href="/favicon.ico" />
<link rel="Bookmark" href="/favicon.ico" />
<link rel="Shortcut Icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite SSR</title>
<meta name="keywords" content="Vite SSR" />
<meta name="description" content="Vite SSR" />
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/entry-client.ts"></script>
<!--app-state-->
</body>
</html>
server.ts
这个server.ts文件的功能是启动一个 Nodejs Web 服务,来响应客户端请求,然后根据请求,读取index.html文件,处理资源后把其中的占位符(注释)进行替换,最后把 HTML 页面内容,发送给请求者阅览。
在构建 SSR 应用程序时,你可能希望由 Web 服务器控制,并将 Vite 与生产环境脱钩。因此,建议以中间件模式使用 Vite。
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import express from 'express';
import { createServer as createViteServer } from 'vite';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function createServer() {
// 创建node服务,设置端口
const app = express();
const PORT = 5173;
app.set('port', PORT);
// 以中间件模式创建 Vite 应用,这将禁用 Vite 自身的 HTML 服务逻辑
// 并让上级服务器接管控制
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
});
// 使用 vite 的 Connect 实例作为中间件
// 如果你使用了自己的 express 路由(express.Router()),你应该使用 router.use
app.use(vite.middlewares);
// 服务 index.html
// exporss 请求拦截器, * 全部路由, req 参数是 HTTP 请求 request,res 参数是请求响应 respones,next 参数,路由
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.ts 导出的 `render`
// 函数调用了适当的 SSR 框架 API。
// 例如 ReactDOMServer.renderToString()
const [appHtml, renderState] = await render(url);
// 传递 Pinia 状态管理。自定义 window 属性 __pinia
let appState = '';
if (renderState) {
appState = "<script>window.__pinia='" + JSON.stringify (renderState) + "'</script>";
}
// 5. 注入渲染后的应用程序 HTML 到模板中。
const html = template.replace(`<!--app-html-->`, appHtml).replace(`<!--app-state-->`, appState);
// 6. 返回渲染后的 HTML。
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (e: any) {
// 如果捕获到了一个错误,让 Vite 来修复该堆栈,这样它就可以映射回你的实际源码中。
vite?.ssrFixStacktrace(e);
next(e);
}
});
process.on('warning', (warning) => {
console.warn(warning.name); // 打印告警名称
console.warn(warning.message); // 打印告警信息
console.warn(warning.stack); // 打印堆栈信息
});
app.listen(PORT);
}
createServer();
router/index.ts
import { createRouter as _createRrouter , createMemoryHistory, createWebHistory } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';
import HomeView from '../views/HomeView.vue';
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue'),
},
];
export function createRouter() {
return _createRrouter ({
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(import.meta.env.BASE_URL),
routes,
});
}
stores/index.ts
import { defineStore, createPinia } from 'pinia';
import type { MetaObj } from '@/types/book';
export const useUserStore = defineStore('user', {
state: () => {
return {
name: '张三',
age: 20
};
},
actions: {
updateName(name: string) {
this.name = name;
},
updateAge(age: number) {
this.age = age;
}
}
});
export const useBrowserStore = defineStore('browser', {
state: () => ({
browserStatus: 0,
}),
getters: {
initBrowserStatus(state) {
return (state.browserStatus = 0);
},
},
});
export const createStore = () => {
const pinia = createPinia();
useBrowserStore(pinia);
useUserStore(pinia);
return pinia;
};
export default createStore ;
main.ts
在 SSR 环境下,服务器只会初始化一次。因为每个请求到达服务端,每次请求必须是全新的实例,为了防止状态污染,所以main.ts每次都返回全新的 vue 实例,router 实例,store 实例等。
import { createSSRApp } from 'vue';
import createStore from '@/stores';
import router from './router';
import App from './App.vue';
import './assets/main.css';
// SSR 要求每个请求都有一个新的应用程序实例,因此我们导出一个函数。
// 创建新的应用程序实例。如果使用 Pinia、Vuex,我们也会在这里创建一个新 Store。
export function createApp() {
const app = createSSRApp(App);
const pinia = createPinia();
app.use(router);
app.use(pinia);
return { app, router, pinia };
}
entry-server.ts
服务端入口文件entry-server.ts:主要是调用 SSR 的renderToString和收集需要发送的资源数据。
import type { RouteLocationRaw } from 'vue-router';
import { renderToString } from 'vue/server-renderer';
import { createApp } from './main';
export async function render(url: RouteLocationRaw) {
const { app, router, pinia } = createApp();
router.push(url);
await router.isReady();
const ctx = {};
const html = await renderToString (app, ctx);
return [html, pinia.state.value];
}
entry-client.ts
客户端入口文件entry-client.ts:主要用于挂载节点和初始化数据。
import { createApp } from './main'
const { app, router, pinia } = createApp()
// wait until router is ready before mounting to ensure hydration match
router.isReady().then(() => {
if (window.__pinia) {
pinia.state.value = JSON.parse (window.__pinia);
}
app.mount('#app')
})
TypeScript 文件env.d.ts中添加:
interface Window {
__pinia: any;
}
