vue-ssr服务端渲染

213 阅读6分钟

简述

服务端渲染,是指将代码的渲染交给服务器,服务器将渲染后的字符串返回给前端。使用服务端渲染,可以有效减小白屏时间,并且有利于seo优化。

vue-ssr原理

一份项目代码打包为两份,一份给server端使用,一份为client使用;server端通过node解析vm实例,转为字符串返回给前端进行渲染,没有交互能力;当server端的字符串渲染后,client端打包后的js被插入到页面中进行事件交互。

服务端渲染--基础部分

安装模块

  • webpack 构建工具
  • webpack-cli解析参数
  • webpack-dev-server webpack开发环境
  • webpack-merge webpack合并文件
  • vue-loader 解析vue文件
  • vue-template-compiler 解析template
  • vue-style-loader 支持服务端渲染style
  • css-loader
  • style-laoder
  • @babel/core @babel/preser-env 转换高级语法为低级语法
  • babel-loader 解析js文件
  • html-webpack-plugin 打包html插入到页面中
  • koa-static

webpack配置:分隔客户端和服务端

webpack.base.js:客户端和服务端公用的公用的webpack配置
const path =require('path')
const VueLoaderPlugin = require('vue-loader/lib/plugin')
module.exports = {
  output: {
    filename:'[name].bundle.js', // name为entry处定义,根据入口起别名
    path: path.resolve(__dirname, '../dist')
  },
  module: {
    rules: [
    	{
        test: /\.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          },
        },
        exclude: /node_modules/
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader']
      },
      {
        test: /\.vue$/,
        use: 'vue-loader', //解析.vue文件,需要配合插件
      }
    ]
  },
  plugins: [new VueLoaderPlugin()] //解析.vue文件的插件
}
webpack.client.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const {merge} = require('webpack-merge')
const base = require('./webpack.base.js')
module.exports = merge(base, {
  mode: 'development',
  entry: { // 入口文件
    client: path.resolve(__dirname, '../src/client-entry.js')
  },
  plugins:[
    new HtmlWebpackPlugin({ // 将打包后的js文件自动插入到模板html文件中
      template: path.resolve(__dirname, '../public/index.client.html'),
      filename: 'index.client.html',
      minify: false
    }),
  ]
})
  1. webpack.server.js
const {merge} = require('webpack-merge')
const base = require('./webpack.base.js')
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = merge(base, {
  mode: "development",
  entry: {
    server: path.resolve(__dirname, '../src/server-entry.js')
  },
  target: 'node',// 打包的服务端给node使用
  output: {
    libraryTarget: 'commonjs2', // 导出的代码采用commonjs规范,即通过module.exports进行导出
  },
  plugins:[
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../public/index.server.html'),
      filename: 'index.server.html',
      minify: false,
      excludeChunks:['server'], // 不自动在server.html中自动引入打包后的文件 服务端自动打包后的server.bundle.js文件不是直接引入到index.server.html使用,需要经过处理渲染成字符串插入到html中,所以在打包输出时,在HtmlWebpackPlugin中排除对该模块的引用 所以这里只是将public目录下的index.server.html复制到了dist目录下
    }),
  ]
})

默认webpack的输出是将打包结果放在一个自执行函数中,通过指定 libraryTarget: 'commonjs2'将结果通过module.exports导出

package.json
"scripts": {
    "client:build": "webpack --config build/webpack.client.js  --watch",
    "client:dev": "webpack-dev-server --config build/webpack.client.js",
    "server:build": "webpack --config build/webpack.server.js  --watch",
    "server:dev": "webpack-dev-server --config build/webpack.server.js",
    "all": "concurrently \"npm run client:build\" \"npm run server:build\""
  }

通过concurrently实现同时执行多个命令

创建html模板

  • 客户端在渲染时,将打包的client.bundle.js插入到index.client.html中,js文件中的vue实例会挂载在html文件的app元素上
  • 服务端渲染时,是无法将vue实例渲染在app元素上,所以在html文件中,没有div#app
// public/index.client.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="app"></div>
</body>
</html>

// public/index.sercer.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <!--vue-ssr-outlet-->
</body>
</html>

在渲染的时候,html片段会被插入到这个注释标记这里

创建组件

// App.vue
<template>
  <div id='app'>
   <Bar></Bar>
   <Foo></Foo>
  </div>
</template>
<script>
import Bar from './components/Bar.vue';
import Foo from './components/Foo.vue';
export default {
  components: { Bar, Foo }
}
</script>
// src/components/bar.vue
<template>
  <div class="bar" @click="handleClick">bar</div>
</template>
<script>
export default {
  methods: {
    handleClick() {
      alert(1)
    }
  }
}
</script>
// src/components/foo.vue
<template>
  <div style='color: red'>foo</div>
</template>

创建vue实例--app.js

在进行服务端渲染时,希望每个用户都是独立的vue实例,所以,这里导出一个函数

import Vue from 'vue';
import App from './App.vue';
export default () => { // 拆成函数,保证在服务端时,每次产生新的实例
  let app = new Vue({
    render: h => h(App),
  })
  return {app}
}

创建打包的入口文件--entry.js

  • 客户端:获取vue实例,进行手动挂载
  • 服务端:返回一个函数,当服务器访问时,会执行这个函数,返回vue实例;因为服务端和客户端是一对多的关系,所以,需要返回一个函数
// client.entry.js
import createApp from './app.js'
const {app} = createApp();
app.$mount('#app'); // 手动进行挂载

// server.entry.js
import createApp from './app.js'
export default () => {
  const {app} = createApp();
  return app
}

创建开启本地服务的文件--server.js

const Vue = require('vue')
const VueServerRender = require('vue-server-renderer')
const Koa = require('koa')

const Router = require('@koa/router')
const fs = require('fs')
const path = require('path')
let app = new Koa()
let router = new Router()
const static = require('koa-static')
const template = fs.readFileSync('./dist/index.server.html', 'utf8')
const serverBundle = fs.readFileSync('./dist/server.bundle.js', 'utf8') // serverBundle:即vue项目实例
let render = VueServerRender.createBundleRenderer(serverBundle, {
  template,
})

router.get('/', async (ctx) => {// 如果渲染内容需要增添样式,需要采用回调方式
 ctx.body = await new Promise((resolve, reject) =>{
  render.renderToString((err, html) => {
    console.log(err, 'err')
    resolve(html)
  })
 })
})
app.use(router.routes())
app.use(static(path.resolve(__dirname, 'dist')))
app.listen(8001)
  • 启动本地服务后,通过fs读取服务端js文件的打包结果serverBundle和html文件的打包结果template;
  • 通过VueServerRender.createBundleRenderer创建渲染器,传递serverBundle和template;
  • 当客户端访问某个路由时,通过render.renderToString将vue实例进行渲染

项目启动

  • 执行 npm run all进行客户端和服务端文件打包
  • 执行 node server.js开启本地服务
  • 浏览器访问localhost:8001/

查看项目源代码可以看到服务端返回的html文件内容

服务端渲染--事件交互

以上配置可以访问到服务端渲染出的页面,但是,没有交互能力,点击事件不生效;是因为服务器将 server.bundle.js渲染的html字符串返回给客户端,事件执行函数是在client.bundle.js中。所以我们需要将client.bundle.js插入到index.server.html中。

webpack配置vue-server-renderer/client-plugin和vue-server-renderer/server-plugin插件

// webpack.client.js
const VueSsrClientPlugin = require('vue-server-renderer/client-plugin')
plugins:[
  ...
  new VueSsrClientPlugin()
]
// webpack.server.js
const VueSsrServerPlugin = require('vue-server-renderer/server-plugin')
plugins:[
  ...
  new VueSsrServerPlugin()
]

重新打包后,生成两个json文件,主要是生成客户端和服务端的对应关系

修改server.js

const static = require('koa-static')
// const serverBundle = fs.readFileSync('./dist/server.bundle.js', 'utf8')
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json') 

let render = VueServerRender.createBundleRenderer(serverBundle, {
  template,
  clientManifest
})
app.use(static(path.resolve(__dirname, 'dist')))

之前是通过server.bundle.js生成渲染器,这里修改为使用serverBundle的json文件和clientManifest的json文件,为index.server.html自动插入client.bundle.js文件. 这里还需要注意使用koa-static处理服务端静态文件的读取,实现dist下文件直接可以通过连接访问。如果不使用,直接读取的话会在报错:GET http://localhost:8001/client.bundle.js net::ERR_ABORTED 404 (Not Found)

服务端渲染--路由

修改App.vue

前端路由是通过history|hash等实现,但是对于服务端而言,只有首屏是通过服务端渲染,要实现路由渲染页面,需要先跳转首页,再通过前端路由进行跳转

<template>
  <div id='app'>
   <router-link to='/'>foo</router-link>
   <router-link to='/bar'>bar</router-link>
   <router-view></router-view>
  </div>
</template>

创建路由实例

// createRouter.js
import Vue from 'vue';
import VueRouter from 'vue-router';
Vue.use(VueRouter);
export default () => {
  const router = new VueRouter({
    mode: 'history',
    routes: [{
      path: '/', component: () => import('./components/Foo.vue')
    }, {
      path: '/bar', component: () => import('./components/Bar.vue')
    }]
  })
  return router;
}

vm实例上注入路由,并对外暴露

import createRouter from './createRouter';
export default () => {
  let router = createRouter()
  let app = new Vue({
    router,
    ...
  })
  return {app, router}
}

server.js将客户端访问的url传递给服务端

  • 在调用renderToString方法时,可以传递上下文对象context。将客户端的访问路径ctx.url保存在context对象上,传递给server-entry.js。在进行服务端渲染时,服务端将路由提前指定,再渲染实例
  • 如果访问不存在的路由,先跳转首页,通过客户端进行跳转
  • 如果访问不存在的路由出现404code,就直接返回404等(视项目情况稳定)
router.get('/', async (ctx) => {
 ctx.body = await new Promise((resolve, reject) =>{
  render.renderToString({ // context对象
    url: ctx.url, 
  }, (err, html) => {
    resolve(html)
  })
 })
})

router.get('(.*)', async (ctx) => { // 访问不存在的路由时,跳转首页,通过前端跳转
  ctx.body = await new Promise((resolve, reject) =>{
    render.renderToString({
      url: ctx.url,
    },(err, html) => {
      if (err && err.code == 404) {
        resolve('404')
      }
      resolve(html)
    })
  })
})

服务端根据用户访问url进行路由跳转

  • 根据context传递的url进行路由跳转。
  • 在路由跳转时,如果路由跳转的是异步组件,可能没有跳转完就返回了vm实例,出现渲染异常;所以这里返回一个promise实例,当理由跳转完成router.onReady后,返回匹配的vue实例
  • 404页面处理:当访问的路由匹配的实例不存在时,返回404code
import createApp from './app.js'
export default (context) => { 
  return new Promise((resolve, reject) => {
    const {app, router} = createApp();
    router.push(context.url);
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents(); //  跳转后匹配的组件个数
      if(!matchedComponents.length) {
        return reject({code: 404})
      }
      resolve(app)
    })
  })
}

服务端渲染--vuex

创建store实例

  • 创建store实例
  • 当服务端将将改变后的state保存在context时,客户端可以通过window.INITIAL_STATE 获取到修改后的state,通过store.replaceState改变客户端store中的state
// createStore.js
import Vue from 'vue';
import Vuex from 'Vuex';
Vue.use(Vuex)
export default () => {
  const store = new Vuex.Store({
   state: { name: 'xx', age: 10 },
   mutations: {
     changeName(state) { state.name= 'ss' },
     changeAge(state) { state.age = 1 }
   },
   actions: {
    changeName({commit}) {
      return new Promise((resolve, reject) =>{
        setTimeout(() => {
          commit('changeName');
          commit('changeAge');
          resolve();
        }, 1000)
      })
     }
   }
  })
  if (typeof window != 'undefined' && window.__INITIAL_STATE__) { // 服务器环将store中状态挂载context即window上,所以在此需要替换
    store.replaceState(window.__INITIAL_STATE__)
  }
  return store;
}

vm实例上注入store,并对外暴露

import createStore from './createStore';
export default () => {
	...
  let store = createStore()
  let app = new Vue({
  	router,
    store,
    render: h => h(App),
  })
  return {app, router, store}
}

修改Bar.vue

<template>
  <div class="bar" @click="handleClick">
    bar {{$store.state.name}}
  </div>
</template>
<script>
export default {
  asyncData(store) {
    return store.dispatch('changeName')
  },
  mounted() {
    this.$store.dispatch('changeName')
  }
}
</script>

在组件中定义asyncData方法派发mutation或action;为了在组件渲染时就使用最新数据,可以在mounted中派发action

在服务端执行组件函数改变数据

  • 从路由匹配的组件中获取方法并执行,传入store;此时,vuex中的数据仅在服务端发生变化,需要将改变后的state保存在context上;此时,客户端可以通过window.INITIAL_STATE 获取到修改后的state,通过store.replaceState改变客户端store中的state
  • 在服务端,需要等到所有的action结束后,再返回vm实例,否则可能导致数据异常
// server-entry.js
const {app, router, store} = createApp();
...
router.onReady(() => {
  const matchedComponents = router.getMatchedComponents();
 	...
  Promise.all(matchedComponents.map(component => {
    if (component.asyncData) {
      return component.asyncData(store)
    }
  })).then(() => {
    context.state = store.state //将状态放在context上,state就会放在window.__INITIAL_STATE__上
    resolve(app)
  })
})