乐趣区

关于vue-cli4:源码vuecli459二

前言

上文简略介绍了 vue - V 的执行过程,本文在此基础上,持续剖析 vue create 的执行过程,理解 vue create <app-name> 经验了哪些过程?

注释

文件门路:/packages/@vue/cli/bin/vue.js

program
  .command('create <app-name>')
  .description('create a new project powered by vue-cli-service')
  .option('-p, --preset <presetName>', 'Skip prompts and use saved or remote preset')
  // 省略 option 相干代码
  ...
  .action((name, cmd) => {const options = cleanArgs(cmd)

    if (minimist(process.argv.slice(3))._.length > 1) {console.log(chalk.yellow('\n Info: You provided more than one argument. The first one will be used as the app\'s name, the rest are ignored.'))
    }
    // --git makes commander to default git to true
    if (process.argv.includes('-g') || process.argv.includes('--git')) {options.forceGit = true}
    require('../lib/create')(name, options)
  })

commander解析到 create <app-name> 命令,会主动执行 action 办法中的回调函数。首先是调用 cleanArgs 办法,它的作用是将传递的 Command 对象 cnd,提取理论的选项到一个新对象中作为 options,次要是为了可读性,5.0 版本曾经将这个办法删除。间接传递 Command 对象作为options 变量,理解即可。而后就是校验命令输出的非 options 参数是否超过 1 个,若是则揭示用户,将应用第一个非 option 参数作为项目名称。其次是判断是否有 -g 选项并从新赋值 forceGit 防止歧义。最重要就是执行 /lib/create 函数。

create.js

文件门路:/packages/@vue/cli/lib/create.js

module.exports = (...args) => {return create(...args).catch(err => {stopSpinner(false) // do not persist
    error(err)
    if (!process.env.VUE_CLI_TEST) {process.exit(1)
    }
  })
}

执行文件定义 createasync 函数, 其中...args 残余参数能够作为参数传递的小技巧。如果抛出异样则执行 catch 函数。

create

async function create (projectName, options) {
  // 是否有 --proxy,若有则定义 process.env.HTTP_PROXY 变量
  if (options.proxy) {process.env.HTTP_PROXY = options.proxy}
  // 获取执行命令行的上下文门路
  const cwd = options.cwd || process.cwd()
  // 是否在当前目录创立我的项目
  const inCurrent = projectName === '.'
  // 获取理论项目名称
  const name = inCurrent ? path.relative('../', cwd) : projectName
  // 获取生成我的项目的文件夹门路
  const targetDir = path.resolve(cwd, projectName || '.')

  const result = validateProjectName(name)
  if (!result.validForNewPackages) {console.error(chalk.red(`Invalid project name: "${name}"`))
    result.errors && result.errors.forEach(err => {console.error(chalk.red.dim('Error:' + err))
    })
    result.warnings && result.warnings.forEach(warn => {console.error(chalk.red.dim('Warning:' + warn))
    })
    exit(1)
  }
  // 判断生成指标文件夹是否存在且命令行没有 --merge 选项
  if (fs.existsSync(targetDir) && !options.merge) {
    // 有 --force 选项,间接移除已存在的指标文件夹
    if (options.force) {await fs.remove(targetDir)
    } else {await clearConsole()
      if (inCurrent) {
        // 1. 当前目录创立我的项目,询问是否持续,否则退出,是则持续
        const {ok} = await inquirer.prompt([
          {
            name: 'ok',
            type: 'confirm',
            message: `Generate project in current directory?`
          }
        ])
        if (!ok) {return}
      } else {
        // 1. 非当前目录创立我的项目,给出 3 个抉择:Overwrite、Merge、Cancel
        const {action} = await inquirer.prompt([
          {
            name: 'action',
            type: 'list',
            message: `Target directory ${chalk.cyan(targetDir)} already exists. Pick an action:`,
            choices: [{ name: 'Overwrite', value: 'overwrite'},
              {name: 'Merge', value: 'merge'},
              {name: 'Cancel', value: false}
            ]
          }
        ])
        // cancel 间接退出
        if (!action) {return} else if (action === 'overwrite') {
        // overwrite 删除原文件夹,抉择 merge 就是继续执行上面代码
          console.log(`\nRemoving ${chalk.cyan(targetDir)}...`)
          await fs.remove(targetDir)
        }
      }
    }
  }
  const creator = new Creator(name, targetDir, getPromptModules())
  await creator.create(options)
}

在当前目录创立我的项目, 会执行 vue create ., 所以通过projectName === '.' 是否为当前目录。validateProjectName函数则是校验项目名称是否合乎 npm 包名规范,如不合乎则正告并退出。接着就是校验要生成指标文件夹是否已存在,若已存在,则抉择是否继续执行(merge),或者间接退出执行,以及删除已存在文件夹继续执行生成我的项目。最终就是生成 Creator 实例 creator,通过creatorcreate办法来创立新的我的项目。

Creator

文件门路:/packages/@vue/cli/lib/Creator.js

将创立我的项目的相干逻辑封装到了 Creator 类中。首先从构造函数开始

class Creator extends EventEmitter {constructor (name, context, promptModules) {super()

        this.name = name
        this.context = process.env.VUE_CLI_CONTEXT = context
        const {presetPrompt, featurePrompt} = this.resolveIntroPrompts()

        this.presetPrompt = presetPrompt
        this.featurePrompt = featurePrompt
        this.outroPrompts = this.resolveOutroPrompts()
        this.injectedPrompts = []
        // 回调函数数组
        this.promptCompleteCbs = []
        this.afterInvokeCbs = []
        this.afterAnyInvokeCbs = []

        this.run = this.run.bind(this)
        
        const promptAPI = new PromptModuleAPI(this)
        // 获取 cli/lib/promptModules 下所有函数,并传入 promptAPI 作为参数
        promptModules.forEach(m => m(promptAPI))
    }
}

实例化 Creator 类会接管 3 个参数,别离是 项目名称 name 生成指标文件夹门路 targetDir, 以及promptModules。这里传入的promptModules 是由 getPromptModules 函数生成。它会返回函数数组,别离动静援用了 cli/lib/promptModules 各个文件暴露出的函数,每个函数都会接管 PromptModuleAPI 实例做为 cli 参数。这里不过多介绍 PromptModuleAPI 类,你能够简略了解为对 creator 实例的二次封装。通过 PromptModuleAPI 实例来实现对 命令行交互提醒 相干逻辑的操作。

文件门路:/packages/@vue/cli/lib/utils/createTools.js

exports.getPromptModules = () => {
  return [
    'vueVersion',
    'babel',
    'typescript',
    'pwa',
    'router',
    'vuex',
    'cssPreprocessors',
    'linter',
    'unit',
    'e2e'
  ].map(file => require(`../promptModules/${file}`))
}

其中 presetPromptfeaturePrompt由 Creator 类的 resolveIntroPrompts 办法生成。

  resolveIntroPrompts () {
    // 获取 preset 对象
    const presets = this.getPresets()
    // preset 对象转换成 array
    const presetChoices = Object.entries(presets).map(([name, preset]) => {
      let displayName = name
      if (name === 'default') {displayName = 'Default'} else if (name === '__default_vue_3__') {displayName = 'Default (Vue 3 Preview)'
      }

      return {name: `${displayName} (${formatFeatures(preset)})`,
        value: name
      }
    })
    // 生成 presetPrompt
    const presetPrompt = {
      name: 'preset',
      type: 'list',
      message: `Please pick a preset:`,
      choices: [
        ...presetChoices,
        {
          name: 'Manually select features',
          value: '__manual__'
        }
      ]
    }
    // 生成 featurePrompt
    const featurePrompt = {
      name: 'features',
      when: isManualMode,
      type: 'checkbox',
      message: 'Check the features needed for your project:',
      choices: [],
      pageSize: 10
    }
    return {
      presetPrompt,
      featurePrompt
    }
  }

resolveIntroPrompts 函数中会首先执行 getPresets 办法来获取 preset。从这里开始就用到 option.js 相干的操作 loadOptions。在 vue create 过程中保留的 preset 会被放在你的 home 目录下的一个配置文件中 (~/.vuerc)。所以resolveIntroPrompts 函数会读取 .vuerc 的 preset。而后将默认内置的 defaults.presets 合并成残缺的 preset 列表对象。

exports.defaults = {
  lastChecked: undefined,
  latestVersion: undefined,

  packageManager: undefined,
  useTaobaoRegistry: undefined,
  presets: {'default': Object.assign({ vueVersion: '2'}, exports.defaultPreset),
    '__default_vue_3__': Object.assign({vueVersion: '3'}, exports.defaultPreset)
  }
}
getPresets () {const savedOptions = loadOptions()
    return Object.assign({}, savedOptions.presets, defaults.presets)
}

文件门路:/packages/@vue/cli/lib/options.js

let cachedOptions

exports.loadOptions = () => {
  // 存在则返回缓存的 Options
  if (cachedOptions) {return cachedOptions}
  // rcPath 个别为用户目录下的.vuerc 文件的绝对路径
  if (fs.existsSync(rcPath)) {
    try {cachedOptions = JSON.parse(fs.readFileSync(rcPath, 'utf-8'))
    } catch (e) {
      error(
        `Error loading saved preferences: ` +
        `~/.vuerc may be corrupted or have syntax errors. ` +
        `Please fix/delete it and re-run vue-cli in manual mode.\n` +
        `(${e.message})`
      )
      exit(1)
    }
    validate(cachedOptions, schema, () => {
      error(
        `~/.vuerc may be outdated. ` +
        `Please delete it and re-run vue-cli in manual mode.`
      )
    })
    return cachedOptions
  } else {return {}
  }
}
  • preset 示例
{
  "useConfigFiles": true,
  "cssPreprocessor": "sass",
  "plugins": {"@vue/cli-plugin-babel": {},
    "@vue/cli-plugin-eslint": {
      "config": "airbnb",
      "lintOn": ["save", "commit"]
    },
    "@vue/cli-plugin-router": {},
    "@vue/cli-plugin-vuex": {}}
}

outroPrompts则是由 resolveOutroPrompts() 生成, 封装了命令行执行的结尾相干的 Prompt, 如是否拆分单个配置化文件,是否保留 preset 到 .vuerc 等。最初还会判断是否有设置默认包管理器,没有的话(换句话说,第一次执行vue create <app-name>)则减少抉择默认包管理器的交互提醒。

  resolveOutroPrompts () {
    const outroPrompts = [
      {
        name: 'useConfigFiles',
        when: isManualMode,
        type: 'list',
        message: 'Where do you prefer placing config for Babel, ESLint, etc.?',
        choices: [
          {
            name: 'In dedicated config files',
            value: 'files'
          },
          {
            name: 'In package.json',
            value: 'pkg'
          }
        ]
      },
      {
        name: 'save',
        when: isManualMode,
        type: 'confirm',
        message: 'Save this as a preset for future projects?',
        default: false
      },
      {
        name: 'saveName',
        when: answers => answers.save,
        type: 'input',
        message: 'Save preset as:'
      }
    ]

    // ask for packageManager once
    const savedOptions = loadOptions()
    if (!savedOptions.packageManager && (hasYarn() || hasPnpm3OrLater())) {const packageManagerChoices = []

      if (hasYarn()) {
        packageManagerChoices.push({
          name: 'Use Yarn',
          value: 'yarn',
          short: 'Yarn'
        })
      }

      if (hasPnpm3OrLater()) {
        packageManagerChoices.push({
          name: 'Use PNPM',
          value: 'pnpm',
          short: 'PNPM'
        })
      }

      packageManagerChoices.push({
        name: 'Use NPM',
        value: 'npm',
        short: 'NPM'
      })

      outroPrompts.push({
        name: 'packageManager',
        type: 'list',
        message: 'Pick the package manager to use when installing dependencies:',
        choices: packageManagerChoices
      })
    }

    return outroPrompts
  }

create 办法

真正生成我的项目的逻辑则是调用了 creator 实例的 create 办法。由上文可知,cliOptions是命令行的参数选项形成的对象,preset不传默认是 null。所以会生成 preset 对象,代码中分为 4 种状况,这里剖析最初一种,通过 promptAndResolvePreset 办法生成 preset 对象。

  async create (cliOptions = {}, preset = null) {
    const isTestOrDebug = process.env.VUE_CLI_TEST || process.env.VUE_CLI_DEBUG
    const {run, name, context, afterInvokeCbs, afterAnyInvokeCbs} = this
    
    if (!preset) {if (cliOptions.preset) {
        // vue create foo --preset bar
        preset = await this.resolvePreset(cliOptions.preset, cliOptions.clone)
      } else if (cliOptions.default) {
        // vue create foo --default
        preset = defaults.presets.default
      } else if (cliOptions.inlinePreset) {// vue create foo --inlinePreset {...}
        try {preset = JSON.parse(cliOptions.inlinePreset)
        } catch (e) {error(`CLI inline preset is not valid JSON: ${cliOptions.inlinePreset}`)
          exit(1)
        }
      } else {preset = await this.promptAndResolvePreset()
      }
    }

    // clone before mutating
    preset = cloneDeep(preset)
    // inject core service
    preset.plugins['@vue/cli-service'] = Object.assign({projectName: name}, preset)

    if (cliOptions.bare) {preset.plugins['@vue/cli-service'].bare = true
    }

    // legacy support for router
    if (preset.router) {preset.plugins['@vue/cli-plugin-router'] = {}

      if (preset.routerHistoryMode) {preset.plugins['@vue/cli-plugin-router'].historyMode = true
      }
    }

    // Introducing this hack because typescript plugin must be invoked after router.
    // Currently we rely on the `plugins` object enumeration order,
    // which depends on the order of the field initialization.
    // FIXME: Remove this ugly hack after the plugin ordering API settled down
    if (preset.plugins['@vue/cli-plugin-router'] && preset.plugins['@vue/cli-plugin-typescript']) {const tmp = preset.plugins['@vue/cli-plugin-typescript']
      delete preset.plugins['@vue/cli-plugin-typescript']
      preset.plugins['@vue/cli-plugin-typescript'] = tmp
    }

    // legacy support for vuex
    if (preset.vuex) {preset.plugins['@vue/cli-plugin-vuex'] = {}}

    const packageManager = (
      cliOptions.packageManager ||
      loadOptions().packageManager ||
      (hasYarn() ? 'yarn' : null) ||
      (hasPnpm3OrLater() ? 'pnpm' : 'npm')
    )

    await clearConsole()
    const pm = new PackageManager({context, forcePackageManager: packageManager})

    log(`✨  Creating project in ${chalk.yellow(context)}.`)
    this.emit('creation', { event: 'creating'})

    // get latest CLI plugin version
    const {latestMinor} = await getVersions()

    // generate package.json with plugin dependencies
    const pkg = {
      name,
      version: '0.1.0',
      private: true,
      devDependencies: {},
      ...resolvePkg(context)
    }
    const deps = Object.keys(preset.plugins)
    deps.forEach(dep => {if (preset.plugins[dep]._isPreset) {return}

      let {version} = preset.plugins[dep]

      if (!version) {if (isOfficialPlugin(dep) || dep === '@vue/cli-service' || dep === '@vue/babel-preset-env') {version = isTestOrDebug ? `file:${path.resolve(__dirname, '../../../', dep)}` : `~${latestMinor}`
        } else {version = 'latest'}
      }

      pkg.devDependencies[dep] = version
    })

    // write package.json
    await writeFileTree(context, {'package.json': JSON.stringify(pkg, null, 2)
    })

    // generate a .npmrc file for pnpm, to persist the `shamefully-flatten` flag
    if (packageManager === 'pnpm') {const pnpmConfig = hasPnpmVersionOrLater('4.0.0')
        ? 'shamefully-hoist=true\n'
        : 'shamefully-flatten=true\n'

      await writeFileTree(context, {'.npmrc': pnpmConfig})
    }

    if (packageManager === 'yarn' && semver.satisfies(process.version, '8.x')) {
      // Vue CLI 4.x should support Node 8.x,
      // but some dependenices already bumped `engines` field to Node 10
      // and Yarn treats `engines` field too strictly
      await writeFileTree(context, {'.yarnrc': '# Hotfix for Node 8.x\n--install.ignore-engines true\n'})
    }

    // intilaize git repository before installing deps
    // so that vue-cli-service can setup git hooks.
    const shouldInitGit = this.shouldInitGit(cliOptions)
    if (shouldInitGit) {log(`🗃  Initializing git repository...`)
      this.emit('creation', { event: 'git-init'})
      await run('git init')
    }

    // install plugins
    log(`⚙\u{fe0f}  Installing CLI plugins. This might take a while...`)
    log()
    this.emit('creation', { event: 'plugins-install'})

    if (isTestOrDebug && !process.env.VUE_CLI_TEST_DO_INSTALL_PLUGIN) {
      // in development, avoid installation process
      await require('./util/setupDevProject')(context)
    } else {await pm.install()
    }

    // run generator
    log(`🚀  Invoking generators...`)
    this.emit('creation', { event: 'invoking-generators'})
    const plugins = await this.resolvePlugins(preset.plugins, pkg)
    const generator = new Generator(context, {
      pkg,
      plugins,
      afterInvokeCbs,
      afterAnyInvokeCbs
    })
    await generator.generate({extractConfigFiles: preset.useConfigFiles})

    // install additional deps (injected by generators)
    log(`📦  Installing additional dependencies...`)
    this.emit('creation', { event: 'deps-install'})
    log()
    if (!isTestOrDebug || process.env.VUE_CLI_TEST_DO_INSTALL_PLUGIN) {await pm.install()
    }

    // run complete cbs if any (injected by generators)
    log(`⚓  Running completion hooks...`)
    this.emit('creation', { event: 'completion-hooks'})
    for (const cb of afterInvokeCbs) {await cb()
    }
    for (const cb of afterAnyInvokeCbs) {await cb()
    }

    if (!generator.files['README.md']) {
      // generate README.md
      log()
      log('📄  Generating README.md...')
      await writeFileTree(context, {'README.md': generateReadme(generator.pkg, packageManager)
      })
    }

    // commit initial state
    let gitCommitFailed = false
    if (shouldInitGit) {await run('git add -A')
      if (isTestOrDebug) {await run('git', ['config', 'user.name', 'test'])
        await run('git', ['config', 'user.email', 'test@test.com'])
        await run('git', ['config', 'commit.gpgSign', 'false'])
      }
      const msg = typeof cliOptions.git === 'string' ? cliOptions.git : 'init'
      try {await run('git', ['commit', '-m', msg, '--no-verify'])
      } catch (e) {gitCommitFailed = true}
    }

    // log instructions
    log()
    log(`🎉  Successfully created project ${chalk.yellow(name)}.`)
    if (!cliOptions.skipGetStarted) {
      log(
        `👉  Get started with the following commands:\n\n` +
        (this.context === process.cwd() ? `` : chalk.cyan(` ${chalk.gray('$')} cd ${name}\n`)) +
        chalk.cyan(` ${chalk.gray('$')} ${packageManager === 'yarn' ? 'yarn serve' : packageManager === 'pnpm' ? 'pnpm run serve' : 'npm run serve'}`)
      )
    }
    log()
    this.emit('creation', { event: 'done'})

    if (gitCommitFailed) {
      warn(
        `Skipped git commit due to missing username and email in git config, or failed to sign commit.\n` +
        `You will need to perform the initial commit yourself.\n`
      )
    }

    generator.printExitLogs()}
  • promptAndResolvePreset 办法

实质上就是应用 inquirer 库来执行 creator 实例中所有相干的 Prompt, 通过用户抉择手动模式或预设模式来生成相应的preset 对象。

  async promptAndResolvePreset (answers = null) {
    // prompt
    if (!answers) {await clearConsole(true)
       // 外围代码
      answers = await inquirer.prompt(this.resolveFinalPrompts())
    }
    debug('vue-cli:answers')(answers)

    if (answers.packageManager) {
      saveOptions({packageManager: answers.packageManager})
    }

    let preset
    // 内置保留 preset
    if (answers.preset && answers.preset !== '__manual__') {preset = await this.resolvePreset(answers.preset)
    } else {
      // 手动抉择我的项目各个个性
      // manual
      preset = {
        useConfigFiles: answers.useConfigFiles === 'files',
        plugins: {}}
      answers.features = answers.features || []
      // run cb registered by prompt modules to finalize the preset
      this.promptCompleteCbs.forEach(cb => cb(answers, preset))
    }

    // validate
    validatePreset(preset)

    // save preset
    if (answers.save && answers.saveName && savePreset(answers.saveName, preset)) {log()
      log(`🎉  Preset ${chalk.yellow(answers.saveName)} saved in ${chalk.yellow(rcPath)}`)
    }

    debug('vue-cli:preset')(preset)
    return preset
  }

其中 resolveFinalPrompts 函数就是获取所有相干的 Prompts。

resolveFinalPrompts () {
    // patch generator-injected prompts to only show in manual mode
    // 手动模式下才执行 injectedPrompts 数组提醒
    this.injectedPrompts.forEach(prompt => {const originalWhen = prompt.when || (() => true)
        prompt.when = answers => {return isManualMode(answers) && originalWhen(answers)
        }
    })

    const prompts = [
        this.presetPrompt,
        this.featurePrompt,
        ...this.injectedPrompts,
        ...this.outroPrompts
    ]
    debug('vue-cli:prompts')(prompts)
    return prompts
}
退出移动版