轶哥

📚 Having fun with AI Agent. Always learning.

    Vue SSR( Vue2 + Koa2 + Webpack4)配置指南
    •   更新:2018-11-12 00:28:16
    •   首发:2018-11-12 00:28:16
    •   源代码
    •   7796

    正如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中创建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配置完成。

    打赏
    交流区

    暂无内容

    尚未登陆
    发布
      上一篇 (Nginx 解决API跨域问题)
    下一篇 (巧用Koa接管“对接微信开发”的工作 - 多用户微信JS-SDK A)  

    评论回复提醒