详解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 相关文章推荐
Extjs学习笔记之四 工具栏和菜单
Jan 07 Javascript
jquery 页面滚动到底部自动加载插件集合
Jan 31 Javascript
DOM基础教程之事件对象
Jan 20 Javascript
jquery实现Li滚动时滚动条自动添加样式的方法
Aug 10 Javascript
基于jQuery实现响应式圆形图片轮播特效
Nov 25 Javascript
jQuery制作图片旋转效果
Feb 02 Javascript
js获取当前周、上一周、下一周日期
Mar 19 Javascript
js实现随机数字字母验证码
Jun 19 Javascript
把JavaScript代码改成ES6语法不完全指南(分享)
Sep 10 Javascript
vue 组件的封装之基于axios的ajax请求方法
Aug 11 Javascript
Vue 3.0双向绑定原理的实现方法
Oct 23 Javascript
Js数组扁平化实现方法代码总汇
Nov 11 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
php模板之Phpbean的目录结构
2008/01/10 PHP
PHP过滤★等特殊符号的正则
2014/01/27 PHP
分享下页面关键字抓取components.arrow.com站点代码
2014/01/30 PHP
javascript中的location用法简单介绍
2007/03/07 Javascript
多种方法实现load加载完成后把图片一次性显示出来
2014/02/19 Javascript
基于socket.io和node.js搭建即时通信系统
2014/07/30 Javascript
JavaScript的9种继承实现方式归纳
2015/05/18 Javascript
js实现按钮颜色渐变动画效果
2015/08/20 Javascript
jQuery实现iframe父窗体和子窗体的相互调用
2016/06/17 Javascript
基于JavaScript实现点击页面任何位置返回
2016/08/31 Javascript
jQuery validate插件功能与用法详解
2016/12/15 Javascript
vue2+el-menu实现路由跳转及当前项的设置方法实例
2017/11/07 Javascript
vue中npm包全局安装和局部安装过程
2019/09/03 Javascript
vue props 单项数据流实例分享
2020/02/16 Javascript
js实现查询商品案例
2020/07/22 Javascript
js实现轮播图效果 纯js实现图片自动切换
2020/08/09 Javascript
[04:40]2016个国际邀请赛中国区预选赛场地——华西村观战指南
2016/06/25 DOTA
[01:00:53]OG vs IG 2018国际邀请赛小组赛BO2 第一场 8.18
2018/08/19 DOTA
在Python的Django框架中编写编译函数
2015/07/20 Python
python3+selenium自动化测试框架详解
2019/03/17 Python
Mac安装python3的方法步骤
2019/08/09 Python
python如何使用Redis构建分布式锁
2020/01/16 Python
torchxrayvision包安装过程(附pytorch1.6cpu版安装)
2020/08/26 Python
在HTML5 canvas里用卷积核进行图像处理的方法
2018/05/02 HTML / CSS
基于canvas的骨骼动画的示例代码
2018/06/12 HTML / CSS
使用html5实现表格实现标题合并的实例代码
2019/05/13 HTML / CSS
C面试题
2015/10/08 面试题
医药工作者的求职信范文
2013/09/21 职场文书
服务生自我鉴定
2014/01/22 职场文书
简历里的自我评价
2014/01/31 职场文书
小学音乐教学反思
2014/02/05 职场文书
高中教师考核方案
2014/05/18 职场文书
社区班子个人对照检查材料思想汇报
2014/10/07 职场文书
医院党的群众路线教育实践活动学习心得体会
2014/10/30 职场文书
科级干部培训心得体会
2016/01/06 职场文书
python基于turtle绘制几何图形
2021/06/15 Python