Руководство по настройке Vue SSR (Vue2 + Koa2 + Webpack4)

внешний интерфейс сервер Vue.js Webpack

Как официально заявил Vue, конфигурация SSR подходит для разработчиков, которые уже знакомы с разработкой Vue, webpack и Node.js. Пожалуйста, двигайтесь первымssr.vuejs.orgИзучите основы ручной настройки SSR.

Создание серверного приложения с нуля довольно сложно. Если у вас есть требования SSR и вы не очень хорошо знакомы с Webpack и Koa, используйте его напрямую.NUXT.js.

Примеры того, что описано в этой статье, находятся вVue SSR Koa2 脚手架:GitHub.com/a-a/v UE-S…

Возьмем для примера последние версии на момент написания: Vue 2, Webpack 4, Koa 2.

Специальное примечание
В этой статье описывается конфигурация, когда API и WEB находятся в одном проекте, и один и тот же пример Koa используется для API, SSR Server и Static.Цель состоит в том, чтобы объяснить метод настройки.Все ошибки отображаются в одном терминале, который удобно для отладки.

Инициализировать проект

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Разделите основной и рендерер, в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 // 是否打开浏览器
  }
}

Настроить ССР

Мы используем 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). В остальном конфигурация аналогична официальной, поэтому не буду вдаваться в подробности. Просто обещание возвращаетсяrequire('http').createServer(app.callback())(подробности см. в исходном коде). Целью этого является совместное использование экземпляра koa2. Кроме того, здесь перехвачено/apiВ начале запроса запрос передается на сервер API для обработки (поскольку он находится в том же экземпляре 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содержимое файла, вы обнаружите, что здесь обрабатываются три конфигурации веб-пакета.

Server for API // 用于处理`/api`开头下的API接口,提供非首屏API接入的能力

Web server for SSR // 用于服务器端对API的代理请求,实现SSR

WEB // 进行常规静态资源的处理

Конфигурация веб-пакета

|-- 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 как можно больше в формальной среде.

тестовое задание

воплощать в жизнь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