webpack4、Koa搭建Vue服务器端渲染(SSR)

我们现在构建应用都是使用Vue或者React等流行框架,但我们接触到的大多数开发的都是单页面应用,对SEO不是很友好。之前公司从vue单页面迁移到nuxt服务端渲染,我也利用webpack搭建了简易的vue服务端渲染应用,也算是对webpack、Vue-SSR一个学习过程

阅读前

  1. 为什么使用服务器渲染? 👉官方解释
  2. 对VueSSR指南简单了解👉官方文档
  3. 对webpack简单了解👉官方文档
  4. Node.js框架Koa简单了解👉官方文档

基础用法

构建服务器端渲染(SSR)我们需要借助vue-server-renderer ,我们先尝试一下官方文档的一段demo,编写server.js

// 第 1 步:创建一个 Vue 实例
const Vue = require('vue')
const app = new Vue({
  template: `<div>Hello World</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 World</div>
})

执行node server.js可以看到控制台打印<div data-server-rendered="true">Hello World</div>

image1

从这段代码我们应该可以明白vue-server-renderer的作用是拿到vue实例并渲染成html结构,但它不仅仅只做着一件事,后面会介绍其他配置参数和配合webpack进行构建。


拿到html结构渲染到页面上是我们接下来要做的事情,这里官方事例用的是express搭建服务器,我这里采用Koa,为什么用Koa?我不会express🤣。Koa起一个服务非常简单,我们还需要借助Koa-router来做路由的处理。修改server.js

const Vue = require('vue')
const Koa = require('koa')
const Router = require('koa-router')
const renderer = require('vue-server-renderer').createRenderer()

//  第 1 步:创建koa、koa-router 实例
const app = new Koa()
const router = new Router()

// 第 2 步:路由中间件
router.get('*', async (ctx, next) => {
  // 创建Vue实例
  const app = new Vue({
    data: {
      url: ctx.url
    },
    template: `<div>访问的 URL 是: {{ url }}</div>`
  })

  // 有错误返回500,无错误返回html结构
  try {
    const html = await renderer.renderToString(app)
    ctx.status = 200
    ctx.body = `
      <!DOCTYPE html>
      <html lang="en">
        <head><title>Hello</title></head>
        <body>${html}</body>
      </html>
    `
  } catch (error) {
    console.log(error)
    ctx.status = 500
    ctx.body = 'Internal Server Error'
  }
})

app
  .use(router.routes())
  .use(router.allowedMethods())

// 第 3 步:启动服务,通过http://localhost:3000/访问
app.listen(3000, () => {
  console.log(`server started at localhost:3000`)
})

从上段代码我们就可以看出服务器端渲染的基本原理了,其实说白了,无服务器端渲染时,前端打包后的html只是包含head部分,body部分都是通过动态插入到id为#app的dom中。如图:

image2

而服务器端渲染(SSR)就是服务器来提前编译Vue生成HTML返回给web浏览器,这样网络爬虫爬取的内容就是网站上所有可呈现的内容。🤓


为了可以个性化页面,我们可以把html结构抽成一个模板template,通过双花括号{{}}进行传值,新建index.template.html按照官网编写如下代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- 三花括号不进行html转义 -->
    {{{ meta }}}
    <title>{{ title }}</title>
  </head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

我们需要通过Node模块fs读取模板,作为vue-server-renderer的template参数传入,修改代码:

const renderer = require('vue-server-renderer').createRenderer({
  // 读取传入template参数
  template: require('fs').readFileSync('./index.template.html', 'utf-8')
})

// ...忽略无关代码
router.get('*', async (ctx, next) => {
  // title、meta会插入模板中
  const context = {
    title: ctx.url,
    meta: `
	<meta charset="UTF-8">
    <meta name="descript" content="基于webpack、koa搭建的SSR">
  `
  }
  try {
    // 传入context渲染上下文对象
    const html = await renderer.renderToString(app, context)
    ctx.status = 200
    // 传入了template, html结构会插入到<!--vue-ssr-outlet-->
    ctx.body = html
  } catch (error) {
    ctx.status = 500
    ctx.body = 'Internal Server Error'
  }
})
// ...忽略无关代码

image3

可以看到我们的标题和meta都被插入啦!👏👏👏。到这里,我们才实现了最基本的用法,接下来我们终于要使用webpack来构建我们项目。

正式环境构建

Node.js服务器是一个长期运行的进程、当我们的代码进入该进程时,它将进行一次取值并留存在内存中。这意味着如果创建一个单例对象,它将在每个传入的请求之间共享,所以我们需要为每个请求创建一个新的根 Vue 实例

不仅vue实例,接下来要用到的vuex、vue-router也是如此。我们利用webpack需要分别对客户端代码和服务器端代码分别打包, 服务器需要「服务器 bundle」然后用于服务器端渲染(SSR),而「客户端 bundle」会发送给浏览器,用于混合静态标记。这里贴一下官方构建图:

image4

我们可以大致的理解为服务器端、客户端通过俩个入口Server entryClinet entry 获取源代码,再通过webpack打包变成俩个bundlevue-ssr-server-bundle.jsonvue-ssr-client-manifest.json,配合生成完成HTML,而app.js是俩个入口通用的代码部分,其作用是暴露出vue实例。所以我们可以按照官方建议整理文件目录,并按照官方事例代码编写,其中起服务的server.js我们用的是Koa,所以可以先不用改。

image5

上面代码需要注意的是entry-server.js,它是暴露出一个函数,接受渲染上下文context参数,然后根据url匹配组件。所以说参数需要在我们调用renderToString传入context,并包括url属性。


生成的俩个bundle其实是作为参数传入到createBundleRenderer()函数中,然后在renderToString变成html结构,与createRenderer不同的是前者是通过bundle参数获取vue组件编译,后者是需要在renderToString时传入vue实例👉文档。我们先编写webpack成功生成bundle后,再去编写server.js,这样有利于我们更好的理解和测试。


首先我们建立build文件夹,用于存放webpack相关配置,在vue-cli3之前,vue init 初始化后的项目都是有build文件夹的,可以清楚看到webpack配置。而vue-cli3后,使用webpack4,并将配置隐藏了起来,如果想了解webpack4构建vue单页面应用可以去我的github上查看👉地址。我们可以模仿vue-cli,创建通用配置webpack.base.conf.js、客户端配置webpack.client.conf.js、服务端配置webpack.server.conf.js。文件目录为

├── build
│   ├── webpack.base.conf.js   # 基本webpack配置
│   ├── webpack.client.conf.js # 客户端webpack配置
│   └── webpack.server.conf.js # 服务器端webpack配置
├── src
├── index.template.html
└── server.js 

webpack.base.conf.js配置主要定义通用的rules,例如vue-loader对.vue文件编译,对js文件babel编译,处理图片、字体等。其基本配置如下:

const path = require('path')
// vue-loader v15版本需要引入此插件
const VueLoaderPlugin = require('vue-loader/lib/plugin')

// 用于返回文件相对于根目录的绝对路径
const resolve = dir => path.posix.join(__dirname, '..', dir)

module.exports = {
  // 入口暂定客户端入口,服务端配置需要更改它
  entry: resolve('src/entry-client.js'),
  // 生成文件路径、名字、引入公共路径
  output: {
    path: resolve('dist'),
    filename: '[name].js',
    publicPath: '/'
  },
  resolve: {
    // 对于.js、.vue引入不需要写后缀
    extensions: ['.js', '.vue'],
    // 引入components、assets可以简写,可根据需要自行更改
    alias: {
      'components': resolve('src/components'),
      'assets': resolve('src/assets')
    }
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
        options: {
          // 配置哪些引入路径按照模块方式查找
          transformAssetUrls: {
            video: ['src', 'poster'],
            source: 'src',
            img: 'src',
            image: 'xlink:href'
          }
        }
      },
      {
        test: /\.js$/, // 利用babel-loader编译js,使用更高的特性,排除npm下载的.vue组件
        loader: 'babel-loader',
        exclude: file => (
          /node_modules/.test(file) &&
          !/\.vue\.js/.test(file)
        )
      },
      {
        test: /\.(png|jpe?g|gif|svg)$/, // 处理图片
        use: [
          {
            loader: 'url-loader',
            options: {
              limit: 10000,
              name: 'static/img/[name].[hash:7].[ext]'
            }
          }
        ]
      },
      {
        test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, // 处理字体
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: 'static/fonts/[name].[hash:7].[ext]'
        }
      }
    ]
  },
  plugins: [
    new VueLoaderPlugin()
  ]
}

webpack.client.conf.js主要是对客户端代码进行打包,它是通过webpack-merge实现对基础配置的合并,其中要实现对css样式的处理,此处我用了stylus,同时要下载对应的stylus-loader来处理。在这里我们先不考虑开发环境,后面会针对开发环境对webpack进行修改。

const path = require('path')
const webpack = require('webpack')
const merge = require('webpack-merge')
const baseWebpackConfig = require('./webpack.base.conf')
// css样式提取单独文件
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
// 服务端渲染用到的插件、默认生成JSON文件(vue-ssr-client-manifest.json)
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

module.exports = merge(baseWebpackConfig, {
  mode: 'production',
  output: {
    // chunkhash是根据内容生成的hash, 易于缓存,
    // 开发环境不需要生成hash,目前先不考虑开发环境,后面详细介绍
    filename: 'static/js/[name].[chunkhash].js',
    chunkFilename: 'static/js/[id].[chunkhash].js'
  },
  module: {
    rules: [
      {
        test: /\.styl(us)?$/,
        // 利用mini-css-extract-plugin提取css, 开发环境也不是必须
        use: [MiniCssExtractPlugin.loader, 'css-loader', 'stylus-loader']
      },
    ]
  },
  devtool: false,
  plugins: [
    // webpack4.0版本以上采用MiniCssExtractPlugin 而不使用extract-text-webpack-plugin
    new MiniCssExtractPlugin({
      filename: 'static/css/[name].[contenthash].css',
      chunkFilename: 'static/css/[name].[contenthash].css'
    }),
    //  当vendor模块不再改变时, 根据模块的相对路径生成一个四位数的hash作为模块id
    new webpack.HashedModuleIdsPlugin(),
    new VueSSRClientPlugin()
  ]
})

编写完,我们需要在package.json定义命令来执行webpack打包命令。如果没有该文件,需要通过npm init初始化生成

// package.json
"scripts": {
  "build:client": "webpack --config build/webpack.client.conf.js", # 打包客户端代码
  "build:server": "webpack --config build/webpack.server.conf.js", # 打包服务端代码
  "start": "node server.js" # 启动服务
}

我们现在可以通过npm run build:client执行打包命令,执行命令之前要把依赖的npm包下载好,目前所需要到的依赖见下图:

image6

当打包命令执行完毕后,我们会发现多了一个dist文件夹,其中除了静态文件以外,生成了用于服务端渲染的JSON文件:vue-ssr-client-manifest.json。

image7

同理,我们需要编写服务端webpack配置,同样打包生成vue-ssr-server-bundle.json。配置代码如下:

const path = require('path')
const webpack = require('webpack')
const merge = require('webpack-merge')
const nodeExternals = require('webpack-node-externals')
const baseWebpackConfig = require('./webpack.base.conf')
const VueServerPlugin = require('vue-server-renderer/server-plugin')

module.exports = merge(baseWebpackConfig, {
  mode: 'production',
  target: 'node',
  devtool: 'source-map',
  entry: path.join(__dirname, '../src/entry-server.js'),
  output: {
    libraryTarget: 'commonjs2',
    filename: 'server-bundle.js',
  },
  // 这里有个坑... 服务端也需要编译样式,但不能使用mini-css-extract-plugin,
  // 因为它会使用document,但服务端并没document,导致打包报错。详情见
  // https://github.com/webpack-contrib/mini-css-extract-plugin/issues/48#issuecomment-375288454
  module: {
    rules: [
      {
        test: /\.styl(us)?$/,
        use: ['css-loader/locals', 'stylus-loader']
      }
    ]
  },
  // 不要外置化 webpack 需要处理的依赖模块
  externals: nodeExternals({
    whitelist: /\.css$/
  }),
  plugins: [
    new webpack.DefinePlugin({
      'process.env.VUE_ENV': '"server"'
    }),
    // 默认文件名为 `vue-ssr-server-bundle.json`
    new VueServerPlugin()
  ]
})

同上,我们执行命令后发现dist文件下生成vue-ssr-server-bundle.json,我们可以新建build命令来一起执行打包。

image8

好了,现在我们可以修改我们的server.js来实现整个服务器端渲染流程。我们需要获取俩个JSON文件、html模板作为参数传入createBundleRenderer,vue实例不再需要,context需要url,因为服务端端入口(entry-server.js) 需要获取访问的路径来匹配对应的vue组件(上面提到过)。部分改动代码如下:

/* 将createRenderer替换成createBundleRenderer,不同之处在上面提到过... */
const { createBundleRenderer } = require('vue-server-renderer')

// ...忽略无关代码

// 获取客户端、服务器端生成的json文件、html模板文件
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const template = require('fs').readFileSync('./index.template.html', 'utf-8')

// 传入 json文件和template, 渲染上下文url需要传入,服务端需要匹配路由
router.get('*', async (ctx, next) => {
  const renderer = createBundleRenderer(serverBundle, {
    runInNewContext: false, // 推荐
    template, // 页面模板
    clientManifest // 客户端构建 manifest
  })

  const context = {
    url: ctx.url,
	// ...
  }

// ...忽略无关代码

改动后,我们运行npm run start,发现页面已经成功渲染出来,但这时有个问题,加载的资源都失败了,文件存在于dist中,很显然,一定是路径不对导致的。这时我们可以通过koa-send来实现静态资源的发送。我们需要在server.js中加入这行代码:

const send = require('koa-send')
// 引入/static/下的文件都通过koa-send转发到dist文件目录下
router.get('/static/*', async (ctx, next) => {
  await send(ctx, ctx.path, { root: __dirname + '/dist' });
})

再重新运行,打开控制台可以看到资源加载成功,并且加载的doc里面包含页面上所有内容。👏

image9

开发环境构建

我们跑通了基本的服务端渲染流程,但还没有涉及到异步数据、缓存等问题。在此之前,我们需要先实现开发环境的搭建,因为我们不可能敲的每一行代码都需要重新打包并起服务。这是不利于调试的。并且很🐷。


想一想vue-cli构建出来的项目,我们可以通过npm run dev(vue-cli3使用了npm run serve)起一个服务,然后更改文件的时候,页面也会自动的热加载,不需要手动刷新。我们也要实现一个类似的开发环境,所以我们需要利用node来构建webpack配置,并且实时监控文件的改变,当改变时应该重新进行打包,重新生成俩个JSON文件,并重新进行BundleRenderer.renderToString()方法。我们除了重新生成JSON文件意外,其他逻辑和之前实现的逻辑大体相同。所以我们可以在server.js基础上进行修改,在原基础上进行环境的判断,做不同的render。我们需要一个环境变量来决定执行哪个逻辑。

这里我们使用cross-env来设置process.env.NODE_ENV变量:

image10

我们把build、start命令都设置了process.env.NODE_ENV为production生产环境,这样我们在文件中可以获取到该值,如果没有我们就默认是development开发环境。那我们的server.js都需要修改哪里呢?

1. 首先是生成BundleRenderer实例,之前我们是通过固定路径(打包后的dist文件夹下)获取JSON文件

// 之前代码逻辑
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const template = require('fs').readFileSync('./index.template.html', 'utf-8')

//...忽略无关代码 

const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false,
  template, // 页面模板
  clientManifest // 客户端构建 manifest
})

我们需要按照环境变量更改逻辑,如果是生产环境上述代码不变,如果是开发环境,我们需要有一个函数来动态的获取打包的JSON文件并且重新生成BundleRenderer实例,我们先定义好这个函数为setupDevServer,顾名思义这个函数是构建开发环境的,它的作用是nodeAPI构建webpack配置,并且做到监听文件。我们server.js中可以通过传递个回调函数来做重新生成BundleRenderer实例的操作。而接受的参数就是俩个新生成的JSON文件。

// 假设已经实现
const setupDevServer = require('./build/setup-dev-server')
// 生成实例公共函数,开发、生产环境只是传入参数不同
const createBundle = (bundle, clientManifest) => {
  return createBundleRenderer(bundle, {
    runInNewContext: false,
    template,
    clientManifest
  })
}
let renderer // 将实例变量提到全局变量,根据环境变量赋值
const template = require('fs').readFileSync('./index.template.html', 'utf-8') // 模板

// 第 2步:根据环境变量生成不同BundleRenderer实例
if (process.env.NODE_ENV === 'production') {
  // 获取客户端、服务器端打包生成的json文件
  const serverBundle = require('./dist/vue-ssr-server-bundle.json')
  const clientManifest = require('./dist/vue-ssr-client-manifest.json')
  // 赋值
  renderer = createBundle(serverBundle, clientManifest)
  // 静态资源,开发环境不需要指定
  router.get('/static/*', async (ctx, next) => {
    console.log('进来')
    await send(ctx, ctx.path, { root: __dirname + '/dist' });
  })
} else {
  // 假设setupDevServer已经实现,并传入的回调函数会接受生成的json文件
  setupDevServer(app, (bundle, clientManifest) => {
    // 赋值
    renderer = createBundle(bundle, clientManifest)
  })
}

2. 其次我们可以把中间件函数也提取出来,命名成render函数

const setupDevServer = require('./build/setup-dev-server')
// 第 2步:根据环境变量生成不同BundleRenderer实例
if (process.env.NODE_ENV === 'production') {
  // 获取客户端、服务器端打包生成的json文件
  const serverBundle = require('./dist/vue-ssr-server-bundle.json')
  const clientManifest = require('./dist/vue-ssr-client-manifest.json')
  // 赋值
  renderer = createBundleRenderer(serverBundle, {
    runInNewContext: false,
    template,
    clientManifest
  })
  // 静态资源,开发环境不需要指定
  router.get('/static/*', async (ctx, next) => {
    console.log('进来')
    await send(ctx, ctx.path, { root: __dirname + '/dist' });
  })
} else {
  // 假设setupDevServer已经实现,并传入的回调函数会接受生成的json文件
  setupDevServer(app, (bundle, clientManifest) => {
    // 赋值
    renderer = createBundleRenderer(bundle, {
      runInNewContext: false,
      template,
      clientManifest
    })
  })
}

这里我们先假设已实现setupDevServer的功能,后面我们再来仔细讲其中的代码逻辑。

我们可以在判断生产环境的地方加上log,打印一下是否如我们所愿,针对不同的NODE_ENV环境执行不同的逻辑。

image11


在之前,我们实现的webpack配置并没有对生产环境与开发环境做区别,但其实,我们应该像vue-cli一样针对环境来做不同的优化,比如开发环境devtool我们可以使用cheap-module-eval-source-map编译会更快,css样式没有必要打包单独文件,使用vue-style-loader做处理就好,并且因为开发环境需要模块热重载,所以不提取文件是必要的。开发环境可以做更友好的错误提示。还有就是生产环境需要做更多的打包优化,比如压缩,缓存之类。在这个系列文章中,我们就不对生产环境做更好的优化,因为我自己对这方面知识也是很懵懂😑。我们先修改webpack.base.conf.js:

// ...
// 定义是否是生产环境的标志位,用于配置中
const isProd = process.env.NODE_ENV === 'production'

module.exports = {
  // 这里使用对象的格式,因为在setDevServer.js中需要添加一个热重载的入口
  entry: {
    app: resolve('src/entry-client.js')
  },
  // 开发环境启动sourcemap可以更好地定位错误位置
  devtool: isProd
    ? false
    : 'cheap-module-eval-source-map',
  // ...... 省略
}

我们在对webpack.client.conf.js进行修改:

// 定义是否是生产环境的标志位,用于配置中
const isProd = process.env.NODE_ENV === 'production'

const pordWebpackConfig = merge(baseWebpackConfig, {
  mode: process.env.NODE_ENV || 'development',
  output: {
    // chunkhash是根据内容生成的hash, 易于缓存。
    // 开发环境不需要生hash、这个我们在setDevServer函数里面改
    filename: 'static/js/[name].[chunkhash].js',
    chunkFilename: 'static/js/[id].[chunkhash].js'
  },
  module: {
    rules: [
      {
        test: /\.styl(us)?$/,
        // 开发环境不需要提取css单独文件
        use: isProd 
          ? [MiniCssExtractPlugin.loader, 'css-loader', 'stylus-loader']
          : ['vue-style-loader', 'css-loader', 'stylus-loader']
      },
    ]
  },
  // ... 省略
}

关于服务器端webpack的配置可以不进行修改,因为它的功能最后只打包出一个JSON文件,并不需要针对环境做一些改变。


好的,接下来我们要编写set-dev-server.js,setDevServer函数主要是利用webpack手动构建应用,并实现热加载。首先我们需要俩个中间件koa-webpack-dev-middlewarekoa-webpack-hot-middleware,前者是通过传入webpack编译好的compiler实现热加载,而后者是实现模块热更替,热加载是监听文件变化,从而进行刷新网页,模块热更替则在它的基础上做到不需要刷新页面。我们客户端webpack配置可以通过前面说的实现自动更新,而服务端compiler,我们通过watchAPI,进行监听。当俩者其中有一个变化时,我们就需要调用传入的回调,将新生成的JSON文件传入。整个流程大致就是这样,具体代码如下:

const fs = require('fs')
const path = require('path')
// memory-fs可以使webpack将文件写入到内存中,而不是写入到磁盘。
const MFS = require('memory-fs')
const webpack = require('webpack')
const clientConfig = require('./webpack.client.conf')
const serverConfig = require('./webpack.server.conf')
// webpack热加载需要
const webpackDevMiddleware = require('koa-webpack-dev-middleware')
// 配合热加载实现模块热替换
const webpackHotMiddleware = require('koa-webpack-hot-middleware')

// 读取vue-ssr-webpack-plugin生成的文件
const readFile = (fs, file) => {
  try {
    return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
  } catch (e) {
    console.log('读取文件错误:', e)
  }
}

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

  // 监听改变后更新函数
  const update = () => {
    if (bundle && clientManifest) {
      cb(bundle, clientManifest)
    }
  }

  // 修改webpack配合模块热替换使用
  clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
  clientConfig.output.filename = '[name].js'
  clientConfig.plugins.push(
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoEmitOnErrorsPlugin()
  )
  

  // 编译clinetWebpack 插入Koa中间件
  const clientshh = webpack(clientConfig)
  const devMiddleware = webpackDevMiddleware(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()
  })

  // 插入Koa中间件(模块热替换)
  app.use(webpackHotMiddleware(clientCompiler))

  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

    //  vue-ssr-webpack-plugin 生成的bundle
    bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
    update()
  })
}

我们用到了memory-fs将生成的JSON文件写入内存中,而不是磁盘中,是为了更快的读写。客户端不需要是因为webpack-dev-middleware已经帮我们完成了。这就是为什么我们在开发环境并有dist文件夹生成。我们现在可以通过npm run dev访问localhost:3000,更改代码,可以实现热加载。

数据预取

在服务器端渲染(SSR)期间,我们本质上是在渲染我们应用程序的"快照",所以如果应用程序依赖于一些异步数据,那么在开始渲染过程之前,需要先预取和解析好这些数据

正如官方文档解释的,SSR本质上就是先执行应用程序并返回HTML,所以我们需要服务端处理数据,客户端与之同步。数据预取官方文档实例代码很详细,我们照着实现一下即可。这里不得不说,vue生态的文档一向都是很友好。并且都配有中文文档,对于跟我一样的小白来说,真是太好不过啦🙈

服务端数据预取

我们像官网一样引入vuex对编写示例代码,并进行修改。修改store/index.js

// ...

export function createStore() {
  return new Vuex.Store({
    state: {
      movie: {}
    },
    actions: {
      // 通过传入id请求电影数据,这里我们模拟一下,先返回id
      fetchMovie({ commit }, id) {
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            resolve({ id })
          }, 500)
        }).then(res => {
          commit('setMoive', { res })
        })
      }
    },
    mutations: {
      // 设置state
      setMoive(state, { res }) {
        state.movie = res
      }
    }
  })
}

修改A.vue

<template>
  <div>
    A页 请求电影数据结果:{{  this.$store.state.movie }}
  </div>
</template>

<script>
export default {
  name: 'A',
  // 定义asyncData, entry-server.js会编译所有匹配的组件中是否包含,包含则执行
  // 将state值挂在到context上,会被序列化为window.__INITIAL_STATE__
  // 
  asyncData ({ store, route }) {
    // 请求电影数据, 传入 ID : 12345
    return store.dispatch('fetchMovie', 12345)
  },
}
</script>

<style lang="stylus" scoped>
h1
  color blue
</style>


服务端预取的原理就是,通过在组件内定义asyncData函数用于异步请求,在entry-server.js服务端中遍历所有匹配到的组件,如果包含asyncData则执行,并将state挂载到context上下文,vue-server-renderer会将state序列化为window.__ INITIAL_STATE __,这样,entry-client.js客户端就可以替换state,实现同步。我们运行代码,打开浏览器会看到

image12

客户端数据预取

因为入口只会在第一次进入应用时执行一次,页面的跳转不会再执行服务端数据预取的逻辑,所以说我们需要客户端数据预取,官网文档实现有俩种方式,这里就只尝试一种,利用router的导航守卫,原理就是在每次进行跳转时,执行没有执行过的asyncData函数,

// 官方代码
router.onReady(() => {
  router.beforeResolve((to, from, next) => {
    const matched = router.getMatchedComponents(to)
    const prevMatched = router.getMatchedComponents(from)

    // 我们只关心非预渲染的组件
    // 所以我们对比它们,找出两个匹配列表的差异组件
    let diffed = false
    const activated = matched.filter((c, i) => {
      return diffed || (diffed = (prevMatched[i] !== c))
    })

    if (!activated.length) {
      return next()
    }

    // 这里如果有加载指示器(loading indicator),就触发

    Promise.all(activated.map(c => {
      if (c.asyncData) {
        return c.asyncData({ store, route: to })
      }
    })).then(() => {

      // 停止加载指示器(loading indicator)

      next()
    }).catch(next)
  })
  app.$mount('#app')
})

这回,我们复制粘贴A.vue修改为B.vue作为B页面传入不同id(如666666),执行命令,查看结果,可以看到在跳转时,state.movie已经被赋予不同的值

设置Head和缓存

title注入

我们做服务端渲染,根据不同的页面会有不同的meta、title。所以我们还需要注入不同的Head。可以用到强大的vue-meta 配合SSR使用。这里我们就按照官方文档来实现一个简单的title注入,首先你需要在你的template模板中定义<title></title> 基本原理跟数据预取类似,我们在特定的时机来获取组件内title函数,或者字符串,然后将它挂载到context上,这样,就可以实现动态改变标题。客户端直接调用document.title = title就可以。我们将官方示例代码title-mixin.js放到mixin文件夹下。app.js中引用,调用Vue.mixin(titleMixin),在访问A页面时,title就变成A页面

// app.js

import titleMixin from './mixins/title-mixin'
Vue.mixin(titleMixin)


// A.vue
export default {
  title: 'A页面', // 或者是 title () { return 'A页面' }
  // ...
}

页面级别缓存

缓存的基本原理官方代码写的也是一目了然。官方代码如下:

// server.js

// 设置缓存参数
const microCache = LRU({
  max: 100, // 最大缓存数
  maxAge: 10000 //  10s过期,意味着10s内请求统一路径,缓存中都有
})

// 判断是否可以缓存,这里先模拟,当访问B就缓存
const isCacheable = ctx => {
  return ctx.url === '/b'
}

const render = async (ctx) => {
  // ...忽略无关代码
    
  // 判断是否可缓存,如果可缓存则先从缓存中查找
  const cacheable = isCacheable(ctx)
  if (cacheable) {
    const hit = microCache.get(ctx.url)
    if (hit) {
      console.log('取到缓存') // 便于调试
      ctx.body = hit
      return
    }
  }
    
  // 存入缓存, 只有当缓存中没有 && 可以缓存
  if (cacheable) {
    console.log('设置缓存') // 便于调试
    microCache.set(ctx.url, html)
  }
}

我们运行代码,刷新页面,查看命令行,可以看到,第一次进入B设置了缓存,10s内无论怎么刷新页面,都是取得缓存。反而A页面不会被缓存。

总结

关于配置Vue服务端渲染到此就结束啦😁。文章主要偏重的还是利用webpack来构建开发环境和生产环境的SSR,因为就我而言在这地方花费时间比较多。像数据的预取、Head的动态设置、路由的缓存基本上都是按照官方文档走下来的,理解起来并不难。但真要是做成可以用于线上项目开发还是有许多要做的。比如nuxt就已经做到很好了,我所在公司也在使用nuxt。之所以有这篇文章,也是想对服务端渲染有更好的理解。项目的完整代码👉地址,如果对你有帮助,别忘给个star哈~