它是传统的服务端渲染和单页应用的一种折中的解决方案:后端渲染出完整的dom结构返回,前端拿到的内容包括:首屏及完整spa结构,应用激活后依然按照spa方式运行,这种页面渲染方式被称为服务端渲染(server side render),它的过程是:
优点:
缺点:
官方提供了 create-ssr-app 脚手架来让用户可以迅速的创建不同类型的应用,我们本次使用midway-vue-ssr技术框架来实现。实际提供的框架还有很多,想了解更多的技术框架可以访问ssr 框架官方文档,开发原理都是一样的,用什么框架都可以。
$ npm init ssr-app my-ssr-project
$ cd my-ssr-project
$ npm install # 可以使用 yarn 不要使用 cnpm
$ npm start
$ open http://localhost:3000 # 访问应用
$ npm run build # 资源构建,等价于 npx ssr build
$ npm run start:vite # 以 vite 模式启动,等价于 npx ssr start --vite
项目运行起来后,我们就可以看一下生成的目录结构
.
├── build # web目录构建产物,与 public 文件夹一样会设置为静态资源文件夹,非应用构建产物静态资源文件如图片/字体等资源建议放在 public 文件夹前端代码通过绝对路径引入
│ ├── client # 存放前端静态资源文件
│ └── server # 存放 external 后的服务端 bundle,
├── public # 作为静态资源目录存放静态资源文件
├── config.js # 定义应用的配置 (框架层面使用,生产环境需要)
├── config.prod.js # (可选) 若存在则视为生产环境的应用配置
├── f.yml # (可选),仅在 Serverless 场景下使用,若调用 ssr deploy 检测到无此文件会自动创建
├── package.json
├── src # 存放服务端 Node.js 相关代码
│ └── index.ts
├── tsconfig.json # 服务端 Node.js 编译配置文件
├── typings # 存放前后端公共类型文件
├── web # 存放前端组件相关代码
│ ├── components # 存放公共组件
│ │ └── header # 公共头部
│ │ │ ├── index.less
│ │ │ └── index.tsx
│ │ └── layout # 页面 html 布局
│ │ └── index.tsx # 页面 html 布局,仅在服务端被渲染
│ │ └── App.tsx # 页面具体的组件内容,用于初始化公共配置
│ │ └── fetch.ts # layout 级别的 fetch,用于获取所有页面的公共数据,将会在每一个页面级别的fetch 调用之前调用
│ ├── pages # pages目录下的文件夹会映射为前端路由表,存放页面级别的组件
│ │ ├── index # index文件夹映射为根路由 /index => /
│ │ │ ├── fetch.ts # 定义fetch文件用来统一服务端/客户端获取数据的方式,通过 __isBrowser__ 变量区分环境,会在首页服务端渲染以及前端路由切换时被调用
│ │ │ ├── index.less
│ │ │ └── render.tsx # 定义render文件用来定义页面渲染逻辑
│ │ └── detail
│ │ │ ├── fetch.ts
│ │ │ ├── index.less
│ │ │ └── render$id.tsx # 映射为 /detail/:id
│ │ │ └── user
│ │ │ ├── fetch.ts
│ │ │ └── render$id.tsx # 多级路由按照规则映射为 /detail/user/:id
│ │ │ └── render$user$id.tsx # 多参数路由映射为 /detail/user/:user/:id
│ │ ├── bar
│ │ │ ├── fetch.ts
│ │ │ └── render.tsx
│ │ │ ├── fetch$id.ts
│ │ │ └── render$id.tsx # 当存在多个 render 类型的文件时,每个 render 文件对应与其同名的 fetch 文件,例如 render$id 对应 fetch$id
│ ├── tsconfig.json # web 目录下的 tsconfig 仅用于编辑器ts语法检测
pages
文件夹下的每个文件夹,我们都会认为它是一个页面。上述结构包含 index
, detail
两个页面。同样我们定义 render
文件代表一个页面的渲染组件。render
文件支持多种格式来应对不同类型的前端路由
最常见的普通路由即 /
, /detail
, /user
这种我们只需要创建同名文件夹即可。这里我们特殊针对根路由,来将 index
文件夹进行映射
/index/render.vue
映射为 /
/detail/render.vue
映射为 /detail
/user/render.vue
映射为 /user
动态路由即携带参数的路由,例如 /user/:id
这种
/user/render$id.vue
映射为 /user/:id
/user/render$foo$bar.vue
多参数的情况下映射为 /user/:foo/:bar
在 React|Vue
场景下均可使用。由于 ?
符号无法作为文件名使用,所以这里我们需要用 #
号代替
/index/render$id#.vue
映射为 /:id?
用来匹配所有符合要求的文件, 综合考虑 path-to-regexp 和 vue-router 文档,使用如下结构
由于 *
符号在 Windows
下无法作为文件名使用,所以这里我们需要用 &
号代替
/detail/render$params&.vue
映射为 /detail/:params*
,本质上对应所有来自 /detail/*
的请求当然我们也可以自己定义路由,添加web/route.ts ,该文件将会被编译为 build/ssr-manual-route.js 文件,所以不要在路由文件中使用相对路径引入其他模块,否则将会无法正确识别路径
// web/route.ts
export const FeRoutes = [
{
"fetch": () => import(/* webpackChunkName: "detail-id-fetch" */ '@/pages/detail/fetch'),
"path": "/detail/:id",
"component": () => import(/* webpackChunkName: "detail-id" */ '@/pages/detail/render$id'), // vue 场景用此写法
"component": async function dynamicComponent () { return await import(/* webpackChunkName: "detail-id" */ '@/pages/detail/render$id') }, // react 场景需要固定函数名称为 dynamicComponent
"webpackChunkName": "detail-id"
},
{
"fetch": () => import(/* webpackChunkName: "index-fetch" */ '@/pages/index/fetch'),
"path": "/",
"component": () => import(/* webpackChunkName: "index" */ '@/pages/index/render'), // vue 场景用此写法
"component": async function dynamicComponent () { return await import(/* webpackChunkName: "index" */ '@/pages/index/render') }, // react 场景需要固定函数名称为 dynamicComponent
"webpackChunkName": "index"
}
]
每一个 http
请求都会先经过一个 server
层,再根据具体的逻辑来决定这个请求到底是返回 json
数据,还是 html
页面,还是前端静态资源。在我们这个场景,server
层就是 Node.js 框架提供的服务。一个服务端渲染页面的请求链路如下。
import { render } from 'ssr-core-vue3'
@Get('/')
@Get('/detail/:id')
async handler (): Promise<void> {
try {
this.ctx.apiService = this.apiService
this.ctx.apiDeatilservice = this.apiDeatilservice
const stream = await render<Readable>(this.ctx, {
stream: true
})
this.ctx.body = stream
} catch (error) {
console.log(error)
this.ctx.body = error
}
}
当我们访问 http://localhost:3000
或者 http://localhost:3000/detail/xxx
时,请求会首先经过我们在 Controller 中注册的路由。并且交由对应的函数进行处理。
示例函数的处理逻辑,调用了 ssr-core-xxx
模块提供的 render
方法,来渲染当前请求所对应的前端组件。并且将返回的结果是一个包含 html
, meta
标签的完整 html
文档结构。我们提供 string
, stream
两种格式的 response
类型给开发者使用。返回的文档结构中已经包含了 script
标签加载客户端资源的相关代码
fetch.ts
文件规范来作为获取数据的入口文件。fetch.ts
的定义是页面级别的组件进行数据获取的入口文件,不包括子组件。由于在服务端一个组件被真正的 render
之前,我们并不知道它依赖哪些子组件。所以我们没有办法调用子组件的 fetch
, 当然也有其他方式可以解决这个问题。稍后我们可以再看
fetch.ts
的文件类型分为两种
Layout
级别的 fetch
(可选),定义在 web/components/layout/fetch.ts
路径
意义: Layout
级别的 fetch
用于初始化一些所有页面都会用到的一些公共数据,若该文件存在则调用。将会把返回的数据与页面级别的 fetch
合并返回给开发者。Layout
场景只允许存在一个 fetch
文件
页面级别的 fetch
(可选, 可以存在多个),定义在 web/pages/xxx/fetch.ts
路径
意义: 页面级别的 fetch
将会在当前访问该前端页面组件对应的 path
时被调用
fetch 与 render 对应关系
fetch
文件与 render
对应关系如下
fetch
文件时,当前文件夹所有的 render
文件都对应这个 fetch
文件fetch
文件存在多个时,render
文件与 fetch
文件名一一对应,例如 render.vue
=> fetch.ts
, render$id.vue
=> fetch$id.ts
页面部署
官方网站详细的说明了阿里云和腾讯云的部署过程,ssr 框架官方文档
如果我们要自己部署其他服务器的话有两种方式
npm run prod
, 这种模式几乎不会出任何问题,唯一的缺点是安装的依赖较多ssr build
将 build/dist
目录提交到 git
仓库。服务器安装生产环境依赖 npm i --production
, 然后把 script prod
脚本里面的 ssr build
这一段干掉,然后执行 npm run prod
框架启动的时候默认使用服务端渲染方式,如果想要启用渲染降级,只需要在请求 URL
后面添加 query
参数 ?csr=xxx
举个栗子, http://ssr-fc.com 网站默认启用了服务端渲染,可以明显感受到页面秒开,没有白屏等待时间,而添加参数后 http://ssr-fc.com?csr=true,也就是启动客户端渲染之后再打开网站,可以明显感受到有一定的白屏时间,具体表现为有一个页面闪烁的过程。
此方案适用于开发者本地进行测试。
服务发布的时候支持两种模式,默认是 mode: 'ssr'
模式,你也可以通过 应用配置 中的 mode: 'csr'
将 csr
设置默认渲染模式。
ssr-core-react 和 ssr-core-vue (vue3)模块均支持该方式
在应用执行出错 catch
到 error
的时候降级为客户端渲染。也可根据具体的业务逻辑,由开发者自行决定在适当的时候通过该方式降级 csr
模式。也可以通过接入发布订阅机制,通过发布平台来实时设置当前的渲染模式。
处理 字符串 返回形式的降级
字符串的降级处理很简单,我们只需要 try catch
到错误后,直接修改渲染模式拿到新的结果即可。因为此时组件的渲染是在 render
方法被调用时就被渲染执行了
import { render } from 'ssr-core-react'
try {
const htmlStr = await render(this.ctx)
return htmlStr
} catch (error) {
const htmlStr = await render(this.ctx, {
mode: 'csr'
})
return htmlStr
}
处理 流 返回形式的降级
流返回形式的降级处理略麻烦。在 Nest.js
或者 express
系的框架中我们可以用以下写法进行降级
const stream = await render<Readable>(ctx, {
stream: true
})
stream.pipe(res, { end: false })
stream.on('error', async () => {
stream.destroy() // 销毁旧的错误流
const newStream = await render<Readable>(ctx, {
stream: true,
mode: 'csr'
})
newStream.pipe(res, { end: false })
newStream.on('end', () => {
res.end()
})
})
stream.on('end', () => {
res.end()
})