前言
现在前端一谈到SSR,不约而同都想到 Nuxt 和 Next 这两个非常有名的SSR框架。
但是在现有的SPA单页面应用中直接使用第三方的SSR框架(Nuxt、Next)重构项目,会大费周章。
但是如果本身对SSR的原理认识比较模糊,那么在现有的项目中,手动集成SSR又会变得无从而手,并且极其复杂。
所以本章带大家从头梳理一下 CSR、SSR、同构、SSG 等相关概念,再带你从头如何在现有项目中手动集成SSR,自己动手实现SSR,也会让你更加了解第三方的SSR框架(Nuxt、Next)的一些底层实现。
CSR、SSR及同构渲染
SSR(传统服务端渲染)
传统的服务端渲染有:asp、jsp、ejs等,服务端语言往往通过这些模板引擎将数据和dom在服务端渲染完成,返回一个完整的静态html页面给客户端,由客户端直接显示。
原理
- 客户端发送http请求
- 服务端响应http请求,返回拼接好的html字符串给客户端
- 客户端渲染html
缺点
- 前后端分离,不好维护
- 用户体验不佳,需要重新加载页面
- 服务端压力大
CSR(客户端渲染)
在现代化的前端项目中,客户端渲染的代表性技术栈是Vue、React、Angular,我们常常使用它们来构建客户端单页应用程序。以SPA构建程序为例,在浏览器端首先渲染的是一套空的HTML,通过JS直接进行页面的渲染和路由跳转等操作,所有的数据通过ajax请求从服务器获取后,在进行客户端的拼装和展示。
原理
- 客户端发起http请求
- 服务端响应http请求,返回一个空的根元素的 html 文件
- 客户端初始化时加载必须的 js文件,请求接口
- 将生成的dom插入到 html 中
缺点
- 首屏加载慢
- 不利于SEO
同构(现代服务端渲染)
一个由服务端渲染的 Vue.js 应用也可以被认为是“同构的”(Isomorphic) 或“通用的”(Universal),因为应用的大部分代码同时运行在服务端和客户端。——Vue3官网
这里说的SSR 特别指支持在 Node.js 中运行前端框架(例如 React、Preact、Vue 和 Svelte),将其预渲染成 HTML,最后在客户端进行水合处理。如果你正在寻找与传统服务器端框架的集成,请查看 后端集成指南。——Vite官网
Vue、React框架的SSR方案(Nuxt、Next)实际上就是同构渲染,这里的SSR,是指在前端单页面应用范畴内,基于Node.js server运行环境的服务端渲染方案,通过在 Node.is 中运行相同应用程序的前端框架(例如 React、Vue等),将其预渲染成 HTML,最后在客户端进行注水化处理。简单来讲,就是应用程序的大部分代码在服务端(node服务端)和客户端上运行,这就是所谓的现代服务端渲染:同构。
原理
- 客户端发起 http 请求
- 服务端渲染把 Vue 实例转换成了静态的 html 发送给客户端
- 客户端渲染需要把事件、响应式特性等 Vue 的特性都绑回去(水合或者叫激活)
优点
- 首屏速度快:这一点在慢网速或者运行缓慢的设备上尤为重要。服务端渲染的 HTML 无需等到所有的 JavaScript 都下载并执行完成之后才显示,所以用户将会更快地看到完整渲染的页面。除此之外,数据获取过程在首次访问时在服务端完成,相比于从客户端获取,可能有更快的数据库连接。这通常可以带来更高的核心 Web 指标评分、更好的用户体验,而对于那些“首屏加载速度与转化率直接相关”的应用来说,这点可能至关重要。
- 统一的心智模型:有一些现成框架(Nuxt.js、Next.js)可以使用相同的语言以及相同的声明式、面向组件的心智模型来开发整个应用,而不需要在后端模板系统和前端框架之间来回切换。
- 更好的 SEO:搜索引擎爬虫可以直接看到完全渲染的页面。
缺点
- 开发中的限制。浏览器端特定的代码只能在某些生命周期钩子中使用;一些外部库可能需要特殊处理才能在服务端渲染的应用中运行。
- 更多的与构建配置和部署相关的要求。服务端渲染的应用需要一个能让 Node.js 服务器运行的环境,因为需要 Node.js 来执行JS代码和构建用户页面,不像完全静态的 SPA 那样可以部署在任意的静态文件服务器上。
- 更高的服务端负载。在 Node.js 中渲染一个完整的应用要比仅仅托管静态文件更加占用 CPU 资源,因此如果你预期有高流量,请为相应的服务器负载做好准备,并采用合理的缓存策略。
判断一个网页是纯SSR、CSR、同构?
如何区分页面是CSR还是SSR?
一般可以通过查看网页的源代码,如果body标签里包含了网页的所有内容html标签,那就是SSR服务端渲染,在服务端生成完整HTML并发送给客户端;
如果body标签里只有少数几个html标签元素,那就是SPA单页面应用,属于CSR,是在客户端通过JS动态构建页面。
也可以通过复制一段页面文本,看网页源代码是否可以搜索到,如果搜索不到就是CSR。反之就是SSR。
如何查看SSR是否是同构渲染?
同构渲染也属于SSR范畴,只不过用的是Vue、React等前端技术栈实现的,比如飞书官网就是利用React技术栈(Nextjs)实现的同构渲染:
Vue项目手动集成SSR
在现有的 Vue 项目中,利用 Vite 脚手架,手动改造成 SSR,包括 Vue-Router、Pinia、数据预取等。
基本原理
- 通过 Vue 的 server-renderer 模块将 Vue 应用实例转换成一段纯文本的 HTML 字符串
- 通过 Nodejs 创建一个静态 Web 服务器
- 通过 Nodejs 将服务端所转换好的 HTML 结构发送到浏览器端进行展示。也就是说部署 SSR 项目,需要服务器提供运行 Node 的环境,即安装 Node。
上图构建过程中的Webpack替换为Vite
根据 Vite 对 SSR渲染的介绍(点击查看Vite官网集成SSR指南),一个典型的 SSR 应用程序的目录结构如下:
- index.html
- server.js # 执行SSR入口文件
- src/
- main.ts # 导出环境无关的(通用的)应用代码
- entry-client.ts # 激活应用挂载到一个 DOM 元素上
- entry-server.ts # 使用 Vue 框架的 SSR API 渲染该应用
下面是从0搭建一个Vue框架的SSR项目模板:首先pnpm create vue@latest
创建一个Vue3项目(包括 ts、vue-router、pinia),手动添加三个文件server.js
、entry-client.ts
、entry-server.ts
。
main.ts
修改原始main.ts文件,为了激活应用,必须使用 createSSRApp()
而不是 createApp()
:
import { createSSRApp } from 'vue' // 为了激活应用,必须使用 createSSRApp() 而不是 createApp()
import App from './App.vue'
import { createRouter } from './router' // 返回一个方法,每次创建新的 Vue-Router 实例
import { createPinia } from 'pinia'
// 每次请求时调用
export function createApp() {
// 创建一个和服务端完全一致的应用实例
const app = createSSRApp(App)
// 对每个请求都创建新的 Vue-Router 实例
const router = createRouter()
// 对每个请求都创建新的 pinia 实例
const pinia = createPinia()
app.use(router)
app.use(pinia)
return { app, router, pinia }
}
// 预取接口数据
// 有两个地方用到,客户端 entry-client.ts 和服务端 entry-server.ts
// 因为是服务端渲染,所以肯定要预取数据,然后将状态序列化为window.__INITIAL_STATE__,注入到HTML
// 客户端也会预取数据,但是会做限制,只有打开页面才会获取数据,激活页面。
// 刷新页面客户端不会二次获取数据,而是 从window.__INITIAL_STATE__恢复数据,减轻服务器压力;但是服务端会重新获取数据,因为要返回新的 HTML
// 有一点需要注意,现在路由是由前端路由控制,路由跳转的时候不会真实请求服务端,所以只会在客户端预取数据,服务端不会预取数据。
// 所以有时候查看源代码的时候,会发现服务端返回 HTML 里的状态window.__INITIAL_STATE__是旧的。要想永远都是新数据,那就必须改造路由跳转,变成window.location.ref的方式跳转
export function asyncData(actived: any, route: any) {
return Promise.all(actived.map((Component: any) => {
if (Component.asyncData) {
return Component.asyncData({
route
})
}
}))
}
router.ts
Vue-Router 需要针对 CSR 和 SSR 渲染,选择不同的 history 模式:
import {
createRouter as _createRouter,
createMemoryHistory, // 在服务端使用 createMemoryHistory() 函数创建历史记录
createWebHistory,
} from 'vue-router'
// 自动生成./pages 目录下文件路由
// https://vitejs.dev/guide/features.html#glob-import
// const pages = import.meta.glob('./pages/*.vue')
// const routes = Object.keys(pages).map((path) => {
// const name = path.match(/./pages(.*).vue$/)![1].toLowerCase()
// return {
// path: name === '/home' ? '/' : name,
// component: pages[path], // () => import('./pages/*.vue')
// }
// })
export function createRouter() {
return _createRouter({
// import.meta.env.SSR 由 Vite 注入提供
history: import.meta.env.SSR
? createMemoryHistory('/')
: createWebHistory('/'),
// routes,
routes: [
{
path: '/',
name: 'home',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/HomeView.vue'),
// SEO优化
meta: {
title: '首页',
keywords: 'SSR,Vue,Vite,Home',
description: '这是vue-ssr-vite项目的首页',
}
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue'),
meta: {
title: '关于',
keywords: 'SSR,Vue,Vite,About',
description: '这是vue-ssr-vite项目的关于页',
}
}
]
})
}
index.html
替换默认的入口文件main.ts
为entry-client.ts
,同时放置占位符<!--preload-links-->
、<!--app-html-->
等,用于给服务端渲染的时候注入内容;同时设置全局变量__INITIAL_STATE__,用于保存pinia数据,每次刷新页面就不用重新获取接口,直接恢复保存的pinia数据即可:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
<meta name="keywords" content="" />
<meta name="description" content="" />
<!--preload-links-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/entry-client.ts"></script>
<script>
window.__INITIAL_STATE__ = '<!--app-state-->';
</script>
</body>
</html>
server.js
server.js 是 SSR 的入口文件,处理 index.html,然后返回给客户端:
import fs from 'node:fs/promises';
import express from 'express';
// 常量
const isProduction = process.env.NODE_ENV === 'production';
const port = process.env.PORT || 5173;
const base = process.env.BASE || '/';
// 缓存生产环境资源
const templateHtml = isProduction
? await fs.readFile('./dist/client/index.html', 'utf-8')
: '';
const ssrManifest = isProduction // 在客户端构建过程中会生成ssr-manifest.json预加载配置,该文件包含所有模块Id的映射,可以解决样式错乱问题
? await fs.readFile('./dist/client/.vite/ssr-manifest.json', 'utf-8')
: undefined;
// 创建 http server
const app = express();
let vite; // 开发环境用到,ViteDevServer 的一个实例
if (!isProduction) {
// 以中间件模式创建 Vite 应用,并将 appType 配置为 'custom',这将禁用 Vite 自身的 HTML 服务逻辑,并让上级服务器接管控制
// 就是在开发环境下,开启 SSR,用 express 接管返回 HTML
const { createServer } = await import('vite');
vite = await createServer({
server: { middlewareMode: true },
appType: 'custom',
base,
});
// vite.middlewares 是一个 Connect 实例,它可以在任何一个兼容 connect 的 Node.js 框架中被用作一个中间件。
// 如果你使用了自己的 express 路由(express.Router()),你应该使用 router.use
// 当服务器重启(例如用户修改了 vite.config.js 后),
// `vite.middlewares` 仍将保持相同的引用(带有 Vite 和插件注入的新的内部中间件堆栈)。即使在重新启动后,以下内容仍然有效。
app.use(vite.middlewares);
} else {
// 生产环境下,将 Vite 与生产环境脱钩,用 sirv 静态文件服务中间件来服务 dist/client 中的文件。
// pnpm add -s compression 压缩
const compression = (await import('compression')).default;
// pnpm add -s sirv 比Node自带的 serve-static 性能更好
const sirv = (await import('sirv')).default;
app.use(compression());
app.use(base, sirv('./dist/client', { extensions: [] }));
}
// 处理供给服务端渲染的 index.html。
// 主要步骤如下:
// 1. 读取并且转换index.html,比如客户端访问 /home 页面,此时 index.html 的内容就是 Home 页面的内容
// 2. 然后交给 entry-server.ts 进行注入处理
app.use('*', async (req, res) => {
try {
const url = req.originalUrl.replace(base, '');
let template;
let render;
if (!isProduction) {
// 1. 读取 index.html。开发环境总是读取最新的index.html
template = await fs.readFile('./index.html', 'utf-8');
// 2. 应用Vite HTML 转换。这将会注入 Vite HMR 客户端
// 同时也会从 Vite 插件应用 HTML 转换
// 例如:@vitejs/plugin-react-refresh 中的 global preambles
template = await vite.transformIndexHtml(url, template);
// 3a. 加载服务器入口。vite.ssrLoadModule 将自动转换你的 ESM 源码使之可以在 Node.js 中运行!无需打包,并提供类似 HMR 的根据情况随时失效。
render = (await vite.ssrLoadModule('/src/entry-server.ts')).render;
// 3b. 从 Vite 5.1 版本开始,你可以试用实验性的 createViteRuntime API。
// 这个 API 完全支持热更新(HMR),其工作原理与 ssrLoadModule 相似
// 如果你想尝试更高级的用法,可以考虑在另一个线程,甚至是在另一台机器上,使用 ViteRuntime 类来创建运行环境。
// const runtime = await vite.createViteRuntime(vite)
// render = (await runtime.executeEntrypoint('./src/entry-server.ts')).render
} else {
template = templateHtml;
// @ts-ignore
render = (await import('./dist/server/entry-server.js')).render;
}
// 4. 渲染应用的 HTML。这假设 entry-server.ts 导出 `render`
// 函数调用了适当的 SSR 框架 API。 例如 ReactDOMServer.renderToString()
const rendered = await render(url, ssrManifest);
// 5. 替换 index.html 里的占位符,注入渲染后的应用程序 HTML 到模板中。
const { title, keywords, description } = rendered.route.meta;
const html = template
.replace(`<!--preload-links-->`, rendered.preloadLinks ?? '')
.replace(`<!--app-html-->`, rendered.html ?? '')
.replace(`<!--app-state-->`, JSON.stringify(rendered.state) ?? '')
.replace('<title>', `<title>${title}`)
.replace(
'<meta name="keywords" content="" />',
`<meta name="keywords" content="${keywords}" />`
)
.replace(
'<meta name="description" content="" />',
`<meta name="description" content="${description}" />`
);
// 6. 返回渲染后的 HTML。
res.status(200).set({ 'Content-Type': 'text/html' }).send(html);
} catch (e) {
// 如果捕获到了一个错误,让 Vite 来修复该堆栈,这样它就可以映射回你的实际源码中。
vite?.ssrFixStacktrace(e);
console.log('🚀', e.stack);
res.status(500).end(e.stack);
}
});
// 启用 http server
app.listen(port, () => {
console.log(
`node server 运行 http://localhost:${port}`,
isProduction ? '生产环境' : '开发环境'
);
});
entry-server.ts
entry-server.ts 是将客户端的页面组件转换成服务端的 HTML 字符串:在 entry-server.ts 文件中,我们需要创建一个 render 函数,初始化一个 Vue 实例,配置必要的中间件(如路由器和存储),并将 URL 路径作为参数。然后导出该实例,供服务器使用,以便将应用程序呈现为一个字符串,供服务器端呈现。
import { basename } from "node:path";
import { renderToString } from 'vue/server-renderer' // 利用Vue的 renderToString API, 可以将组件转成 HTML 字符串j
import { createApp, asyncData } from './main'
export async function render(url: string, manifest: any) {
const { app, router, pinia } = createApp()
await router.push(url)
await router.isReady()
const matchedComponents = router.currentRoute.value.matched.flatMap(record =>
Object.values(record.components!)
)
console.log('匹配组件', matchedComponents)
// 对所有匹配的路由组件调用 `asyncData()`,在服务端进行数据预取,并将状态序列化为window.__INITIAL_STATE__,注入到HTML
await asyncData(matchedComponents, router.currentRoute)
const ctx: any = {}
// 传递 SSR context 对象,可以通过 useSSRContext() api 获取
// @vitejs/plugin-vue injects code into a component's setup() that registers
// itself on ctx.modules. After the render, ctx.modules would contain all the
// components that have been instantiated during this render call.
const html = await renderToString(app, ctx)
const state = pinia.state.value
if (import.meta.env.PROD) {
const preloadLinks = renderPreloadLinks(ctx.modules, manifest)
return { html, state, preloadLinks }
} else {
return { html, state }
}
}
function renderPreloadLinks(modules: any, manifest: any) {
let links = "";
const seen = new Set();
modules.forEach((id: string) => {
const files = manifest[id];
if (files) {
files.forEach((file: any) => {
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: any) {
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 "";
}
}
entry-client.ts
entry-client.ts 作用就是在客户端激活(hydrate)挂载#app
:
/**
* 客户端浏览器使用
*/
import './assets/main.css' // 一些样式文件
import { createApp, asyncData } from './main'
const { app, router, pinia } = createApp()
// 在 index.html 里,window.__INITIAL_STATE__ = '<!--app-state-->'
// 在 server.ts 中已经处理将 pinia 数据赋值给了 window.__INITIAL_STATE__
// 所以需要在客户端激活 pinia 的 state。使用场景为首次加载和页面刷新(页面刷新的时候,不重新获取数据,减轻服务器压力,而是从这里恢复数据)
if ((window as any).__INITIAL_STATE__) {
pinia.state.value = JSON.parse(((window as any).__INITIAL_STATE__))
}
router.isReady().then(() => {
// 1. 先进行激活(hydrate),主要是数据和交互事件绑定
router.beforeResolve((to, from, next) => {
const toComponents = router.resolve(to).matched.flatMap(record =>
Object.values(record.components!)
)
const fromComponents = router.resolve(from).matched.flatMap(record =>
Object.values(record.components!)
)
// 防止前端数据的二次预取(只有打开页面才会获取数据,刷新页面客户端不会重新获取数据,减轻服务器压力。但是刷新页面服务端还是会预取一下数据)
const actived = toComponents.filter((c, i) => {
return fromComponents[i] !== c
})
// 可以先跳转到路由页面,然后再获取数据,填充页面
// next()
// asyncData(actived, router.currentRoute).then(() => {
// console.log('结束loading。。。。。')
// })
// 但一般都是客户端数据预取,在路由导航之前拿到数据,然后再处理视图
// 两种区别就是,在哪里等待。一个是跳过去等待数据刷新,一个是等待数据后再跳过去
if (!actived.length) {
return next()
}
console.log('开始loading。。。。。') // 这里可以模拟loading
asyncData(actived, router.currentRoute).then(() => {
console.log('结束loading。。。。。')
next()
})
})
// 2. 然后进行挂载
app.mount('#app')
})
// 修改 title 和 meta 信息,进行 SEO 优化
// 虽然服务端渲染的时候,会拼接好当前页面的所有信息返回给客户端
// 但由于是前端路由管理,跳转页面不会请求服务端,所以 title 和 meta 等信息都是旧的,所以客户端需要在下面修改一下
router.afterEach((to, from, next) => {
const { title, keywords, description } = to.meta
if (title) {
document.title = `${title}`
} else {
document.title = ""
}
const keywordsMeta = document.querySelector('meta[name="keywords"]')
keywordsMeta && keywordsMeta.setAttribute("content", `${keywords}`)
const descriptionMeta = document.querySelector('meta[name="description"]')
descriptionMeta?.setAttribute("content", `${description}`)
})
package.json
配置 ssr 脚本命令
"scripts": {
"dev": "vite",
"dev:ssr": "cross-env NODE_ENV=development node server", // 本地预览开发环境SSR
"build:ssr": "pnpm run build:client && pnpm run build:server", // 打包客户端和服务端文件
"build:client": "vite build --ssrManifest --outDir dist/client", // 打包客户端文件。--ssrManifest 标志会在客户端构建输出目录中生成一份 .vite/ssr-manifest.json
"build:server": "vite build --ssr src/entry-server.js --outDir dist/server", // 打包服务端文件。--ssr 标志表明这是一个 SSR 构建,同时需要指定 SSR 的入口
"prod:ssr": "cross-env NODE_ENV=production node server", // 本地预览生产环境开启SSR
"preview": "vite preview"
},
"dependencies": {
"compression": "^1.7.4",
"express": "^4.19.2",
"pinia": "^2.1.7",
"sirv": "^2.0.4",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@tsconfig/node20": "^20.1.2",
"@types/node": "^20.11.28",
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"@vue/tsconfig": "^0.5.1",
"cross-env": "^7.0.3",
"npm-run-all2": "^6.1.2",
"typescript": "~5.4.0",
"vite": "^5.1.6",
"vue-tsc": "^2.0.6"
}
本地/线上测试
本地测试
利用项目创建时候自带的 useCounterStore,同时改造下 AboutView.vue
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 }
})
注意:这里没有使用 setup 语法糖,因为 asyncData() 是和 setup() 同级:
<template>
<div class="about">
<h1>计数器: {{ store.count }}</h1>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted } from 'vue';
import { useCounterStore } from '../stores/counter';
export default defineComponent({
setup() {
// 不要在这里直接调用浏览器端特定的代码,服务端渲染的时候会报错
// 可以将执行浏览器端的代码放到onMounted()生命周期里,服务端渲染的时候会自动过滤掉
onMounted(() => {});
const store = useCounterStore();
return {
store,
};
},
// 预取接口数据,执行在 setup() 函数之前
// 这里没有使用真实接口,只是用pinia模拟一下返回,可以在store里定义异步数据获取
asyncData({ route }: any) {
// const { id } = route.value.params;
const store = useCounterStore();
return store.increment();
},
});
</script>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>
跑一下项目看看效果,执行pnpm run dev:ssr
本地开发环境启动SSR,整个过程会发生:
- 打开网址,客户端会发起请求
- 服务端收到请求,执行
server.js
,本地会启动一个express服务 - 继续读取
index.html
文件,加载entry-server.ts
服务端渲染入口文件,将对应页面内容替换,返回index.html
- 客户端收到返回的
index.html
,这里index.html
里面有<script type="module" src="/src/entry-client.ts"></script>
,所以会执行entry-client.ts
客户端渲染文件,进行注水激活页面
线上测试
线上部署,就是将dist
、package.json
、server.js
等文件放到服务器中,然后服务器需要安装 Node 环境,配置好 Nginx,然后安装依赖后执行npm run build:prod
命令启动项目,即可公网访问项目。
SSG/预渲染
静态站点生成 (Static-Site Generation,缩写为 SSG),也被称为预渲染,是另一种流行的构建快速网站的技术。如果用服务端渲染一个页面所需的数据对每个用户来说都是相同的,那就可以只渲染一次,提前在构建过程中完成,而不是每次请求进来都重新渲染页面。预渲染的页面生成后作为静态 HTML 文件被服务器托管。
SSG 保留了和 SSR 应用相同的性能表现:它带来了优秀的首屏加载性能。同时,它比 SSR 应用的花销更小,也更容易部署,因为它输出的是静态 HTML 和资源文件。这里的关键词是静态:SSG 仅可以用于消费静态数据的页面,即数据在构建期间就是已知的,并且在多次部署期间不会改变。每当数据变化时,都需要重新部署。
如果你调研 SSR 只是为了优化为数不多的营销页面的 SEO (例如 /、/about 和 /contact 等),那么你可能需要 SSG 而不是 SSR。SSG 也非常适合构建基于内容的网站,比如文档站点或者博客。VitePress 就是一个由 Vite 和 Vue 驱动的静态站点生成器。