详解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 相关文章推荐
JS 事件绑定函数代码
Apr 28 Javascript
基于jquery的web页面日期格式化插件
Nov 15 Javascript
jQuery判断密码强度实现思路及代码
Apr 24 Javascript
原生JS可拖动弹窗效果实例代码
Nov 09 Javascript
jQuery toggleClass应用实例(附效果图)
Apr 06 Javascript
jq实现左滑显示删除按钮,点击删除实现删除数据功能(推荐)
Aug 23 Javascript
jQuery遮罩层实例讲解
May 11 jQuery
jQuery md5加密插件jQuery.md5.js用法示例
Aug 24 jQuery
vue计算属性computed的使用方法示例
Mar 13 Javascript
Vue实现导航栏的显示开关控制
Nov 01 Javascript
如何实现js拖拽效果及原理解析
May 08 Javascript
OpenLayers3实现地图鹰眼以及地图比例尺的添加
Sep 25 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
地摊中国 - 珍藏老照片
2020/08/18 杂记
php session应用实例 登录验证
2009/03/16 PHP
PHP 变量类型的强制转换
2009/10/23 PHP
php将金额数字转化为中文大写
2015/07/09 PHP
ThinkPHP实现生成和校验验证码功能
2017/04/28 PHP
用roll.js实现的图片自动滚动+鼠标触动的特效
2007/03/18 Javascript
js动态创建、删除表格示例代码
2013/08/07 Javascript
用jquery的方法制作一个简单的导航栏
2014/06/23 Javascript
javascript的push使用指南
2014/12/05 Javascript
jQuery实现加入购物车飞入动画效果
2015/03/14 Javascript
javascript常用函数(2)
2015/11/05 Javascript
AngularJS 使用 UI Router 实现表单向导
2016/01/29 Javascript
JS判断元素是否在数组内的实现代码
2016/03/30 Javascript
JavaScript学习小结之使用canvas画“哆啦A梦”时钟
2016/07/24 Javascript
基于JavaScript实现鼠标向下滑动加载div的代码
2016/08/31 Javascript
3分钟掌握常用的JS操作JSON方法总结
2017/04/25 Javascript
js实现鼠标跟随运动效果
2020/08/02 Javascript
bootstrap里bootstrap动态加载下拉框的实例讲解
2018/08/10 Javascript
vue中的watch监听数据变化及watch中各属性的详解
2018/09/11 Javascript
Javascript实现秒表倒计时功能
2018/11/17 Javascript
基于JavaScript实现简单扫雷游戏
2021/01/02 Javascript
[38:21]2018DOTA2亚洲邀请赛3月30日 小组赛A组 LGD VS Newbee
2018/03/31 DOTA
用python分割TXT文件成4K的TXT文件
2009/05/23 Python
Python查找相似单词的方法
2015/03/05 Python
在django-xadmin中APScheduler的启动初始化实例
2019/11/15 Python
Python 列表中的修改、添加和删除元素的实现
2020/06/11 Python
如何基于Python爬取隐秘的角落评论
2020/07/02 Python
Expedia马来西亚旅游网站:廉价酒店,度假村和航班预订
2016/07/26 全球购物
中文系师范生自荐信
2013/10/01 职场文书
实习生自我鉴定范文
2013/12/05 职场文书
商场拾金不昧表扬信
2014/01/13 职场文书
优秀士兵先进事迹
2014/02/06 职场文书
电子工程求职信
2014/07/17 职场文书
关于感恩的作文
2019/08/26 职场文书
导游词之珠海轮廓
2019/10/25 职场文书
详解Python生成器和基于生成器的协程
2021/06/03 Python