- 首先说明如果是生产使用强烈推荐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/entry-client.ts src/entry-server.js
- server.js:服务端启动文件
- entry-client.ts:客户端入口,应用挂载元素
- entry-server.js:服务端入口,处理服务端逻辑和静态资源
- 修改
package.json
运行脚本"scripts": {
"dev": "node server",
}
- 然后需要把应用创建都改为函数的方式进行调用创建,因为在SSR环境下,和纯客户端不一样,服务器只会初始化一次,所以为了防止状态污染,每次请求必须是全新的实例
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 }
}
- roter同理
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>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script type="module" src="/src/entry-client.ts" ></script>
<script>
// 服务端获取的数据统一挂载到window上
window.__INITIAL_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 () => {
const app = express()
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 {
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)
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)
}
})
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. 组件和页面
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
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 ''
}
}
总结
参考资料