准备工作

首先创建一个vue项目

vue create ssr

既然是服务端渲染,我们需要一个服务器,这里我们选择express;此外还需要一个库vue-server-renderer

npm install vue-server-renderer express -D

开始干活

服务器启动脚本

const express = require('express')
const Vue = require('vue')

const app = express()

// 创建渲染器
// createRenderer()是一个工厂函数,会染回一个渲染器
const renderer = require('vue-server-renderer').createRenderer()

const page = new Vue({
  template: `<div>hello ssr</div>`
})

// 设置路由
app.get('/', async (req, res) => {
  try {
    const html = await renderer.renderToString(page)
    res.send(html)
  } catch (error) {
    res.status(500).send('服务器错错误')
  }
})

app.listen(3000, () => {
  console.log('server listening port:3000');
})

image-20210215115449616

通过编译器可以看见renderToString方法接收一个Vue示例作为参数,也就是上面编写的page

你可能会发现一个问题,在服务端编写路由并不是非常灵活,而且也不方便我们后续的spa,这时候我们需要将路由的控制权交给vue-router

配置vue-router

import Vue from 'vue'
import Router from 'vue-router'

import About from '../pages/About'
import Index from '../pages/Index'


Vue.use(Router)

// 使用工厂函数,返回一个路由的构造器,防止污染不同访问者的路由
export default function createRouter() {
  return new Router({
    mode: 'history',
    routes: [
      { path: '/', component: Index },
      { path: '/about', component: About },
    ],
  })
}

这里注意向外暴露的不再是一个VueRouter实例,而是一个工厂函数,因为不同的用户会发送不同的请求,如果都返回同一个实例,则会对其他用户造成影响

这里我们再来看一下构建流程

image-20210215122213757

通过server-bundle构建之后的html不再只有一个根div,而是已经被渲染的html,当第一次请求结束时,又会变成一个spa,不在跟服务端有联系,而是通过ajax直接访问api服务器

编写入口

总入口app.js

import Vue from 'vue'
import App from './App'

import createRouter from './router/index'

export default function createApp() {
  const router = createRouter()

  const app = new Vue({
    router,
    render: h => h(App)
  }) // 这里不需要挂载到根节点,因为没有根节点

  return {app, router}
}

服务端入口server-entry.js

import createApp from './app'

export default context => {
  return new Promise((resolve, reject) => {
    const { app, router } = createApp()
    // 进入首屏
    router.push(context.url)
    router.onReady(() => {
      resolve(app)
    }, reject)
  })
}

客户端入口client-entry.js

// 挂载激活app
import createApp from './app'

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

进行打包配置

安装依赖

npm install webpack-node-externals lodash.merge cross-env -D

根目录添加vue.config.js

const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const nodeExternals = require('webpack-node-externals')
const merge = require('lodash.merge')
// 决定入口是服务端还是客户端
const TARGET_NODE = process.env.WEBPACK_TARGET === 'node'
const target = TARGET_NODE ? "server" : "client"

module.exports = {
  css: {
    extract: false
  },
  outputDir: './dist' + target,
  configureWebpack: () => ({
    // 指向entry文件
    entry: `./src/${target}-entry.js`,
    // 对bundle提供source-map支持
    devtool: 'source-map',
    target: TARGET_NODE ? 'node' : 'web',
    node: TARGET_NODE ? undefined : false,
    output: {
      libraryTarget: TARGET_NODE ? 'commonjs2' : undefined
    },
    externals: TARGET_NODE ? nodeExternals({
      allowlist: [/\.css$/]
    })
      : undefined,
    optimization: {
      splitChunks: TARGET_NODE ? false : undefined,
    },
    plugins: [TARGET_NODE ? new VueSSRServerPlugin() : new VueSSRClientPlugin()]
  }),
  chainWebpack: config => {
    config.module
      .rule("vue")
      .use("vue-loader")
      .tap(options => {
        merge(options, {
          optimizeSSR: false
        })
      })
  }
}

然后在package.json中添加脚本

  "scripts": {
    "serve": "vue-cli-service serve",
    "lint": "vue-cli-service lint",
    "build:server": "vue-cli-service build",
    "build:client": "cross-env WEBPACK_TARGET=node vue-cli-service build --mode server",
    "build": "npm run build:server && npm run build:client"
  },

打包之后再来修改服务器启动脚本

const express = require('express')
const fs = require('fs')
// const Vue = require('vue')

const app = express()

const serverBundle = require('../dist/server/vue-ssr-server-bundle.json')
const clientManifest = require('../dist/client/vue-ssr-client-manifest.json')

// 创建渲染器
// createRenderer()是一个工厂函数,会染回一个渲染器
const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false,
  // 记得新建一个模板
  template: fs.readFileSync('../public/index.temp.html', 'utf-8'),
  clientManifest
})

app.use(express.static('../dist/client', {index: false}))

// const page = new Vue({
//   template: `<div>hello ssr</div>`
// })

// 设置路由
app.get('*', async (req, res) => {
  try {
    const context = {
      url: req.url,
      title: 'ssr test'
    }
    const html = await renderer.renderToString(context)
    res.send(html)
  } catch (error) {
    res.status(500).send('服务器错错误')
  }
})

app.listen(3000, () => {
  console.log('server listening port:3000');
})

大功告成

此时我们再启动服务器,就可以看到成果了

image-20210215141452447

可以看到html文档中不再是一个app根元素了

总结

SSR(server side render)解决了SEO和首屏渲染慢的问题,首屏在服务器端进渲染,等全部文件加载完之后在浏览器再激活mount实现spa

源码已经上传码云仓库


前端小白