【服务端渲染】现有Vue项目改造SSR(简单上手)

一、前言

首先,我们要了解
SSR是什么?
SSR就是服务端渲染:在后台将vue实例渲染为HTML字符串直接返回,在前端激活为交互程序。SSR又被认为是‘同构’或‘通用’,如果你的项目有大量的detail页面,相互特别频繁,建议选择服务端渲染。
SSR实现原理是什么?

  1. 客户端发送请求给服务器
  2. 服务器查询数据库,使用视图、模板引擎等拼接成html字符串,返回给客户端
  3. 客户端渲染html

SSR的优缺点?
优点:

  • 网页内容在服务器端渲染完成,一次性传输到浏览器,所以首屏加载速度非常快
  • 有利于SEO,因为服务器返回的是一个完整的html,在浏览器可以看到完整的dom,对于爬虫、百度搜索等引擎就比较友好
    缺点:
  • 在后续跳转其它链接时,整个页面还要重复这样的操作,不断地请求响应、请求响应,相对来说,消耗的带宽资源、后续请求的时间就多了
  • 服务器压力大

那么,我们开始将现有vue项目 改造成SSR吧

二、改造前后目录文件对比

在这里插入图片描述
黄线部分是改造后新增的文件,一共六个文件。我们先手动创建一下子,然后CV第三节的内容就好了 ^ . ^
我们先把代码copy上,然后再慢慢理解~

  • server.dev.conf.js 本地调试和热更新需要的配置文件
  • webpack.client.conf.js 客户端打包配置文件,ssr打包是生成分为客户端和服务端的两部分打包文件
  • webpack.server.conf.js 服务端打包配置文件,ssr打包是生成分为客户端和服务端的两部分打包文件
  • entry-client.js 客户端入口文件。spa的入口是main.js,ssr就分为两个入口(服务端和客户端)
  • entry-server.js 服务端入口文件。spa的入口是main.js,ssr就分为两个入口(服务端和客户端)
  • index.template.html 模板文件,因为服务端渲染是通过服务器把页面丢出来,所以我们需要一个模板,作为页面初始载体,然后往里面添加内容。
  • server.js 启动文件,服务端渲染我们需要启动一个node服务器,主要配置在这个文件里面。

三、实现:关键文件

1. server.dev.conf.js文件

//server.dev.conf.js

const fs = require('fs')
const path = require('path')
const MFS = require('memory-fs')
const webpack = require('webpack')
const chokidar = require('chokidar')
const clientConfig = require('./webpack.client.conf.js')
const serverConfig = require('./webpack.server.conf.js')

const readFile = (fs, file) => {
    
    
  try {
    
    
    return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
  } catch (e) {
    
    }
}

module.exports = function setupDevServer (app, templatePath, cb) {
    
    
  let bundle
  let template
  let clientManifest

  let ready
  const readyPromise = new Promise(r => {
    
     ready = r })
  const update = () => {
    
    
    if (bundle && clientManifest) {
    
    
      ready()
      cb(bundle, {
    
    
        template,
        clientManifest
      })
    }
  }

  // read template from disk and watch
  template = fs.readFileSync(templatePath, 'utf-8')
  chokidar.watch(templatePath).on('change', () => {
    
    
    template = fs.readFileSync(templatePath, 'utf-8')
    console.log('index.html template updated.')
    update()
  })

  // modify client config to work with hot middleware
  clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
  clientConfig.output.filename = '[name].js'
  clientConfig.plugins.push(
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoEmitOnErrorsPlugin()
  )

  // dev middleware
  const clientCompiler = webpack(clientConfig)
  const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
    
    
    publicPath: clientConfig.output.publicPath,
    noInfo: true
  })
  app.use(devMiddleware)
  clientCompiler.plugin('done', stats => {
    
    
    stats = stats.toJson()
    stats.errors.forEach(err => console.error(err))
    stats.warnings.forEach(err => console.warn(err))
    if (stats.errors.length) return
    clientManifest = JSON.parse(readFile(
      devMiddleware.fileSystem,
      'vue-ssr-client-manifest.json'
    ))
    update()
  })

  // hot middleware
  app.use(require('webpack-hot-middleware')(clientCompiler, {
    
     heartbeat: 5000 }))

  // watch and update server renderer
  const serverCompiler = webpack(serverConfig)
  const mfs = new MFS()
  serverCompiler.outputFileSystem = mfs
  serverCompiler.watch({
    
    }, (err, stats) => {
    
    
    if (err) throw err
    stats = stats.toJson()
    if (stats.errors.length) return

    // read bundle generated by vue-ssr-webpack-plugin
    bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
    update()
  })

  return readyPromise
}

2. webpack.client.conf.js文件------webpack客户端配置

const webpack = require('webpack')
const merge = require('webpack-merge')
const baseConfig = require('./webpack.base.conf.js')
const HtmlWebpackPlugin  = require('html-webpack-plugin')
const path = require('path')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

module.exports = merge(baseConfig, {
    
    
  entry: './src/entry-client.js',
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
    
    
      name: "manifest",
      minChunks: Infinity
    }),
    // 此插件在输出目录中
    // 生成 `vue-ssr-client-manifest.json`。
    new VueSSRClientPlugin(),
    new HtmlWebpackPlugin({
    
    
      template: path.resolve(__dirname, './../src/index.template.html'),
      filename: 'index.template.html'
    })
  ]
})

这里面和spa项目有两点不同,第一是入口变了,变为了entry-client.js。第二是VueSSRClientPlugin,这个是生成一个vue-ssr-client-manifest.json客户端入口文件。

3. webpack.server.conf.js文件---------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')

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'
  },
  externals: nodeExternals({
    
    
    // 不要外置化 webpack 需要处理的依赖模块。
    // 你可以在这里添加更多的文件类型。例如,未处理 *.vue 原始文件,
    // 你还应该将修改 `global`(例如 polyfill)的依赖模块列入白名单
    whitelist: /\.css$/
  }),

  // 这是将服务器的整个输出
  // 构建为单个 JSON 文件的插件。
  // 默认文件名为 `vue-ssr-server-bundle.json`
  plugins: [
    new VueSSRServerPlugin()
  ]
})

这段代码一目了然,第一是是告诉webpack这是要打包node能运行的东西,第二是打包一个服务端入口vue-ssr-server-bundle.json

4. entry-client.js文件

import {
    
     createApp } from './main'
const {
    
    app, router, store} = createApp();

if (window.__INITIAL_STATE__) {
    
    
    store.replaceState(window.__INITIAL_STATE__);
}
// 因为可能存在异步组件,所以等待router将所有异步组件加载完毕,服务器端配置也需要此操作
router.onReady(()=> {
    
    
    app.$mount('#app')
})

5. entry-server.js文件

import {
    
     createApp } from './app'

export default context => {
    
    
  return new Promise((resolve, reject) => {
    
    
    const {
    
     app, router, store } = createApp()

    router.push(context.url)

    router.onReady(() => {
    
    
      const matchedComponents = router.getMatchedComponents()
      if (!matchedComponents.length) {
    
    
        return reject({
    
     code: 404 })
      }

      // 对所有匹配的路由组件调用 `asyncData()`
      Promise.all(matchedComponents.map(Component => {
    
    
        if (Component.asyncData) {
    
    
          return Component.asyncData({
    
    
            store,
            route: router.currentRoute
          })
        }
      })).then(() => {
    
    
        // 在所有预取钩子(preFetch hook) resolve 后,
        // 我们的 store 现在已经填充入渲染应用程序所需的状态。
        // 当我们将状态附加到上下文,
        // 并且 `template` 选项用于 renderer 时,
        // 状态将自动序列化为 `window.__INITIAL_STATE__`,并注入 HTML。
        context.state = store.state

        resolve(app)
      }).catch(reject)
    }, reject)
  })
}
  1. index.html
<!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>
        <!--vue-ssr-outlet-->
    </body>
</html>

前后不能加空格!

四、 实现步骤解释

1.webpack添加客户端与服务端配置

1.1 webpack客户端配置

  • webpack.client.conf.js修改打包入口
  • VueSSRClientPlugin,这个是生成一个vue-ssr-client-manifest.json客户端入口文件。

1.2 webpack服务端配置
webpack.server.conf.js

  • 第一是是告诉webpack这是要打包node能运行的东西
  • 第二是打包一个服务端入口vue-ssr-server-bundle.json

2. vue、router、store实例改造

当编写纯客户端 (client-only) 代码时,我们习惯于每次在新的上下文中对代码进行取值。但是,Node.js 服务器是一个长期运行的进程。当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享。

nodejs是一个运行时,如果只是个单例的话,所有的请求都会共享这个单例,会造成状态污染。所以我们需要为每个请求创造一个vue,router,store实例。

2.1. 第一步修改main.js

// main.js
import Vue from 'vue'
import App from './App.vue'
import {
    
     createRouter } from './router'
import {
    
     createStore } from './store/store.js'
import {
    
     sync } from 'vuex-router-sync'

export function createApp () {
    
    
  // 创建 router 实例
  const router = createRouter()
  const store = createStore()

  // 同步路由状态(route state)到 store
  sync(store, router)

  const app = new Vue({
    
    
    // 注入 router 到根 Vue 实例
    router,
    store,
    render: h => h(App)
  })

  // 返回 app 和 router
  return {
    
     app, router, store }
}

看到这个createApp没,没错,它就是我们熟悉的工厂模式。同样的store和router一样改造

2.2 改造router

// router.js
import Vue from 'vue'
import Router from 'vue-router'
import HelloWorld from '@/components/HelloWorld'

Vue.use(Router)


export let createRouter = () => {
    
    
  let route  = new Router({
    
    
    mode:'history',
    routes: []
  })
  return route
}

2.3 改造store

// store.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export function createStore () {
    
    
  return new Vuex.Store({
    
    
    state: {
    
    
    },
    actions: {
    
    
    },
    mutations: {
    
    
    }
  })
}

到这里,三个实例对象改造完成了。是不是很简单~

3. 数据预取和存储

服务器渲染,可以理解为在被访问的时候,服务端做预渲染生成页面,上面说过,预渲染的缺点就是,实时数据的获取。所以如果应用程序依赖于一些异步数据,那么在开始渲染过程之前,需要先预取和解析好这些数据。

另一个需要关注的问题是在客户端,在挂载 (mount) 到客户端应用程序之前,需要获取到与服务器端应用程序完全相同的数据 - 否则,客户端应用程序会因为使用与服务器端应用程序不同的状态,然后导致混合失败。这个地方上面提过,叫同构(服务端渲染一遍,客户端拿到数据再渲染一遍)。

因为我们用的vue框架嘛,那当然数据存储选vuex咯。然后我们来理一下总体的流程:

客户端访问网站 —> 服务器获取动态数据,生成页面,并把数据存入vuex中,然后返回html —> 客户端获取html(此时已经返回了完整的页面) —> 客户端获取到vuex的数据,并解析到vue里面,然后再一次找到根元素挂载vue,重复渲染页面。(同构阶段)

流程清楚之后,那我们怎么设定,哪个地方的代码,被服务端执行,并获取数据存入vuex呢? 我们分为三步:

3.1.自定义函数asyncData
官方的例子是定义一个asyncData函数(这个名字不是唯一的哈,是自己定义的,可以随便取,不要理解为内置的函数哈),这个函数写在路由组件里面。
假设有一个Item.vue组件(官网的例子)

<!-- Item.vue -->
<template>
  <div>{
    
    {
    
     item.title }}</div>
</template>

<script>
export default {
    
    
  asyncData ({
     
      store, route }) {
    
    
    // 触发 action 后,会返回 Promise
    return store.dispatch('fetchItem', route.params.id)
  },
  computed: {
    
    
    // 从 store 的 state 对象中的获取 item。
    item () {
    
    
      return this.$store.state.items[this.$route.params.id]
    }
  }
}
</script>

3.2. 服务端入口entry-server.js配置
到这里,asyncData函数,我们知道它是放在哪里了。接下来,我们有了这个函数,我们服务器肯定要去读到这个函数,然后去获取数据吧?我们把目光放到entry-server.js,之前我们提到过,这是服务端的入口页面。那我们是不是能够在这里面处理asyncData呢。

代码见entry-server.js

简单的读下这个文件。首先为什么是返回Promise呢?因为可能是异步路由和组件,我们得保证,服务器渲染之前,已经完全准备就绪了。 然后注意**matchedComponents **它是通过传入的地址,获取到和路由匹配到的组件,然后如果存在asyncData,我们就去执行它,然后注入到context(渲染上下文,可以在客户端获取)里面。

是不是简单?这一步我们就已经从服务器端取到动态数据了,同时丢到页面里面了。如果不是为了客户端数据同步,这一步我们已经搞完服务端渲染了~ = =

3.3 客户端入口client-server.js配置
搞完服务器端的配置,该客户端了,毕竟数据要同步嘛。然后配置客户端入口代码文件client-server.js

之前服务端入口说过,状态将自动序列化为 window.INITIAL_STATE,并注入 HTML。
所以客户端我们获取到了,服务端已经搞好了数据了,我们拿过来直接替换现有的vuex就好了。

看到这里,不是已经完成啦,完整的流程。但是到此为止了吗?还没呢,既然是服务端渲染,你总要启动服务器吧…

Ps: 数据预期,我们刚才讲到的只是服务端预取,其实还有客户端预取。什么是客户端预取呢,简单的理解就是,我们可以在路由钩子里面,找有当前路由组件没有asyncData,有的话,就去请求,获取到数据后,填充完之后,再渲染页面。

4. 启动服务(server.js)配置

服务端渲染,服务端,肯定要一个启动服务的文件哈,

const express = require("express");

const fs = require('fs');
let path = require("path");
const server = express()
const {
    
     createBundleRenderer } = require('vue-server-renderer')

let renderer

const resolve = file => path.resolve(__dirname, file)
const templatePath = resolve('./src/index.template.html')
function createRenderer (bundle, options) {
    
    

  return createBundleRenderer(bundle, Object.assign(options, {
    
    
    runInNewContext: false
  }))
}

const template = fs.readFileSync(templatePath, 'utf-8')
const bundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
renderer = createRenderer(bundle, {
    
    
  template,
  clientManifest
})


server.use(express.static('./dist'))
// 在服务器处理函数中……
server.get('*', (req, res) => {
    
    
  const context = {
    
     url: req.url }
  // 这里无需传入一个应用程序,因为在执行 bundle 时已经自动创建过。
  renderer.renderToString(context, (err, html) => {
    
    
    // 处理异常……
    res.end(html)
  })
})
server.listen(3001, () => {
    
    
    console.log('服务已开启')
})

这就是服务端的启动代码了,只需处理获取几个打包过后的参数(template模板和clientManifest),传入createBundleRenderer函数。然后通过renderToString,展现给客户端。

5.热更新与本地调试

上面一步是启动服务,但是我们本地调试的时候,不可能每次build之后,再启动,然后再修改,再build吧?那也太麻烦了。所以我们借助webpack搞一个热更新。这里在build里面添加一个文件server.dev.conf.js,具体代码见对应文件

完结撒花

到这里所有的ssr改造已经完成了,当然我们还能优化,下面给出几个点,自己思考哈:

服务器缓存,既然是node服务器,我们当然可以做服务器缓存拉。

流式渲染 (Streaming) 用 renderToStream 替代 renderToString;当 renderer 遍历虚拟 DOM 树 (virtual DOM tree) 时,会尽快发送数据。这意味着我们可以尽快获得"第一个 chunk",并开始更快地将其发送给客户端

おすすめ

転載: blog.csdn.net/qq_38974163/article/details/120779353