Skip to content

改造CSR项目

改造《用户管理系统》

步骤:

  1. 改造router
  2. 改造入口文件
  3. 服务器端入口文件
  4. 创建服务器
  5. 还原SPA
  6. 解决Element-plus渲染不一致
  7. 数据预装载
  8. 数据预取

1. 改造router

之前 SPA 是导出一个路由实例,但是现在是 SSR 环境,每一次请求都必须要是全新的路由实例。

export function createRouter() {
return _createRouter({
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
routes,
})
}
  1. 导出一个方法,调用该方法后返回一个新的路由实例
  2. 不同的环境,history 模式不同
    • 服务器端:createMemoryHistory
    • 客户端:createWebHistory

2. 改造入口文件

要做的事情和改造 router 一致,单独导出一个方法来创建应用实例

export function createApp() {
// 创建Vue应用实例
const app = createSSRApp(App)
// 创建路由实例
const router = createRouter()
const pinia = createPinia()
// 引入图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(router).use(pinia).use(ElementPlus)
// 需要将这些实例返回给外部
return { app, router, pinia }
}

保证每一次请求得到的是一个新的应用实例。

3. 服务器端入口文件

导出一个方法,该方法会根据传入的 url 做对应的首屏渲染

// 服务器端入口文件
import { createApp } from '../main'
import { renderToString } from 'vue/server-renderer'
/**
*
* @param {*} url 用户请求的url
* 根据不同的url,返回不同的首屏内容
*/
export async function render(url) {
const { app, router } = createApp()
// 根据用户请求的url,跳转到对应的路由
router.push(url)
// 等待路由跳转完成
await router.isReady()
// 目前为止,说明咱们的应用已经跳转到了对应的页面
// 接下来需要对这个页面进行服务器端渲染,渲染为字符串
const html = renderToString(app)
return [html]
}

4. 创建服务器

  1. 创建 express 应用
  2. 将 vite 创建的服务器以中间件的形式添加到 express 应用中,从而能够使用 vite 的热模块更新等特性
  3. 处理请求
    1. 拿到具体的 url 请求
    2. 读取 index.html 模板内容,拿到的是模板的字符串
    3. 往模板注入 vite 相关资源
    4. 拿到 render 方法,根据 url 进行 vue 应用实例的渲染,返回的也是一段字符串
    5. 替换模板指定位置的内容
    6. 返回给客户端

目前达到的效果:服务器端能够根据对应的 url 返回对应的页面(有内容)

遗留的问题:

  1. 表格没有数据
  2. 事件丢失了
  3. 目前是一个多页应用,无论跳转哪一个链接,都需要请求服务器,然后服务器返回完整的页面。

5. 还原SPA

进行水合操作,就是指第一次请求完整的 html 之后,需要重新变成一个单页应用。

渲染好后的 html 到达客户端后,去请求对应的客户端 JS,客户端 JS 就会把整个应用重新还原为单页应用。

创建一个客户端入口文件:

import { createApp } from '../main'
const { app, router } = createApp()
router.isReady().then(() => {
app.mount('#app')
})
  1. 调用 createApp 方法得到 Vue 应用实例以及路由实例
  2. 进行客户端的Vue应用实例挂载操作

遗留的问题:

Hydration mismatch:水合操作出现服务器端渲染和客户端渲染不一致的问题

6. 解决Element-plus渲染不一致

Element-plus 官网提供了详细的解决步骤,主要有如下几个问题需要处理:

  1. 提供一个 ID
  2. 配置 Zindex
  3. Teleports的处理: Teleports 在 Element-plus 中会被多个组件用到,ElDialog, ElDrawer, ElTooltip, ElDropdown, ElSelect …

7. 数据预装载

所谓数据预装载,就是指将数据装载到 html 里面后一起发送给客户端。换句话说,即便不做水合操作,第一次请求首页的时候,表格应该是有数据的。

自定义了一个 hook:

export const useServerData = async (cb) => {
// 判断如果是服务器端环境
// 那么就直接调用cb
if (import.meta.env.SSR && cb) {
await cb()
}
}

该 hook 的作用很简单,就是在服务器端执行对应的回调函数。回头在组件中使用该 hook 指定服务器环境下获取数据。

8. 数据预取

所谓数据预取,指的是服务器端向客户端返回完整的 html 的时候,不仅仅已经装载好了数据,还要将数据以某种形式额外的返回给客户端。

思考🤔 这有什么用?

主要目的就是为了让客户端的数据仓库和服务端返回的数据保持一致。

两个重要概念:

  1. 脱水(Dehydration):是指在服务器端渲染的过程中,将渲染时使用的关键状态和数据提取出来,并以一种可序列化的形式嵌入到 HTML 中,发送给客户端。这样客户端在加载时可以直接使用这些数据,而不需要再次请求服务器。

    export async function render(url) {
    // ...
    // 在进行返回的时候,除了首屏内容、teleports以外,还有仓库数据
    return [html, teleportsStr, JSON.stringify(pinia.state.value)]
    }
    const html = template
    .replace(`<!--vue-ssr-outlet-->`, appHtml)
    .replace(/(\n|\r\n)\s*<!--app-teleports-->/, teleportsStr)
    .replace(`<!--pinia-state-->`, piniaState)
    <body>
    <!--app-teleports-->
    <div id="app"><!--vue-ssr-outlet--></div>
    <script type="module" src="/src/main.js"></script>
    <script type="module" src="/src/entry/client-entry.js"></script>
    <script>
    // 在window对象上面新增一个特殊的属性,用于存储仓库数据
    window.__INITIAL_STATE__ = '<!--pinia-state-->'
    </script>
    </body>
  2. 注水(Rehydration):是指在客户端接管服务器渲染的页面时,将脱水的状态重新加载到客户端应用中,并使得客户端的 JavaScript 逻辑接管页面上的交互和功能。通过注水,客户端可以避免重复请求数据,实现无缝的客户端功能接管。

    router.isReady().then(() => {
    if (window.__INITIAL_STATE__) {
    // 说明返回的html上面挂了额外的数据
    // 需要将这个数据同步到客户端的仓库中
    pinia.state.value = JSON.parse(window.__INITIAL_STATE__)
    }
    app.mount('#app')
    })

-EOF-