Vue3+Vite3 SSR 根本搭建
- 首先阐明如果是生产应用强烈推荐 Nuxt,然而如果想深刻服务端渲染的运行原理,能够看本篇,会依据渲染流程搭建一个 demo 版 ssr,源码在最初会贴上
- 次要技术栈:Vite3 + Vue3 + pinia + VueRouter4 + express
- 开始搭建之前,先说一下 SSR 渲染流程
SSR 渲染流程
- 首先浏览器向服务器申请,而后服务器依据申请的路由,会匹配相干的路由组件,而后执行组件的自定义服务端生命周期 (例:Nuxt 的 asyncData) 或者自定义获取数据的 hook,并且把执行后的数据收集起来,对立在 window 的属性中存储
- 而后 vue 的组件会被 renderToString 渲染成动态 HTML 字符串,替换掉 index.html 的提前指定的占位代码。而后 index.html 扭转后的动态字符串发给客户端
-
客户端拿到后,首先对数据进行初始化,而后进行激活,因为以后 html 只是静态数据,激活次要做两件事
- 把页面中的 DOM 元素与虚构 DOM 之间建立联系
- 为页面中的 DOM 元素增加事件绑定
1. 创立我的项目
-
首先用 vite 命令创立我的项目
pnpm create vite vue-ssr --template vue-ts
- 装置相干依赖:
pnpm add express pinia vue-router@4
- 装置相干依赖:
-
创立三个文件
touch server.js src src/entry-client.ts src/entry-server.js
- server.js:服务端启动文件
- entry-client.ts:客户端入口,利用挂载元素
- entry-server.js:服务端入口,解决服务端逻辑和动态资源
-
批改
package.json
运行脚本"scripts": {"dev": "node server", // 运行开发环境}
-
而后须要把利用创立都改为函数的形式进行调用创立,因为在 SSR 环境下,和纯客户端不一样,服务器只会初始化一次,所以为了避免状态净化,每次申请必须是全新的实例
// src/main.ts import {createSSRApp} from 'vue' import App from './App.vue' import {createRouter} from './router' import {createPinia} from 'pinia' export function createApp() {const app = createSSRApp(App) const router = createRouter() const pinia = createPinia() app.use(router) app.use(pinia) return {app, router, pinia} }
-
router 同理
// src/router/index import {createRouter as _createRrouter, createMemoryHistory, createWebHistory, RouteRecordRaw} from 'vue-router' const routes: RouteRecordRaw[] = [...] export function createRouter() { return _createRrouter({history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(), routes, }) }
-
而后批改 index.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> <!-- 动态资源占位 .js .css ... --> <!--preload-links--> </head> <body> <!-- 利用代码占位 --> <div id="app"><!--ssr-outlet--></div> <script type="module" src="/src/main.ts"></script> <!-- 援用客户端入口文件 --> <script type="module" src="/src/entry-client.ts" ></script> <script> // 服务端获取的数据对立挂载到 window 上 window.__INITIAL_STATE__ = '<!--pinia-state-->' </script> </body> </html>
2. 服务端启动文件
- 创立我的项目后,就开始编写服务端启动文件,也就是我的项目根门路下的 server.js 文件
-
这个文件的性能是启动一个 node 服务,而后依据申请,读取 html 文件,解决资源后把正文进行替换,最初把 html 发送给客户端
import fs from 'fs' import path from 'path' import {fileURLToPath} from 'url' import express from 'express' import {createRequire} from 'module'; const __dirname = path.dirname(fileURLToPath(import.meta.url)) const require = createRequire(import.meta.url); const resolve = (p) => path.resolve(__dirname, p); const createServer = async () => { // 创立 node 服务 const app = express() /** * @官网解释 * 以中间件模式创立 vite 利用,这将禁用 vite 本身的 HTML 服务逻辑 * 并让下级服务器接管 */ const vite = await require('vite').createServer({ server: {middlewareMode: true,}, appType: 'custom' }); app.use(vite.middlewares); app.use('*', async (req, res, next) => { const url = req.originalUrl try { // 读取 index.html let template = fs.readFileSync(resolve('index.html'), 'utf-8' ) // 利用 vite html 转换,会注入 vite HMR template = await vite.transformIndexHtml(url, template) // 加载服务端入口 const render = (await vite.ssrLoadModule('/src/entry-server.js')).render const [appHtml, piniaState] = await render(url) // 替换解决过后的模版 const html = template .replace(`<!--ssr-outlet-->`, appHtml) .replace(`<!--pinia-state-->`, piniaState) res.status(200).set({'Content-Type': 'text/html'}).end(html) } catch (error) {vite?.ssrFixStacktrace(error) next(e) } }) // 监听 5100 端口 app.listen(5100) } createServer();
3. 服务端入口文件
-
服务端入口文件次要是调用 SSR 的 renderToString 和收集须要发送的资源和数据
import {renderToString} from 'vue/server-renderer' import {createApp} from './main' export async function render(url, manifest) {const { app, router, pinia} = createApp() router.push(url) await router.isReady() const ctx = {} const html = await renderToString(app, ctx) return [html, JSON.stringify(pinia.state.value)] }
4. 客户端入口文件
-
客户端入口文件次要用于挂载节点和初始化数据
import {createApp} from './main' const {app, router, pinia} = createApp() router.isReady().then(() => {if (window.__INITIAL_STATE__) {pinia.state.value = JSON.parse(window.__INITIAL_STATE__); } app.mount('#app') })
5. 组件和页面
-
组件和页面获取数据次要有两种形式,一种是减少一个
asyncData
选项,而后在 enter-server.js 的逻辑中减少遍历以后组件的逻辑,对立触发asyncData
,然而当初都是用script setup
的形式写业务代码,所以有点麻烦,<script> export defualt {asyncData() {// 服务端获取数据逻辑} } </script> <script setup lang='ts'> ... </script>
- 另一种就是 hook 的形式,通过
import.meta.env.SSR
的形式进行判断 - 对于数据具体存储形式,大略有三种,一种是存在 vuex 或者 pinia 这种全局状态库中,一种是存在 context 上下文中,还有一种是自定义数据
6. 生产环境
6.1 pacnakge.json
-
减少构建脚本
"scripts": { "dev": "node server", "build": "npm run build:client && npm run build:server", "build:client": "vite build --ssrManifest --outDir dist/client", "build:server": "vite build --ssr src/entry-server.js --outDir dist/server", "serve": "cross-env NODE_ENV=production node server" },
6.2 服务端运行文件
- 针对生产环境,须要启动动态资源服务,援用门路须要改为 dist 目录下
import fs from 'fs'
import path from 'path'
import {fileURLToPath} from 'url'
import express from 'express'
import {createRequire} from 'module';
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url);
const resolve = (p) => path.resolve(__dirname, p);
const createServer = async (isProd = process.env.NODE_ENV === 'production') => {const app = express()
- const vite = await require('vite').createServer({
- server: {
- middlewareMode: true,
- },
- appType: 'custom'
- });
- app.use(vite.middlewares);
+ let vite;
+ if (isProd) {+ app.use(require('compression')());
+ app.use(+ require('serve-static')(resolve('./dist/client'), {
+ index: false
+ })
+ );
+ } else {+ vite = await require('vite').createServer({
+ server: {
+ middlewareMode: true,
+ },
+ appType: 'custom'
+ });
+ app.use(vite.middlewares);
+ }
// 通过 bulid --ssrManifest 命令生成的动态资源映射须要在生产环境下援用
+ const manifest = isProd ? fs.readFileSync(resolve('./dist/client/ssr-manifest.json'), 'utf-8') :{}
app.use('*', async (req, res, next) => {
const url = req.originalUrl
try {
- let template = fs.readFileSync(- resolve('index.html'),
- 'utf-8'
- )
- template = await vite.transformIndexHtml(url, template)
- const render = (await vite.ssrLoadModule('/src/entry-server.js')).render
- const [appHtml, piniaState] = await render(url)
+ let template, render
+ if (isProd) {+ template = fs.readFileSync(resolve('./dist/client/index.html'), 'utf-8')
+ render = (await import('./dist/server/entry-server.js')).render
+ } else {
+ template = fs.readFileSync(+ resolve('index.html'),
+ 'utf-8'
+ )
+ template = await vite.transformIndexHtml(url, template)
+ render = (await vite.ssrLoadModule('/src/entry-server.js')).render
+ }
+ const [appHtml, preloadLinks, piniaState] = await render(url, manifest)
const html = template
.replace(`<!--preload-links-->`, preloadLinks)
.replace(`<!--ssr-outlet-->`, appHtml)
+ .replace(`<!--pinia-state-->`, piniaState)
res.status(200).set({'Content-Type': 'text/html'}).end(html)
} catch (error) {vite?.ssrFixStacktrace(error)
next()}
})
app.listen(5100)
}
createServer();
6.3 服务端入口文件
-
服务端入口文件次要是减少了构建时生成的动态资源映射解决的逻辑
import {basename} from 'path' import {renderToString} from 'vue/server-renderer' import {createApp} from './main' export async function render(url, manifest) {const { app, router, pinia} = createApp() router.push(url) await router.isReady() const ctx = {} const html = await renderToString(app, ctx) const preloadLinks = renderPreloadLinks(ctx.modules, manifest) return [html, preloadLinks, JSON.stringify(pinia.state.value)] } function renderPreloadLinks(modules, manifest) { let links = '' const seen = new Set() modules.forEach((id) => {const files = manifest[id] if (files) {files.forEach((file) => {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) {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 {return ''} }
总结
- repo
参考资料
- Server-Side Rendering