当前位置: 首页 > 工具软件 > Vue-SSR > 使用案例 >

VUE SSR 从入门到放弃

韩英锐
2023-12-01

什么是ssr

        它是传统的服务端渲染和单页应用的一种折中的解决方案:后端渲染出完整的dom结构返回,前端拿到的内容包括:首屏及完整spa结构,应用激活后依然按照spa方式运行,这种页面渲染方式被称为服务端渲染(server side render),它的过程是:

  1. 客户端发送请求给服务器
  2. 服务器读取模板,解析成dom节点,返回一个完整的首屏html结构
  3. 客户端进行首屏激活(把用户写的交互的代码,在前端激活,重新变成一个spa应用)
  4. 这样后续,用户再点击超链接、跳转时,不会再向服务器发送请求了,而是使用前端路由跳转,只会发送一些ajax请求数据

优点:

  • SEO友好
  • 首屏加载快

缺点:

  • 开发逻辑复杂
  • 开发条件限制:比如一些生命周期不能用,一些第三方库也可能不能用
  • 服务器负载大

官方提供了 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 文件夹下的每个文件夹,我们都会认为它是一个页面。上述结构包含 indexdetail 两个页面。同样我们定义 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 方法,来渲染当前请求所对应的前端组件。并且将返回的结果是一个包含 htmlmeta 标签的完整 html 文档结构。我们提供 stringstream 两种格式的 response 类型给开发者使用。返回的文档结构中已经包含了 script 标签加载客户端资源的相关代码

数据获取

fetch.ts 文件规范来作为获取数据的入口文件。fetch.ts 的定义是页面级别的组件进行数据获取的入口文件,不包括子组件。由于在服务端一个组件被真正的 render 之前,我们并不知道它依赖哪些子组件。所以我们没有办法调用子组件的 fetch, 当然也有其他方式可以解决这个问题。稍后我们可以再看

fetch.ts 的文件类型分为两种

Layout fetch

Layout 级别的 fetch (可选),定义在 web/components/layout/fetch.ts 路径

意义: Layout 级别的 fetch 用于初始化一些所有页面都会用到的一些公共数据,若该文件存在则调用。将会把返回的数据与页面级别的 fetch 合并返回给开发者。Layout 场景只允许存在一个 fetch 文件

页面级 fetch

页面级别的 fetch (可选, 可以存在多个),定义在 web/pages/xxx/fetch.ts 路径

意义: 页面级别的 fetch 将会在当前访问该前端页面组件对应的 path 时被调用

fetch 与 render 对应关系

fetch 文件与 render 对应关系如下

  • 当只有一个 fetch 文件时,当前文件夹所有的 render 文件都对应这个 fetch 文件
  • fetch 文件存在多个时,render 文件与 fetch 文件名一一对应,例如 render.vue => fetch.tsrender$id.vue => fetch$id.ts

页面部署

官方网站详细的说明了阿里云和腾讯云的部署过程,ssr 框架官方文档

如果我们要自己部署其他服务器的话有两种方式

两种部署方式

  • ci 构建,服务器安装所有依赖 + 执行 npm run prod, 这种模式几乎不会出任何问题,唯一的缺点是安装的依赖较多
  • 本地构建,本地执行 ssr build 将 build/dist 目录提交到 git 仓库。服务器安装生产环境依赖 npm i --production, 然后把 script prod 脚本里面的 ssr build 这一段干掉,然后执行 npm run prod

渲染降级

URL Query 参数

框架启动的时候默认使用服务端渲染方式,如果想要启用渲染降级,只需要在请求 URL 后面添加 query 参数 ?csr=xxx

举个栗子, http://ssr-fc.com 网站默认启用了服务端渲染,可以明显感受到页面秒开,没有白屏等待时间,而添加参数后 http://ssr-fc.com?csr=true,也就是启动客户端渲染之后再打开网站,可以明显感受到有一定的白屏时间,具体表现为有一个页面闪烁的过程。

此方案适用于开发者本地进行测试。

config.js 配置

服务发布的时候支持两种模式,默认是 mode: 'ssr' 模式,你也可以通过 应用配置 中的 mode: 'csr' 将 csr 设置默认渲染模式。

通过 core 模块提供的 render 方法降级

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()
})

 类似资料: