1、什么是 SSR ?
服务端渲染(server side render)简称为 SSR ,是在浏览器请求页面url时,服务端将我们所需要的 html文本拼装好并返回给浏览器,这个html文本被浏览器解析之后,不需要经过js脚本的执行,就可以直接构建出dom结构并将其展示到页面中。这一个组装的过程,叫做服务端渲染。
传统客户端渲染的网络请求:
页面上的内容是通过执行js之后生成dom,并且渲染到页面上
服务端渲染的网络请求:
页面的html内容是直接由服务端生成并返回的
客户端渲染 VS 服务端渲染
服务端渲染的大体流程与客户端渲染有些相似,前端采用Node.js部署了前端服务器。首先是浏览器请求URL,前端服务器接收到URL请求之后,根据不同的URL,前端服务器向后端服务器请求数据,请求完成后,前端服务器会组装一个携带了具体数据的HTML文本,并且返回给浏览器,浏览器得到HTML之后开始渲染页面,同时,浏览器加载并执行JavaScript脚本,给页面上的元素绑定事件,让页面变得可交互,当用户与浏览器页面进行交互,如跳转到下一个页面时,浏览器会执行JavaScript 脚本,向后端服务器请求数据,获取完数据之后再次执行JavaScript代码动态渲染页面,这样在用户在看到页面首屏内容时只和服务器有一个http的请求交互用于获取html内容,这个内容就是完整的页面内容,之后的后续交互仍在前端完成。
服务端渲染的优势:
- SEO 支持: 服务端渲染可以有效的进行seo优化,当页面爬虫请求页面地址时,就可以获取到完整的页面内容,而客户端渲染爬虫获取到的只是一个html的空壳,里面并没有内容
- 白屏时间:服务端渲染在浏览器请求url之后已经得到了一个带有数据的html文本,浏览器只需要进行dom构建,之后渲染页面即可,客户端渲染则需要通过等待js脚本的下载和运行之后才可以看到,在复杂的应用中白屏时间会较漫长
2、vite 服务端渲染
同构:采用一套代码,构建双端(服务端和客户端)逻辑,最大限度的重用代码,不需要维护两套代码
源码结构
开始构建 SSR 项目
客户端渲染
1、创建一个 vite vue 的项目
pnpm create vite
之后按照提示依次选择 vue命令,完成项目的初始化,并去除项目中无关的代码
2、在 main.js 的同级创建两个入口:entery-client.js 和 entery-server.js 分别作为客户端入口和服务端入口
3、对 mian.js 进行改造
import App from './App.vue'
import { createSSRApp } from 'vue'
import { createRouter } from './router'
import './style.css'
export const createApp = () => {
// 创建一个ssr 的实例
const app = createSSRApp(App)
const router = createRouter()
app.use(router)
// 暴露 app 实例
return { app, router }
}
4、对 entry-client.js 进行改造,使其将实例挂载到一个dom元素上
import { createApp } from './main'
const { app, router } = createApp()
router.isReady().then(() => {
app.mount('#app')
console.log('hydrated')
})
5、改造 index.html 文件并将客户端入口(entry-client.js)引入,其中 preload-links 这个注释和 app-html 注释是为了进行占位,方便后续在 ssr 生成的字符串中进行对应的替换,(注意,这里app-html切记不要换行,否则会出现渲染两次的问题,一定要保证在#app这个容器内部尽量不要有换行符,否则在ssr生成的时候需要做一些特殊的处理)
<!DOCTYPE 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>
<!--preload-links-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/entry-client.js"></script>
</body>
</html>
6、修改 package.json 文件
"scripts": {
"dev:client": "vite",
"build:client": "vue-tsc && vite build --outDir dist/client --ssrManifest",
},
--outDir参数为其指定了构建后所产生的文件存放的目录地址,--ssrManifest表示在进行客户端生产构建后,会生成一个ssr-manifest.json文件,这个文件标识了静态资源的映射信息,这样在服务端渲染时,它就可以自动推断并向渲染出来的HTML中注入需要preload/prefetch的资源,并且包括了懒加载的组件所对应的资源。
preload VS prefetch 他们均是以link标签来使用的
- preload: 提前加载资源,告诉浏览器预先请求当前页需要的资源,从而提高这些资源的请求优先级,加载但是不运行,会占用浏览器对同一个域名的请求并发数
- prefetch: 在浏览器空闲时下载资源并缓存,当有页面使用时直接从缓存中读取
vite中主要使用的是preload,对于es6模块改为了moudulepreload,当访问首屏时,会提前加载其他页面所需要的资源,这样当打开其他页面时,就会减少等待时间,提升用户体验。正常的客户端渲染出来的HTML默认情况下都会带有这个优化,服务端渲染的HTML则需要上面的ssr-manifest.json才能有对应的优化
至此,客户端渲染的逻辑已经基本完成,并且可以正常启动应用
服务端渲染
1、在index.html同级创建 server.js,作为服务端渲染的node服务器入口 引入一个node服务框架 express
pnpm i express // 安装express
pnpm i serve-static
pnpm i nodemon //自动监听文件变化并重启服务
pnpm i cross-env //设置环境变量
// server.js
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import express from 'express'
// 该模块的路径
const isTest = process.env.VITEST
export async function createServer(
root = process.cwd(),
isProd = process.env.NODE_ENV === 'production',
hrmProt
) {
console.log('🚀 ~ file: server.js:11 ~ isProd:', isProd)
// 返回当前模块的 URL 路径的 dirname
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const resolve = (p) => path.resolve(__dirname, p)
//生产环境直接从构建产物中读取
const indexProd = isProd
? fs.readFileSync(resolve('dist/client/index.html'), 'utf8')
: ''
const manifest = isProd
? JSON.parse(
fs.readFileSync(path.resolve('dist/client/ssr-manifest.json'), 'utf-8')
)
: {}
const app = express()
let vite
if (!isProd) {
// 开发环境下需要链接vite
vite = await (
await import('vite')
).createServer({
base: '/',
root,
logLevel: 'info',
server: {
middlewareMode: true,
watch: {
usePolling: true,
interval: 100,
},
hrm: {
port: hrmProt,
},
},
appType: 'custom',
})
// 使用 vite 的 Connect 实例作为中间件
app.use(vite.middlewares)
} else {
app.use((await import('compression')).default())
// 生产环境下把 dist/client 映射为根路径,防止静态资源路由不匹配的问题
app.use(
'/',
(await import('serve-static')).default(path.resolve('dist/client'), {
index: false,
})
)
// 这种方法是express自带的 app.use(express.static('dist/client'))
}
// 拦截所有的请求
app.use('*', async (req, res, next) => {
// 服务 index.html
const url = req.originalUrl
try {
let render, template
// 读取模版 index.html
if (!isProd) {
template = fs.readFileSync(
path.resolve(__dirname, 'index.html'),
'utf-8'
)
// 应用vite html转换 注入vite hmr客户端
template = await vite.transformIndexHtml(url, template)
// 加载服务器入口。vite.ssrLoadModule 将自动转换
// 你的 ESM 源码使之可以在 Node.js 中运行!无需打包
// 并提供类似 HMR 的根据情况随时失效。
render = (await vite.ssrLoadModule('/src/entry-server.js')).render
} else {
template = indexProd
render = (await import('./dist/server/entry-server.js')).render
}
// 渲染应用的 HTML。这假设 entry-server.js 导出的 `render`
const [appHtml, preloadLinks] = await render(url, manifest)
console.log('🚀 ~ file: server.js:55 ~ app.use ~ appHtml:', appHtml)
// 替换
const html = template
.replace('<!--preload-links-->', preloadLinks)
.replace('<!--app-html-->', appHtml)
res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
} catch (error) {
console.log('🚀 ~ file: server.js: ~ app.use ~ error:', error)
vite && vite.ssrFixStacktrace(error)
res.status(500).end(error)
}
})
return { app, vite }
}
if (!isTest) {
createServer().then(({ app }) => {
app.listen(3354, () => {
console.log('http://localhost:3354')
})
})
}
2、对 entry-server.js 进行改造,这里主要是实现render和renderPreloadLinks方法,将页面实例和资源链接转换成字符串返回
import { basename } from 'node:path'
import { renderToString } from '@vue/server-renderer'
import { createApp } from './main'
export async function render(url, manifest, lang = 'zh') {
const { app, router } = createApp()
// set the router to the desired URL before rendering
await router.push(url)
await router.isReady()
const ctx = {}
const html = await renderToString(app, ctx)
const preloadLinks = renderPreloadLinks(ctx.modules, manifest)
return [html, preloadLinks]
}
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 {
// TODO
return ''
}
}
3、package.json命令
"scripts": {
"dev": "vue-tsc && nodemon server",
"prod": "vue-tsc && cross-env NODE_ENV=production node server",
"build": "vite build --outDir dist/static && npm run build:client && npm run build:server",
"dev:client": "vite",
"build:client": "vue-tsc && vite build --outDir dist/client --ssrManifest",
"build:server": "vue-tsc && vite build --outDir dist/server --ssr src/entry-server.js"
},
至此服务端渲染已经基本完成啦
预渲染
对于一些静态页面,可以使用预渲染来实现服务端渲染的优势,可以直接生成完整的静态页面,提升首屏优化
在server.js同级创建prerender.js
// 预渲染
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const getAbsoluteFilePath = (filePath) => path.resolve(__dirname, filePath)
const manifest = JSON.parse(
fs.readFileSync(getAbsoluteFilePath('dist/client/ssr-manifest.json'), 'utf-8')
)
const template = fs.readFileSync(
getAbsoluteFilePath('dist/client/index.html'),
'utf-8'
)
const { render } = await import('./dist/server/entry-server.js')
const routersToPreRender = fs
.readdirSync(getAbsoluteFilePath('src/pages'))
.map((file) => {
const name = file.replace(/\.vue$/, '').toLowerCase()
return name === 'home' ? '/' : `/${name}`
})
console.log(
'🚀 ~ file: prerender.js:20 ~ routersToPreRender ~ routersToPreRender:',
routersToPreRender
)
;(async () => {
// pre-render each route...
for (const url of routersToPreRender) {
const [appHtml, preloadLinks] = await render(url, manifest, 'en')
const html = template
.replace(`<!--preload-links-->`, preloadLinks)
.replace(`<!--app-html-->`, appHtml)
const filePath = `dist/static${url === '/' ? '/index' : url}.html`
fs.writeFileSync(getAbsoluteFilePath(filePath), html)
console.log('pre-rendered:', filePath)
}
// done, delete ssr manifest
// fs.unlinkSync(getAbsoluteFilePath('dist/static/ssr-manifest.json'))
})()
package.json
"scripts": {
"dev": "vue-tsc && nodemon server",
"prod": "vue-tsc && cross-env NODE_ENV=production node server",
"build": "vite build --outDir dist/static && npm run build:client && npm run build:server && npm run build:prerender",
"dev:client": "vite",
"build:client": "vue-tsc && vite build --outDir dist/client --ssrManifest",
"build:server": "vue-tsc && vite build --outDir dist/server --ssr src/entry-server.js",
"build:prerender": "node prerender"
},
执行构建命令,查看dist/static/index.html,可以发现已经生成了一个完整的html文件
<!DOCTYPE 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>
<link rel="modulepreload" crossorigin href="/assets/home-75a0ccf2.js"><link rel="stylesheet" href="/assets/home-22c2b1b8.css">
<script type="module" crossorigin src="/assets/index-8b378272.js"></script>
<link rel="stylesheet" href="/assets/index-bf4ee8fb.css">
</head>
<body>
<div id="app"><div><a aria-current="page" href="/" class="router-link-active router-link-exact-active">Home</a>| <a href="/list" class="">list</a><div class="test" data-v-908e912e><h3 data-v-908e912e>这是一个测试</h3></div></div></div>
</body>
</html>
至此,整个ssr可以暂时告一个段落,之后对其进行多语言改造
多语言改造
1、依赖安装:选择使用vue-i18n来进行开发
pnpm i mkdirp //创建不存在的文件夹,往一个不存在的文件夹的文件写入内容会报错
pnpm i @intlify/unplugin-vue-i18n -D //vite插件
pnpm i vue-i18n //开发依赖
在 vite.config.ts 中引入18n的插件
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'node:path'
import VueI18n from '@intlify/unplugin-vue-i18n/vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
VueI18n({
runtimeOnly: true,
compositionOnly: true,
fullInstall: true,
include: [path.resolve(__dirname, 'src/locale/yaml/**')],
}),
],
})
在src目录下创建locale文件夹,用来存放与语言相关的逻辑代码及静态语言文件
// src/locale/index.ts
import { createI18n as _createI18n } from 'vue-i18n'
import localeEn from './yaml/en.yaml'
import localeZh from './yaml/zh.yaml'
export const createI18n = () => {
return _createI18n({
locale: 'en',
legacy: false,
messages: {
en: localeEn,
zh: localeZh,
},
})
}
// src/locale/yaml/en.yaml
test: this is a test
// src/locale/yaml/zh.yaml
test: 这是一个测试
之后在main.ts中将其引入
// src/main.ts
import App from './App.vue'
import { createSSRApp } from 'vue'
import { createRouter } from './router'
import './style.css'
import { createI18n } from './locale'
export const createApp = () => {
const app = createSSRApp(App)
const router = createRouter()
创建i18n的实例
const i18n = createI18n()
app.use(router)
app.use(i18n)
// 暴露 app 实例
return { app, router }
}
最后在项目中去使用i18n
// src/pages/home.vue
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<div class="test">
<h3>{{ t('test') }}</h3>
</div>
</template>
<style scoped lang="css">
.test {
color: cadetblue;
}
</style>
回到浏览器刷新页面查看结果
至此,已经在项目中成功使用多语言了
疑问:为什么不在上面的多语言文件引入时采用异步的引入方式?
采用异步的方式去引入语言文件虽然能在语言文件较多时通过 import.meta.glob()的方式去获取所有的语言文件,然后在使用该语言时进行异步的加载,看上去并没有什么问题,但是在页面初始化和语言切换过程中会出现页面显示的是t函数中的key值的现象,当语言加载完成之后,才会去替换它,在网络情况等条件因素影响下,可能出现页面一直是key的情况,这是比较严重的,通过在最外层使用await的方式去等待它加载完成可以解决这样的一个问题,但是随之而来的是一个浏览器版本的兼容问题,vite目前默认打包的是Chrome >=87,Firefox >=78,Safari >=14,Edge >=88,而顶层await需要将打包版本设定为ESNExt,这对于低版本浏览器来说是不支持的
预渲染的多语言的打包问题
对于静态页面而言,将每一个页面打包成html文件能够有效提高页面的性能,那么就需要对每一种语言都输出其对应的html文件,所以需要对与渲染逻辑进行改造
// prerender.js
// 预渲染
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { mkdirp } from 'mkdirp'
const languageList = ['zh', 'en']
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const getAbsoluteFilePath = (filePath) => path.resolve(__dirname, filePath)
const manifest = JSON.parse(
fs.readFileSync(getAbsoluteFilePath('dist/client/ssr-manifest.json'), 'utf-8')
)
const template = fs.readFileSync(
getAbsoluteFilePath('dist/client/index.html'),
'utf-8'
)
const { render } = await import('./dist/server/entry-server.js')
const routersToPreRender = fs
.readdirSync(getAbsoluteFilePath('src/pages'))
.map((file) => {
const name = file.replace(/\.vue$/, '').toLowerCase()
return name === 'home' ? '/' : `/${name}`
})
console.log(
'🚀 ~ file: prerender.js:20 ~ routersToPreRender ~ routersToPreRender:',
routersToPreRender
)
;(async () => {
for (const lang of languageList) {
for (let url of routersToPreRender) {
if (lang === 'en') {
if (url === '/') {
url = `${url}en`
} else {
url = `/en${url}`
}
}
const [appHtml, preloadLinks] = await render(url, manifest, lang, true)
const html = template
.replace(`<!--preload-links-->`, preloadLinks)
.replace(`<!--app-html-->`, appHtml)
const filePath = `dist/static${
url === '/'
? '/index'
: url === '/en'
? '/en/index'
: url.replace('/products', '')
}.html`
mkdirp('dist/static/en').then(() => {
fs.writeFileSync(getAbsoluteFilePath(filePath), html)
console.log('pre-rendered:', filePath)
})
}
}
})()
// entry-server.js
import { basename } from 'node:path'
import { renderToString } from '@vue/server-renderer'
import { createApp } from './main'
import { createI18n } from './locale'
export async function render(
url,
manifest,
language = 'zh',
isPreRender = false
) {
const { app, router } = createApp()
if (isPreRender) {
const i18n = createI18n(language)
app.use(i18n)
}
// set the router to the desired URL before rendering
await router.push(url)
await router.isReady()
const ctx = {}
const html = await renderToString(app, ctx)
const preloadLinks = renderPreloadLinks(ctx.modules, manifest)
return [html, preloadLinks]
}
在执行构建命令之后,能够发现在dist目录的static目录下已经生成了静态文件
通过检查对应文件夹下的文件发现,输出的内容与语言已经关联起来了,符合预期效果 至此,在ssr中使用i18n实现多语言已经完成
demo: github.com/HamsterCat-…