详解Vue SSR( Vue2 + Koa2 + Webpack4)配置指南


Posted in Javascript onNovember 13, 2018

正如Vue官方所说,SSR配置适合已经熟悉 Vue, webpack 和 Node.js 开发的开发者阅读。请先移步ssr.vuejs.org 了解手工进行SSR配置的基本内容。

从头搭建一个服务端渲染的应用是相当复杂的。如果您有SSR需求,对Webpack及Koa不是很熟悉,请直接使用NUXT.js。

本文所述内容示例在 Vue SSR Koa2 脚手架 : https://github.com/yi-ge/Vue-SSR-Koa2-Scaffold

我们以撰写本文时的最新版:Vue 2,Webpack 4,Koa 2为例。

特别说明

此文描述的是API与WEB同在一个项目的情况下进行的配置,且API、SSR Server、Static均使用了同一个Koa示例,目的是阐述配置方法,所有的报错显示在一个终端,方便调试。

初始化项目

git init
yarn init
touch .gitignore

在 .gitignore 文件,将常见的目录放于其中。

.DS_Store
node_modules

# 编译后的文件以下两个目录
/dist/web
/dist/api

# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*

根据经验来预先添加肯定会用到的依赖项:

echo "yarn add cross-env # 跨平台的环境变量设置工具
 koa
 koa-body # 可选,推荐
 koa-compress # 压缩数据
 compressible # https://github.com/jshttp/compressible
 axios # 此项目作为API请求工具
 es6-promise 
 vue
 vue-router # vue 路由 注意,SSR必选
 vuex # 可选,但推荐使用,本文基于此做Vuex在SSR的优化
 vue-template-compiler
 vue-server-renderer # 关键
 lru-cache # 配合上面一个插件缓存数据
 vuex-router-sync" | sed 's/#[[:space:]].*//g' | tr '\n' ' ' | sed 's/[ ][ ]*/ /g' | bash

echo "yarn add -D webpack
 webpack-cli
 webpack-dev-middleware # 关键
 webpack-hot-middleware # 关键
 webpack-merge # 合并多个Webpack配置文件的配置
 webpack-node-externals # 不打包node_modules里面的模块
 friendly-errors-webpack-plugin # 显示友好的错误提示插件
 case-sensitive-paths-webpack-plugin # 无视路径大小写插件
 copy-webpack-plugin # 用于拷贝文件的Webpack插件
 mini-css-extract-plugin # CSS压缩插件
 chalk # console着色
 @babel/core # 不解释
 babel-loader
 @babel/plugin-syntax-dynamic-import # 支持动态import
 @babel/plugin-syntax-jsx # 兼容JSX写法
 babel-plugin-syntax-jsx # 不重复,必须的
 babel-plugin-transform-vue-jsx
 babel-helper-vue-jsx-merge-props
 @babel/polyfill
 @babel/preset-env
 file-loader
 json-loader
 url-loader
 css-loader
 vue-loader
 vue-style-loader
 vue-html-loader" | sed 's/#[[:space:]].*//g' | tr '\n' ' ' | sed 's/[ ][ ]*/ /g' | bash

现在的npm模块命名越来越语义化,基本上都是见名知意。关于Eslint以及Stylus、Less等CSS预处理模块我没有添加,其不是本文研究的重点,况且既然您在阅读本文,这些配置相信早已不在话下了。

效仿 electorn 分离main及renderer,在 src 中创建 api 及 web 目录。效仿 vue-cli ,在根目录下创建 public 目录用于存放根目录下的静态资源文件。

|-- public # 静态资源
|-- src
 |-- api # 后端代码
 |-- web # 前端代码

譬如 NUXT.js ,前端服务器代理API进行后端渲染,我们的配置可以选择进行一层代理,也可以配置减少这层代理,直接返回渲染结果。通常来说,SSR的服务器端渲染只渲染首屏,因此API服务器最好和前端服务器在同一个内网。

配置 package.json 的 scripts :

"scripts": {
 "serve": "cross-env NODE_ENV=development node config/server.js",
 "start": "cross-env NODE_ENV=production node config/server.js"
}
  • yarn serve : 启动开发调试
  • yarn start : 运行编译后的程序
  • config/app.js 导出一些常见配置:
module.exports = {
 app: {
 port: 3000, // 监听的端口
 devHost: 'localhost', // 开发环境下打开的地址,监听了0.0.0.0,但是不是所有设备都支持访问这个地址,用127.0.0.1或localhost代替
 open: true // 是否打开浏览器
 }
}

配置SSR

我们以Koa作为调试和实际运行的服务器框架, config/server.js :

const path = require('path')
const Koa = req uire('koa')
const koaCompress = require('koa-compress')
const compressible = require('compressible')
const koaStatic = require('./koa/static')
const SSR = require('./ssr')
const conf = require('./app')

const isProd = process.env.NODE_ENV === 'production'

const app = new Koa()

app.use(koaCompress({ // 压缩数据
 filter: type => !(/event\-stream/i.test(type)) && compressible(type) // eslint-disable-line
}))

app.use(koaStatic(isProd ? path.resolve(__dirname, '../dist/web') : path.resolve(__dirname, '../public'), {
 maxAge: 30 * 24 * 60 * 60 * 1000
})) // 配置静态资源目录及过期时间

// vue ssr处理,在SSR中处理API
SSR(app).then(server => {
 server.listen(conf.app.port, '0.0.0.0', () => {
 console.log(`> server is staring...`)
 })
})

上述文件我们根据是否是开发环境,配置了对应的静态资源目录。需要说明的是,我们约定编译后的API文件位于 dist/api ,前端文件位于 dist/web 。

参考 koa-static 实现静态资源的处理, config/koa/static.js :

'use strict'

/**
 * From koa-static
 */

const { resolve } = require('path')
const assert = require('assert')
const send = require('koa-send')

/**
 * Expose `serve()`.
 */

module.exports = serve

/**
 * Serve static files from `root`.
 *
 * @param {String} root
 * @param {Object} [opts]
 * @return {Function}
 * @api public
 */

function serve (root, opts) {
 opts = Object.assign({}, opts)

 assert(root, 'root directory is required to serve files')

 // options
 opts.root = resolve(root)
 if (opts.index !== false) opts.index = opts.index || 'index.html'

 if (!opts.defer) {
 return async function serve (ctx, next) {
  let done = false

  if (ctx.method === 'HEAD' || ctx.method === 'GET') {
  if (ctx.path === '/' || ctx.path === '/index.html') { // exclude index.html file
   await next()
   return
  }
  try {
   done = await send(ctx, ctx.path, opts)
  } catch (err) {
   if (err.status !== 404) {
   throw err
   }
  }
  }

  if (!done) {
  await next()
  }
 }
 }

 return async function serve (ctx, next) {
 await next()

 if (ctx.method !== 'HEAD' && ctx.method !== 'GET') return
 // response is already handled
 if (ctx.body != null || ctx.status !== 404) return // eslint-disable-line

 try {
  await send(ctx, ctx.path, opts)
 } catch (err) {
  if (err.status !== 404) {
  throw err
  }
 }
 }
}

我们可以看到, koa-static 仅仅是对 koa-send 进行了简单封装( yarn add koa-send )。接下来就是重头戏SSR相关的配置了, config/ssr.js :

const fs = require('fs')
const path = require('path')
const chalk = require('chalk')
const LRU = require('lru-cache')
const {
 createBundleRenderer
} = require('vue-server-renderer')
const isProd = process.env.NODE_ENV === 'production'
const setUpDevServer = require('./setup-dev-server')
const HtmlMinifier = require('html-minifier').minify

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

module.exports = app => {
 return new Promise((resolve, reject) => {
 const createRenderer = (bundle, options) => {
  return createBundleRenderer(bundle, Object.assign(options, {
  cache: LRU({
   max: 1000,
   maxAge: 1000 * 60 * 15
  }),
  basedir: pathResolve('../dist/web'),
  runInNewContext: false
  }))
 }

 let renderer = null
 if (isProd) {
  // prod mode
  const template = HtmlMinifier(fs.readFileSync(pathResolve('../public/index.html'), 'utf-8'), {
  collapseWhitespace: true,
  removeAttributeQuotes: true,
  removeComments: false
  })
  const bundle = require(pathResolve('../dist/web/vue-ssr-server-bundle.json'))
  const clientManifest = require(pathResolve('../dist/web/vue-ssr-client-manifest.json'))
  renderer = createRenderer(bundle, {
  template,
  clientManifest
  })
 } else {
  // dev mode
  setUpDevServer(app, (bundle, options, apiMain, apiOutDir) => {
  try {
   const API = eval(apiMain).default // eslint-disable-line
   const server = API(app)
   renderer = createRenderer(bundle, options)
   resolve(server)
  } catch (e) {
   console.log(chalk.red('\nServer error'), e)
  }
  })
 }

 app.use(async (ctx, next) => {
  if (!renderer) {
  ctx.type = 'html'
  ctx.body = 'waiting for compilation... refresh in a moment.'
  next()
  return
  }

  let status = 200
  let html = null
  const context = {
  url: ctx.url,
  title: 'OK'
  }

  if (/^\/api/.test(ctx.url)) { // 如果请求以/api开头,则进入api部分进行处理。
  next()
  return
  }

  try {
  status = 200
  html = await renderer.renderToString(context)
  } catch (e) {
  if (e.message === '404') {
   status = 404
   html = '404 | Not Found'
  } else {
   status = 500
   console.log(chalk.red('\nError: '), e.message)
   html = '500 | Internal Server Error'
  }
  }
  ctx.type = 'html'
  ctx.status = status || ctx.status
  ctx.body = html
  next()
 })

 if (isProd) {
  const API = require('../dist/api/api').default
  const server = API(app)
  resolve(server)
 }
 })
}

这里新加入了 html-minifier 模块来压缩生产环境的 index.html 文件( yarn add html-minifier )。其余配置和官方给出的差不多,不再赘述。只不过Promise返回的是 require('http').createServer(app.callback()) (详见源码)。这样做的目的是为了共用一个koa2实例。此外,这里拦截了 /api 开头的请求,将请求交由API Server进行处理(因在同一个Koa2实例,这里直接next()了)。在 public 目录下必须存在 index.html 文件:

<!DOCTYPE html>
<html lang="zh-cn">
<head>
 <title>{{ title }}</title>
 ...
</head>
<body>
 <!--vue-ssr-outlet-->
</body>
</html>

开发环境中,处理数据的核心在 config/setup-dev-server.js 文件:

const fs = require('fs')
const path = require('path')
const chalk = require('chalk')
const MFS = require('memory-fs')
const webpack = require('webpack')
const chokidar = require('chokidar')
const apiConfig = require('./webpack.api.config')
const serverConfig = require('./webpack.server.config')
const webConfig = require('./webpack.web.config')
const webpackDevMiddleware = require('./koa/dev')
const webpackHotMiddleware = require('./koa/hot')
const readline = require('readline')
const conf = require('./app')
const {
 hasProjectYarn,
 openBrowser
} = require('./lib')

const readFile = (fs, file) => {
 try {
 return fs.readFileSync(path.join(webConfig.output.path, file), 'utf-8')
 } catch (e) {}
}

module.exports = (app, cb) => {
 let apiMain, bundle, template, clientManifest, serverTime, webTime, apiTime
 const apiOutDir = apiConfig.output.path
 let isFrist = true

 const clearConsole = () => {
 if (process.stdout.isTTY) {
  // Fill screen with blank lines. Then move to 0 (beginning of visible part) and clear it
  const blank = '\n'.repeat(process.stdout.rows)
  console.log(blank)
  readline.cursorTo(process.stdout, 0, 0)
  readline.clearScreenDown(process.stdout)
 }
 }

 const update = () => {
 if (apiMain && bundle && template && clientManifest) {
  if (isFrist) {
  const url = 'http://' + conf.app.devHost + ':' + conf.app.port
  console.log(chalk.bgGreen.black(' DONE ') + ' ' + chalk.green(`Compiled successfully in ${serverTime + webTime + apiTime}ms`))
  console.log()
  console.log(` App running at: ${chalk.cyan(url)}`)
  console.log()
  const buildCommand = hasProjectYarn(process.cwd()) ? `yarn build` : `npm run build`
  console.log(` Note that the development build is not optimized.`)
  console.log(` To create a production build, run ${chalk.cyan(buildCommand)}.`)
  console.log()
  if (conf.app.open) openBrowser(url)
  isFrist = false
  }
  cb(bundle, {
  template,
  clientManifest
  }, apiMain, apiOutDir)
 }
 }

 // server for api
 apiConfig.entry.app = ['webpack-hot-middleware/client?path=/__webpack_hmr&timeout=2000&reload=true', apiConfig.entry.app]
 apiConfig.plugins.push(
 new webpack.HotModuleReplacementPlugin(),
 new webpack.NoEmitOnErrorsPlugin()
 )
 const apiCompiler = webpack(apiConfig)
 const apiMfs = new MFS()
 apiCompiler.outputFileSystem = apiMfs
 apiCompiler.watch({}, (err, stats) => {
 if (err) throw err
 stats = stats.toJson()
 if (stats.errors.length) return
 console.log('api-dev...')
 apiMfs.readdir(path.join(__dirname, '../dist/api'), function (err, files) {
  if (err) {
  return console.error(err)
  }
  files.forEach(function (file) {
  console.info(file)
  })
 })
 apiMain = apiMfs.readFileSync(path.join(apiConfig.output.path, 'api.js'), 'utf-8')
 update()
 })
 apiCompiler.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

 apiTime = stats.time
 // console.log('web-dev')
 // update()
 })

 // web server for ssr
 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
 // console.log('server-dev...')
 bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
 update()
 })
 serverCompiler.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

 serverTime = stats.time
 })

 // web
 webConfig.entry.app = ['webpack-hot-middleware/client?path=/__webpack_hmr&timeout=2000&reload=true', webConfig.entry.app]
 webConfig.output.filename = '[name].js'
 webConfig.plugins.push(
 new webpack.HotModuleReplacementPlugin(),
 new webpack.NoEmitOnErrorsPlugin()
 )
 const clientCompiler = webpack(webConfig)
 const devMiddleware = webpackDevMiddleware(clientCompiler, {
 // publicPath: webConfig.output.publicPath,
 stats: { // or 'errors-only'
  colors: true
 },
 reporter: (middlewareOptions, options) => {
  const { log, state, stats } = options

  if (state) {
  const displayStats = (middlewareOptions.stats !== false)

  if (displayStats) {
   if (stats.hasErrors()) {
   log.error(stats.toString(middlewareOptions.stats))
   } else if (stats.hasWarnings()) {
   log.warn(stats.toString(middlewareOptions.stats))
   } else {
   log.info(stats.toString(middlewareOptions.stats))
   }
  }

  let message = 'Compiled successfully.'

  if (stats.hasErrors()) {
   message = 'Failed to compile.'
  } else if (stats.hasWarnings()) {
   message = 'Compiled with warnings.'
  }
  log.info(message)

  clearConsole()

  update()
  } else {
  log.info('Compiling...')
  }
 },
 noInfo: true,
 serverSideRender: false
 })
 app.use(devMiddleware)

 const templatePath = path.resolve(__dirname, '../public/index.html')

 // read template from disk and watch
 template = fs.readFileSync(templatePath, 'utf-8')
 chokidar.watch(templatePath).on('change', () => {
 template = fs.readFileSync(templatePath, 'utf-8')
 console.log('index.html template updated.')
 update()
 })

 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'
 ))

 webTime = stats.time
 })
 app.use(webpackHotMiddleware(clientCompiler))
}

由于篇幅限制, koa 及 lib 目录下的文件参考示例代码。其中 lib 下的文件均来自 vue-cli ,主要用于判断用户是否使用 yarn 以及在浏览器中打开URL。 这时,为了适应上述功能的需要,需添加以下模块(可选):

yarn add memory-fs chokidar readline

yarn add -D opn execa

通过阅读 config/setup-dev-server.js 文件内容,您将发现此处进行了三个webpack配置的处理。

Server for API // 用于处理`/api`开头下的API接口,提供非首屏API接入的能力

Web server for SSR // 用于服务器端对API的代理请求,实现SSR

WEB // 进行常规静态资源的处理

Webpack 配置

|-- config
 |-- webpack.api.config.js // Server for API
 |-- webpack.base.config.js // 基础Webpack配置
 |-- webpack.server.config.js // Web server for SSR
 |-- webpack.web.config.js // 常规静态资源

由于Webpack的配置较常规Vue项目以及Node.js项目并没有太大区别,不再一一赘述,具体配置请翻阅源码。

值得注意的是,我们为API和WEB指定了别名:

alias: {
 '@': path.join(__dirname, '../src/web'),
 '~': path.join(__dirname, '../src/api'),
 'vue$': 'vue/dist/vue.esm.js'
},

此外, webpack.base.config.js 中设定编译时拷贝 public 目录下的文件到 dist/web 目录时并不包含 index.html 文件。

编译脚本:

"scripts": {
 ...
 "build": "rimraf dist && npm run build:web && npm run build:server && npm run build:api",
 "build:web": "cross-env NODE_ENV=production webpack --config config/webpack.web.config.js --progress --hide-modules",
 "build:server": "cross-env NODE_ENV=production webpack --config config/webpack.server.config.js --progress --hide-modules",
 "build:api": "cross-env NODE_ENV=production webpack --config config/webpack.api.config.js --progress --hide-modules"
},

执行 yarn build 进行编译。编译后的文件存于 /dist 目录下。正式环境请尽量分离API及SSR Server。

测试

执行 yarn serve (开发)或 yarn start (编译后)命令,访问 http://localhost:3000 。

通过查看源文件可以看到,首屏渲染结果是这样的:

~ curl -s http://localhost:3000/ | grep Hello
 <div id="app" data-server-rendered="true"><div>Hello World SSR</div></div>

至此,Vue SSR配置完成。希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
jquery 实现的全选和反选
Apr 15 Javascript
JS无限树状列表实现代码
Jan 11 Javascript
jQuery数据显示插件整合实现代码
Oct 24 Javascript
JQuery触发事件例如click
Sep 11 Javascript
AngularJS表单编辑提交功能实例
Feb 13 Javascript
JQuery选择器、过滤器大整理
May 26 Javascript
jQuery实现连续动画效果实例分析
Oct 09 Javascript
Vuejs第十一篇组件之slot内容分发实例详解
Sep 09 Javascript
vue2 前后端分离项目ajax跨域session问题解决方法
Apr 27 Javascript
Angular排序实例详解
Jun 28 Javascript
bootstrap 点击空白处popover弹出框隐藏实例
Jan 24 Javascript
使用react render props实现倒计时的示例代码
Dec 06 Javascript
详解Vue组件插槽的使用以及调用组件内的方法
Nov 13 #Javascript
Vue实现一个无限加载列表功能
Nov 13 #Javascript
Vue实现移动端页面切换效果【推荐】
Nov 13 #Javascript
vue中slot(插槽)的介绍与使用
Nov 12 #Javascript
vuex的module模块用法示例
Nov 12 #Javascript
React手稿之 React-Saga的详解
Nov 12 #Javascript
基于游标的分页接口实现代码示例
Nov 12 #Javascript
You might like
一个显示某段时间内每个月的方法 返回由这些月份组成的数组
2012/05/16 PHP
PHP生成随机用户名和密码的实现代码
2013/02/27 PHP
PHP的几个常用加密函数
2016/02/03 PHP
Jquery 复选框取值兼容FF和IE8(测试有效)
2013/10/29 Javascript
浅谈javascript六种数据类型以及特殊注意点
2013/12/20 Javascript
jQuery函数的第二个参数获取指定上下文中的DOM元素
2014/05/19 Javascript
jquery取消事件冒泡的三种方法(推荐)
2016/05/28 Javascript
AngularJS extend用法详解及实例代码
2016/11/15 Javascript
JavaScript数据结构链表知识详解
2016/11/21 Javascript
js实现登录框鼠标拖拽效果
2017/03/09 Javascript
Vue.js如何优雅的进行form validation
2017/04/07 Javascript
Vue.js通用应用框架-Nuxt.js的上手教程
2017/12/25 Javascript
nodejs操作mongodb的填删改查模块的制作及引入实例
2018/01/02 NodeJs
微信小程序项目实践之验证码倒计时功能
2018/07/18 Javascript
使用imba.io框架得到比 vue 快50倍的性能基准
2019/06/17 Javascript
JS组件库AlloyTouch实现图片轮播过程解析
2020/05/29 Javascript
Python进程间通信之共享内存详解
2017/10/30 Python
Django+Ajax+jQuery实现网页动态更新的实例
2018/05/28 Python
Python3中内置类型bytes和str用法及byte和string之间各种编码转换 问题
2018/09/27 Python
Python3实现对列表按元组指定列进行排序的方法分析
2018/12/22 Python
Django框架文件上传与自定义图片上传路径、上传文件名操作分析
2019/05/10 Python
python使用opencv实现马赛克效果示例
2019/09/28 Python
opencv3/Python 稠密光流calcOpticalFlowFarneback详解
2019/12/11 Python
详解从Django Allauth中进行登录改造小结
2019/12/18 Python
django中的数据库迁移的实现
2020/03/16 Python
python输出国际象棋棋盘的实例分享
2020/11/26 Python
CSS3 animation实现简易幻灯片轮播特效
2016/09/27 HTML / CSS
HTML5之SVG 2D入门3—文本与图像及渲染文本介绍
2013/01/30 HTML / CSS
C#和SQL Server的面试题
2016/08/12 面试题
面试求职的个人自我评价
2013/11/16 职场文书
毕业研究生的自我鉴定
2013/11/30 职场文书
餐饮业员工工作决心书
2014/03/11 职场文书
同意迁入证明模板
2014/10/26 职场文书
2014年仓库管理员工作总结
2014/11/18 职场文书
详解Redis集群搭建的三种方式
2021/05/31 Redis
缓存替换策略及应用(以Redis、InnoDB为例)
2021/07/25 Redis