Vue 服务端渲染(SSR)

Wesley13
• 阅读 658

Vue 服务端渲染(SSR)

什么是服务端渲染,简单理解是将组件或页面通过服务器生成html字符串,再发送到浏览器,最后将静态标记"混合"为客户端上完全交互的应用程序。于传统的SPA(单页应用)相比,服务端渲染能更好的有利于SEO,减少页面首屏加载时间,当然对开发来讲我们就不得不多学一些知识来支持服务端渲染。同时服务端渲染对服务器的压力也是相对较大的,和服务器简单输出静态文件相比,通过node去渲染出页面再传递给客户端显然开销是比较大的,需要注意准备好相应的服务器负载。

一、一个简单的例子

  1. // 第 1 步:创建一个 Vue 实例

  2. const Vue = require('vue')

  3. const app = new Vue({

  4. template: `<div>Hello World</div>`

  5. })

  6. // 第 2 步:创建一个 renderer

  7. const renderer = require('vue-server-renderer').createRenderer()

  8. // 第 3 步:将 Vue 实例渲染为 HTML

  9. renderer.renderToString(app, (err, html) => {

  10. if (err) throw err

  11. console.log(html)

  12. // => <div data-server-rendered="true">Hello World</div>

  13. })

上面例子利用 vue-server-renderer npm 包将一个vue示例最后渲染出了一段 html。将这段html发送给客户端就轻松的实现了服务器渲染了。

  1. const server = require('express')()

  2. server.get('*', (req, res) => {

  3. // ... 生成 html

  4. res.end(html)

  5. })

  6. server.listen(8080)

二、官方渲染步骤

上面例子虽然简单,但在实际项目中往往还需要考虑到路由,数据,组件化等等,所以服务端渲染不是只用一个 vue-server-renderer npm包就能轻松搞定的,下面给出一张Vue官方的服务器渲染示意图:

Vue 服务端渲染(SSR)

流程图大致意思是:将 Source(源码)通过 webpack 打包出两个 bundle,其中 Server Bundle 是给服务端用的,服务端通过渲染器 bundleRenderer 将 bundle 生成 html 给浏览器用;另一个 Client Bundle 是给浏览器用的,别忘了服务端只是生成前期首屏页面所需的 html ,后期的交互和数据处理还是需要能支持浏览器脚本的 Client Bundle 来完成。

三、具体怎么实现

实现过程就是将上面的示意图转化成代码实现,不过这个过程还是有点小复杂的,需要多点耐心去推敲每个细节。

1、先实现一个基本版

项目结构示例:

  1. ├── build

  2. │ ├── webpack.base.config.js # 基本配置文件

  3. │ ├── webpack.client.config.js # 客户端配置文件

  4. │ ├── webpack.server.config.js # 服务端配置文件

  5. └── src

  6. ├── router

  7. │ └── index.js # 路由

  8. └── views

  9. │ ├── comp1.vue # 组件

  10. │ └── copm2.vue # 组件

  11. ├── App.vue # 顶级 vue 组件

  12. ├── app.js # app 入口文件

  13. ├── client-entry.js # client 的入口文件

  14. ├── index.template.html # html 模板

  15. ├── server-entry.js # server 的入口文件

  16. ├── server.js # server 服务

其中:

(1)、comp1.vue 和 copm2.vue 组件

  1. <template>

  2. <section>组件 1</section>

  3. </template>

  4. <script>

  5. export default {

  6. data () {

  7. return {

  8. msg: ''

  9. }

  10. }

  11. }

  12. </script>

(2)、App.vue 顶级 vue 组件

  1. <template>

  2. <div id="app">

  3. <h1>vue-ssr</h1>

  4. <router-link class="link" to="/comp1">to comp1</router-link>

  5. <router-link class="link" to="/comp2">to comp2</router-link>

  6. <router-view class="view"></router-view>

  7. </div>

  8. </template>

  9. <style lang="stylus">

  10. .link

  11. margin 10px

  12. </style>

(3)、index.template.html html 模板

  1. <!DOCTYPE html>

  2. <html lang="zh_CN">

  3. <head>

  4. <title>{{ title }}</title>

  5. <meta charset="utf-8"/>

  6. <meta name="mobile-web-app-capable" content="yes"/>

  7. <meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1"/>

  8. <meta name="renderer" content="webkit"/>

  9. <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"/>

  10. <meta name="theme-color" content="#f60"/>

  11. </head>

  12. <body>

  13. <!--vue-ssr-outlet-->

  14. </body>

  15. </html>

(4)、上面基础代码不解释,接下来看

路由 router

  1. import Vue from 'vue'

  2. import Router from 'vue-router'

  3. import comp1 from '../views/comp1.vue'

  4. import comp2 from '../views/comp2.vue'

  5. Vue.use(Router)

  6. export function createRouter () {

  7. return new Router({

  8. mode: 'history',

  9. scrollBehavior: () => ({ y: 0 }),

  10. routes: [

  11. {

  12. path: '/comp1',

  13. component: comp1

  14. },

  15. {

  16. path: '/comp2',

  17. component: comp2

  18. },

  19. { path: '/', redirect: '/comp1' }

  20. ]

  21. })

  22. }

app.js app 入口文件

  1. import Vue from 'vue'

  2. import App from './App.vue'

  3. import { createRouter } from './router'

  4. export function createApp (ssrContext) {

  5. const router = createRouter()

  6. const app = new Vue({

  7. router,

  8. ssrContext,

  9. render: h => h(App)

  10. })

  11. return { app, router }

  12. }

我们通过 createApp 暴露一个根 Vue 实例,这是为了确保每个用户能得到一份新的实例,避免状态污染,所以我们写了一个可以重复执行的工厂函数 createApp。 同样路由 router 我们也是一样的处理方式 createRouter 来暴露一个 router 实例

(5)client-entry.js client 的入口文件

  1. import { createApp } from './app'

  2. const { app, router } = createApp()

  3. router.onReady(() => {

  4. app.$mount('#app')

  5. })

客户端代码是在路由解析完成的时候讲 app 挂载到 #app 标签下

(7)server-entry.js server 的入口文件

  1. import { createApp } from './app'

  2. export default context => {

  3. // 因为这边 router.onReady 是异步的,所以我们返回一个 Promise

  4. // 确保路由或组件准备就绪

  5. return new Promise((resolve, reject) => {

  6. const { app, router } = createApp(context)

  7. router.push(context.url)

  8. router.onReady(() => {

  9. resolve(app)

  10. }, reject)

  11. })

  12. }

服务器的入口文件我们返回了一个 promise

2、打包

在第一步我们大费周章实现了一个带有路由的日常功能模板代码,接着我们需要利用webpack将上面的代码打包出服务端和客户端key的代码,入口文件分别是 server-entry.js 和 client-entry.js

(1)、 webpack构建配置

一般配置分为三个文件:base, client 和 server。基本配置(base config)包含在两个环境共享的配置,例如,输出路径(output path),别名(alias)和 loader。服务器配置(server config)和客户端配置(client config),可以通过使用 webpack-merge 来简单地扩展基本配置。

webpack.base.config.js 配置文件

  1. const path = require('path')

  2. const webpack = require('webpack')

  3. const ExtractTextPlugin = require('extract-text-webpack-plugin')

  4. module.exports = {

  5. devtool: '#cheap-module-source-map',

  6. output: {

  7. path: path.resolve(__dirname, '../dist'),

  8. publicPath: '/dist/',

  9. filename: '[name]-[chunkhash].js'

  10. },

  11. resolve: {

  12. alias: {

  13. 'public': path.resolve(__dirname, '../public'),

  14. 'components': path.resolve(__dirname, '../src/components')

  15. },

  16. extensions: ['.js', '.vue']

  17. },

  18. module: {

  19. noParse: /es6-promise\.js$/,

  20. rules: [

  21. {

  22. test: /\.(js|vue)/,

  23. use: 'eslint-loader',

  24. enforce: 'pre',

  25. exclude: /node_modules/

  26. },

  27. {

  28. test: /\.vue$/,

  29. use: {

  30. loader: 'vue-loader',

  31. options: {

  32. preserveWhitespace: false,

  33. postcss: [

  34. require('autoprefixer')({

  35. browsers: ['last 3 versions']

  36. })

  37. ]

  38. }

  39. }

  40. },

  41. {

  42. test: /\.js$/,

  43. use: 'babel-loader',

  44. exclude: /node_modules/

  45. },

  46. {

  47. test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,

  48. use: {

  49. loader: 'url-loader',

  50. options: {

  51. limit: 10000,

  52. name: 'img/[name].[hash:7].[ext]'

  53. }

  54. }

  55. },

  56. {

  57. test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,

  58. use: {

  59. loader: 'url-loader',

  60. options: {

  61. limit: 10000,

  62. name: 'fonts/[name].[hash:7].[ext]'

  63. }

  64. }

  65. },

  66. {

  67. test: /\.css$/,

  68. use: ['vue-style-loader', 'css-loader']

  69. },

  70. {

  71. test: /\.json/,

  72. use: 'json-loader'

  73. }

  74. ]

  75. },

  76. performance: {

  77. maxEntrypointSize: 300000,

  78. hints: 'warning'

  79. },

  80. plugins: [

  81. new webpack.optimize.UglifyJsPlugin({

  82. compress: { warnings: false }

  83. }),

  84. new ExtractTextPlugin({

  85. filename: 'common.[chunkhash].css'

  86. })

  87. ]

  88. }

webpack.client.config.js 配置文件

  1. const path = require('path')

  2. const webpack = require('webpack')

  3. const merge = require('webpack-merge')

  4. const base = require('./webpack.base.config')

  5. const glob = require('glob')

  6. const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')

  7. const config = merge(base, {

  8. entry: {

  9. app: './src/client-entry.js'

  10. },

  11. resolve: {

  12. alias: {

  13. 'create-api': './create-api-client.js'

  14. }

  15. },

  16. plugins: [

  17. new webpack.DefinePlugin({

  18. 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),

  19. 'process.env.VUE_ENV': '"client"',

  20. 'process.env.DEBUG_API': '"true"'

  21. }),

  22. new webpack.optimize.CommonsChunkPlugin({

  23. name: 'vendor',

  24. minChunks: function (module) {

  25. return (

  26. /node_modules/.test(module.context) && !/\.css$/.test(module.require)

  27. )

  28. }

  29. }),

  30. new webpack.optimize.CommonsChunkPlugin({

  31. name: 'manifest'

  32. }),

  33. // 这是将服务器的整个输出

  34. // 构建为单个 JSON 文件的插件。

  35. // 默认文件名为 `vue-ssr-server-bundle.json`

  36. new VueSSRClientPlugin()

  37. ]

  38. })

  39. module.exports = config

webpack.server.config.js 配置文件

  1. const path = require('path')

  2. const webpack = require('webpack')

  3. const merge = require('webpack-merge')

  4. const base = require('./webpack.base.config')

  5. const nodeExternals = require('webpack-node-externals')

  6. const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')

  7. module.exports = merge(base, {

  8. target: 'node',

  9. devtool: '#source-map',

  10. entry: './src/server-entry.js',

  11. output: {

  12. filename: 'server-bundle.js',

  13. libraryTarget: 'commonjs2'

  14. },

  15. resolve: {

  16. alias: {

  17. 'create-api': './create-api-server.js'

  18. }

  19. },

  20. externals: nodeExternals({

  21. whitelist: /\.css$/

  22. }),

  23. plugins: [

  24. new webpack.DefinePlugin({

  25. 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),

  26. 'process.env.VUE_ENV': '"server"'

  27. }),

  28. new VueSSRServerPlugin()

  29. ]

  30. })

webpack 配置完成,其实东西也不多,都是常规配置。需要注意的是 webpack.server.config.js 配置,output是生成一个 commonjs 的 library, VueSSRServerPlugin 用于这是将服务器的整个输出构建为单个 JSON 文件的插件。

(2)、 webpack build poj

build 代码

  1. webpack --config build/webpack.client.config.js

  2. webpack --config build/webpack.server.config.js

打包后会生成一些打包文件,其中 server.config 打包后会生成 vue-ssr-server-bundle.json 文件,这个文件是给 createBundleRenderer 用的,用于服务端渲染出 html 文件

  1. const { createBundleRenderer } = require('vue-server-renderer')

  2. const renderer = createBundleRenderer('/path/to/vue-ssr-server-bundle.json', {

  3. // ……renderer 的其他选项

  4. })

细心的你还会发现 client.config 不仅生成了一下客服端用的到 js 文件,还会生成一份 vue-ssr-client-manifest.json 文件,这个文件是客户端构建清单,服务端拿到这份构建清单找到一下用于初始化的js脚步或css注入到 html 一起发给浏览器。

(3)、 服务端渲染

其实上面都是准备工作,最重要的一步是将webpack构建后的资源代码给服务端用来生成 html 。我们需要用node写一个服务端应用,通过打包后的资源生成 html 并发送给浏览器

server.js

  1. const fs = require('fs')

  2. const path = require('path')

  3. const Koa = require('koa')

  4. const KoaRuoter = require('koa-router')

  5. const serve = require('koa-static')

  6. const { createBundleRenderer } = require('vue-server-renderer')

  7. const LRU = require('lru-cache')

  8. const resolve = file => path.resolve(__dirname, file)

  9. const app = new Koa()

  10. const router = new KoaRuoter()

  11. const template = fs.readFileSync(resolve('./src/index.template.html'), 'utf-8')

  12. function createRenderer (bundle, options) {

  13. return createBundleRenderer(

  14. bundle,

  15. Object.assign(options, {

  16. template,

  17. cache: LRU({

  18. max: 1000,

  19. maxAge: 1000 * 60 * 15

  20. }),

  21. basedir: resolve('./dist'),

  22. runInNewContext: false

  23. })

  24. )

  25. }

  26. let renderer

  27. const bundle = require('./dist/vue-ssr-server-bundle.json')

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

  29. renderer = createRenderer(bundle, {

  30. clientManifest

  31. })

  32. /**

  33. * 渲染函数

  34. * [@param](https://my.oschina.net/u/2303379) ctx

  35. * [@param](https://my.oschina.net/u/2303379) next

  36. * [@returns](https://my.oschina.net/u/3935246) {Promise}

  37. */

  38. function render (ctx, next) {

  39. ctx.set("Content-Type", "text/html")

  40. return new Promise (function (resolve, reject) {

  41. const handleError = err => {

  42. if (err && err.code === 404) {

  43. ctx.status = 404

  44. ctx.body = '404 | Page Not Found'

  45. } else {

  46. ctx.status = 500

  47. ctx.body = '500 | Internal Server Error'

  48. console.error(`error during render : ${ctx.url}`)

  49. console.error(err.stack)

  50. }

  51. resolve()

  52. }

  53. const context = {

  54. title: 'Vue Ssr 2.3',

  55. url: ctx.url

  56. }

  57. renderer.renderToString(context, (err, html) => {

  58. if (err) {

  59. return handleError(err)

  60. }

  61. console.log(html)

  62. ctx.body = html

  63. resolve()

  64. })

  65. })

  66. }

  67. app.use(serve('/dist', './dist', true))

  68. app.use(serve('/public', './public', true))

  69. router.get('*', render)

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

  71. const port = process.env.PORT || 8089

  72. app.listen(port, '0.0.0.0', () => {

  73. console.log(`server started at localhost:${port}`)

  74. })

这里我们用到了最开始 demo 用到的 vue-server-renderer npm 包,通过读取 vue-ssr-server-bundle.json 和 vue-ssr-client-manifest.json 文件 renderer 出 html,最后 ctx.body = html 发送给浏览器, 我们试着console.log(html) 出 html 看看服务端到底渲染出了何方神圣:

  1. <!DOCTYPE html>

  2. <html lang="zh_CN">

  3. <head>

  4. <title>Vue Ssr 2.3</title>

  5. <meta charset="utf-8"/>

  6. <meta name="mobile-web-app-capable" content="yes"/>

  7. <meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1"/>

  8. <meta name="renderer" content="webkit"/>

  9. <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"/>

  10. <meta name="theme-color" content="#f60"/>

  11. <link rel="preload" href="https://my.oschina.net/dist/manifest-56dda86c1b6ac68c0279.js" as="script"><link rel="preload" href="https://my.oschina.net/dist/vendor-3504d51340141c3804a1.js" as="script"><link rel="preload" href="https://my.oschina.net/dist/app-ae1871b21fa142b507e8.js" as="script"><style data-vue-ssr-id="41a1d6f9:0">

  12. .link {

  13. margin: 10px;

  14. }

  15. </style><style data-vue-ssr-id="7add03b4:0"></style></head>

  16. <body>

  17. <div id="app" data-server-rendered="true">

  18. <h1>vue-ssr</h1>

  19. <a href="https://my.oschina.net/comp1" class="link router-link-exact-active router-link-active">to comp1</a>

  20. <a href="https://my.oschina.net/comp2" class="link">to comp2</a>

  21. <section class="view">组件 1</section>

  22. </div>

  23. <script src="https://my.oschina.net/dist/manifest-56dda86c1b6ac68c0279.js" defer>

  24. </script>

  25. <script src="https://my.oschina.net/dit/vendor-3504d51340141c3804a1.js" defer></script>

  26. <script src="https://my.oschina.net/dist/app-ae1871b21fa142b507e8.js" defer></script>

  27. </body>

  28. </html>

可以看到服务端把路由下的 组件 1 也给渲染出来了,而不是让客服端去动态加载,其次是 html 也被注入了一些 <script 标签去加载对应的客户端资源。这里再多说一下,有的同学可能不理解,服务端渲染不就是最后输出 html 让浏览器渲染吗,怎么 html 还带 js 脚本,注意,服务端渲染出的 html 只是首次展示给用户的页面而已,用户后期操作页面处理数据还是需要 js 脚本去跑的,也就是 webpack 为什么要打包出一套服务端代码(用于渲染首次html用),一套客户端代码(用于后期交互和数据处理用)

四、小结

本篇简单了解了 vue ssr 的简单流程,上面例子的demo放在github 欢迎提 issue 和 star 。服务端渲染还有比较重要的一部分是首屏数据的获取渲染,一般页面展示都会有一些网络数据初始化,服务端渲染可以将这些数据获取到插入到 html ,由于这部份内容涉及到的知识点也不少,放在下次讲。

2018-5-28 更新

评论区提到的bug已经修复,如果有其他问题可以到github对应的工程下提 issues,github 的 issues 有 markdown 格式,方便问题描述和讨论,问题描述可以尽量清楚,方面作者排查问题原因

运行项目

  1. npm run install

  2. npm run build:client // 生成 clientBundle

  3. npm run build:server // 生成 serverBundle

  4. npm run dev // 启动 node 渲染服务

点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
待兔 待兔
4个月前
手写Java HashMap源码
HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22
Jacquelyn38 Jacquelyn38
3年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
Stella981 Stella981
3年前
KVM调整cpu和内存
一.修改kvm虚拟机的配置1、virsheditcentos7找到“memory”和“vcpu”标签,将<namecentos7</name<uuid2220a6d1a36a4fbb8523e078b3dfe795</uuid
Easter79 Easter79
3年前
Twitter的分布式自增ID算法snowflake (Java版)
概述分布式系统中,有一些需要使用全局唯一ID的场景,这种时候为了防止ID冲突可以使用36位的UUID,但是UUID有一些缺点,首先他相对比较长,另外UUID一般是无序的。有些时候我们希望能使用一种简单一些的ID,并且希望ID能够按照时间有序生成。而twitter的snowflake解决了这种需求,最初Twitter把存储系统从MySQL迁移
Wesley13 Wesley13
3年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Stella981 Stella981
3年前
Django中Admin中的一些参数配置
设置在列表中显示的字段,id为django模型默认的主键list_display('id','name','sex','profession','email','qq','phone','status','create_time')设置在列表可编辑字段list_editable
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
Python进阶者 Python进阶者
10个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这