nextjs学习1:回顾服务端渲染SSR

服务端渲染的发展历史

传统的服务端渲染

传统的服务端渲染有 asp, jsp(java), ejs(nodejs)等,服务端语言往往通过这些模板引擎将数据 datahtml 结构在服务端组装,返回一个完整的静态的 html 字符串给客户端,由客户端直接显示。

缺点

  • 前后端不分离,前后端代码混在一个工程目录中,维护不方便,。
  • 用户体验不佳,每次页面有改动都需要重新加载整个页面 。比如,一个列表页面,当用户增加一项时,后台需要重新组装数据 datahtml 结构,返回一个新的页面给前端,这样用户才能看到页面的变化。
  • 服务端压力大,不仅要响应静态 html 文件,还要响应数据api接口。

客户端渲染(CSR)

在现代化的前端项目中,客户端渲染的代表性技术栈是 vue/react/angular,我们常常使用它们来构建客户端单页或者多页应用程序。

SPA 构建程序为例,在浏览器端首先渲染的是一套空的 html,然后下载bundle.js并执行,通过 JavaScript 直接进行页面的渲染和路由跳转等操作,所有的数据通过 ajax 请求从服务端获取。

路由的跳转是通过history api 实现的,它最大的作用是能改变url地址,但是不会刷新页面,也就是不会发送请求。这样对用户操作来说就非常的无感,克服了传统服务端渲染每次都要请求服务器的困扰。

缺点

  • 首屏加载慢,因为第一次会请求一个空的html文件,再去加载 bundle.js等打包后的文件。
  • 不利于网站 SEO,因为首次请求回来的是空的 html 文件,爬虫无法获取有效内容信息,其实现在的爬虫也能爬取spa网站了。

现代服务端渲染(同构)

我们现在讲的服务端渲染概念,是指在前端范畴或者说在 Vue/React 等单页面技术范畴内的,基于 Nodejs server 运行环境的服务端渲染方案,这种方案的本质是同构渲染。它的步骤如下:

  1. Nodejs 中运行相同的前端代码,将用Vue/React框架写的代码转化为html 结构,然后返回给浏览器渲染,这样爬虫就能爬取到完整的页面信息。
  2. 客户端获取到服务端返回的页面后,再进行注水(hydrate)化处理,由客户端代码(SPA代码)来接管页面。

为什么要进行注水处理呢?

因为服务端环境毕竟不同于浏览器环境,缺少浏览器环境必要的变量和API。比如,页面中的点击事件就无法在服务端进行注册,因为在服务端环境中是没有DOM节点的概念的,它只是一堆字符串而已,自然无法使用 document.addEventListener 这样的API。也就是如果客户端代码不接管页面,那么页面里面所有的点击事件将不可用。

什么是同构?

同构简单来讲就是服务端和客户端复用同一套代码。比如,页面html结构、store数据存储、router路由都能共享一套代码。这就是所谓的现代服务端渲染:同构

缺点

  1. SSR 的数据获取必须在组件渲染之前;
  2. 组件的 JavaScript 必须先加载到客户端,才能开始水合;
  3. 所有组件必须先水合,然后才能跟其中任意一个组件交互;

可以看出 SSR 这种技术大开大合,加载整个页面的数据,加载整个页面的 JavaScript,水合整个页面,还必须按此顺序串行执行。如果有某些部分慢了,都会导致整体效率降低。

此外,SSR 只用于页面的初始化加载,对于后续的交互、页面更新、数据更改,SSR 并无作用

为什么选择 SSR?

相比于客户端渲染 CRS (单页面应用),SSR 主要的好处是:

更快的内容呈现

尤其是网络连接缓慢或设备运行速度缓慢的时候,服务端标记不需要等待所有的 JavaScript 脚本都被下载并执行之后才显示,所以用户可以更快看到完整的渲染好的内容。这带来了更好的用户体验,同时对于内容呈现时间和转化率呈正相关的应用来说尤为关键

更好的搜索引擎优化 (SEO)

因为后端会一次性的把网站内容返回给前端,所以搜索引擎爬虫会直接读取完整的渲染出来的页面。但如果你的JavaScript 脚本是通过 API 调用获取内容,则爬虫不会等待页面加载完成。这意味着如果你的页面有异步加载的内容且 SEO 很重要,那么你可能需要 SSR。

除了上面两个优点外,这里还有一些点来决定是是否选用SSR:

  • 开发一致性。浏览器特有的 API 只能在特定的生命周期钩子中使用;一些外部的库在服务端渲染应用中可能需要经过特殊处理。
  • 需要更多的构建设定和部署要求 。不同于一个完全静态的 SPA 可以部署在任意的静态文件服务器,服务端渲染应用需要一个能够运行 Nodejs 服务器的环境。
  • 更多的服务端负载 。在 Nodejs 中渲染一个完整的应用会比仅供应静态文件产生更密集的 CPU 运算。所以如果流量很高,请务必准备好与其负载相对应的服务器,并采取明智的缓存策略。

在应用中使用 SSR 之前,你需要问自己的第一个问题是:你是否真的需要它?

它通常是由内容呈现时间对应用的重要程度决定的

例如,如果你正在搭建一个内部管理系统,几百毫秒的初始化加载时间对它来说无关紧要,这种情况下就没有必要使用 SSR。然而,如果内容呈现时间非常关键,SSR 可以助你实现最佳的初始加载性能。

SSR vs 预渲染

如果你仅希望通过 SSR 来改善一些推广页面 (例如 //about/contact 等) 的 SEO,那么预渲染也许会更合适。和使用动态编译 HTML 的 web 服务器相比,预渲染可以在构建时为指定的路由生成静态 HTML 文件。

如果你正在使用 webpack,你可以通过 prerender-spa-plugin 来支持预渲染。

现代服务端渲染(同构)大致实现原理

通过前面的介绍,服务端渲染就是返回一个带有具体内容的 html 字符串给浏览器,那么这个具体的内容是什么呢?

这个具体的内容就是用 Vue 开发的页面内容,但是如果直接把带有 Vue 语法塞进 html 模板浏览器根本无法识别,因此,服务端渲染也需要使用 Vite 进行编译打包转化为浏览器能识别的javascript语法。

根据同构概念理解,客户端和服务端是共用同一套的页面内容代码的,所以客户端和服务端需要分别打包编译。

首先就是编写通用代码,适用于客户端和服务端。

一. 编写通用代码

由于平台 API 的差异,当运行在不同环境中时,我们写的通用代码将与纯客户端代码不会完全相同。需要注意一下几点:

1. 避免状态单例

在纯客户端应用程序中,每个用户会在他们各自的浏览器中使用新的应用程序实例。对于服务器端渲染,我们也希望如此。

但是,Node.js 服务器是一个长期运行的进程。当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。

所以,必须要求每个请求都应该都是全新的、独立的应用程序实例,以便不会有交叉请求造成的状态污染。

因此,我们不应该直接创建一个应用程序实例,而是应该暴露一个可以重复执行的工厂函数,为每个请求创建新的应用程序实例:

对原有客户端代码代码进行改造:

main.js

js 复制代码
# 原有代码
const app = createApp(App)
app.config.globalProperties.$message = ElMessage
app.use(router)
app.use(store, key)
app.use(ElementPlus)
app.use(i18n)
app.mount('#app')

# 改造后代码:变为工厂函数
import { createSSRApp } from 'vue'
export function createApp() {
  const app = createSSRApp(App)
  const store = createSSRStore()
  const router = createSSRRouter()
  const i18n = createSSRI18n()
  sync(store, router)
  app.config.globalProperties.$message = ElMessage
  app.use(store, key)
  app.use(router)
  app.use(ElementPlus)
  app.use(i18n)

  return { app, router, store }
}

同理,项目中的数据存储store,路由router等都需要改造成工厂函数的形式,比如路由:

js 复制代码
export function createSSRRouter() {
  return createRouter({
    # import.meta.env.SSR是vite提供环境变量
    # 服务端渲染只能用createMemoryHistory
    history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
    routes
  })
}

2. 组件生命周期钩子函数

由于服务端没有动态更新,所有的生命周期钩子函数中,只有 beforeCreatecreated 会在服务器端渲染 (SSR) 过程中被调用。这就是说任何其他生命周期钩子函数中的代码(例如 beforeMountmounted),只会在客户端执行。

此外还需要注意的是,你应该避免在 beforeCreatecreated 生命周期时产生全局副作用的代码,例如在其中使用 setInterval 设置 timer。在纯客户端的代码中,我们可以设置一个 timer,然后在 beforeDestroydestroyed 生命周期时将其销毁。但是,由于在 SSR 期间并不会调用销毁钩子函数,所以 timer 将永远保留下来。

3. 访问特定平台API

通用代码不可接受特定平台的 API,因此如果你的代码中,直接使用了像 windowdocument,这种仅浏览器可用的全局变量,则会在 Node.js 中执行时抛出错误,反之也是如此。

对于共享于服务器和客户端,但用于不同平台 API 的任务,建议将平台特定实现包含在通用 API 中,例如,axios是一个 HTTP 客户端,可以向服务器和客户端都暴露相同的 API。

请注意,考虑到如果第三方 library 不是以上面的通用用法编写,则将其集成到服务器渲染的应用程序中,可能会很棘手。

二、 构建步骤

编写好通用代码之后,就需要使用构建工具webpack, Vite进行打包构建,我们将会采用Vite进行构建,构建过程如下:

1. 创建客户端入口和服务端入口文件

一个典型的 SSR 应用应该有如下的源文件结构:

js 复制代码
- index.html # 模版html文件
- server.ts # main application server
- src/
  - main.js # 公共通用代码,不是入口文件
  - entry-client.ts  # 客户端入口: 将应用挂载到一个 DOM 元素上
  - entry-server.ts  # 服务端入口:使用某框架的 SSR API 渲染该应用

index.html

首先看下index.html文件,它原来长这个样子:

js 复制代码
<!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>一个经典的前台项目</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

<script type="module" src="/src/main.js"></script>中的type="module"的含义与带 defer 的普通脚本一致:

  • 不会阻塞 HTML 解析;
  • 会等待 HTML 解析完成后,按脚本在页面中的顺序执行;
  • 普通脚本(无 defer/async)会阻塞解析,执行完才继续解析 HTML。

开发完代码后,执行npm run build进行打包后长这个样子:

js 复制代码
<!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>一个经典的前台项目</title>
    <script type="module" crossorigin src="/assets/index-IiCrRs2g.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-NLCfHQLN.css">
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

其中的index-IiCrRs2g.js是整个前端应用的核心入口文件,其核心作用是启动 Vue 应用、挂载根组件,并加载应用运行所需的核心依赖 / 资源。

现在需要改造如下:

index.html 将需要引用 entry-client.ts,而不是原来的main.ts,并包含一个占位标记供给服务端渲染时注入:

js 复制代码
<div id="app"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client.ts"></script>

服务端渲染后的html字符串会替换<!--ssr-outlet-->/src/entry-client.ts代码主要是用于接管页面,使其具备交互能力。

entry-client.ts

js 复制代码
import { createApp } from './main'

const { app, router, store } = createApp()

router.isReady().then(() => {
  app.mount('#app')
})

entry-server.ts

js 复制代码
import { createApp } from './main'
import { renderToString } from '@vue/server-renderer'

// 服务端渲染核心函数
export async function render(url) {
  const { app, router, store } = createApp() 
  # 根据url来渲染对应的页面
  await router.push(url) 
  await router.isReady() 
  const appHtml = await renderToString(app) 
  return appHtml
}

server.ts

js 复制代码
const fs = require('fs')
const path = require('path')
const express = require('express')
const serveStatic = require('serve-static')
const { createServer: createViteServer } = require('vite')

async function createServer() {
  const app = express()

  const vite = await createViteServer({
    server: { middlewareMode: 'ssr' }
  })
 
  app.use(vite.middlewares)

  app.use('*', async (req, res) => {
    const url = req.originalUrl
    # 1. 读取 index.html
    let template = fs.readFileSync(path.resolve(__dirname, 'index.html'),
          'utf-8')
          
    template = await vite.transformIndexHtml(url, template)      
          
    # 2. entry-server.ts 暴露了render方法
    let render = (await vite.ssrLoadModule('/src/entry-server.ts')).render
       
    # 3. 根据url渲染对应的
    const appHtml = await render(url)
    
    # 4. 插入到div中
    const html = template.replace('<!--ssr-outlet-->', appHtml)
    
    res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
   
  })

  app.listen(3000, () => {
    console.log('node server run at:', isProd ? '生产环境' : '开发环境')
  })
}

createServer()

package.json

package.json 中的 dev 脚本也应该相应地改变,使用服务器脚本:

js 复制代码
"scripts": {
    // "dev": "vite"
    "dev": "cross-env NODE_ENV=development node server.js",
}

2. 打包客户端和服务端代码

package.json 中的脚本应该看起来像这样:

js 复制代码
{
  "scripts": {
    "dev": "node server",
    "build:client": "vite build --outDir dist/client",
    "build:server": "vite build --outDir dist/server --ssr src/entry-server.js "
  }
}

使用 --ssr 标志表明这将会是一个 SSR 构建,同时需要指定 SSR 的入口。

接着,在 server.js 中,通过 process.env.NODE_ENV 条件,需要添加一些用于生产环境的特定逻辑:

  • index.html模版变更:使用 dist/client/index.html 作为模板,而不是根目录的 index.html,因为前者包含了到客户端构建的正确资源链接。
  • 服务端入口文件变更:原来是/src/entry-server.js, 现在要使用 import('./dist/server/entry-server.js')

修改后代码如下:

js 复制代码
const fs = require('fs')
const path = require('path')
const express = require('express')
const serveStatic = require('serve-static')
const { createServer: createViteServer } = require('vite')

const isProd = process.env.NODE_ENV === 'production'

async function createServer() {
  const app = express()

  const vite = await createViteServer({
    server: { middlewareMode: 'ssr' }
  })
  
  # 在生产环境需要vite与express进行脱钩
  if (!isProd) {
    # 使用 vite 的 Connect 实例作为中间件,利用这个中间件来起一个静态资源服务器
    app.use(vite.middlewares)
  } else {
    # 在生产环境,利用express框架自带的中间件serve-static,利用这个中间件来起一个静态资源服务器
    # 把dist/client文件夹下的资源都可以访问
    app.use(
      serveStatic(path.resolve(__dirname, 'dist/client'), { index: false })
    )
  }

  app.use('*', async (req, res) => {
    const url = req.originalUrl
    let template
    let render
    try {
      # 在生产环境需要vite与express进行脱钩
      if (!isProd) {
        // 1. 读取 index.html
        template = fs.readFileSync(
          path.resolve(__dirname, 'index.html'),
          'utf-8'
        )
        
        # 2. 应用 Vite 进行 HTML 转换,这将会注入 Vite HMR 客户端,
        template = await vite.transformIndexHtml(url, template)
        
        # 3. 加载服务器入口文件,vite.ssrLoadModule 将自动转换你的 ESM 源码使之可以在 Node.js 中运行。既然是加载文件,肯定是异步的,所以使用await
        render = (await vite.ssrLoadModule('/src/entry-server.ts')).render
      } else {
        # 1. 生产环境需要加载编译后的模版文件index.html
        template = fs.readFileSync(
          path.resolve(__dirname, 'dist/client/index.html'),
          'utf-8'
        )
        # 2. 使用SSR构建后的最终render函数
        render = require('./dist/server/entry-server.js').render
      }

      # 4. 渲染应用的 HTML
      const appHtml = await render(url, manifest)

      # 5. 注入渲染后的应用程序 HTML 到模板中。
      const html = template
        .replace('<!--ssr-outlet-->', appHtml)
       
      // 6. 返回渲染后的 HTML。
      res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    } catch (e) {
      ...
    }
  })

  app.listen(3000, () => {
    console.log('node server run at:', isProd ? '生产环境' : '开发环境')
  })
}

createServer()

三、数据获取

在服务器端渲染(SSR)期间,我们本质上是在渲染我们应用程序的"快照",所以如果应用程序依赖于一些异步数据,那么在开始渲染过程之前,需要先预取和解析好这些数据

另一个需要关注的问题是在客户端代码接管时,需要获取到与服务器端应用程序完全相同的数据,否则客户端应用程序会因为使用与服务器端应用程序不同的状态,然后导致水合失败。

为了解决这个问题,获取的数据需要位于视图组件之外,即放置在专门的数据预取存储容器(data store)或"状态容器(state container)"中。

为此,我们将使用官方状态管理库 Vuex。

那么,我们在哪里放置dispatch 数据预取 action的代码?

事实上,给定路由所需的数据,也是在该路由上渲染组件时所需的数据。所以在路由组件中放置数据预取逻辑,是很自然的事情。

我们将在路由组件上暴露出一个自定义静态函数 asyncData。注意,由于此函数会在组件实例化之前调用,所以它无法访问 this。需要将 store 和路由信息作为参数传递进去:

js 复制代码
<script setup>
import { ref } from 'vue'

async function asyncData({ store, route }: any) {
 return store.dispatch('getRoomList')
}

defineExpose({ asyncData })
</script>

1. 服务器端数据预取

entry-server.js 中,我们可以通过路由获得相匹配的组件,如果组件暴露出 asyncData,我们就调用这个方法。然后我们需要将解析完成的数据,绑定到到window上。

修改entry-server.ts:

js 复制代码
export async function render(url: string, manifest: any) {
  const { app, router, store } = createApp()
  
  await router.push(url)
  await router.isReady()

  const matchedComponents = router.currentRoute.value.matched.flatMap(record =>
    Object.values(record.components)
  )
 
  await Promise.all(
    matchedComponents.map((Component: any) => {
      # 如果组件中定义了asyncData函数,说明这个组件需要去后台获取接口数据
      if (Component.asyncData) {
        return Component.asyncData({
          // 传入store和当前route,store参数用来执行store.dispatch,发起请求
          store,
          route: router.currentRoute
        })
      }
      return []
    })
  )

  const appHtml = await renderToString(app)
  
  # 此时state里面包含了请求接口获取的数据,然后就把这个数据绑定到window某个属性上
  # 这样当同构的时候,客户端的store.state就能用这个值作为初始化化数据,就不用再去调一次接口了
  const state = store.state
 
  return { appHtml, state }
}

修改模板文件index.html:

js 复制代码
<div id="app"><!--ssr-outlet--></div>
<script type="module" src="/src/entry-client.ts"></script>
<script>
  window.__INITIAL_STATE__ = '<!--vuex-state-->'
</script>

修改server.ts

js 复制代码
const { appHtml, state } = await render(url)
const html = template
    .replace('<!--ssr-outlet-->', appHtml)  
    .replace("'<!--vuex-state-->'", JSON.stringify(state))

修改entry-client.js: 使用__INITIAL_STATE__来初始化store,这样客户端接管时也是带有数据的。

js 复制代码
const { app, router, store } = createApp()

if ((window as any).__INITIAL_STATE__) {
  store.replaceState((window as any).__INITIAL_STATE__)
}

2. 客户端数据预取

首先思考下为什么会有客户端数据预取?

当我从服务端请求页面内容(/login)后,客户端代码立即接管页面,此时页面跳转时不会再向服务端发送请求了。

当从/login页面跳转到/home页面,由于组件的部分生命周期在服务端不能使用,我们没有在代码中写onMounted钩子函数,也就无法执行获取接口的函数 ,那么此时/home页面是没有数据的。所以就需要在客户端进行数据预取,既然组件生命周期钩子不能用,还有什么钩子可以用呢?

答案是路由钩子。

修改entry-client.ts:

js 复制代码
router.isReady().then(() => {
  # beforeResolve表示所有的异步组件全部resolve了
  router.beforeResolve((to, from, next) => {
    # 找出两个匹配列表的差异组件。
    # 如果你刷新当前页面,会发送请求到服务器,服务前拼接好数据和html返回前端,但是有了这个路由钩子,它还会再去请求一遍数据,这就相当于前后台都去请求了一次回去,这没有必要。
    # 所以需要做一个判断是否是在刷新页面,也就是to和from是不是一样的,如果一样的,就是刷新操作,那么actived为空,不会执行后面的逻辑,也就是客户端不会再次请求数据接口,用服务端带过来的数据就可以了,这就是防止客户端数据二次预取。
    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
    })
    # 客户端预取数据有两种方式:
    # 一种是在匹配到路由视图之后就跳转,然后去请求接口,
    # 另一种是在请求接口数据返回后再进行跳转,此时页面是包含数据的
    if (!actived.length) {
      return next()
    } else {
      # 第一种: 匹配路由之后直接跳转,然后去请求接口
      // next()
    }
    // 显示loading
    const loadingInstance = ElLoading.service({
      lock: true,
      text: 'Loading',
      background: 'rgba(0, 0, 0, 0.7)'
    })

    Promise.all(
      actived.map((Component: any) => {
        if (Component.asyncData) {
          return Component.asyncData({
            store,
            route: router.currentRoute
          })
        }
        return []
      })
    ).then(() => {
      // 关闭loading
      loadingInstance.close()
      # 第二种: 等数据请求之后再跳转
      next()
    })
  })

  app.mount('#app')
})

至此,整个服务端渲染就基本完成了。

相关推荐
Irene19912 小时前
Vue:defineProps、defineEmits、defineExpose 深度解析
vue.js·编译器宏
小徐不会敲代码~3 小时前
Vue3 学习 6
开发语言·前端·vue.js·学习
幽络源小助理3 小时前
SpringBoot+Vue多维分类知识管理系统源码 | Java知识库项目免费下载 – 幽络源
java·vue.js·spring boot
fengyucaihong_1233 小时前
vue加声音播放
javascript·vue.js·ecmascript
华仔啊3 小时前
Vue3 的设计目标是什么?相比 Vue2 做了哪些关键优化?
前端·vue.js
麦麦大数据3 小时前
F066 vue+flask中医草药靶点知识图谱智能问答系统|中医中药医学知识图谱
vue.js·flask·知识图谱·中医·草药·成分知识图谱·靶点
鹏多多3 小时前
前端纯js实现图片模糊和压缩
前端·javascript·vue.js
Aliex_git4 小时前
Vue 2 - 模板编译源码理解
前端·javascript·vue.js·笔记·前端框架
Irene19914 小时前
Vue:Props 和 Emits 对比总结
vue.js·props·emits