当前位置: 移动技术网 > IT编程>脚本编程>vue.js > 详解Vue SSR( Vue2 + Koa2 + Webpack4)配置指南

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

2018年11月26日  | 移动技术网IT编程  | 我要评论

正如vue官方所说,ssr配置适合已经熟悉 vue, webpack 和 node.js 开发的开发者阅读。请先移步了解手工进行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 || ''

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

  if (ctx.method === 'head' || ctx.method === 'get') {
  if (ctx.path === '/' || ctx.path === '/') { // exclude  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/'), '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 模块来压缩生产环境的 文件( yarn add html-minifier )。其余配置和官方给出的差不多,不再赘述。只不过promise返回的是 require('http').createserver(app.callback()) (详见源码)。这样做的目的是为了共用一个koa2实例。此外,这里拦截了 /api 开头的请求,将请求交由api server进行处理(因在同一个koa2实例,这里直接next()了)。在 public 目录下必须存在 文件:

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

 // 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(' 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 目录时并不包含 文件。

编译脚本:

"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配置完成。希望对大家的学习有所帮助,也希望大家多多支持移动技术网。

如对本文有疑问, 点击进行留言回复!!

相关文章:

验证码:
移动技术网