Vue服务端渲染SSR实战

868 阅读11分钟

什么是 SSR ?

现代化的前端项目,大部分都是单页应用程序,也就是我们说的 SPA ,整个应用只有一个页面,通过组件的方式,展示不同的页面内容,所有的数据通过请求服务器获取后,在进行客户端的拼装和展示;这就是目前前端框架的默认渲染逻辑,我们称为:客户端渲染方案( Client Side Render 简称: CSR );

加载渲染过程如下: HTML/CSS 代码 --> 加载 JavaScript 代码 --> 执行 JavaScript 代码 --> 渲染页面数据

SPA 应用的客户端渲染方式,最大的问题有两个方面:

1:白屏时间过长,用户体验不好;

2:HTML 中无内容,SEO 不友好;

这个问题的原因在于,首次加载时,需要先下载整个 SPA 脚本程序,浏览器执行代码逻辑后,才能去获取页面真正要展示的数据,而 SPA 脚本的下载需要较长的等待和执行时间,同时,下载到浏览器的 SPA 脚本是没有页面数据的, 浏览器实际并没有太多的渲染工作,因此用户看到的是没有任何内容的页面,不仅如此,因为页面中没有内容,搜索引擎的爬虫爬到的也是空白的内容,也就不利于 SEO 关键字的获取;

相较于传统的站点,浏览器获取到的页面都是经过服务器处理的有内容的静态页面,有过后端编程经验的可能会比较熟悉一些,页面结构和内容,都是通过服务器处理后,返回给客户端;

两相比较我们会发现,传统站点的页面数据合成在后台服务器,而 SPA 应用的页面数据合成在浏览器,但是无论那种,最终的渲染展示,还是交给浏览器完成的,所以,不要误会,我们这里所说的 服务端渲染 和 客户端渲染,指的是页面结构和数据合成的工作,不是浏览器展示的工作;

那么能不能借助传统网站的思路来解决 SPA 的问题又能够保留SPA的优势呢?不管是白屏时间长还是 SEO 不友好,实际都是首屏的页面结构先回到浏览器,然后再获取数据后合成导致的问题,那么,首屏的页面结构和数据,只要像传统站点一样,先在服务端合成后再返回,同时将 SPA 脚本的加载依然放到首屏中,此时返回的页面就是结构和数据都有的完整内容了,这样浏览器在展示首页数据的同时也能加载 SPA 脚本,搜索引擎的爬虫同样也能获取到对应的数据,解决 SEO 的问题;为了更好的理解这个逻辑,我画了一个流程图:

没错,这就是我们所说的 服务端渲染的基本逻辑,服务端渲染也就是 SSR (Server Side Rendering) ;

白屏时间过长的问题得以解决,因为首次加载时,服务器会先将渲染好的静态页面返回,在静态页面中再次加载请求 SPA 脚本;

基本原理:首页内容及数据,在用户请求之前生成为静态页面,同时加入 SPA 的脚本代码引入,在浏览器渲染完成静态页面后,请求 SPA 脚本应用,之后的页面交互依然是客户端渲染;

SSR 的优势

  • 更好的SEO,由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。相比客户端渲染,服务端渲染可以有效提高搜索引擎爬取的精度,进而提高网站的易传播性。
  • 更快的首屏渲染速度。特别是对于网路速度慢或者运行缓慢的设备,无需等待所有的js都下载和解析完成才渲染页面,而是在服务端渲染好直接发送给客户端渲染页面。这就使得服务器渲染将不再需要进行数据请求,可以拥有更短的首屏时间。

为啥学习官网开发?

首先,同样作为 C 端网站,官网拥有与其他 C 端网站相同的技术架构选型,通过服务器端渲染(简称:SSR,后文均用 SSR 表示)来进行项目的开发和 SEO 优化,并且压测、备案、部署这些流程和大部分 C 端网站都是类似的。

官网开发的技术核心

那么官网开发的核心重点有哪些呢?我们从项目开发、用户体验、运营维护及部署流程四个方向来说明:

项目开发 ---- 服务端渲染

用户体验 ---- 1.首屏优化 2.低网速优化、多媒体适配

运营维护 ---- 数据的可灵活配置

部署流程 ---- 1.压力测试、埋点、SEO优化 2.备案

带你走近Vue服务器端渲染(VUE SSR)

在正式搭建项目之前,我们还是要回顾下vue服务器端渲染的一些特性。
服务器端渲染的 Vue.js 应用程序,是使vue应用既可以在客户端(浏览器)执行,也可以在服务器端执行,我们称之为“同构”或“通用”。

Snipaste_2022-10-25_11-06-48.png

之所以能够实现同构,是因为在客户端和服务端都创建了vue应用程序,并都用webpack进行打包,生成了server bundle和client bundle。server bundle用于服务器渲染,client bundle是一个客户端的静态标记,服务器渲染好html页面片段后,会发送给客户端,然后混合客户端静态标记,这样应用就具有vue应用的特性。
需要注意是:

  • 服务器端渲染过程中,只会调用beforeCreatecreated两个钩子函数,其它的只会在客户端执行。那么以前spa应用中,在created中创建一个setInterval,然后在destroyed中将其销毁的类似操作就不能出现了,服务器渲染期间不会调用销毁钩子函数,所以这个定时器会永远保留下来,服务器很容易就崩了。
  • 由于服务器可客户端是两种不同的执行平台环境,那么一些特定平台的API就不能用了,比如windowdocument,在node.js(比如created钩子函数)中执行就会报错。并且,我们使用的第三方API中,需要确保能在node和浏览器都能正常运行,比如axios,它向服务器和客户端都暴露相同的 API(浏览器的源生XHR就不行)。

一:服务器渲染从0到1

我们先不考虑同构、不考虑各种配置,先实现一个基础的服务器端渲染demo。

准备

npm install vue vue-server-renderer express --save

vue-server-renderervue服务器端渲染的核心模块,它需要匹配你的vue版本。安装express是因为我们等会会使用它来起个服务看到我们的页面效果。

三步渲染一个Vue实例

// 第 1 步:创建一个 Vue 实例
const Vue = require('vue')
const app = new Vue({
  template: `<div>Hello Vue SSR</div>`
})

// 第 2 步:创建一个 renderer
const renderer = require('vue-server-renderer').createRenderer()

// 第 3 步:将 Vue 实例渲染为 HTML
renderer.renderToString(app, (err, html) => {
  if (err) throw err
  console.log(html)   // <div data-server-rendered="true">Hello Vue SSR</div>
})

使用模板

上面只是生产了一个html代码片段,一般来说,需要将html片段插入一个模板文件里。OK,那我们就来写一个模板文件index.html

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head><title>Hello</title></head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

在渲染的时候,html片段会被插入到<!--vue-ssr-outlet-->这个注释标记这里。

const Vue = require('vue')
const app = new Vue({
  template: `<div>Hello Vue SSR</div>`
})
const renderer = require('vue-server-renderer').createRenderer({
  template: require('fs').readFileSync('./index.html', 'utf-8')
})
renderer.renderToString(app, (err, html) => {
  if (err) throw err
  console.log(html) // html 将是注入应用程序内容的完整页面
})

我们用fs模块将文件读取进来丢入render的template中,再重复上述步骤将html片段插入到我们的标记位。

在node.js服务器中使用

现在,我们将使用express来启动一个node服务,验证一下页面效果。

const Vue = require('vue')
// 第一步: 创建一个 express 应用
const server = require('express')()

// 第二步: 创建一个 Vue 实例
const app = new Vue({
  data: {
    msg: 'Hello Vue SSR'
  },
  template: `<div>{{msg}}</div>`
})

// 第三步: 创建一个 renderer
const renderer = require('vue-server-renderer').createRenderer({
  template: require('fs').readFileSync('./index.html', 'utf-8')
})

// 第四步: 设置路由,"*" 表示任意路由都可以访问它
server.get('*', (req, res) => {
  renderer.renderToString(app, (err, html) => {
    if (err) {
      res.status(500).end('Internal Server Error')
      return
    }
    res.end(html)
  })
})

// 第五步: 启动服务并监听从8080端口进入的所有连接请求
server.listen(8080)

这样,我们的一个简单的页面渲染就完成了,看下页面效果和Response数据。

二:改造 —— 从SPA到SSR

知道了怎么在服务器端渲染出一个页面,下一步就是实现同构啦。为了跳过各种项目配置,我们就从熟悉的vue-cli模板下手。
官方提供了vue-cli的项目快速构建工具,可以用它也进行SPA项目的快速搭建,我们现在就把这个模板,改造成一个能够集成SSR的模板。

准备

安装vue-cli (至少v2.x版本)后,使用基础模板搭建个项目

vue init webpack spa_ssr
cd spa_ssr

跑一下确保项目能够正常运行,然后记得安装vue-server-renderer模块

npm install vue-server-renderer --save-dev

安装完成,我们就开始进入下一步。

改造src下的文件

我们需要在src目录下创建两个js。

src
├── router
│   └── index.js
├── components
│   └── HelloSsr.vue
├── App.vue
├── main.js
├── entry-client.js # 仅运行于浏览器
└── entry-server.js # 仅运行于服务器

这两个entry之后会进行配置,先来改造main.js
在改造main.js之前,需要说明一下,因单线程的机制,在服务器端渲染时,过程中有类似于单例的操作,那么所有的请求都会共享这个单例的操作,所以应该使用工厂函数来确保每个请求之间的独立性。比如在main.js中,我们原先直接创建一个Vue实例,并直接挂载到DOM。现在的main.js作为通用entry文件,它应该改造成一个可以重复执行的工厂函数,为每个请求创建新的应用程序实例。挂载的工作,是由之后的客户端entry来完成。

import Vue from 'vue'
import App from './App'
import { CreateRouter } from './router'

export function createApp () {
  const router = new CreateRouter()
  const app = new Vue({
    router,
    render: h => h(App)
  })
  return { app, router }
}

/router/index.js中,我们同样需要使用工厂函数来创建路由实例。然后将路由配置改为history模式(因为哈希不支持)

import Vue from 'vue'
import Router from 'vue-router'
import HelloSsr from '@/components/HelloSsr'

Vue.use(Router)

export function CreateRouter () {
  return new Router({
    mode: 'history',
    routes: [{
      path: '/ssr',
      name: 'HelloSsr',
      component: HelloSsr
    }]
  })
}

接下来我们来写客户端的entry和服务器端的entry。客户端的entry要做的很简单,就是将vue实例挂载到DOM上,只不过,考虑到可能存在异步组件,需要等到路由将异步组件加载完毕,才进行此操作。

// entry-client.js
import { createApp } from './main'
const { app, router } = createApp()

router.onReady(() => {
  app.$mount('#app')
})

服务器entry要做的有两步:1.解析服务器端路由;2.返回一个vue实例用于渲染。

// entry-server.js
import { createApp } from './main'
export default context => {
  // 因为有可能会是异步路由钩子函数或组件,所以我们将返回一个 Promise,
  // 以便服务器能够等待所有的内容在渲染前,
  // 就已经准备就绪。
  return new Promise((resolve, reject) => {
    const { app, router } = createApp()
    // 设置服务器端 router 的位置
    router.push(context.url)
    // 等到 router 将可能的异步组件和钩子函数解析完
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      // 匹配不到的路由,执行 reject 函数,并返回 404
      if (!matchedComponents.length) {
        // eslint-disable-next-line
        return reject({ code: 404 })
      }
      // Promise 应该 resolve 应用程序实例,以便它可以渲染
      resolve(app)
    }, reject)
  })
}

webpack配置

vue相关代码已处理完毕,接下来就需要对webpack打包配置进行修改了。 官方推荐了下面配置:

 build
  ├── webpack.base.conf.js  # 基础通用配置
  ├── webpack.client.conf.js  # 客户端打包配置
  └── webpack.server.conf.js  # 服务器端打包配置

我们的项目中的配置文件是basedevprod,现在我们仍然保留这三个配置文件,只需要增加webpack.server.conf.js即可。

webpack.base.conf.js修改

我们首先修改webpack.base.conf.jsentry入口配置为:./src/entry-client.js,来生成客户端的构建清单client manifest。服务器端的配置由于引用base配置,entry会通过merge覆盖,来指向server-entry.js

// webpack.base.conf.js
module.exports = {
  entry: {
    // app: './src/main.js'
    app: './src/entry-client.js'   // <-修改入口文件改为
  },
  // ...
}

webpack.prod.conf.js修改

在客户端的配置prod中,我们需要引入一个服务器端渲染的插件client-plugin,用来生成vue-ssr-client-manifest.json(用作静态资源注入),同时,我们需要把HtmlWebpackPlugin给去掉,在SPA应用中,我们用它来生成index.html文件,但是这里我们有vue-ssr-client-manifest.json之后,服务器端会帮我们做好这个工作。

const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
// ...
  plugins: [
    new webpack.DefinePlugin({
      'process.env': env,
      'process.env.VUE_ENV': '"client"' // 增加process.env.VUE_ENV
    }),
    // ...
    // 以下内容注释(或去除)
    // new HtmlWebpackPlugin({
    //   filename: config.build.index,
    //   template: 'index.html',
    //   inject: true,
    //   minify: {
    //     removeComments: true,
    //     collapseWhitespace: true,
    //     removeAttributeQuotes: true
    //     // more options:
    //     // https://github.com/kangax/html-minifier#options-quick-reference
    //   },
    //   // necessary to consistently work with multiple chunks via CommonsChunkPlugin
    //   chunksSortMode: 'dependency'
    // }),
    // ...
    // 此插件在输出目录中生成 `vue-ssr-client-manifest.json`。
    new VueSSRClientPlugin()
  ]
// ...

webpack.server.conf.js配置

server配置基本参考官方的配置,这里还是说明下:

  1. 我们需要去掉baseConfig中的打包css的配置;
  2. 这里使用了webpack-node-externals来加快构建速度和减小打包体积,所以我们要先安装一下它:npm install webpack-node-externals --save-dev
  3. prod配置一样,这里需要引入并使用server-plugin插件来生成vue-ssr-server-bundle.json。这东西是用来等会做服务器端渲染的。
const webpack = require('webpack')
const merge = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseConfig = require('./webpack.base.conf.js')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
// 去除打包css的配置
baseConfig.module.rules[1].options = ''

module.exports = merge(baseConfig, {
  // 将 entry 指向应用程序的 server entry 文件
  entry: './src/entry-server.js',
  // 这允许 webpack 以 Node 适用方式(Node-appropriate fashion)处理动态导入(dynamic import),
  // 并且还会在编译 Vue 组件时,
  // 告知 `vue-loader` 输送面向服务器代码(server-oriented code)。
  target: 'node',
  // 对 bundle renderer 提供 source map 支持
  devtool: 'source-map',
  // 此处告知 server bundle 使用 Node 风格导出模块(Node-style exports)
  output: {
    libraryTarget: 'commonjs2'
  },
  // https://webpack.js.org/configuration/externals/#function
  // https://github.com/liady/webpack-node-externals
  // 外置化应用程序依赖模块。可以使服务器构建速度更快,
  // 并生成较小的 bundle 文件。
  externals: nodeExternals({
    // 不要外置化 webpack 需要处理的依赖模块。
    // 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
    // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
    whitelist: /\.css$/
  }),
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
      'process.env.VUE_ENV': '"server"'
    }),
    // 这是将服务器的整个输出
    // 构建为单个 JSON 文件的插件。
    // 默认文件名为 `vue-ssr-server-bundle.json`
    new VueSSRServerPlugin()
  ]
})

package.json打包命令修改

"scripts": {
    //...
    "build:client": "node build/build.js",
    "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.conf.js --progress --hide-modules",
    "build": "rimraf dist && npm run build:client && npm run build:server"
} 

这里需要先安装cross-env。(cross-env用来防止使用NODE_ENV =production 来设置环境变量时,Windows命令提示会报错)

npm install --save-dev cross-env

修改index.html

如第一层说的,我们需要在这个index.html外层模板文件中,插入一个<!--vue-ssr-outlet-->注释标记,用来标识服务器渲染的html代码片段插入的地方,同时删掉原先的<div id="app">
服务器端会在这个标记的位置自动生成一个<div id="app" data-server-rendered="true">,客户端会通过app.$mount('#app')挂载到服务端生成的元素上,并变为响应式的。

  • ps:这里单纯将模板改为服务器端渲染适用的模板,但是在dev模式下,会因为找不到#app而报错,这里就不做dev下的处理,如果需要,可以为dev模式单独建立一个html模板。

打包构建

npm run build

在dist目录下会生成两个json文件:vue-ssr-server-bundle.jsonvue-ssr-client-manifest.json,用于服务端端渲染和静态资源注入。

构建服务器端

这里还是采用express来作为服务器端,先进行安装:

npm install express --save

之后在根目录下创建server.js,代码主要分为3步:

  1. 采用createBundleRenderer来创建renderer,我们引入之前生成好的json文件,并读取index.html作为外层模板;
  2. 设置路由,当请求指定路由的时候,设置请求头,调用渲染函数,将渲染好的html返回给客户端;
  3. 监听3001端口。
const express = require('express')
const app = express()

const fs = require('fs')
const path = require('path')
const { createBundleRenderer } = require('vue-server-renderer')

const resolve = file => path.resolve(__dirname, file)

// 生成服务端渲染函数
const renderer = createBundleRenderer(require('./dist/vue-ssr-server-bundle.json'), {
  // 模板html文件
  template: fs.readFileSync(resolve('./index.html'), 'utf-8'),
  // client manifest
  clientManifest: require('./dist/vue-ssr-client-manifest.json')
})

function renderToString (context) {
  return new Promise((resolve, reject) => {
    renderer.renderToString(context, (err, html) => {
      err ? reject(err) : resolve(html)
    })
  })
}
app.use(express.static('./dist'))

app.use(async(req, res, next) => {
  try {
    const context = {
      title: '服务端渲染测试', // {{title}}
      url: req.url
    }
    // 设置请求头
    res.set('Content-Type', 'text/html')
    const render = await renderToString(context)
    // 将服务器端渲染好的html返回给客户端
    res.end(render)
  } catch (e) {
    console.log(e)
    // 如果没找到,放过请求,继续运行后面的中间件
    next()
  }
})

app.listen(3000)

完事后启动服务命令:

node server.js

访问localhost:3000/ssr,就能获取我们之前定义好的页面。

三:Nuxt.js框架

Nuxt.js是什么

Nuxt.js是Vue官方推荐的一个项目,它是一个基于 Vue.js 的通用应用框架。预设了服务器端渲染所需的各种配置,如异步数据,中间件,路由,只要遵循其中的规则就能轻松实现SSR。开箱即用,体验友好。通过对客户端/服务端基础架构的抽象组织,Nuxt.js 主要关注的是应用的 UI渲染。

Nuxt.js 能做什么

  • 无需再为了路由划分而烦恼,只需要按照对应的文件夹层级创建 .vue 文件就行
  • 无需考虑数据传输问题,nuxt 会在模板输出之前异步请求数据(需要引入 axios 库),而且对 vuex 有进一步的封装
  • 内置了 webpack,省去了配置 webpack 的步骤,nuxt 会根据配置打包对应的文件

先来看一下上线之后搜索引擎的收录情况吧

小米移动SEO综合查询

Nuxt.js和纯Vue项目的简单对比

1. build后目标产物不同

vue: dist

nuxt: .nuxt

2. 网页渲染流程

vue: 客户端渲染,先下载js后,通过ajax来渲染页面;

nuxt: 服务端渲染,可以做到服务端拼接好html后直接返回,首屏可以做到无需发起ajax请求;

3. 部署流程

vue: 只需部署dist目录到服务器,没有服务端,需要用nginx等做Web服务器;

nuxt: 需要部署几乎所有文件到服务器(除node_modules,.git),自带服务端,需要pm2管理(部署时需要reload pm2),若要求用域名,则需要nginx做代理。

4. 项目入口

vue: /src/main.js,在main.js可以做一些全局注册的初始化工作; nuxt: 没有main.js入口文件,项目初始化的操作需要通过nuxt.config.js进行配置指定。

从零搭建一个Nuxt.js项目并配置

新建一个项目

直接使用脚手架进行安装:安装文档地址:zh.nuxtjs.org/guide/insta… 我们采用 create-nuxt-app 命令来安装

npx create-nuxt-app <项目名>

image.png

大概选上面这些选项。

值得一说的是,关于Choose custom server framework(选择服务端框架),可以根据你的业务情况选择一个服务端框架,常见的就是Express、Koa,默认是None,即Nuxt默认服务器,我这里选了Express

  • 选择默认的Nuxt服务器,不会生成server文件夹,所有服务端渲染的操作都是Nuxt帮你完成,无需关心服务端的细节,开发体验更接近Vue项目,缺点是无法做一些服务端定制化的操作。
  • 选择其他的服务端框架,比如Express,会生成server文件夹,帮你搭建一个基本的Node服务端环境,可以在里面做一些node端的操作。比如我公司业务需要(解析protobuf)使用了Express,对真正的服务端api做一层转发,在node端解析protobuf后,返回json数据给客户端。

还有Choose Nuxt.js modules(选择nuxt.js的模块),可以选axiosPWA,如果选了axios,则会帮你在nuxt实例下注册$axios,让你可以在.vue文件中直接this.$axios发起请求。

进入项目运行,浏览器进入 http://127.0.0.1:3000,可以看到我们的项目已经可以运行了。

image.png

打包部署

一般来说,部署前可以先在本地打包,本地跑一下确认无误后再上传到服务器部署。命令:

// 打包
npm run build
// 本地跑
npm start

除node_modules,.git,.env,将其他的文件都上传到服务器,然后通过pm2进行管理,可以在项目根目录建一个pm2.json方便维护:

{
  "name": "nuxt-test",
  "script": "./server/index.js",
  "instances": 2,
  "cwd": "."
}

然后配置生产环境的环境变量,一般是直接用.env.prod的配置:cp ./.env.prod ./.env。 首次部署或有新的依赖包,需要在服务器上npm install一次,然后就可以用pm2启动进程啦:

// 项目根目录下运行
pm2 start ./pm2.json

需要的话,可以设置开机自动启动pm2: pm2 save && pm2 startup。 需要注意的是,每次部署都得重启一下进程:pm2 reload nuxt-test

最后

Nuxt.js引入了Node,同时nuxt.config.js替代了main.js的一些作用,目录结构和vue项目都稍有不同,增加了很多的约定,还是得看一遍官方的文档。