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

栏目: Node.js · 发布时间: 7年前

内容简介:正如Vue官方所说,SSR配置适合已经熟悉 Vue, webpack 和 Node.js 开发的开发者阅读。请先移步ssr.vuejs.org 了解手工进行SSR配置的基本内容。从头搭建一个服务端渲染的应用是相当复杂的。如果您有SSR需求,对Webpack及Koa不是很熟悉,请直接使用NUXT.js。本文所述内容示例在

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

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

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

我们以撰写本文时的最新版: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 中创建 apiweb 目录。效仿 vue-cli ,在根目录下创建 public 目录用于存放根目录下的静态资源文件。

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

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

配置 package.jsonscripts

"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))
}
复制代码

由于篇幅限制, koalib 目录下的文件参考示例代码。其中 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配置完成。

原创内容。文章来源:www.wyr.me/post/593


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Pro Git

Pro Git

Scott Chacon / Apress / 2009-8-27 / USD 34.99

Git is the version control system developed by Linus Torvalds for Linux kernel development. It took the open source world by storm since its inception in 2005, and is used by small development shops a......一起来看看 《Pro Git》 这本书的介绍吧!

JS 压缩/解压工具
JS 压缩/解压工具

在线压缩/解压 JS 代码

URL 编码/解码
URL 编码/解码

URL 编码/解码

SHA 加密
SHA 加密

SHA 加密工具