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 } = awaitrender (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 标记。