1. vue-cli-service 解决什么问题?

根据官方文档的描述,vue-cli-servicevue-cli 的运行时依赖。它可以:

  1. 基于webpack构建,内置了合理的默认配置;
  2. 可以通过配置文件进行配置webpack;
  3. 可以通过插件扩展 vue-cli-service 的命令

2. 带着问题看源码

  1. vue-cli-service 主要流程是怎样的?
  2. vue-cli-service servevue-cli-service build 做了什么?帮我们预设了哪些webpack配置?
  3. vue-cli-service 如何做到webpack配置的缝合的?缝合的顺序又是怎样的?
  4. 如何注册一个命令?

3. vue-cli-service 主要流程是怎样的?

3.1 目录结构

├─lib
|  ├─options.js
|  ├─PluginAPI.js
|  ├─Service.js
|  ├─config
|  |   ├─app.js
|  |   ├─base.js
|  |   ├─css.js
|  |   ├─index-default.html
|  |   ├─prod.js
|  |   └terserOptions.js
|  ├─commands
|  |    ├─help.js
|  |    ├─inspect.js
|  |    ├─serve.js
|  |    ├─build
|  |    |   ├─index.js
|  |    |   ├─resolveAppConfig.js
|  |    |   ├─resolveLibConfig.js
|  |    |   ├─resolveWcConfig.js
|  |    |   ├─resolveWcEntry.js
├─bin
|  └vue-cli-service.js

注意:这里只展示了部分目录和文件,为了直观显示本文需要讲述的文件以及所在的目录。

3.2 查看入口文件 vue-cli-service.js

// packages\@vue\cli-service\bin\vue-cli-service.js

#!/usr/bin/env node

const { semver, error } = require('@vue/cli-shared-utils')
const requiredVersion = require('../package.json').engines.node

if (!semver.satisfies(process.version, requiredVersion, { includePrerelease: true })) {
  error(
    `You are using Node ${process.version}, but vue-cli-service ` +
    `requires Node ${requiredVersion}.\nPlease upgrade your Node version.`
  )
  process.exit(1)
}

const Service = require('../lib/Service')
const service = new Service(process.env.VUE_CLI_CONTEXT || process.cwd())

const rawArgv = process.argv.slice(2)
const args = require('minimist')(rawArgv, {
  boolean: [
    // build
    // FIXME: --no-module, --no-unsafe-inline, no-clean, etc.
    'modern',
    'report',
    'report-json',
    'inline-vue',
    'watch',
    // serve
    'open',
    'copy',
    'https',
    // inspect
    'verbose'
  ]
})
const command = args._[0]

service.run(command, args, rawArgv).catch(err => {
  error(err)
  process.exit(1)
})

从入口可以看出,vue-cli-service 的核心是使用了 Service 类,实例化并调用run方法。下面我们看看 Service 类。

3.2 Service类

// packages\@vue\cli-service\lib\Service.js
// ...
module.exports = class Service {
  constructor (context, { plugins, pkg, inlineOptions, useBuiltIn } = {}) {
    process.VUE_CLI_SERVICE = this
    this.initialized = false
    // 项目的工作路径
    this.context = context
    this.inlineOptions = inlineOptions
    // webpack配置相关
    this.webpackChainFns = []
    // webpack配置相关
    this.webpackRawConfigFns = []
    this.devServerConfigFns = []
    this.commands = {}
    // Folder containing the target package.json for plugins
    this.pkgContext = context
    // package.json containing the plugins
    // 解析package.json
    this.pkg = this.resolvePkg(pkg)
    // If there are inline plugins, they will be used instead of those
    // found in package.json.
    // When useBuiltIn === false, built-in plugins are disabled. This is mostly
    // for testing.
    // 解析插件
    this.plugins = this.resolvePlugins(plugins, useBuiltIn)
    // pluginsToSkip will be populated during run()
    // 需要跳过的插件
    this.pluginsToSkip = new Set()
    // resolve the default mode to use for each command
    // this is provided by plugins as module.exports.defaultModes
    // so we can get the information without actually applying the plugin.
    // 模式,基于每个插件设置的defaultModes来决定
    this.modes = this.plugins.reduce((modes, { apply: { defaultModes } }) => {
      return Object.assign(modes, defaultModes)
    }, {})
  }
// ...

Service构造函数,初始化了需要的变量。下面重点看看 this.resolvePlugins 是如何解析插件的。

3.3 Service类方法 resolvePlugins

// packages\@vue\cli-service\lib\Service.js

  resolvePlugins (inlinePlugins, useBuiltIn) {
    const idToPlugin = (id, absolutePath) => ({
      id: id.replace(/^.\//, 'built-in:'),
      apply: require(absolutePath || id)
    })

    let plugins
    // 内建插件
    const builtInPlugins = [
      './commands/serve',
      './commands/build',
      './commands/inspect',
      './commands/help',
      // config plugins are order sensitive
      './config/base',
      './config/assets',
      './config/css',
      './config/prod',
      './config/app'
    ].map((id) => idToPlugin(id))
    // 行内插件
    if (inlinePlugins) {
      plugins = useBuiltIn !== false
        ? builtInPlugins.concat(inlinePlugins)
        : inlinePlugins
    } else {
    // 在package.json符合@vue/cli-plugin-xxx规范的插件
      const projectPlugins = Object.keys(this.pkg.devDependencies || {})
        .concat(Object.keys(this.pkg.dependencies || {}))
        .filter(isPlugin)
        .map(id => {
          if (
            this.pkg.optionalDependencies &&
            id in this.pkg.optionalDependencies
          ) {
            let apply = loadModule(id, this.pkgContext)
            if (!apply) {
              warn(`Optional dependency ${id} is not installed.`)
              apply = () => {}
            }

            return { id, apply }
          } else {
            return idToPlugin(id, resolveModule(id, this.pkgContext))
          }
        })

      plugins = builtInPlugins.concat(projectPlugins)
    }
    // 本地插件
    // Local plugins
    if (this.pkg.vuePlugins && this.pkg.vuePlugins.service) {
      const files = this.pkg.vuePlugins.service
      if (!Array.isArray(files)) {
        throw new Error(`Invalid type for option 'vuePlugins.service', expected 'array' but got ${typeof files}.`)
      }
      plugins = plugins.concat(files.map(file => ({
        id: `local:${file}`,
        apply: loadModule(`./${file}`, this.pkgContext)
      })))
    }
    debug('vue:plugins')(plugins)

    const orderedPlugins = sortPlugins(plugins)
    debug('vue:plugins-ordered')(orderedPlugins)

    return orderedPlugins
  }
// @vue\cli-shared-utils\lib\pluginResolution.js

const pluginRE = /^(@vue\/|vue-|@[\w-]+(\.)?[\w-]+\/vue-)cli-plugin-/
exports.isPlugin = id => pluginRE.test(id)

可以看到,插件根据引入的位置可以分为四种:内置插件、行内插件、本地插件、在package.json符合 pluginRE 正则的插件。然后用 idToPlugin 封装成 {id: 插件id, apply: 插件方法}
其中内置插件,包括了 serve build inspect help 的内置命令,也包括了 base assets css prod app 的内置webpack配置。下文除了 inspecthelp 都会一一讲述。
下面先看看 run 方法。

3.4 Service类方法 run

// packages\@vue\cli-service\lib\Service.js

  async run (name, args = {}, rawArgv = []) {
    // resolve mode
    // prioritize inline --mode
    // fallback to resolved default modes from plugins or development if --watch is defined
    const mode = args.mode || (name === 'build' && args.watch ? 'development' : this.modes[name])

    // --skip-plugins arg may have plugins that should be skipped during init()
    // 根据入参来决定跳过的插件
    this.setPluginsToSkip(args)

    // load env variables, load user config, apply plugins
    // 初始化:加载环境变量、加载用户配置,执行插件
    await this.init(mode)

    args._ = args._ || []
    // 根据name来筛选命令
    let command = this.commands[name]
    if (!command && name) {
      error(`command "${name}" does not exist.`)
      process.exit(1)
    }
    if (!command || args.help || args.h) {
      command = this.commands.help
    } else {
      args._.shift() // remove command itself
      rawArgv.shift()
    }
    // 执行命令方法
    const { fn } = command
    return fn(args, rawArgv)
  }

run 方法,主要做了以下事情:通过参数设置跳过的插件,通过 this.init 来初始化之后,执行命令方法。下面看看 this.init 的实现

3.5 Service类方法 init

// packages\@vue\cli-service\lib\Service.js

init (mode = process.env.VUE_CLI_MODE) {
    if (this.initialized) {
      return
    }
    this.initialized = true
    this.mode = mode

    // load mode .env
    if (mode) {
      this.loadEnv(mode)
    }
    // load base .env
    this.loadEnv()

    // load user config
    const userOptions = this.loadUserOptions()
    const loadedCallback = (loadedUserOptions) => {
      this.projectOptions = defaultsDeep(loadedUserOptions, defaults())

      debug('vue:project-config')(this.projectOptions)

      // apply plugins.
      this.plugins.forEach(({ id, apply }) => {
        if (this.pluginsToSkip.has(id)) return
        apply(new PluginAPI(id, this), this.projectOptions)
      })

      // apply webpack configs from project config file
      if (this.projectOptions.chainWebpack) {
        this.webpackChainFns.push(this.projectOptions.chainWebpack)
      }
      if (this.projectOptions.configureWebpack) {
        this.webpackRawConfigFns.push(this.projectOptions.configureWebpack)
      }
    }

    if (isPromise(userOptions)) {
      return userOptions.then(loadedCallback)
    } else {
      return loadedCallback(userOptions)
    }
  }

可以看到 this.init 加载环境变量、加载用户配置,执行插件。关于4种环境变量的说明,可以参考下官方文档关于环境变量的说明,当然也很容易从源码中中看出执行顺序。
在执行插件的时候,api传入了 PluginAPI 实例,它是插件的一些api。

3.6 PluginAPI

// packages\@vue\cli-service\lib\PluginAPI.js

const path = require('path')
const hash = require('hash-sum')
const { semver, matchesPluginId } = require('@vue/cli-shared-utils')

// Note: if a plugin-registered command needs to run in a specific default mode,
// the plugin needs to expose it via `module.exports.defaultModes` in the form
// of { [commandName]: mode }. This is because the command mode needs to be
// known and applied before loading user options / applying plugins.

class PluginAPI {
  /**
   * @param {string} id - Id of the plugin.
   * @param {Service} service - A vue-cli-service instance.
   */
  constructor (id, service) {
    this.id = id
    this.service = service
  }

  get version () {
    return require('../package.json').version
  }

  assertVersion (range) {
    if (typeof range === 'number') {
      if (!Number.isInteger(range)) {
        throw new Error('Expected string or integer value.')
      }
      range = `^${range}.0.0-0`
    }
    if (typeof range !== 'string') {
      throw new Error('Expected string or integer value.')
    }

    if (semver.satisfies(this.version, range, { includePrerelease: true })) return

    throw new Error(
      `Require @vue/cli-service "${range}", but was loaded with "${this.version}".`
    )
  }

  /**
   * Current working directory.
   */
  getCwd () {
    return this.service.context
  }

  /**
   * Resolve path for a project.
   *
   * @param {string} _path - Relative path from project root
   * @return {string} The resolved absolute path.
   */
  resolve (_path) {
    return path.resolve(this.service.context, _path)
  }

  /**
   * Check if the project has a given plugin.
   *
   * @param {string} id - Plugin id, can omit the (@vue/|vue-|@scope/vue)-cli-plugin- prefix
   * @return {boolean}
   */
  hasPlugin (id) {
    return this.service.plugins.some(p => matchesPluginId(id, p.id))
  }

  /**
   * Register a command that will become available as `vue-cli-service [name]`.
   *
   * @param {string} name
   * @param {object} [opts]
   *   {
   *     description: string,
   *     usage: string,
   *     options: { [string]: string }
   *   }
   * @param {function} fn
   *   (args: { [string]: string }, rawArgs: string[]) => ?Promise
   */
  registerCommand (name, opts, fn) {
    if (typeof opts === 'function') {
      fn = opts
      opts = null
    }
    this.service.commands[name] = { fn, opts: opts || {} }
  }

  /**
   * Register a function that will receive a chainable webpack config
   * the function is lazy and won't be called until `resolveWebpackConfig` is
   * called
   *
   * @param {function} fn
   */
  chainWebpack (fn) {
    this.service.webpackChainFns.push(fn)
  }

  /**
   * Register
   * - a webpack configuration object that will be merged into the config
   * OR
   * - a function that will receive the raw webpack config.
   *   the function can either mutate the config directly or return an object
   *   that will be merged into the config.
   *
   * @param {object | function} fn
   */
  configureWebpack (fn) {
    this.service.webpackRawConfigFns.push(fn)
  }

  /**
   * Register a dev serve config function. It will receive the express `app`
   * instance of the dev server.
   *
   * @param {function} fn
   */
  configureDevServer (fn) {
    this.service.devServerConfigFns.push(fn)
  }

  /**
   * Resolve the final raw webpack config, that will be passed to webpack.
   *
   * @param {ChainableWebpackConfig} [chainableConfig]
   * @return {object} Raw webpack config.
   */
  resolveWebpackConfig (chainableConfig) {
    return this.service.resolveWebpackConfig(chainableConfig)
  }

  /**
   * Resolve an intermediate chainable webpack config instance, which can be
   * further tweaked before generating the final raw webpack config.
   * You can call this multiple times to generate different branches of the
   * base webpack config.
   * See https://github.com/mozilla-neutrino/webpack-chain
   *
   * @return {ChainableWebpackConfig}
   */
  resolveChainableWebpackConfig () {
    return this.service.resolveChainableWebpackConfig()
  }

  /**
   * Generate a cache identifier from a number of variables
   */
  genCacheConfig (id, partialIdentifier, configFiles = []) {
    const fs = require('fs')
    const cacheDirectory = this.resolve(`node_modules/.cache/${id}`)

    // replace \r\n to \n generate consistent hash
    const fmtFunc = conf => {
      if (typeof conf === 'function') {
        return conf.toString().replace(/\r\n?/g, '\n')
      }
      return conf
    }

    const variables = {
      partialIdentifier,
      'cli-service': require('../package.json').version,
      env: process.env.NODE_ENV,
      test: !!process.env.VUE_CLI_TEST,
      config: [
        fmtFunc(this.service.projectOptions.chainWebpack),
        fmtFunc(this.service.projectOptions.configureWebpack)
      ]
    }

    try {
      variables['cache-loader'] = require('cache-loader/package.json').version
    } catch (e) {
      // cache-loader is only intended to be used for webpack 4
    }

    if (!Array.isArray(configFiles)) {
      configFiles = [configFiles]
    }
    configFiles = configFiles.concat([
      'package-lock.json',
      'yarn.lock',
      'pnpm-lock.yaml'
    ])

    const readConfig = file => {
      const absolutePath = this.resolve(file)
      if (!fs.existsSync(absolutePath)) {
        return
      }

      if (absolutePath.endsWith('.js')) {
        // should evaluate config scripts to reflect environment variable changes
        try {
          return JSON.stringify(require(absolutePath))
        } catch (e) {
          return fs.readFileSync(absolutePath, 'utf-8')
        }
      } else {
        return fs.readFileSync(absolutePath, 'utf-8')
      }
    }

    variables.configFiles = configFiles.map(file => {
      const content = readConfig(file)
      return content && content.replace(/\r\n?/g, '\n')
    })

    const cacheIdentifier = hash(variables)
    return { cacheDirectory, cacheIdentifier }
  }
}

module.exports = PluginAPI

PluginAPI是提供给插件使用的api,下面是一些比较常用到的api:

  1. registerCommand,注册命令
  2. chainWebpack,以 webpack-chain 的方式记录webpack配置到 Service 实例的 webpackChainFns 数组中
  3. configureWebpack,以 webpack-merge 的方式记录webpack配置到 Service 实例的 webpackRawConfigFns 数组中
  4. resolveWebpackConfig,解析webpack配置
    …………

4. vue-cli-service serve 做了什么?

4.1 serve.js

// lib\commands\serve.js

const {
    info,
    error,
    hasProjectYarn,
    hasProjectPnpm,
    IpcMessenger
  } = require('@vue/cli-shared-utils')

  const defaults = {
    host: '0.0.0.0',
    port: 8080,
    https: false
  }

  /** @type {import('@vue/cli-service').ServicePlugin} */
  module.exports = (api, options) => {
    api.registerCommand('serve', {
      description: 'start development server',
      usage: 'vue-cli-service serve [options] [entry]',
      options: {
        '--open': `open browser on server start`,
        '--copy': `copy url to clipboard on server start`,
        '--stdin': `close when stdin ends`,
        '--mode': `specify env mode (default: development)`,
        '--host': `specify host (default: ${defaults.host})`,
        '--port': `specify port (default: ${defaults.port})`,
        '--https': `use https (default: ${defaults.https})`,
        '--public': `specify the public network URL for the HMR client`,
        '--skip-plugins': `comma-separated list of plugin names to skip for this run`
      }
    }, async function serve (args) {
      info('Starting development server...')
      // ...
      // configs that only matters for dev server
      api.chainWebpack(webpackConfig => {
        // ...
      })
      // resolve webpack config
      const webpackConfig = api.resolveWebpackConfig()
      // check for common config errors
      validateWebpackConfig(webpackConfig, api, options)
      // ...
      // create compiler
      const compiler = webpack(webpackConfig)

      // handle compiler error
      compiler.hooks.failed.tap('vue-cli-service serve', msg => {
        error(msg)
        process.exit(1)
      })

      // create server
      const server = new WebpackDevServer(Object.assign({
        // ...
      }), compiler)
      // ...
      return new Promise((resolve, reject) => {
        // ...
            resolve({
              server,
              url: localUrlForBrowser
            })
        // ...
        server.start().catch(err => reject(err))
      })
    })
  }
  // ...
  module.exports.defaultModes = {
    serve: 'development'
  }
// packages\@vue\cli-service\lib\PluginAPI.js

  registerCommand (name, opts, fn) {
    if (typeof opts === 'function') {
      fn = opts
      opts = null
    }
    this.service.commands[name] = { fn, opts: opts || {} }
  }

serve 无非做了三件事:

  1. 调用 PluginAPIregisterCommand 方法注册 serve 命令。传了三个参数:命令名称、命令的用法配置、命令函数。注册命令其实就是在 service实例 commands 变量添加一个字段,以 {fn: 命令方法, otps: 命令使用方法选项} 的方式存起来
  2. 使用 api.chainWebpack 来拼接webpack配置。生成webpack配置,验证通过之后执行webpack
  3. 初始化变量并开启 webpackDevServer

4.2 serve内置了什么webpack配置?

 // configs that only matters for dev server
    api.chainWebpack(webpackConfig => {
      if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') {
        webpackConfig
          .devtool('eval-cheap-module-source-map')

        webpackConfig
          .plugin('hmr')
            .use(require('webpack/lib/HotModuleReplacementPlugin'))

        // https://github.com/webpack/webpack/issues/6642
        // https://github.com/vuejs/vue-cli/issues/3539
        webpackConfig
          .output
            .globalObject(`(typeof self !== 'undefined' ? self : this)`)

        if (!process.env.VUE_CLI_TEST && options.devServer.progress !== false) {
          // the default progress plugin won't show progress due to infrastructreLogging.level
          webpackConfig
            .plugin('progress')
            .use(require('progress-webpack-plugin'))
        }
      }
    })

    // resolve webpack config
    const webpackConfig = api.resolveWebpackConfig()

    // check for common config errors
    validateWebpackConfig(webpackConfig, api, options)

    // load user devServer options with higher priority than devServer
    // in webpack config
    const projectDevServerOptions = Object.assign(
      webpackConfig.devServer || {},
      options.devServer
    )

    // expose advanced stats
    if (args.dashboard) {
      const DashboardPlugin = require('../webpack/DashboardPlugin')
      webpackConfig.plugins.push(new DashboardPlugin({
        type: 'serve'
      }))
    }

    // entry arg
    const entry = args._[0]
    if (entry) {
      webpackConfig.entry = {
        app: api.resolve(entry)
      }
    }

serve命令内置了这些webpack配置:

  1. 默认配置sourcemap:eval-cheap-module-source-map
  2. 配置hmr插件,可以热加载
  3. 配置output的globalObject为self/this,一般用途是作为library输出,尤其是 umd 标准,这个全局对象在node.js/浏览器上需要指定为 this,类似web的目标则需要指定为 self。所以可以通过这样去赋值:(typeof self !== 'undefined' ? self : this)
  4. 使用插件 progress-webpack-plugin 显示进度
  5. 如果参数带了 dashboard,则加载插件 DashboardPlugin
  6. 配置entry
    剩下的就是配置 webpackDevServer,就不展开了。下面我们看看 build 命令内置了哪些webpack配置

5. vue-cli-service build 内置了哪些webpack配置?

// packages\@vue\cli-service\lib\commands\build\index.js

const defaults = {
  clean: true,
  target: 'app',
  module: true,
  formats: 'commonjs,umd,umd-min'
}

const buildModes = {
  lib: 'library',
  wc: 'web component',
  'wc-async': 'web component (async)'
}

const modifyConfig = (config, fn) => {
  if (Array.isArray(config)) {
    config.forEach(c => fn(c))
  } else {
    fn(config)
  }
}

module.exports = (api, options) => {
  api.registerCommand('build', {
    description: 'build for production',
    usage: 'vue-cli-service build [options] [entry|pattern]',
    options: {
      '--mode': `specify env mode (default: production)`,
      '--dest': `specify output directory (default: ${options.outputDir})`,
      '--no-module': `build app without generating <script type="module"> chunks for modern browsers`,
      '--target': `app | lib | wc | wc-async (default: ${defaults.target})`,
      '--inline-vue': 'include the Vue module in the final bundle of library or web component target',
      '--formats': `list of output formats for library builds (default: ${defaults.formats})`,
      '--name': `name for lib or web-component mode (default: "name" in package.json or entry filename)`,
      '--filename': `file name for output, only usable for 'lib' target (default: value of --name)`,
      '--no-clean': `do not remove the dist directory contents before building the project`,
      '--report': `generate report.html to help analyze bundle content`,
      '--report-json': 'generate report.json to help analyze bundle content',
      '--skip-plugins': `comma-separated list of plugin names to skip for this run`,
      '--watch': `watch for changes`,
      '--stdin': `close when stdin ends`
    }
  }, async (args, rawArgs) => {
    for (const key in defaults) {
      if (args[key] == null) {
        args[key] = defaults[key]
      }
    }
    args.entry = args.entry || args._[0]
    if (args.target !== 'app') {
      args.entry = args.entry || 'src/App.vue'
    }

    process.env.VUE_CLI_BUILD_TARGET = args.target

    const { log, execa } = require('@vue/cli-shared-utils')
    const { allProjectTargetsSupportModule } = require('../../util/targets')

    let needsDifferentialLoading = args.target === 'app' && args.module
    if (allProjectTargetsSupportModule) {
      log(
        `All browser targets in the browserslist configuration have supported ES module.\n` +
        `Therefore we don't build two separate bundles for differential loading.\n`
      )
      needsDifferentialLoading = false
    }

    args.needsDifferentialLoading = needsDifferentialLoading
    if (!needsDifferentialLoading) {
      await build(args, api, options)
      return
    }

    process.env.VUE_CLI_MODERN_MODE = true
    if (!process.env.VUE_CLI_MODERN_BUILD) {
      // main-process for legacy build
      const legacyBuildArgs = { ...args, moduleBuild: false, keepAlive: true }
      await build(legacyBuildArgs, api, options)

      // spawn sub-process of self for modern build
      const cliBin = require('path').resolve(__dirname, '../../../bin/vue-cli-service.js')
      await execa('node', [cliBin, 'build', ...rawArgs], {
        stdio: 'inherit',
        env: {
          VUE_CLI_MODERN_BUILD: true
        }
      })
    } else {
      // sub-process for modern build
      const moduleBuildArgs = { ...args, moduleBuild: true, clean: false }
      await build(moduleBuildArgs, api, options)
    }
  })
}

async function build (args, api, options) {
  const fs = require('fs-extra')
  const path = require('path')
  const webpack = require('webpack')
  const { chalk } = require('@vue/cli-shared-utils')
  const formatStats = require('./formatStats')
  const validateWebpackConfig = require('../../util/validateWebpackConfig')
  const {
    log,
    done,
    info,
    logWithSpinner,
    stopSpinner
  } = require('@vue/cli-shared-utils')

  log()
  const mode = api.service.mode
  if (args.target === 'app') {
    const bundleTag = args.needsDifferentialLoading
      ? args.moduleBuild
        ? `module bundle `
        : `legacy bundle `
      : ``
    logWithSpinner(`Building ${bundleTag}for ${mode}...`)
  } else {
    const buildMode = buildModes[args.target]
    if (buildMode) {
      const additionalParams = buildMode === 'library' ? ` (${args.formats})` : ``
      logWithSpinner(`Building for ${mode} as ${buildMode}${additionalParams}...`)
    } else {
      throw new Error(`Unknown build target: ${args.target}`)
    }
  }

  if (args.dest) {
    // Override outputDir before resolving webpack config as config relies on it (#2327)
    options.outputDir = args.dest
  }

  const targetDir = api.resolve(options.outputDir)
  const isLegacyBuild = args.needsDifferentialLoading && !args.moduleBuild

  // resolve raw webpack config
  let webpackConfig
  if (args.target === 'lib') {
    webpackConfig = require('./resolveLibConfig')(api, args, options)
  } else if (
    args.target === 'wc' ||
    args.target === 'wc-async'
  ) {
    webpackConfig = require('./resolveWcConfig')(api, args, options)
  } else {
    webpackConfig = require('./resolveAppConfig')(api, args, options)
  }

  // check for common config errors
  validateWebpackConfig(webpackConfig, api, options, args.target)

  if (args.watch) {
    modifyConfig(webpackConfig, config => {
      config.watch = true
    })
  }

  if (args.stdin) {
    process.stdin.on('end', () => {
      process.exit(0)
    })
    process.stdin.resume()
  }

  // Expose advanced stats
  if (args.dashboard) {
    const DashboardPlugin = require('../../webpack/DashboardPlugin')
    modifyConfig(webpackConfig, config => {
      config.plugins.push(new DashboardPlugin({
        type: 'build',
        moduleBuild: args.moduleBuild,
        keepAlive: args.keepAlive
      }))
    })
  }

  if (args.report || args['report-json']) {
    const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
    modifyConfig(webpackConfig, config => {
      const bundleName = args.target !== 'app'
        ? config.output.filename.replace(/\.js$/, '-')
        : isLegacyBuild ? 'legacy-' : ''
      config.plugins.push(new BundleAnalyzerPlugin({
        logLevel: 'warn',
        openAnalyzer: false,
        analyzerMode: args.report ? 'static' : 'disabled',
        reportFilename: `${bundleName}report.html`,
        statsFilename: `${bundleName}report.json`,
        generateStatsFile: !!args['report-json']
      }))
    })
  }

  if (args.clean) {
    await fs.emptyDir(targetDir)
  }

  return new Promise((resolve, reject) => {
    webpack(webpackConfig, (err, stats) => {
      stopSpinner(false)
      if (err) {
        return reject(err)
      }

      if (stats.hasErrors()) {
        return reject(new Error('Build failed with errors.'))
      }

      if (!args.silent) {
        const targetDirShort = path.relative(
          api.service.context,
          targetDir
        )
        log(formatStats(stats, targetDirShort, api))
        if (args.target === 'app' && !isLegacyBuild) {
          if (!args.watch) {
            done(`Build complete. The ${chalk.cyan(targetDirShort)} directory is ready to be deployed.`)
            info(`Check out deployment instructions at ${chalk.cyan(`https://cli.vuejs.org/guide/deployment.html`)}\n`)
          } else {
            done(`Build complete. Watching for changes...`)
          }
        }
      }

      // test-only signal
      if (process.env.VUE_CLI_TEST) {
        console.log('Build complete.')
      }

      resolve()
    })
  })
}

module.exports.defaultModes = {
  build: 'production'
}

build命令内置了这些webpack配置:

  1. 判断 构建目标 以决定webpack入口entry
  2. 考虑 浏览器兼容性,默认开启 现代模式(process.env.VUE_CLI_MODERN_MODE = true) 。当开启 process.env.VUE_CLI_MODERN_BUILD,只打包一个现代版的包,面向支持 ES modules 的现代浏览器,否则会产生两个应用的版本:一个现代版的包,面向支持 ES modules 的现代浏览器,另一个旧版的包,面向不支持的旧浏览器。
  3. 如果参数传递了 dest,则指定对应的输出目录
  4. 根据参数 target 加载不同的webpack配置,这里 target 的值为 app,所以会加载 resolveAppConfig 的配置,并验证webpack配置是否合法。见下方代码分析。本文没有讨论 target 的值为 lib wc wc-async 的情况,分别对应着作为一个库、web component、异步web component作为打包目标。
  5. 如果参数传递了 watch,则配置为watch模式
  6. 如果参数传递了 stdin,则监听 end 方法,并且调用 process.stdin.resume() 来读取流
  7. 如果参数传递了 dashboard,则添加 DashboardPlugin 插件,可以将CLI分析输出可以以面板的方式输出,效果可以看这里
  8. 如果参数传递了 report,则添加 BundleAnalyzerPlugin 插件,可以分析打包大小
  9. 如果参数传递了 clean,则打包前先清空目标目录
  10. 打包

下面看看 resolveAppConfig 做了哪些webpack配置

// packages\@vue\cli-service\lib\commands\build\resolveAppConfig.js

module.exports = (api, args, options) => {
  // respect inline entry
  if (args.entry && !options.pages) {
    api.configureWebpack(config => {
      config.entry = { app: api.resolve(args.entry) }
    })
  }

  const config = api.resolveChainableWebpackConfig()
  const targetDir = api.resolve(args.dest || options.outputDir)

  // respect inline build destination in copy plugin
  if (args.dest && config.plugins.has('copy')) {
    config.plugin('copy').tap(pluginArgs => {
      pluginArgs[0].patterns.to = targetDir
      return pluginArgs
    })
  }

  if (process.env.VUE_CLI_MODERN_MODE) {
    const ModernModePlugin = require('../../webpack/ModernModePlugin')
    const SafariNomoduleFixPlugin = require('../../webpack/SafariNomoduleFixPlugin')

    if (!args.moduleBuild) {
      // Inject plugin to extract build stats and write to disk
      config
        .plugin('modern-mode-legacy')
        .use(ModernModePlugin, [{
          targetDir,
          isModuleBuild: false
        }])
    } else {
      config
        .plugin('safari-nomodule-fix')
        .use(SafariNomoduleFixPlugin, [{
          // as we may generate an addition file asset (if Safari 10 fix is needed)
          // we need to provide the correct directory for that file to place in
          jsDirectory: require('../../util/getAssetPath')(options, 'js')
        }])

      // Inject plugin to read non-modern build stats and inject HTML
      config
        .plugin('modern-mode-modern')
        .use(ModernModePlugin, [{
          targetDir,
          isModuleBuild: truea
        }])
    }
  }

  return api.resolveWebpackConfig(config)
}

其实也就是针对 现代模式 添加插件,是否既支持现代浏览器es module形式的,也支持旧浏览器。还是只需要打包支持现代浏览器。

6. config/base.js 内置了哪些webpack配置?

const { semver, loadModule } = require('@vue/cli-shared-utils')

module.exports = (api, options) => {
  api.chainWebpack(webpackConfig => {
    const isLegacyBundle = process.env.VUE_CLI_MODERN_MODE && !process.env.VUE_CLI_MODERN_BUILD
    const resolveLocal = require('../util/resolveLocal')
    const getAssetPath = require('../util/getAssetPath')
    const inlineLimit = 4096

    const genAssetSubPath = dir => {
      return getAssetPath(
        options,
        `${dir}/[name]${options.filenameHashing ? '.[hash:8]' : ''}.[ext]`
      )
    }

    const genUrlLoaderOptions = dir => {
      return {
        limit: inlineLimit,
        // use explicit fallback to avoid regression in url-loader>=1.1.0
        fallback: {
          loader: require.resolve('file-loader'),
          options: {
            name: genAssetSubPath(dir)
          }
        }
      }
    }

    webpackConfig
      .mode('development')
      .context(api.service.context)
      .entry('app')
        .add('./src/main.js')
        .end()
      .output
        .path(api.resolve(options.outputDir))
        .filename(isLegacyBundle ? '[name]-legacy.js' : '[name].js')
        .publicPath(options.publicPath)

    webpackConfig.resolve
      // This plugin can be removed once we switch to Webpack 6
      .plugin('pnp')
        .use({ ...require('pnp-webpack-plugin') })
        .end()
      .extensions
        .merge(['.mjs', '.js', '.jsx', '.vue', '.json', '.wasm'])
        .end()
      .modules
        .add('node_modules')
        .add(api.resolve('node_modules'))
        .add(resolveLocal('node_modules'))
        .end()
      .alias
        .set('@', api.resolve('src'))

    webpackConfig.resolveLoader
      .plugin('pnp-loaders')
        .use({ ...require('pnp-webpack-plugin').topLevelLoader })
        .end()
      .modules
        .add('node_modules')
        .add(api.resolve('node_modules'))
        .add(resolveLocal('node_modules'))

    webpackConfig.module
      .noParse(/^(vue|vue-router|vuex|vuex-router-sync)$/)

    // js is handled by cli-plugin-babel ---------------------------------------

    // vue-loader --------------------------------------------------------------
    // try to load vue in the project
    // fallback to peer vue package in the instant prototyping environment
    const vue = loadModule('vue', api.service.context) || loadModule('vue', __dirname)

    if (vue && semver.major(vue.version) === 2) {
      // for Vue 2 projects
      const vueLoaderCacheConfig = api.genCacheConfig('vue-loader', {
        'vue-loader': require('vue-loader/package.json').version,
        '@vue/component-compiler-utils': require('@vue/component-compiler-utils/package.json').version,
        'vue-template-compiler': require('vue-template-compiler/package.json').version
      })

      webpackConfig.resolve
        .alias
          .set(
            'vue$',
            options.runtimeCompiler
              ? 'vue/dist/vue.esm.js'
              : 'vue/dist/vue.runtime.esm.js'
          )

      webpackConfig.module
        .rule('vue')
          .test(/\.vue$/)
          .use('cache-loader')
            .loader(require.resolve('cache-loader'))
            .options(vueLoaderCacheConfig)
            .end()
          .use('vue-loader')
            .loader(require.resolve('vue-loader'))
            .options(Object.assign({
              compilerOptions: {
                whitespace: 'condense'
              }
            }, vueLoaderCacheConfig))

      webpackConfig
        .plugin('vue-loader')
          .use(require('vue-loader').VueLoaderPlugin)
    } else if (vue && semver.major(vue.version) === 3) {
      // for Vue 3 projects
      const vueLoaderCacheConfig = api.genCacheConfig('vue-loader', {
        'vue-loader': require('vue-loader-v16/package.json').version,
        '@vue/compiler-sfc': require('@vue/compiler-sfc/package.json').version
      })

      webpackConfig.resolve
        .alias
          .set(
            'vue$',
            options.runtimeCompiler
              ? 'vue/dist/vue.esm-bundler.js'
              : 'vue/dist/vue.runtime.esm-bundler.js'
          )

      webpackConfig.module
        .rule('vue')
          .test(/\.vue$/)
          .use('cache-loader')
            .loader(require.resolve('cache-loader'))
            .options(vueLoaderCacheConfig)
            .end()
          .use('vue-loader')
            .loader(require.resolve('vue-loader-v16'))
            .options({
              ...vueLoaderCacheConfig,
              babelParserPlugins: ['jsx', 'classProperties', 'decorators-legacy']
            })
            .end()
          .end()

      webpackConfig
        .plugin('vue-loader')
          .use(require('vue-loader-v16').VueLoaderPlugin)

      // feature flags <http://link.vuejs.org/feature-flags>
      webpackConfig
        .plugin('feature-flags')
          .use(require('webpack').DefinePlugin, [{
            __VUE_OPTIONS_API__: 'true',
            __VUE_PROD_DEVTOOLS__: 'false'
          }])
    }

    // static assets -----------------------------------------------------------

    webpackConfig.module
      .rule('images')
        .test(/\.(png|jpe?g|gif|webp)(\?.*)?$/)
        .use('url-loader')
          .loader(require.resolve('url-loader'))
          .options(genUrlLoaderOptions('img'))

    // do not base64-inline SVGs.
    // https://github.com/facebookincubator/create-react-app/pull/1180
    webpackConfig.module
      .rule('svg')
        .test(/\.(svg)(\?.*)?$/)
        .use('file-loader')
          .loader(require.resolve('file-loader'))
          .options({
            name: genAssetSubPath('img')
          })

    webpackConfig.module
      .rule('media')
        .test(/\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/)
        .use('url-loader')
          .loader(require.resolve('url-loader'))
          .options(genUrlLoaderOptions('media'))

    webpackConfig.module
      .rule('fonts')
        .test(/\.(woff2?|eot|ttf|otf)(\?.*)?$/i)
        .use('url-loader')
          .loader(require.resolve('url-loader'))
          .options(genUrlLoaderOptions('fonts'))

    // Other common pre-processors ---------------------------------------------

    const maybeResolve = name => {
      try {
        return require.resolve(name)
      } catch (error) {
        return name
      }
    }

    webpackConfig.module
      .rule('pug')
        .test(/\.pug$/)
          .oneOf('pug-vue')
            .resourceQuery(/vue/)
            .use('pug-plain-loader')
              .loader(maybeResolve('pug-plain-loader'))
              .end()
            .end()
          .oneOf('pug-template')
            .use('raw')
              .loader(maybeResolve('raw-loader'))
              .end()
            .use('pug-plain-loader')
              .loader(maybeResolve('pug-plain-loader'))
              .end()
            .end()

    // shims

    webpackConfig.node
      .merge({
        // prevent webpack from injecting useless setImmediate polyfill because Vue
        // source contains it (although only uses it if it's native).
        setImmediate: false,
        // process is injected via DefinePlugin, although some 3rd party
        // libraries may require a mock to work properly (#934)
        process: 'mock',
        // prevent webpack from injecting mocks to Node native modules
        // that does not make sense for the client
        dgram: 'empty',
        fs: 'empty',
        net: 'empty',
        tls: 'empty',
        child_process: 'empty'
      })

    const resolveClientEnv = require('../util/resolveClientEnv')
    webpackConfig
      .plugin('define')
        .use(require('webpack').DefinePlugin, [
          resolveClientEnv(options)
        ])

    webpackConfig
      .plugin('case-sensitive-paths')
        .use(require('case-sensitive-paths-webpack-plugin'))

    // friendly error plugin displays very confusing errors when webpack
    // fails to resolve a loader, so we provide custom handlers to improve it
    const { transformer, formatter } = require('../util/resolveLoaderError')
    webpackConfig
      .plugin('friendly-errors')
        .use(require('@soda/friendly-errors-webpack-plugin'), [{
          additionalTransformers: [transformer],
          additionalFormatters: [formatter]
        }])

    const TerserPlugin = require('terser-webpack-plugin')
    const terserOptions = require('./terserOptions')
    webpackConfig.optimization
      .minimizer('terser')
        .use(TerserPlugin, [terserOptions(options)])
  })
}

config/base内置了以下webapck配置:

  1. 配置 modedevelopment
  2. 配置 entry
  3. 配置 output
  4. 配置resolve添加 pnp-webpack-plugin 插件,resolveLoader添加 pnp-loaders,是为了解决 yarn 提高依赖管理效率的问题,详细了解可以参这篇文章:https://blog.csdn.net/qq_2972...
  5. 不解析 vue vue-router vuex vuex-router-sync 模块
  6. 如果 vue 的主版本是 2

    1. 根据选项 options.runtimeCompiler 决定别名 vue$ 指向 vue/dist/vue.esm.js 还是 vue/dist/vue.runtime.esm.js
    2. 针对 vue 配置模块规则,先加载 vue-loader,再加载 cache-loader。其中 cache-loader 的作用是缓存 vue-loader @vue/component-compiler-utils vue-template-compiler 到磁盘
    3. 配置 vue-loader 插件
  7. 如果 vue 的主版本是 3

    1. 根据选项 options.runtimeCompiler 决定别名 vue$ 指向 vue/dist/vue.esm-bundler.js 还是 vue/dist/vue.runtime.esm-bundler.js
    2. 针对 vue 配置模块规则,先加载 vue-loader-v16,再加载 cache-loader。其中 cache-loader 的作用是缓存 vue-loader @vue/compiler-sfc 到磁盘
    3. 配置 vue-loader-v16 插件
    4. 为了优化树摇而配置的特性标识:__VUE_OPTIONS_API__: 'true' __VUE_PROD_DEVTOOLS__: 'false'
  8. 针对除svg之外的图片、媒体文件、字体,使用 url-loader 加载,并且配置选项:如果小于 4096kb 则会转为 base64 形式。且使用 file-loader 做兜底处理,且资源名称规范命名为 ${dir}/[name]${options.filenameHashing ? '.[hash:8]' : ''}.[ext]
  9. 针对svg资源,使用 file-loader 处理,并规范命名
  10. 针对 pug 配置相关的loader
  11. polyfill/mock node的全局变量:mock process,不pollyfill setImmediate,置空 dgram fs net tls child_process
  1. 设置以VUE_APP_开头的key或者key为NODE_ENV的环境变量
  2. 配置路径大小写敏感插件
  3. 配置友好错误提示插件 @soda/friendly-errors-webpack-plugin
  4. 配置优化项,使用 terser-webpack-plugin 压缩器

7. config/css.js 内置了哪些webpack配置?

// lib\config\css.js
const fs = require('fs')
const path = require('path')
const { semver, warn, pauseSpinner, resumeSpinner } = require('@vue/cli-shared-utils')

const findExisting = (context, files) => {
  for (const file of files) {
    if (fs.existsSync(path.join(context, file))) {
      return file
    }
  }
}

module.exports = (api, rootOptions) => {
  api.chainWebpack(webpackConfig => {
    const getAssetPath = require('../util/getAssetPath')
    const shadowMode = !!process.env.VUE_CLI_CSS_SHADOW_MODE
    const isProd = process.env.NODE_ENV === 'production'

    let sassLoaderVersion
    try {
      sassLoaderVersion = semver.major(require('sass-loader/package.json').version)
    } catch (e) {}
    if (sassLoaderVersion < 8) {
      pauseSpinner()
      warn('A new version of sass-loader is available. Please upgrade for best experience.')
      resumeSpinner()
    }

    const defaultSassLoaderOptions = {}
    try {
      defaultSassLoaderOptions.implementation = require('sass')
      // since sass-loader 8, fibers will be automatically detected and used
      if (sassLoaderVersion < 8) {
        defaultSassLoaderOptions.fiber = require('fibers')
      }
    } catch (e) {}

    const {
      extract = isProd,
      sourceMap = false,
      loaderOptions = {}
    } = rootOptions.css || {}

    let { requireModuleExtension } = rootOptions.css || {}
    if (typeof requireModuleExtension === 'undefined') {
      if (loaderOptions.css && loaderOptions.css.modules) {
        throw new Error('`css.requireModuleExtension` is required when custom css modules options provided')
      }
      requireModuleExtension = true
    }

    const shouldExtract = extract !== false && !shadowMode
    const filename = getAssetPath(
      rootOptions,
      `css/[name]${rootOptions.filenameHashing ? '.[contenthash:8]' : ''}.css`
    )
    const extractOptions = Object.assign({
      filename,
      chunkFilename: filename
    }, extract && typeof extract === 'object' ? extract : {})

    // use relative publicPath in extracted CSS based on extract location
    const cssPublicPath = process.env.VUE_CLI_BUILD_TARGET === 'lib'
      // in lib mode, CSS is extracted to dist root.
      ? './'
      : '../'.repeat(
        extractOptions.filename
            .replace(/^\.[\/\\]/, '')
            .split(/[\/\\]/g)
            .length - 1
      )

    // check if the project has a valid postcss config
    // if it doesn't, don't use postcss-loader for direct style imports
    // because otherwise it would throw error when attempting to load postcss config
    const hasPostCSSConfig = !!(loaderOptions.postcss || api.service.pkg.postcss || findExisting(api.resolve('.'), [
      '.postcssrc',
      '.postcssrc.js',
      'postcss.config.js',
      '.postcssrc.yaml',
      '.postcssrc.json'
    ]))

    if (!hasPostCSSConfig) {
      loaderOptions.postcss = {
        plugins: [
          require('autoprefixer')
        ]
      }
    }

    // if building for production but not extracting CSS, we need to minimize
    // the embbeded inline CSS as they will not be going through the optimizing
    // plugin.
    const needInlineMinification = isProd && !shouldExtract

    const cssnanoOptions = {
      preset: ['default', {
        mergeLonghand: false,
        cssDeclarationSorter: false
      }]
    }
    if (rootOptions.productionSourceMap && sourceMap) {
      cssnanoOptions.map = { inline: false }
    }

    function createCSSRule (lang, test, loader, options) {
      const baseRule = webpackConfig.module.rule(lang).test(test)

      // rules for <style lang="module">
      const vueModulesRule = baseRule.oneOf('vue-modules').resourceQuery(/module/)
      applyLoaders(vueModulesRule, true)

      // rules for <style>
      const vueNormalRule = baseRule.oneOf('vue').resourceQuery(/\?vue/)
      applyLoaders(vueNormalRule, false)

      // rules for *.module.* files
      const extModulesRule = baseRule.oneOf('normal-modules').test(/\.module\.\w+$/)
      applyLoaders(extModulesRule, true)

      // rules for normal CSS imports
      const normalRule = baseRule.oneOf('normal')
      applyLoaders(normalRule, !requireModuleExtension)

      function applyLoaders (rule, isCssModule) {
        if (shouldExtract) {
          rule
            .use('extract-css-loader')
            .loader(require('mini-css-extract-plugin').loader)
            .options({
              hmr: !isProd,
              publicPath: cssPublicPath
            })
        } else {
          rule
            .use('vue-style-loader')
            .loader(require.resolve('vue-style-loader'))
            .options({
              sourceMap,
              shadowMode
            })
        }

        const cssLoaderOptions = Object.assign({
          sourceMap,
          importLoaders: (
            1 + // stylePostLoader injected by vue-loader
            1 + // postcss-loader
            (needInlineMinification ? 1 : 0)
          )
        }, loaderOptions.css)

        if (isCssModule) {
          cssLoaderOptions.modules = {
            localIdentName: '[name]_[local]_[hash:base64:5]',
            ...cssLoaderOptions.modules
          }
        } else {
          delete cssLoaderOptions.modules
        }

        rule
          .use('css-loader')
          .loader(require.resolve('css-loader'))
          .options(cssLoaderOptions)

        if (needInlineMinification) {
          rule
            .use('cssnano')
            .loader(require.resolve('postcss-loader'))
            .options({
              sourceMap,
              plugins: [require('cssnano')(cssnanoOptions)]
            })
        }

        rule
          .use('postcss-loader')
          .loader(require.resolve('postcss-loader'))
          .options(Object.assign({ sourceMap }, loaderOptions.postcss))

        if (loader) {
          let resolvedLoader
          try {
            resolvedLoader = require.resolve(loader)
          } catch (error) {
            resolvedLoader = loader
          }

          rule
            .use(loader)
            .loader(resolvedLoader)
            .options(Object.assign({ sourceMap }, options))
        }
      }
    }

    createCSSRule('css', /\.css$/)
    createCSSRule('postcss', /\.p(ost)?css$/)
    createCSSRule('scss', /\.scss$/, 'sass-loader', Object.assign(
      {},
      defaultSassLoaderOptions,
      loaderOptions.scss || loaderOptions.sass
    ))
    if (sassLoaderVersion < 8) {
      createCSSRule('sass', /\.sass$/, 'sass-loader', Object.assign(
        {},
        defaultSassLoaderOptions,
        {
          indentedSyntax: true
        },
        loaderOptions.sass
      ))
    } else {
      createCSSRule('sass', /\.sass$/, 'sass-loader', Object.assign(
        {},
        defaultSassLoaderOptions,
        loaderOptions.sass,
        {
          sassOptions: Object.assign(
            {},
            loaderOptions.sass && loaderOptions.sass.sassOptions,
            {
              indentedSyntax: true
            }
          )
        }
      ))
    }
    createCSSRule('less', /\.less$/, 'less-loader', loaderOptions.less)
    createCSSRule('stylus', /\.styl(us)?$/, 'stylus-loader', Object.assign({
      preferPathResolver: 'webpack'
    }, loaderOptions.stylus))

    // inject CSS extraction plugin
    if (shouldExtract) {
      webpackConfig
        .plugin('extract-css')
          .use(require('mini-css-extract-plugin'), [extractOptions])

      // minify extracted CSS
      if (isProd) {
        webpackConfig
          .plugin('optimize-css')
            .use(require('@intervolga/optimize-cssnano-plugin'), [{
              sourceMap: rootOptions.productionSourceMap && sourceMap,
              cssnanoOptions
            }])
      }
    }
  })
}

config/css内置了以下webpack配置:

  1. 添加预处理loader:sass/scss/less/stylus
  2. 添加后处理器:postcss
  3. 添加css处理器:css-loader
  4. 使用插件 mini-css-extract-plugin 抽取css成为独立的文件
  5. 使用插件 @intervolga/optimize-cssnano-plugin 最小化抽取出来的css文件

8. config/prod.js 内置了哪些webpack配置?

// lib\config\prod.js
module.exports = (api, options) => {
  api.chainWebpack(webpackConfig => {
    if (process.env.NODE_ENV === 'production') {
      webpackConfig
        .mode('production')
        .devtool(options.productionSourceMap ? 'source-map' : false)

      // keep module.id stable when vendor modules does not change
      webpackConfig
        .plugin('hash-module-ids')
          .use(require('webpack/lib/HashedModuleIdsPlugin'), [{
            hashDigest: 'hex'
          }])

      // disable optimization during tests to speed things up
      if (process.env.VUE_CLI_TEST) {
        webpackConfig.optimization.minimize(false)
      }
    }
  })
}

config/prod内置了以下webpack配置:
NODE_ENV生产环境 时:

  1. 设置为 生产模式,添加source-map
  2. 添加插件 webpack/lib/HashedModuleIdsPlugin,当vendor没有改变时,打包出来的module id保持不变

9. config/app.js 内置了哪些webpack配置?

// lib\config\app.js

// config that are specific to --target app
const fs = require('fs')
const path = require('path')

// ensure the filename passed to html-webpack-plugin is a relative path
// because it cannot correctly handle absolute paths
function ensureRelative (outputDir, _path) {
  if (path.isAbsolute(_path)) {
    return path.relative(outputDir, _path)
  } else {
    return _path
  }
}

module.exports = (api, options) => {
  api.chainWebpack(webpackConfig => {
    // only apply when there's no alternative target
    if (process.env.VUE_CLI_BUILD_TARGET && process.env.VUE_CLI_BUILD_TARGET !== 'app') {
      return
    }

    const isProd = process.env.NODE_ENV === 'production'
    const isLegacyBundle = process.env.VUE_CLI_MODERN_MODE && !process.env.VUE_CLI_MODERN_BUILD
    const outputDir = api.resolve(options.outputDir)

    const getAssetPath = require('../util/getAssetPath')
    const outputFilename = getAssetPath(
      options,
      `js/[name]${isLegacyBundle ? `-legacy` : ``}${isProd && options.filenameHashing ? '.[contenthash:8]' : ''}.js`
    )
    webpackConfig
      .output
        .filename(outputFilename)
        .chunkFilename(outputFilename)

    // code splitting
    if (process.env.NODE_ENV !== 'test') {
      webpackConfig
        .optimization.splitChunks({
          cacheGroups: {
            vendors: {
              name: `chunk-vendors`,
              test: /[\\/]node_modules[\\/]/,
              priority: -10,
              chunks: 'initial'
            },
            common: {
              name: `chunk-common`,
              minChunks: 2,
              priority: -20,
              chunks: 'initial',
              reuseExistingChunk: true
            }
          }
        })
    }

    // HTML plugin
    const resolveClientEnv = require('../util/resolveClientEnv')

    // #1669 html-webpack-plugin's default sort uses toposort which cannot
    // handle cyclic deps in certain cases. Monkey patch it to handle the case
    // before we can upgrade to its 4.0 version (incompatible with preload atm)
    const chunkSorters = require('html-webpack-plugin/lib/chunksorter')
    const depSort = chunkSorters.dependency
    chunkSorters.auto = chunkSorters.dependency = (chunks, ...args) => {
      try {
        return depSort(chunks, ...args)
      } catch (e) {
        // fallback to a manual sort if that happens...
        return chunks.sort((a, b) => {
          // make sure user entry is loaded last so user CSS can override
          // vendor CSS
          if (a.id === 'app') {
            return 1
          } else if (b.id === 'app') {
            return -1
          } else if (a.entry !== b.entry) {
            return b.entry ? -1 : 1
          }
          return 0
        })
      }
    }

    const htmlOptions = {
      title: api.service.pkg.name,
      templateParameters: (compilation, assets, pluginOptions) => {
        // enhance html-webpack-plugin's built in template params
        let stats
        return Object.assign({
          // make stats lazy as it is expensive
          get webpack () {
            return stats || (stats = compilation.getStats().toJson())
          },
          compilation: compilation,
          webpackConfig: compilation.options,
          htmlWebpackPlugin: {
            files: assets,
            options: pluginOptions
          }
        }, resolveClientEnv(options, true /* raw */))
      }
    }

    // handle indexPath
    if (options.indexPath !== 'index.html') {
      // why not set filename for html-webpack-plugin?
      // 1. It cannot handle absolute paths
      // 2. Relative paths causes incorrect SW manifest to be generated (#2007)
      webpackConfig
        .plugin('move-index')
        .use(require('../webpack/MovePlugin'), [
          path.resolve(outputDir, 'index.html'),
          path.resolve(outputDir, options.indexPath)
        ])
    }

    if (isProd) {
      Object.assign(htmlOptions, {
        minify: {
          removeComments: true,
          collapseWhitespace: true,
          collapseBooleanAttributes: true,
          removeScriptTypeAttributes: true
          // more options:
          // https://github.com/kangax/html-minifier#options-quick-reference
        }
      })

      // keep chunk ids stable so async chunks have consistent hash (#1916)
      webpackConfig
        .plugin('named-chunks')
          .use(require('webpack/lib/NamedChunksPlugin'), [chunk => {
            if (chunk.name) {
              return chunk.name
            }

            const hash = require('hash-sum')
            const joinedHash = hash(
              Array.from(chunk.modulesIterable, m => m.id).join('_')
            )
            return `chunk-` + joinedHash
          }])
    }

    // resolve HTML file(s)
    const HTMLPlugin = require('html-webpack-plugin')
    const PreloadPlugin = require('@vue/preload-webpack-plugin')
    const multiPageConfig = options.pages
    const htmlPath = api.resolve('public/index.html')
    const defaultHtmlPath = path.resolve(__dirname, 'index-default.html')
    const publicCopyIgnore = ['.DS_Store']

    if (!multiPageConfig) {
      // default, single page setup.
      htmlOptions.template = fs.existsSync(htmlPath)
        ? htmlPath
        : defaultHtmlPath

      publicCopyIgnore.push({
        glob: path.relative(api.resolve('public'), api.resolve(htmlOptions.template)),
        matchBase: false
      })

      webpackConfig
        .plugin('html')
          .use(HTMLPlugin, [htmlOptions])

      if (!isLegacyBundle) {
        // inject preload/prefetch to HTML
        webpackConfig
          .plugin('preload')
            .use(PreloadPlugin, [{
              rel: 'preload',
              include: 'initial',
              fileBlacklist: [/\.map$/, /hot-update\.js$/]
            }])

        webpackConfig
          .plugin('prefetch')
            .use(PreloadPlugin, [{
              rel: 'prefetch',
              include: 'asyncChunks'
            }])
      }
    } else {
      // multi-page setup
      webpackConfig.entryPoints.clear()

      const pages = Object.keys(multiPageConfig)
      const normalizePageConfig = c => typeof c === 'string' ? { entry: c } : c

      pages.forEach(name => {
        const pageConfig = normalizePageConfig(multiPageConfig[name])
        const {
          entry,
          template = `public/${name}.html`,
          filename = `${name}.html`,
          chunks = ['chunk-vendors', 'chunk-common', name]
        } = pageConfig

        // Currently Cypress v3.1.0 comes with a very old version of Node,
        // which does not support object rest syntax.
        // (https://github.com/cypress-io/cypress/issues/2253)
        // So here we have to extract the customHtmlOptions manually.
        const customHtmlOptions = {}
        for (const key in pageConfig) {
          if (
            !['entry', 'template', 'filename', 'chunks'].includes(key)
          ) {
            customHtmlOptions[key] = pageConfig[key]
          }
        }

        // inject entry
        const entries = Array.isArray(entry) ? entry : [entry]
        webpackConfig.entry(name).merge(entries.map(e => api.resolve(e)))

        // resolve page index template
        const hasDedicatedTemplate = fs.existsSync(api.resolve(template))
        const templatePath = hasDedicatedTemplate
          ? template
          : fs.existsSync(htmlPath)
            ? htmlPath
            : defaultHtmlPath

        publicCopyIgnore.push({
          glob: path.relative(api.resolve('public'), api.resolve(templatePath)),
          matchBase: false
        })

        // inject html plugin for the page
        const pageHtmlOptions = Object.assign(
          {},
          htmlOptions,
          {
            chunks,
            template: templatePath,
            filename: ensureRelative(outputDir, filename)
          },
          customHtmlOptions
        )

        webpackConfig
          .plugin(`html-${name}`)
            .use(HTMLPlugin, [pageHtmlOptions])
      })

      if (!isLegacyBundle) {
        pages.forEach(name => {
          const filename = ensureRelative(
            outputDir,
            normalizePageConfig(multiPageConfig[name]).filename || `${name}.html`
          )
          webpackConfig
            .plugin(`preload-${name}`)
              .use(PreloadPlugin, [{
                rel: 'preload',
                includeHtmlNames: [filename],
                include: {
                  type: 'initial',
                  entries: [name]
                },
                fileBlacklist: [/\.map$/, /hot-update\.js$/]
              }])

          webpackConfig
            .plugin(`prefetch-${name}`)
              .use(PreloadPlugin, [{
                rel: 'prefetch',
                includeHtmlNames: [filename],
                include: {
                  type: 'asyncChunks',
                  entries: [name]
                }
              }])
        })
      }
    }

    // CORS and Subresource Integrity
    if (options.crossorigin != null || options.integrity) {
      webpackConfig
        .plugin('cors')
          .use(require('../webpack/CorsPlugin'), [{
            crossorigin: options.crossorigin,
            integrity: options.integrity,
            publicPath: options.publicPath
          }])
    }

    // copy static assets in public/
    const publicDir = api.resolve('public')
    if (!isLegacyBundle && fs.existsSync(publicDir)) {
      webpackConfig
        .plugin('copy')
          .use(require('copy-webpack-plugin'), [[{
            from: publicDir,
            to: outputDir,
            toType: 'dir',
            ignore: publicCopyIgnore
          }]])
    }
  })
}

config/app内置了以下webpack配置:

  1. 配置output的文件名称和chunk文件名称
  2. 对非测试环境进行code splittinghttps://juejin.cn/post/684490... https://zh.javascript.info/modules-dynamic-imports

    1. 静态引用node_modules的模块,则打包到vendors包中
    2. 如果该模块被至少2个模块静态引用,打包到common包中。如果该模块已经从主包中分离出来,直接重用

    官方文档的介绍,chunks有三种:

    关于动态引入和静态引入,可以看这篇

    关于这三种chunks有什么区分以及如何影响打包,可以看这篇

  3. 配置插件 HtmlWebpackPlugin,支持单页和多页的不同情况
  4. 配置插件 NamedChunksPlugin,让chunk id保持一致,以让异步chunks有一致的hash值
  5. 配置插件 PreloadPlugin,支持prefetch preload
  6. 如果传入 options.crossorigin 或者 options.integrity 则使用内置的插件支持cors:在 htmlWebpackPluginAlterAssetTags hook的时候,对script标签、link标签,添加 crossorigin 属性;对script标签,样式link标签添加 intergrity,值为该资源的hash值
  7. 配置插件 CopyPlugin 复制资源文件到output目录

10. 如何实现webpack配置的缝合?

serve 命令为例,我们可以看到调用了 api.resolveWebpackConfig,我们看看是如何实现的

// lib\commands\serve.js
const webpackConfig = api.resolveWebpackConfig()
// lib\PluginAPI.js
  resolveWebpackConfig (chainableConfig) {
    return this.service.resolveWebpackConfig(chainableConfig)
  }
// packages\@vue\cli-service\lib\Service.js
  resolveChainableWebpackConfig () {
    const chainableConfig = new Config()
    // apply chains
    this.webpackChainFns.forEach(fn => fn(chainableConfig))
    return chainableConfig
  }
  resolveWebpackConfig (chainableConfig = this.resolveChainableWebpackConfig()) {
    if (!this.initialized) {
      throw new Error('Service must call init() before calling resolveWebpackConfig().')
    }
    // get raw config
    let config = chainableConfig.toConfig()
    const original = config
    // apply raw config fns
    this.webpackRawConfigFns.forEach(fn => {
      if (typeof fn === 'function') {
        // function with optional return value
        const res = fn(config)
        if (res) config = merge(config, res)
      } else if (fn) {
        // merge literal values
        config = merge(config, fn)
      }
    })
    // ...
    return config
  }

可以看出,service 就是通过 webpackChainFns 变量和 webpackRawConfigFns 变量记录webpack配置,前者通过 webpack-chain 来合并配置,然后通过 toConfig 来转换为object。而后者通过 webpack-merge 来合并配置。
显然,webpackRawConfigFns 的优先级高于 webpackChainFns,而且越往后push进优先级越高。下面探究下内置webpack配置(base css prod app)、命令内置配置(serve / build)、用户配置(vue.config.js)的优先级顺序。
此时我们回头看 Servicerun 执行顺序。resolvePluginsinit,加载用户配置:

// lib\Service.js
module.exports = class Service {
  constructor (context, { plugins, pkg, inlineOptions, useBuiltIn } = {}) {
    // ...
    this.plugins = this.resolvePlugins(plugins, useBuiltIn)
    // ...
  }
// lib\Service.js
  resolvePlugins (inlinePlugins, useBuiltIn) {
    const idToPlugin = id => ({
      id: id.replace(/^.\//, 'built-in:'),
      apply: require(id)
    })

    let plugins

    const builtInPlugins = [
      './commands/serve',
      './commands/build',
      './commands/inspect',
      './commands/help',
      // config plugins are order sensitive
      './config/base',
      './config/css',
      './config/prod',
      './config/app'
    ].map(idToPlugin)

    if (inlinePlugins) {
      plugins = useBuiltIn !== false
        ? builtInPlugins.concat(inlinePlugins)
        : inlinePlugins
    } else {
      const projectPlugins = Object.keys(this.pkg.devDependencies || {})
        .concat(Object.keys(this.pkg.dependencies || {}))
        .filter(isPlugin)
        .map(id => {
          if (
            this.pkg.optionalDependencies &&
            id in this.pkg.optionalDependencies
          ) {
            let apply = () => {}
            try {
              apply = require(id)
            } catch (e) {
              warn(`Optional dependency ${id} is not installed.`)
            }

            return { id, apply }
          } else {
            return idToPlugin(id)
          }
        })
      plugins = builtInPlugins.concat(projectPlugins)
    }

    // Local plugins
    if (this.pkg.vuePlugins && this.pkg.vuePlugins.service) {
      const files = this.pkg.vuePlugins.service
      if (!Array.isArray(files)) {
        throw new Error(`Invalid type for option 'vuePlugins.service', expected 'array' but got ${typeof files}.`)
      }
      plugins = plugins.concat(files.map(file => ({
        id: `local:${file}`,
        apply: loadModule(`./${file}`, this.pkgContext)
      })))
    }

    return plugins
  }

此处针对config和command两种文件,封装调用方法。下面看看他们的构造

// lib\config\base.js
module.exports = (api, options) => {
  api.chainWebpack(webpackConfig => {
    // ...
    webpackConfig
      .mode('development')
      .context(api.service.context)
      .entry('app')
        .add('./src/main.js')
        .end()
      .output
        .path(api.resolve(options.outputDir))
        .filename(isLegacyBundle ? '[name]-legacy.js' : '[name].js')
        .publicPath(options.publicPath)
    // ...
  })
}

如果调用 apply,config会马上执行,把内置的webpack命令push到数组

// lib\commands\serve.js
  module.exports = (api, options) => {
    api.registerCommand('serve', {
      description: 'start development server',
      usage: 'vue-cli-service serve [options] [entry]',
      options: {
        '--open': `open browser on server start`,
        '--copy': `copy url to clipboard on server start`,
        '--stdin': `close when stdin ends`,
        '--mode': `specify env mode (default: development)`,
        '--host': `specify host (default: ${defaults.host})`,
        '--port': `specify port (default: ${defaults.port})`,
        '--https': `use https (default: ${defaults.https})`,
        '--public': `specify the public network URL for the HMR client`,
        '--skip-plugins': `comma-separated list of plugin names to skip for this run`
      }
    }, async function serve (args) {
      // ...
      // configs that only matters for dev server
      api.chainWebpack(webpackConfig => {
        if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') {
          webpackConfig
            .devtool('eval-cheap-module-source-map')

          webpackConfig
            .plugin('hmr')
              .use(require('webpack/lib/HotModuleReplacementPlugin'))

          // https://github.com/webpack/webpack/issues/6642
          // https://github.com/vuejs/vue-cli/issues/3539
          webpackConfig
            .output
              .globalObject(`(typeof self !== 'undefined' ? self : this)`)

          if (!process.env.VUE_CLI_TEST && options.devServer.progress !== false) {
            webpackConfig
              .plugin('progress')
              .use(require('webpack/lib/ProgressPlugin'))
          }
        }
      })
    }
      // ...
  }

如果调用 apply,command调用注册命令,命令函数并没有执行。

// packages\@vue\cli-service\lib\Service.js
  init (mode = process.env.VUE_CLI_MODE) {
    // ...
    // load user config
    // 加载用户配置,就是vue.config.js
    const userOptions = this.loadUserOptions()
    const loadedCallback = (loadedUserOptions) => {
      this.projectOptions = defaultsDeep(loadedUserOptions, defaults())
      // apply plugins.
      // 注册插件,放到this.service.commands下,注意此时未执行命令函数
      this.plugins.forEach(({ id, apply }) => {
        if (this.pluginsToSkip.has(id)) return
        apply(new PluginAPI(id, this), this.projectOptions)
      })
      // apply webpack configs from project config file
      // 把用户的webpack配置保存起来
      if (this.projectOptions.chainWebpack) {
        this.webpackChainFns.push(this.projectOptions.chainWebpack)
      }
      if (this.projectOptions.configureWebpack) {
        this.webpackRawConfigFns.push(this.projectOptions.configureWebpack)
      }
    }
    if (isPromise(userOptions)) {
      return userOptions.then(loadedCallback)
    } else {
      return loadedCallback(userOptions)
    }
  }

初始化的时候,先加载本地配置,加载完了,apply 内置config的webpack命令,注册command的webpack命令;用户的webpack命令把它们分别存到 webpackChainFnswebpackRawConfigFns;然后执行命令的时候,再把内置的命令push进去。所以,webpack的优先级顺序:内置配置webpack命令 < vue.config.js的webpack配置 < 命令配置webpack命令

11. 如何注册一个 vue-cli-service 命令?

根据官方文档描述,我们是可以自己写拆件,来扩展 vue-cli-service 命令的。可以参考《uni-app是如何构建小程序的?》,解析了 uniapp@dcloudio/vue-cli-plugin-uni 包是如何注册一个 vue-cli-serviceuni-build 命令的。
无非就是使用 PluginAPIapi.registerCommand 注册命令,以及使用 api.configureWebpackapi.chainWebpack 来合并webpack配置。

03-05 15:43