发布时间:2024-10-21 08:01
pnpm create vite vue-ssr --template vue-ts
pnpm add express pinia vue-router@4
touch server.js src/entry-client.ts src/entry-server.js
package.json
运行脚本"scripts": {
"dev": "node server", // 运行开发环境
}
// 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 }
}
// 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,
})
}
<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>
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();
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)]
}
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')
})
asyncData
选项,然后在enter-server.js的逻辑中增加遍历当前组件的逻辑,统一触发asyncData
,但是现在都是用script setup
的方式写业务代码,所以有点麻烦,<script>
export defualt {
asyncData() {
// 服务端获取数据逻辑
}
}
</script>
<script setup lang='ts'>
...
</script>
import.meta.env.SSR
的方式进行判断"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"
},
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();
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 ''
+ }
+ }