likes
comments
collection
share

如何实现一个简单的 SSR 应用

作者站长头像
站长
· 阅读数 10

前言

使用 vue 构建的项目,通常是 SPA(single page web application) 网站,其页面源代码往往像下图所示那样,内容部分是空的,只有一个 <div id="app"></div>,其余部分需要等待相关 js 执行后加载:

如何实现一个简单的 SSR 应用

这就导致搜索引擎的爬虫无法抓取什么有用的信息,不利于网站的 SEO。 所以在做一些比如商城之类的,对搜索引擎优化比较看重的前台项目时,如果使用 vue 我们会采用 nuxt 来构建 SSR(Server Side Render,即服务器端渲染) 网站。为了帮助理解其运行原理,本文将不使用 nuxt 而只通过 express 和 vue 提供的 api,集成 vue-router,利用 webpack 来搭建个 SSR 网站。

实现原理

实现 SSR 的原理大致是当我们在浏览器地址栏输入了 url,服务器端(假设是通过 node 的 express 框架搭建的)接收到请求后,一方面会先使用 vue 提供的 createSSRApp()renderToString() 这两个接口,在服务器端构建应用实例并返回给浏览器一个静态的页面;另一方面则把使用 createApp() 构建应用的相关 js 文件通过 <script src> 也发送给浏览器,让浏览器自行下载运行,在浏览器端构建应用,以实现页面的交互。 如何实现一个简单的 SSR 应用

搭建服务器

先使用 express 搭建个服务器:

// src\server\index.js
const express = require('express')
const server = express()

server.get('/', (req, res) => {
  res.send('Hello Juejin')
})

server.listen(4396, () => {
  console.log('服务器启动')
})

由于之后会用到 vue 相关的内容,我们使用 webpack 对文件进行个处理,将上述 src\server\index.js 打包为 build\server\server_bundle.js。

安装相关依赖:

npm i webpack webpack-cli webpack-node-externals -D

对于打包服务器端文件,webpack 初步配置如下:

// config\server.config.js
const path = require('path')
const nodeExternals = require('webpack-node-externals')
module.exports = {
  target: 'node',
  mode: 'development',
  entry: './src/server/index.js',
  output: {
    filename: 'server_bundle.js',
    path: path.resolve(__dirname, '../build/server')
  },
  externals: [nodeExternals()],
  watch: true
}

注意几点:

  1. target 需要指定为 node,这样 webpack 就会在类 Node.js 环境编译代码,不会去加载诸如 path 或 fs 这些 node 的内置模块;
  2. entry 的路径是相对于执行 node 命令所在的文件的;
  3. externals: [nodeExternals()] 是为了让打包生成的 server_bundle.js 排除掉 node_modules 中的内容,而不是将我们自己的代码和依赖的库的代码捆绑在一起;
  4. watch: true 让 webpack 能够自动在 webpack 依赖图中的文件发生改变时重新编译。

现在就可以在 package.json 中添加脚本命令:

{
  "scripts": {
   
    "build:server": "webpack --config ./config/server.config.js",
    "start": "nodemon ./build/server/server_bundle.js"
  }
}

执行 npm run build:server 打包服务器端文件,执行 npm run start 启动服务。

创建 vue 应用

接着我们开始创建 vue 应用,先安装一些会用到的依赖:

npm i vue
npm i vue-loader babel-loader @babel/core @babel/preset-env -D

然后写个简单的 .vue 文件作为应用的根组件:

<!-- src\App.vue -->
<script setup>
import { ref } from 'vue'
const count = ref(0)
function add() {
  count.value++
}
</script>

<template>
  <div>
    {{ count }}
    <button @click="add">+1</button>
  </div>
</template>

生成静态页面

之后要做的就是通过 vue 提供的 createSSRApp() 来创建应用实例了,它的用法与在用 vue 创建 SPA 应用时使用的 createApp() 完全相同,只不过是以 SSR 激活模式创建:

// src\app.js
import { createSSRApp } from 'vue'
import App from './App.vue'

export default function my_createApp() {
  const app = createSSRApp(App)
  return app
}

这里将创建 app 的方法写在一个函数 my_createApp 内,是因为 app 实例是在服务器端创建的,为了避免不同客户端之间可能出现的跨请求状态的污染,就需要在每次接收到请求时,都创建全新的实例返回。

在 src\server\index.js 中,导入 my_createApp,并将创建的实例通过 vue 提供的 api renderToString 转成字符串,然后插入到 html 模板中返回给浏览器:

import my_createApp from '../app'
import { renderToString } from '@vue/server-renderer'

server.get('/', async (req, res) => {
  const app = my_createApp()
  const html = await renderToString(app)
  res.send(
    `
      <!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
      </head>
      <body>
        <div id="app">
          ${html}
        </div>
      </body>
      </html>
    `
  )
})

config\server.config.js 也需要添加以下内容,以处理 vue 相关的内容:

const { VueLoaderPlugin } = require('vue-loader/dist/index')
module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env']
        }
      },
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      }
    ]
  },
  plugins: [new VueLoaderPlugin()]
}

VueLoaderPlugin 插件的职责是将定义过的其它规则复制并应用到 .vue 文件里相应语言的块(blocks)。

现在,在浏览器输入 localhost:4396 得到的就是个静态页面了:

如何实现一个简单的 SSR 应用

查看页面源代码,可以看到相关元素,爬虫就可以获取到内容信息了:

如何实现一个简单的 SSR 应用

Hydration

但此时点击按钮并不能实现加 1 的功能。想要让页面变为可交互的,我们还需要使用 createApp() 生成一个应用实例,然后挂载到 <div id="app"> 内,来替换之前的静态页面,即实现所谓的 Hydration(水合作用):

// src\client\index.js
import { createApp } from 'vue'
import App from '../App.vue'

const app = createApp(App)
app.mount('#app')

上述文件可以通过 webpack 打包,生成 client_bundle.js。因为打包后的文件是运行在浏览器端的,所以配置略有不同,target 需改为默认的 web,然后打包的入口路径、输出文件名称稍作调整,删除 externals: [nodeExternals()],其余配置同 config\server.config.js 一致(如果想进一步优化可以使用 webpack-merge 对配置进行公用部分的抽取):

// config\client.config.js
const path = require('path')
const { VueLoaderPlugin } = require('vue-loader/dist/index')
module.exports = {
  target: 'web',
  mode: 'development',
  entry: './src/client/index.js',
  output: {
    filename: 'client_bundle.js',
    path: path.resolve(__dirname, '../build/client')
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        options: {
          presets: ['@babel/preset-env']
        }
      },
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      }
    ]
  },
  plugins: [new VueLoaderPlugin()],
  watch: true
}

打包生成 client_bundle.js 通过 <script src="/client/client_bundle.js"></script> 同之前的 html 字符串一同返回给浏览器:

如何实现一个简单的 SSR 应用 为了让浏览器能够获取到 client_bundle.js ,还需设置 client 文件夹为静态资源

// src\server\index.js
server.use(express.static('build'))

现在,我们的页面已经可以实现交互了:

如何实现一个简单的 SSR 应用

使用路由

目前我们实现的网站只有一个页面,如果有多个页面,就需要对 vue-router 进行安装集成:

npm i vue-router

创建路由对象

新建 src\router\index.js:

import { createRouter } from 'vue-router'

const routes = [
  {
    path: '/',
    component: () => import('../views/home.vue')
  },
  {
    path: '/about',
    component: () => import('../views/about.vue')
  }
]

export default function (history) {
  return createRouter({
    history,
    routes
  })
}

这里我们同样需要把 createRouter() 封装到一个函数中执行,以保证每个请求都会创建新的实例。

至于用于选择路由模式的 history 属性,因为在服务器端无法使用 html5 的 history(createWebHistory()) 或 url 的 hash 模式(createWebHashHistory()),而是使用 createMemoryHistory() 创建的基于内存的历史,所以值在调用时传递。

服务器端加载路由

修改 src\server\index.js,添加安装路由相关代码:

import { createMemoryHistory } from 'vue-router'
import createRouter from '../router'

server.get('/*', async (req, res) => {
  const app = my_createApp()

  // 路由
  // createMemoryHistory 创建一个基于内存的历史。该历史的主要目的是为了处理服务端渲染
  const router = createRouter(createMemoryHistory())
  app.use(router)
  await router.push(req.url || '/')
  await router.isReady()

  const html = await renderToString(app)
  // ...
})

先将匹配对应请求的路径改为 /*,以保证可以响应 home 和 about 页面的请求。

在服务器端,我们需要手动将路由跳转到初始位置,所以执行了 router.push(req.url || '/'),其返回值是个 promise 对象,故而在前面添加 await

另外还需要调用 router.isReady(),以保证服务器和客户端的输出一致,它的返回值也是 promise 对象,它会在路由完成初始导航时立即执行 resolve,将状态变为 fulfilled,这也意味着与初始路由相关的异步组件或钩子函数已经加载完毕。

客户端加载路由

在客户端执行的 src\client\index.js 亦需要修改:

import { createWebHistory } from 'vue-router'
import createRouter from '../router'

const app = createApp(App)

const router = createRouter(createWebHistory())
app.use(router)

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

history 的值改为 createWebHistory(),并且在 router.isReady() 返回的 promise 状态变为 fulfilled 后再执行挂载。

小结

回顾一下不难发现,当请求发生时,我们在服务器端通过 createSSRApp() 构建了一次应用,然后转成字符串返回给了浏览器生成静态页面,这样使得网站拥有了更快的静态页面显示速度以及利于爬虫抓取的网页内容,有利于SEO。

另外,我们也将使用 createApp() 构建应用的 js 文件打包后通过 <script src> 的形式告诉浏览器去下载,那么我们的应用就会在浏览器端再构建一次(所以 SSR 应用也称为同构应用),使得我们的网页可以像普通的 SPA 网站那样使用前端路由、响应式数据和虚拟 DOM 等。 虽然 SSR 应用有着首屏启动快,利于 SEO 等优点,但每次请求都会在服务器端创建全新的实例(包括路由或 pinia 等),导致了一个缺点就是增加了服务器压力,提高了成本。

如何实现一个简单的 SSR 应用 如何实现一个简单的 SSR 应用