vite + vue3 + ts + SSR 使用 vite-ssr 插件
vite-ssr,是基于Vite 内置 SSR 功能的插件。适用于 vue3、react。vite-ssr 是由@frandiox 来创建的,作为 Node.js 中 Vite 的一个简单而又强大的 SSR 解决方案。这是将 Vite 的 SSR API 作为高级解决方案公开的另一种方式。@frandiox 还是 vitedge 的创建者,这是一个在 Cloudflare Workers 上运行的全栈 Vite 框架。
vite-ssr 是为 vite2 ,在 nonde.js 环境中,实现服务端渲染的解决方案,它开发体验简单而功能强大。
- 快速的热启动,即使在 SSR 环境下。由 vite 提供支持。
- 一致的开发体验,可以抽离出大部分 SSR,降低复杂性。
- 小型库,不关心页面路由和 API 逻辑。
- 体验:快速呈现 SEO 功能,与 SPA 接管快速。
- 兼容 Vite 的插件生态系统,如基于文件的路由、PWA 等。
vite-ssr 可以部署到任何 Node.js 或 browser-like 的环境。可以部署到任何服务器平台,如 Vercel、Netlify,甚至 Cloudflare Workers。它还可以与 Express.js 、Fastify 等更传统的服务器一起运行。
安装
安装:vite、vue3 TypeScript 版,项目名称:vite-ssr
cd /var/web/wwwyarn create vite vite-ssr --template vue-ts
cd /var/web/www/vite-ssryarn yarn dev
安装:vite-ssr、vue-router、@vueuse/head
# vite-ssr 依赖 vue-router@4yarn add vite-ssr vue-router@4 @vueuse/head
yarn add pinia axios
查看已经安装的包
yarn list --depth=0
vite 配置别名 alias
安装 node TypeScript 类型支持
yarn add -D @types/node
修改vite.config.ts、tsconfig.json
// vite.config.ts
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
});
// tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
修改完毕后,重启 vscode
格式化工具
安装 eslint、prettier,说明文档
yarn add -D eslint eslint-plugin-vueyarn add -D --exact prettier
配置完毕后,需要重启 vscode 编辑器。
vue3 测试数据
为了便于测试效果,创建一些组件、路由、Pinia 数据
使用路由 router
src/router目录下,新建index.ts
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '@/views/HomeView.vue';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/about',
name: 'about',
component: () => import('@/views/AboutView.vue'),
},
],
});
export default router;
使用组件
src目录下,新建views目录,然后创建组件HomeView.vue、AboutView.vue、CountNunber.vue:
// src/views/HomeView.vue
<script setup lang="ts">
import CountNunber from './CountNunber.vue';
</script>
<template>
<main>主页</main>
<CountNunber msg="计数器" />
</template>
<style scoped>
main {
width: 100%;
font-size: 36px;
text-align: center;
margin-top: 10rem;
}
</style>
// CountNunber.vue
<script setup lang="ts">
import { ref } from 'vue';
defineProps<{ msg: string }>();
const count = ref(0);
</script>
<template>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
</div>
<p>{{ msg }}</p>
</template>
<style scoped></style>
// AboutView.vue
<script setup lang="ts"></script>
<template>
<div class="about">
<h1>关于</h1>
</div>
</template>
<style scoped></style>
修改根组件App.vue:
// App.vue
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router';
</script>
<template>
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
<RouterView />
</template>
<style scoped>
nav {
width: 100%;
font-size: 18px;
text-align: center;
margin-top: 2rem;
}
nav a.router-link-exact-active {
color: var(--color-text);
}
nav a.router-link-exact-active:hover {
background-color: transparent;
}
nav a {
display: inline-block;
padding: 0 1rem;
border-left: 1px solid var(--color-border);
}
nav a:first-of-type {
border: 0;
}
</style>
使用 Pinia
src下,新建stores/counter.ts
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', () => {
const count = ref(0);
const doubleCount = computed(() => count.value * 2);
function increment() {
count.value++;
}
return { count, doubleCount, increment };
});
main.ts
修改入口文件main.ts:
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { routes, router } from './router';
import App from './App.vue';
import './style.css';
const app = createApp(App);
const pinia = createPinia();
app.use(router);
app.use(pinia);
app.mount('#app');
测试运行
根目录下,运行命令
yarn dev
搭建 SSR
vue3 SPA 开发,有几个重要文件:index.html,入口文件main.ts,根组件App.vue,配置文件vite.config.ts。但使用vite-ssr插件,搭建 SSR,重要文件只有两个:入口文件main.ts,配置文件vite.config.ts。其余的文件与 vue3 SPA 开发相同即可。相对于使用Vite 内置 SSR 功能来搭建 SSR,确实简单很多。
vite.config.ts
修改vite.config.ts,导入vitessr()插件:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import viteSSR from 'vite-ssr/plugin.js'
export default defineConfig({
plugins: [
viteSSR(),
vue()
]
})
main.ts
在入口文件main.ts中导入 Vite SSR 主处理程序viteSSR():
import { routes, router } from './router';
import App from './App.vue';
import './style.css';
import viteSSR from 'vite-ssr';
import { createPinia } from 'pinia';
import { createHead } from '@vueuse/head';
export default viteSSR(App, { routes }, (context) => {
const { app, router, initialState } = context;
const pinia = createPinia();
const head = createHead();
pinia.state.value = initialState.pinia;
app.use(pinia);
app.use(head);
return { head };
});
vite-ssr 文档
viteSSR()
默认情况下,只有一个入口文件main.ts(或 main.js),它将负责为代码提供正确的环境。查看完整示例:Vue、React。
import { createApp } from 'vue';
import App from './App.vue';
import viteSSR from 'vite-ssr';
export default viteSSR(App, { routes }, (context) => {
/* Vite SSR main hook for custom logic */
/* const { app, router, initialState, ... } = context */
});
如果需要只在客户端或服务器中运行的条件逻辑,请使用 Vite 的import.meta.env.SSR布尔值判断,使用tree-shaking将完成其余操作。
SSR 功能实现,一个方面是服务端生产 HTML,返回给请求。同时,客户端创建虚拟 DOM,激活 HTML,便于交互。viteSSR(),三个参数,分别对应组件、Vue(Vue 本身以及客户端hydration功能)、Vite SSR(服务端 SSR 功能)。
第一个参数
App根组件。
第二个参数
配置 Vue 以及客户端hydration功能。传递的参数,是一个对象,可以接受以下选项:
- routes:路由数组,搭配应用程序的路由器。
- base():是个函数,返回带有 base 路由器的字符串。对于i18n路由或应用程序,部署在根目录时非常有用。
- routerOptions:其他路由器选项,如 vue 路由器中的
scrollBehavior。 - transformState:用于修改,序列化或反序列化的 state。
- pageProps.passToPage:是否应将每个路由的
initialState作为props自动传递给页面组件。 - debug.mount:传递
false以防止在客户端中挂载应用程序 app。您需要自己手动完成此操作,但知道SSR和hydration之间的差异。 - styleCollector:仅在 React 中。JS中提取 CSS 的机制。
第三个参数
配置 Vite SSR(服务端)。传递的参数,是一个箭头函数,是 Vite SSR(服务端)的主钩子,它在开始时只运行一次。它接收 SSR 上下文,可用于初始化应用程序或设置状态管理或其他插件的内容。请参见示例:Vue + Pinia
context:传递给主钩子的上下文,包含:
- initialState:可以在 SSR 期间更改以保存要序列化的任何数据的对象。同样的对象和数据可以在浏览器中读取。
- url:初始 URL。
- isClient:类似于 import.meta.env.SSR 的布尔值。与它不同的是,isClient 不会触发tree shaking。
- request:在 SSR 期间可用。
- redirect():用于重定向到其他 URL 的同构函数。
- writeResponse:向响应对象添加状态或头部信息的函数(仅在后端)。
- router:Vue 中的路由器实例,React 中的自定义路由器,用于访问路由和页面组件。
- app:App 实例,仅在 Vue 中。
- initialRoute:初始路由对象,仅在 Vue 中。
还可以使用useContext钩子,从任何组件访问此上下文context:
import { useContext } from 'vite-ssr'
//...
function() {
// In a component
const { initialState, redirect } = useContext()
// ...
}
使用 state
SSR 中获取 state
前端开发中,常用到能够跨组件、跨页面,而能共享的统一变量状态,跟 vue 搭配的有 Pinia、vuex储存库。SSR 开发中,需要传递获取状态 state。Vue 向 Vite SSR(服务端)提供初始状态 state,有多种方式:
通过route.meta.state传递。在输入路由之前调用 Router 的beforeEach或beforeEnter,并填充route.meta.state。Vite SSR(服务端)将获取第一条路由的状态,并将其用作 SSR 初始状态。请参阅:完整示例
export default viteSSR(
App,
{ routes },
async ({ app, router }) => {
router.beforEach (async (to, from) => {
if (to.meta.state) {
return // Already has state
}
const response = await fetch('my/api/data/' + to.name)
// This will modify initialState
to.meta.state = await response.json()
})
})
通过Store库传递。在 SSR 期间,在组件serverPrefetch钩子运行时,使用 Pinia(或者 vuex 库),可以获取 state、更改 state、储存 state。在 Nuxt.js 框架中,还可以重新创建 asyncData。
// 组件
export default {
beforeMount() {
// In browser
this.fetchMyData()
},
async serverPrefetch() {
// During SSR
await this.fetchMyData()
},
methods: {
fetchMyData() {
const store = useStore()
if (!store.myData) {
return fetch('my/api/data').then(res => res.json()).then((myData) => {
store.myData = myData
})
}
},
},
}
// Main.ts
export default viteSSR(App, { routes }, ({ app, initialState }) => {
// You can pass it to your state management
// or use `useContext()` like in the Suspense example
const pinia = createPinia()
// Sync initialState with the store:
if (import.meta.env.SSR) {
initialState.pinia = pinia.state.value
} else {
pinia.state.value = initialState.pinia
}
app.use(pinia)
})
使用Suspense直接从 Vue 组件调用 API,并将结果在 SSR 初始状态 state。在这里查看Suspense的完整示例。如果您喜欢Axios,这里还有一个示例。
import { useContext } from 'vite-ssr'
import { useRoute } from 'vue-router'
import { inject, ref } from 'vue'
// This is a custom hook to fetch data in components
export async function useFetchData(endpoint) {
const { initialState } = useContext()
const { name } = useRoute() // this is just a unique key
const state = ref(initialState[name] || null)
if (!state.value) {
state.value = await (await fetch(endpoint)).json()
if (import.meta.env.SSR) {
initialState[name] = state.value
}
}
return state
}
// Page Component with Async Setup
export default {
async setup() {
const state = await useFetchData('my-api-endpoint')
return { data }
},
}
// Use Suspense in your app root
<template>
<RouterView v-slot="{ Component }">
<Suspense>
<component :is="Component" />
</Suspense>
</RouterView>
</template>
SSR 中序列化 state
SSR 中initialState,是应用程序数据,是经过序列化处理的,是作为服务端渲染 HTML 的一部分数据,以便以后在浏览器中hydration。通常从 API 代码中,使用 fetch 或 DB requests 收集此数据。
initialState,由传递给应用程序的普通 JS 对象组成,可以在 SSR 期间随意修改。此对象将被序列化,稍后在浏览器中自动hydration,并再次传递给应用程序,以便您可以将其用作数据源。
export default viteSSR(App, { routes }, ({ initialState }) => {
if (import.meta.env.SSR) {
// Write in server
initialState.myData = 'DB/API data'
} else {
// Read in browser
console.log(initialState.myData) // => 'DB/API data'
}
// 根据您的用途,初始状态,可以给提供store、组件等。
})
Vite SSR(服务端)只使用JSON.stringify用于序列化状态,转义某些字符以阻止 XSS,并将其保存在 DOM 中。如果需要支持 dates、regexp 或函数序列化,可以使用transformState钩子覆盖此行为:
import viteSSR from 'vite-ssr'
import App from './app'
import routes from './routes'
export default viteSSR(App, {
routes,
transformState (state, defaultTransformer) {
if (import.meta.env.SSR) {
// Serialize during SSR by using,
// for example, using @nuxt/devalue
return customSerialize(state)
// -- Or use the defaultTransformer after modifying the state:
// state.apolloCache = state.apolloCache.extract()
// return defaultTransformer(state)
} else {
// Deserialize in browser
return customDeserialize(state)
}
},
})
访问响应和请求对象
在开发中,响应和请求对象都在 SSR 期间传递给主钩子:
export default viteSSR(
App,
{ routes },
({ initialState, request, response }) => {
// Access request cookies, etc.
}
)
在生产环境中,您可以控制服务器,因此必须将这些对象传递给渲染函数,以便在主钩子中使用它们:
import render from './dist/server'
//...
const { html } = await render (url, {
manifest,
preload: true,
request,
response,
// Anything here will be available in the main hook.
initialState: { hello: 'world' }, // Optional prefilled state
})
请注意,在开发环境中,Vite 使用默认的Node.js+Connect用做中间件。因此,在生产环境,如果使用任何服务器框架(如 Fastify、Express.js 或者 Polka),请求和响应对象,会有所不同。如果您想在开发期间使用自己的服务器,请使用中间件模式。
编辑响应和重定向
可以使用writeResponse实用程序为响应设置状态和标头。对于重定向,重定向实用程序可在 SSR(服务器重定向)和浏览器(历史推送)中工作:
import { useContext } from 'vite-ssr'
// In a component
function () {
const { redirect , writeResponse } = useContext()
if (/* ... */) {
redirect ('/another-page', 302)
}
if (import.meta.env.SSR && /* ... */) {
writeResponse ({
status: 404,
headers: {}
})
}
// ...
}
在浏览器中,这只会表现为正常的路由器推送。
仅在客户端/浏览器中渲染
vite-ssr 导出,仅在浏览器中,渲染其子组件的ClientOnly组件:
<script setup lang="ts">
import { ClientOnly } from 'vite-ssr'
</script>
<template>
<div>
<ClientOnly>
<div>...</div>
</ClientOnly>
</div>
</template>
自定义 TypeScript 类型
您可以使用 vite-ssr 定义自己的 TypeScript 类型。要声明自定义类型,文件主要需要导入或导出一些内容,而不是破坏其他类型。将请求和响应转换为快递类型的示例:
import { Request, Response } from 'express'
declare module 'vite-ssr/vue' {
export interface Context {
request: Request
response: Response
}
}
使用单独的入口文件:
尽管 vite-ssr 默认使用 1 个单独的入口文件,从而从应用程序中抽象出复杂性,但如果需要更大的灵活性,您仍然可以为客户端和服务器提供单独的入口文件。例如,在 vite-ssr 上构建库时,可能会发生这种情况。
只需在 index.html 中,提供客户端入口文件(就像在 SPA 中通常做的那样),并为服务器端提供入口文件,就像传递 CLI 标志一样:vite-ssr[dev|build]--ssr <path/to/entry-server>.
然后,从vite-ssr/vue/entry-client和vite-ssr/vue/entry-server,导入主处理 SSR 程序文件。为 React,使用vite-ssr/react/*。
head 标签和全局属性
import { createHead } from '@vueuse/head'
export default viteSSR(App, { routes }, ({ app }) => {
const head = createHead()
app.use(head)
return { head }
})
// In your components:
// import { useHead } from '@vueuse/head'
// ... useHead({ ... })
开发环境
在开发环境中,Vite 使用默认的Node.js+Connect用做中间件。在本机开发环境,运行项目,有两种办法:
- SPA 模式:
vite dev命令直接运行 Vite,无需任何 SSR 服务器。 - SSR 模式:
vite-ssr dev命令启动本地 SSR 服务器。它支持与 Vite CLI 类似的属性,例如Vite-ssr --port 1337 --open。
SPA 模式将稍快一些,但 SSR 模式的行为将更接近生产环境。
中间件模式
如果您想运行自己的开发服务器(例如 Express.js)而不是 Vite 的默认Node+Connect,您可以在中间件模式下使用 Vite SSR:
const express = require('express')
const { createSsrServer } = require('vite-ssr/dev')
async function createServer() {
const app = express()
// Create vite-ssr server in middleware mode.
const viteServer = await createSsrServer({
server: { middlewareMode: 'ssr' },
})
// Use vite's connect instance as middleware
app.use(viteServer.middlewares)
app.listen(3000)
}
createServer()
生产环境
运行vite-ssr build命令构建应用程序。这将创建 2 个构建文件(客户端和服务器),您可以从 Node 后端,导入和使用它们。请在此处查看 Express.js 服务器示例。
在 SSR 应用程序中,index.html已经嵌入到服务器构建中,因此从客户端构建中删除,以防止错误地提供它。然而,客户端构建中,若你们想保留index.html的话,(例如,当使用服务器端路由为路由子集选择性地使用 SSR 时),可以设置build选项中的keepIndexHtml为true:
// vite.config.js
export default {
plugins: [
viteSSR({
build: {
keepIndexHtml: true ,
},
}),
[...]
],
}
@vueuse/head 文档
@vueuse/head,Vue 组合式 API,用于管理 document head。
useHead(head: MaybeComputedRef<HeadObject>)
用于修改 document head。您可以在任何页面或组件中调用此函数。所有值都是响应式的,支持ref和计算属性computed的getter语法。要提供内部内容,您应该使用textContent属性(以前是不推荐使用的子属性)。
将对提供给useHead的所有值进行编码,以避免XSS注入。如果需要插入原始数据,请使用useHeadRaw。
const myPage = ref({
description: 'This is my page',
})
const title = ref('title')
useHead ({
// ref syntax
title,
meta: [
// computer getter syntax
{ name: 'description', content: () => myPage.value.description },
],
style: [
{ type: 'text/css', textContent: 'body { background: red; }' },
],
script: [
// primitive values are also fine
{
src: 'https://example.com/script.js',
defer: true
},
],
})
您可以查看@zhead/schema的完整类型。
interface HeadObject {
title?: MaybeRef<string>
titleTemplate?: MaybeRef<string> | ((title?: string) => string)
meta?: MaybeRef<HeadAttrs[]>
link?: MaybeRef<HeadAttrs[]>
base?: MaybeRef<HeadAttrs>
style?: MaybeRef<HeadAttrs[]>
script?: MaybeRef<HeadAttrs[]>
noscript?: MaybeRef<HeadAttrs[]>
htmlAttrs?: MaybeRef<HeadAttrs>
bodyAttrs?: MaybeRef<HeadAttrs>
}
useHeadRaw(head: MaybeComputedRef<HeadObject>)
具有与useHead相同的功能,但不编码值。这对于插入原始数据(如脚本和属性事件)很有用。
插入原始内部内容时,应使用innerHTML。
useHeadRaw ({ bodyAttrs: { onfocus: 'alert("hello")', }, script: [ { innerHTML: 'alert("hello world")', }, ], })
重复数据消除:对于meta标记,我们使用name和property来防止重复标记,如果允许相同的name或property,则可以使用key属性:
useHead ({ meta: [ { property: "og:locale:alternate", content: "zh", key: "zh", }, { property: "og:locale:alternate", content: "en", key: "en", }, ], })
正文标签:要在<body>末尾渲染标记,请在HeadAttrs对象中设置body:true。
useHeadRaw({
script: [
{
children: `console.log('Hello world!')`,
body: true,
},
],
})
文本内容:要设置元素的textContent,请使用children属性:
useHead({
style: [
{
children: `body {color: red}`,
},
],
noscript: [
{
children: `Javascript is required`,
},
],
})
useHead 还将reactive对象或ref作为参数,例如:
const head =reactive ({ title: "Website Title" }) useHead(head) const title =ref ("Website Title") useHead({ title })
<Head>组件
除了useHead,您还可以使用<Head>组件操作 head 标记:
<script setup lang="ts">
import { Head } from "@vueuse/head"
</script>
<template>
<Head>
<title>Hello World</title>
<base href="/base" />
<html lang="en-US" class="theme-dark" />
</Head>
</template>
请注意,您需要使用<html>和<body>分别设置 htmlAttrs 和 bodyAttrs,这两个标记的子标记和自动关闭标记(如<meta>、<link>和<base>)也会被忽略。
集成
用于将@vueuse/head 与框架集成。示例:Vite - SSG
注册 vue 插件
import { createApp } from "vue"
import { createHead } from "@vueuse/head"
const app = createApp()
const head = createHead()
app.use(head)
app.mount("#app")
在组件中使用:
<script>
import { defineComponent, reactive } from "vue"
import { useHead } from "@vueuse/head"
export default defineComponent({
setup() {
const siteData = reactive({
title: `My website`,
description: `My beautiful website`,
});
useHead ({
// Can be static or computed
title: () => siteData.title,
meta: [
{
name: `description`,
content: () => siteData.description,
},
],
})
},
})
</script>
服务器端渲染
import { renderToString } from "@vue/server-renderer"
import { renderHeadToString } from "@vueuse/head"
const appHTML = await renderToString(yourVueApp)
// `head` is created from `createHead()`
const { headTags, htmlAttrs, bodyAttrs, bodyTags } = renderHeadToString(head)
const finalHTML = `
<html${htmlAttrs}>
<head>
${headTags}
</head>
<body${bodyAttrs}>
<div id="app">${appHTML}</div>
${bodyTags}
</body>
</html>
`API
createHead(head?: HeadObject | Ref)
创建 head manager 实例。
renderHeadToString(head: Head)
返回HTMLResult
export interface HTMLResult {
// Tags in `<head>`
readonly headTags: string
// Attributes for `<html>`
readonly htmlAttrs: string
// Attributes for `<body>`
readonly bodyAttrs: string
// Tags in `<body>`
readonly bodyTags: string
}
将 head manager 实例呈现为字符串形式的 HTML 标记。
