让vue-cli初始化后的项目支持服务端渲染,SSR 实战

3,167 阅读12分钟

转载请注明出处,原文地址:
blog.myweb.kim/vue/%E8%AE%…

当前 SPA 架构流行的趋势如日中天,但在 SEO 方面好像一直是个痛点,所以众多流行的 mv* 等框架也为此痛点提出了解决方案。
vue 官方提供了快速构建项目的工具 vue-cli,其方便快捷性众所周知。本文章来分享一下使用vue cli构建项目后如何集成 SSR(server side render 服务器端渲染),本文主要说明使用两种方式来实现SSR的效果。

两个示例的git地址:
  1. vue-prerender-demo
  2. vue-ssr-demo

此文章面向对 vue 较为熟悉以及对 vue-cli 有些许了解的同学。

1. 我的环境

  • node v8.5.0
  • npm v5.0.0
  • vue-cli v2.8.2
  • MacOS 10.12.3

以上并不是重要因素~~

2. 方式一:使用prerender-spa-plugin插件获得SSR的效果。

2.1 说明

插件地址:prerender-spa-plugin

严格上来说,此种实现方式并非叫做 SSR,而是预渲染。不过效果上是一样的,甚至某种程度上来说可能要比 SSR 更好。相比官方提供的 SSR 繁琐配置,prerender 配置更简单快捷,如无特殊要求只需在 webpack 中加一个 plugin 的配置即可。 但是此方式只支持h5 history方式的路由,不支持hash方式的路由。

prerender大致工作流程

prerender 主要是利用phantom js模拟浏览器环境,将指定的路由页面放在 phantom j s中运行,这样.vue便会在 phantom 中工作并完成渲染,prerender再去获取渲染后的dom结构并将其写入对应路由的 html 文件。
服务启动后,在真实浏览器环境中输入对应的路由地址,服务器便会将 prerender 已渲染好生成的 html 返回给浏览器,从而达到了 SSR 的效果。

2.2 初始化

确保 vue-cli 安装成功,执行命令:

vue init webpack vue-prerender-demo //此文章都是在webpack基础上配置的

回车之后构建工具会提示一些项目信息的相关设置。这里的我选择了vue-router、代码检查ESLintStandard,没有选择集成测试与单元测试,安装包太耗时了。

初始化完毕:

cd vue-prerender-demo
npm install
npm run dev

进入项目,安装完毕,启动项目,确保项目正常工作。

2.3 配置

为了方便测试效果,我对初始化好的 demo 做了以下修改:

  1. 将路由的mode修改为history
  2. 增加一个Test组件,与Hello组件评级,作为一个单独的路由页面
  3. 修改router/index.js中的配置,增加/test路由。
  4. 在Hello组件中加入了<router-link to="/test">/test</router-link>, 在Test组件中加入了<router-link to="/">回到首页</router-link>

如此之后查看页面,从首页->Test->首页之间跳转,路由可正常工作。

2.4 开始

那么接下来即可开始正式工作:

1. 安装 prerender-spa-plugin, 因为依赖phantom js,phantom 的安装比较蛋疼,太耗时了~
npm install prerender-spa-plugin -D
2. 开始 prerender 相关的配置:

修改 webpack.prod.conf.js,只在生产环境进行预渲染。

//引用
var PrerenderSpaPlugin = require('prerender-spa-plugin')

//...

plugins: {
    //....
    //配置 prerender-spa-plugin
    new PrerenderSpaPlugin(
        // 生成文件的路径,此处与webpack打包地址一致
        path.join(config.build.assetsRoot), //config.build.assetsRoot为vue cli生成的配置,打包后的文件地址
        // 配置要做预渲染的路由,只支持h5 history方式
        [ '/', '/test']
    )

    //....
}
3. 编译

运行命令:

npm run build

等待命令完成后,可以看到 dist 目录下的文件结构:

dist目录结构

​ 相比原配置打包出来的内容多出了一个test目录,此目录对应prerender配置中的/test路由,那么配置的/就是dist/index.html吗?对的,是这样。

​ 打开dist/index.html查看一下内容,此文件内有很多东西,不再是以前孤单单的一个<div id="app"></div>,现在body里面的dom结构其实是与在浏览器中渲染的/路径页面dom结构是一致的!test/index.html便是对应访问/test渲染后的dom结构。

4. 验证

可忽略此步骤。 这里面使用了python作为快速启动的server。

为了在真实环境中确认最终效果是正确的,我在本地使用 python 启动了一个 http 服务(没有使用 webpack 与 node 作为服务)

cd dist //进入到对应目录
python -m SimpleHTTPServer 8888 //将dist作为根目录,启动8888端口,

在浏览器中直接输入localhost:8888/test,并右键选择查看显示网页源代码

test 页面
test 查看源代码页面

/test的 response 内容中我们可以看到返回的是渲染之后的 dom 结构,搜索引擎的小蜘蛛可以顺利的获取到内容,从而达到了 SEO 的效果。

2.5 优缺点

  • 简单、易上手、无需配置即可满足基本的 SEO 要求

  • 只支持h5 history

    不适合频繁变动的页面,如果这个页面希望做SEO优化。因为预渲染只是类似于快照的概念。

此方式实现的 demo 地址: vue-prerender-demo

3. 方式二:使用官方提供的轮子在node端做SSR

3.1 说明

本示例只说明如何完成一个相对基础的 SSR,vuex以及缓存等可参考官网说明。

官方文档地址:ssr.vuejs.org/zh/

官方文档开篇便说明 「vue 与 library 的版本最低要求、为什么使用 SSR 以及对比上面提到的 prerender-spa-plugin 插件」。

以及如下:

本指南将会非常深入,并且假设你已经熟悉 Vue.js 本身,并且具有 Node.js 和 webpack 的相当不错的应用经验。如果你倾向于使用提供了平滑开箱即用体验的更高层次解决方案,你应该去尝试使用 Nuxt.js。它建立在同等的 Vue 技术栈之上,但抽象出很多模板,并提供了一些额外的功能,例如静态站点生成。但是,如果你需要更直接地控制应用程序的结构,Nuxt.js 并不适合这种使用场景。

官方 vue ssr demo : HackerNews Demo

相对于 prerender 插件来对比,SSR 上手复杂度可以用天差地别来形如了吧~~
跟随本章节可以实现一个基础版本的 SSR 构建配置。

3.2 约束

如果你打算为你的vue项目在node使用 SSR,那么在通用代码中,我们有必要并且需要遵守下面的这些约定:

通用代码: 在客户端与服务器端都会运行的部分为通用代码。

  1. 注意服务端只调用beforeCreat与created两个钩子,所以不可以做类似于在created初始化一个定时器,然后在mounted或者destroyed销毁这个定时器,不然服务器会慢慢的被这些定时器给榨干了
  2. 因单线程的机制,在服务器端渲染时,过程中有类似于单例的操作,那么所有的请求都会共享这个单例的操作,所以应该使用工厂函数来确保每个请求之间的独立性。
  3. 如有在beforeCreat与created钩子中使用第三方的API,需要确保该类API在node端运行时不会出现错误,比如在created钩子中初始化一个数据请求的操作,这是正常并且及其合理的做法。但如果只单纯的使用XHR去操作,那在node端渲染时就出现问题了,所以应该采取axios这种浏览器端与服务器端都支持的第三方库。
  4. 最重要一点: 切勿在通用代码中使用document这种只在浏览器端可以运行的API,反过来也不可以使用只在node端可以运行的API。

3.3 准备工作

使用 vue-cli再次初始化一个项目:

vue init webpack vue-ssr-demo

then:

cd vue-ssr-demo
npm install
npm run dev

确保初始化的项目可正常运行,接下来开始慢慢折腾吧~~

3.4 开始折腾

1. 首先安装 ssr 支持
npm i -D vue-server-renderer

重要的是 vue-server-renderer 与 vue 版本必须一致匹配

2. 增加路由test与页面

随便写了个计数器,以验证服务端渲染时,vue 的机制会正常工作。

<template>
  <div>
    Just a test page.
    <div>
      <router-link to="/">Home</router-link>
    </div>
    <div><h2>{{mode}}</h2></div>
    <div><span>{{count}}</span></div>
    <div><button @click="count++">+1</button></div>
  </div>
</template>
<script>
  export default {
    data () {
      return {
	    mode: process.env.VUE_ENV === 'server' ? 'server' : 'client',
        count: 2
      }
    }
  }
</script>
3. 在src目录下创建两个js:
src
├── entry-client.js # 仅运行于浏览器
└── entry-server.js # 仅运行于服务器
4. 修改router配置。

无论什么系统路由总是最重要的,服务器端渲染自然也要公用一套路由系统,并且为了避免产生单例的影响,这里主要只为每一个请求都导出一个新的router实例:

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

Vue.use(Router)

export function createRouter () {
  return new Router({
    mode: 'history', // 注意这里也是为history模式
    routes: [
      {
        path: '/',
        name: 'Hello',
        component: HelloWorld
      }, {
        path: '/test',
        name: 'Test',
        component: () => import('@/components/Test') // 异步组件
      }
    ]
  })
}
5. 改造main.js

main.js初始化的只适合在浏览器的运行,所以要改造两端都可以使用的文件,同样为了避免产生单例的影响,这里将导出一个createApp的工厂函数:

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

export function createApp () {
  // 创建 router 实例
  const router = new createRouter()
  const app = new Vue({
    // 注入 router 到根 Vue 实例
    router,
    render: h => h(App)
  })
  // 返回 app 和 router
  return { app, router }
}
6. entry-client.js加入以下内容:
import { createApp } from './main'
const { app, router } = createApp()
// 因为可能存在异步组件,所以等待router将所有异步组件加载完毕,服务器端配置也需要此操作
router.onReady(() => {
  app.$mount('#app')
})
7. entry-server.js
// 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)
  })
}
8. webpack配置

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

官网推荐下面这种配置:

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

vue-cli初始化的配置文件也有三个:base、dev、prod ,我们依然保留这三个配置文件,只需要增加webpack.server.conf.js即可。

9. webpack 客户端的配置

修改webpack.base.conf.jsentry入口配置为: ./src/entry-client.js。这样原 dev 配置与 prod 配置都不会受到影响。

服务器端的配置也会引用base配置,但会将entry通过merge覆盖为 server-entry.js。

生成客户端构建清单client manifest

好处:

  1. 在生成的文件名中有哈希时,可以取代 html-webpack-plugin 来注入正确的资源 URL。
  2. 在通过 webpack 的按需代码分割特性渲染 bundle 时,我们可以确保对 chunk 进行最优化的资源预加载/数据预取,并且还可以将所需的异步 chunk 智能地注入为 <script> 标签,以避免客户端的瀑布式请求(waterfall request),以及改善可交互时间(TTI - time-to-interactive)。

其实很简单,在prod配置中引入一个插件,并配置到plugin中即可:

const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
	// ...
	// ...
	plugins: [
      new webpack.DefinePlugin({
        'process.env': env,
        'process.env.VUE_ENV': '"client"' // 增加process.env.VUE_ENV
      }),
      //...
      // 另外需要将 prod 的HtmlWebpackPlugin 去除,因为我们有了vue-ssr-client-manifest.json之后,服务器端会帮我们做好这个工作。
      // 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()
	]
// ...
10. webpack 服务器端的配置

server的配置有用到新插件运行安装: npm i -D webpack-node-externals

webpack.server.conf.js配置如下:

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()
  ]
})

注意此处对baseConfig删除了一个属性

baseConfig.module.rules[1].options = '' // 去除分离css打包的插件
11. 配置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找不到,请安装npm i -D cross-env

12. 修改index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>vue-ssr-demo</title>
  </head>
  <body>
    <!--vue-ssr-outlet-->
  </body>
</html>

原来的<div id="app">删掉,只在 body 中保留一个标记即可:<!--vue-ssr-outlet-->。 服务器端会在这个标记的位置自动生成一个<div id="app" data-server-rendered="true">,客户端会通过app.$mount('#app')挂载到服务端生成的元素上,并变为响应式的。

注意一下,此处将模板 html 修改为服务端渲染适用的模板了,但项目中的 dev 模式也适用的这个模板,但会因为找不到#app到报错,可以这样处理一下:

  1. 最简单的办法,为dev模式单独建立一个 html 模板。。。
  2. dev模式也集成服务端渲染模式,这样无论生产环境与开发环境共同处于服务端渲染模式下也是相当靠谱的一件事。(官方例子是这样操作的)
13. 运行构建命令
npm run build

然后在dist目录下可见生成的两个 json 文件: vue-ssr-server-bundle.jsonvue-ssr-client-manifest.json

这两个文件都会应用在 node 端,进行服务器端渲染与注入静态资源文件。

14. 构建服务器端(官方例子使用的express,所以此 demo 将采用koa2来作为服务器端,当然,无论是 koa 与 express 都不重要…)
npm i -S koa

在项目根目录创建server.js,内容如下

const Koa = require('koa')
const app = new Koa()

// response
app.use(ctx => {
  ctx.body = 'Hello Koa'
})

app.listen(3001)

运行node server.js,访问localhost:3001,确保浏览器得到了Hello Koa

15. 编写服务端代码

需要安装koa静态资源中间件: npm i -D koa-static

server.js代码如下:

const Koa = require('koa')
const app = new Koa()
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'), {
  // 推荐
  runInNewContext: false,
  // 模板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(require('koa-static')(resolve('./dist')))
// response
app.use(async (ctx, next) => {
  try {
    const context = {
      title: '服务端渲染测试', // {{title}}
      url: ctx.url
    }
    // 将服务器端渲染好的html返回给客户端
    ctx.body = await renderToString(context)

    // 设置请求头
    ctx.set('Content-Type', 'text/html')
    ctx.set('Server', 'Koa2 server side render')
  } catch (e) {
    // 如果没找到,放过请求,继续运行后面的中间件
    next()
  }
})

app.listen(3001)

运行启动服务命令:

node server.js
16. 大功告成

浏览器访问: localhost:3001/test,截图为服务器渲染成功的页面

test 页面

test 页面

test.vue中的 data 属性便已证明服务器端渲染工作是正常的(mode: process.env.VUE_ENV === 'server' ? 'server' : 'client',),但在客户端数据混合的时候,mode 是等于 client 的。

3.4 优缺点

  • 可以做到真实数据实时渲染,完全可供SEO小蜘蛛尽情的爬来爬去

    完全前后端同构,路由配置共享,不再影响服务器404请求

  • 依旧只支持h5 history的路由模式,(没办法,哈希就是提交不到服务器能咋办呢。。。)

    配置比较麻烦、处理流程比较复杂 (比对预渲染插件,复杂太多)

    约束较多,不能随心所欲的乱放大招

    对服务器会造成较大的压力,既然让浏览器更快的渲染了,那就得以占用服务器的性能来买单了

本示例git地址: vue-ssr-demo

以上,致那颗骚动的心……