前言

上文简单介绍了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
    // 引入这个hack是因为typescript插件必须在router之后调用。
    // 目前我们依赖于' plugins '对象枚举顺序,
    // 这取决于字段初始化的顺序。
    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
    // 添加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
    // 通过plugin依赖生成 package.json对象
    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
    // 生成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 {
      // 安装node_modules依赖
      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
}

在获取完preset之后,就可以根据preset对象来决定package.json需要的依赖。来生成package.json文件.并判断是否拆分单个配置文件一次生成。生成目标文件是由writeFileTree方法执行。

// 写入文件到目标文件夹
module.exports = async function writeFileTree (dir, files, previousFiles) {
  if (process.env.VUE_CLI_SKIP_WRITE) {
    return
  }
  if (previousFiles) {
    await deleteRemovedFiles(dir, files, previousFiles)
  }
  Object.keys(files).forEach((name) => {
    const filePath = path.join(dir, name)
    fs.ensureDirSync(path.dirname(filePath))
    fs.writeFileSync(filePath, files[name])
  })
}

接着通过PackageManager实例pm调用install方法下载node_modules相关依赖。

PackageManager

class PackageManager {
  constructor ({ context, forcePackageManager } = {}) {
    this.context = context || process.cwd()
    this._registries = {}

    if (forcePackageManager) {
      this.bin = forcePackageManager
    } else if (context) {
      if (hasProjectYarn(context)) {
        this.bin = 'yarn'
      } else if (hasProjectPnpm(context)) {
        this.bin = 'pnpm'
      } else if (hasProjectNpm(context)) {
        this.bin = 'npm'
      }
    }

    // if no package managers specified, and no lockfile exists
    if (!this.bin) {
      this.bin = loadOptions().packageManager || (hasYarn() ? 'yarn' : hasPnpm3OrLater() ? 'pnpm' : 'npm')
    }

    if (this.bin === 'npm') {
      // npm doesn't support package aliases until v6.9
      const MIN_SUPPORTED_NPM_VERSION = '6.9.0'
      const npmVersion = stripAnsi(execa.sync('npm', ['--version']).stdout)

      if (semver.lt(npmVersion, MIN_SUPPORTED_NPM_VERSION)) {
        warn(
          'You are using an outdated version of NPM.\n' +
          'there may be unexpected errors during installation.\n' +
          'Please upgrade your NPM version.'
        )

        this.needsNpmInstallFix = true
      }

      if (semver.gte(npmVersion, '7.0.0')) {
        this.needsPeerDepsFix = true
      }
    }

    if (!SUPPORTED_PACKAGE_MANAGERS.includes(this.bin)) {
      log()
      warn(
        `The package manager ${chalk.red(this.bin)} is ${chalk.red('not officially supported')}.\n` +
        `It will be treated like ${chalk.cyan('npm')}, but compatibility issues may occur.\n` +
        `See if you can use ${chalk.cyan('--registry')} instead.`
      )
      PACKAGE_MANAGER_CONFIG[this.bin] = PACKAGE_MANAGER_CONFIG.npm
    }

    // Plugin may be located in another location if `resolveFrom` presents.
    const projectPkg = resolvePkg(this.context)
    const resolveFrom = projectPkg && projectPkg.vuePlugins && projectPkg.vuePlugins.resolveFrom

    // Logically, `resolveFrom` and `context` are distinct fields.
    // But in Vue CLI we only care about plugins.
    // So it is fine to let all other operations take place in the `resolveFrom` directory.
    if (resolveFrom) {
      this.context = path.resolve(context, resolveFrom)
    }
  }

  // Any command that implemented registry-related feature should support
  // `-r` / `--registry` option
  async getRegistry (scope) {
    const cacheKey = scope || ''
    if (this._registries[cacheKey]) {
      return this._registries[cacheKey]
    }

    const args = minimist(process.argv, {
      alias: {
        r: 'registry'
      }
    })

    let registry
    if (args.registry) {
      registry = args.registry
    } else if (!process.env.VUE_CLI_TEST && await shouldUseTaobao(this.bin)) {
      registry = registries.taobao
    } else {
      try {
        if (scope) {
          registry = (await execa(this.bin, ['config', 'get', scope + ':registry'])).stdout
        }
        if (!registry || registry === 'undefined') {
          registry = (await execa(this.bin, ['config', 'get', 'registry'])).stdout
        }
      } catch (e) {
        // Yarn 2 uses `npmRegistryServer` instead of `registry`
        registry = (await execa(this.bin, ['config', 'get', 'npmRegistryServer'])).stdout
      }
    }

    this._registries[cacheKey] = stripAnsi(registry).trim()
    return this._registries[cacheKey]
  }

  async getAuthToken (scope) {
    // get npmrc (https://docs.npmjs.com/configuring-npm/npmrc.html#files)
    const possibleRcPaths = [
      path.resolve(this.context, '.npmrc'),
      path.resolve(require('os').homedir(), '.npmrc')
    ]
    if (process.env.PREFIX) {
      possibleRcPaths.push(path.resolve(process.env.PREFIX, '/etc/npmrc'))
    }
    // there's also a '/path/to/npm/npmrc', skipped for simplicity of implementation

    let npmConfig = {}
    for (const loc of possibleRcPaths) {
      if (fs.existsSync(loc)) {
        try {
          // the closer config file (the one with lower index) takes higher precedence
          npmConfig = Object.assign({}, ini.parse(fs.readFileSync(loc, 'utf-8')), npmConfig)
        } catch (e) {
          // in case of file permission issues, etc.
        }
      }
    }

    const registry = await this.getRegistry(scope)
    const registryWithoutProtocol = registry
      .replace(/https?:/, '')     // remove leading protocol
      .replace(/([^/])$/, '$1/')  // ensure ending with slash
    const authTokenKey = `${registryWithoutProtocol}:_authToken`

    return npmConfig[authTokenKey]
  }

  async setRegistryEnvs () {
    const registry = await this.getRegistry()

    process.env.npm_config_registry = registry
    process.env.YARN_NPM_REGISTRY_SERVER = registry

    this.setBinaryMirrors()
  }

  // set mirror urls for users in china
  async setBinaryMirrors () {
    const registry = await this.getRegistry()

    if (registry !== registries.taobao) {
      return
    }

    try {
      // node-sass, chromedriver, etc.
      const binaryMirrorConfigMetadata = await this.getMetadata('binary-mirror-config', { full: true })
      const latest = binaryMirrorConfigMetadata['dist-tags'] && binaryMirrorConfigMetadata['dist-tags'].latest
      const mirrors = binaryMirrorConfigMetadata.versions[latest].mirrors.china
      for (const key in mirrors.ENVS) {
        process.env[key] = mirrors.ENVS[key]
      }

      // Cypress
      const cypressMirror = mirrors.cypress
      const defaultPlatforms = {
        darwin: 'osx64',
        linux: 'linux64',
        win32: 'win64'
      }
      const platforms = cypressMirror.newPlatforms || defaultPlatforms
      const targetPlatform = platforms[require('os').platform()]
      // Do not override user-defined env variable
      // Because we may construct a wrong download url and an escape hatch is necessary
      if (targetPlatform && !process.env.CYPRESS_INSTALL_BINARY) {
        // We only support cypress 3 for the current major version
        const latestCypressVersion = await this.getRemoteVersion('cypress', '^3')
        process.env.CYPRESS_INSTALL_BINARY =
          `${cypressMirror.host}/${latestCypressVersion}/${targetPlatform}/cypress.zip`
      }
    } catch (e) {
      // get binary mirror config failed
    }
  }

  // https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md
  async getMetadata (packageName, { full = false } = {}) {
    const scope = extractPackageScope(packageName)
    const registry = await this.getRegistry(scope)

    const metadataKey = `${this.bin}-${registry}-${packageName}`
    let metadata = metadataCache.get(metadataKey)

    if (metadata) {
      return metadata
    }

    const headers = {}
    if (!full) {
      headers.Accept = 'application/vnd.npm.install-v1+json;q=1.0, application/json;q=0.9, */*;q=0.8'
    }

    const authToken = await this.getAuthToken(scope)
    if (authToken) {
      headers.Authorization = `Bearer ${authToken}`
    }

    const url = `${registry.replace(/\/$/g, '')}/${packageName}`
    try {
      metadata = (await request.get(url, { headers })).body
      if (metadata.error) {
        throw new Error(metadata.error)
      }
      metadataCache.set(metadataKey, metadata)
      return metadata
    } catch (e) {
      error(`Failed to get response from ${url}`)
      throw e
    }
  }

  async getRemoteVersion (packageName, versionRange = 'latest') {
    const metadata = await this.getMetadata(packageName)
    if (Object.keys(metadata['dist-tags']).includes(versionRange)) {
      return metadata['dist-tags'][versionRange]
    }
    const versions = Array.isArray(metadata.versions) ? metadata.versions : Object.keys(metadata.versions)
    return semver.maxSatisfying(versions, versionRange)
  }

  getInstalledVersion (packageName) {
    // for first level deps, read package.json directly is way faster than `npm list`
    try {
      const packageJson = loadModule(`${packageName}/package.json`, this.context, true)
      return packageJson.version
    } catch (e) {}
  }

  async runCommand (command, args) {
    await this.setRegistryEnvs()
    return await executeCommand(
      this.bin,
      [
        ...PACKAGE_MANAGER_CONFIG[this.bin][command],
        ...(args || [])
      ],
      this.context
    )
  }

  async install () {
    if (process.env.VUE_CLI_TEST) {
      try {
        process.env.PUPPETEER_SKIP_CHROMIUM_DOWNLOAD = true
        await this.runCommand('install', ['--offline', '--silent', '--no-progress'])
        delete process.env.PUPPETEER_SKIP_CHROMIUM_DOWNLOAD
      } catch (e) {
        delete process.env.PUPPETEER_SKIP_CHROMIUM_DOWNLOAD
        await this.runCommand('install', ['--silent', '--no-progress'])
      }
    }

    if (this.needsNpmInstallFix) {
      // if npm 5, split into several `npm add` calls
      // see https://github.com/vuejs/vue-cli/issues/5800#issuecomment-675199729
      const pkg = resolvePkg(this.context)
      if (pkg.dependencies) {
        const deps = Object.entries(pkg.dependencies).map(([dep, range]) => `${dep}@${range}`)
        await this.runCommand('install', deps)
      }

      if (pkg.devDependencies) {
        const devDeps = Object.entries(pkg.devDependencies).map(([dep, range]) => `${dep}@${range}`)
        await this.runCommand('install', [...devDeps, '--save-dev'])
      }

      if (pkg.optionalDependencies) {
        const devDeps = Object.entries(pkg.devDependencies).map(([dep, range]) => `${dep}@${range}`)
        await this.runCommand('install', [...devDeps, '--save-optional'])
      }

      return
    }

    return await this.runCommand('install', this.needsPeerDepsFix ? ['--legacy-peer-deps'] : [])
  }

  async add (packageName, {
    tilde = false,
    dev = true
  } = {}) {
    const args = dev ? ['-D'] : []
    if (tilde) {
      if (this.bin === 'yarn') {
        args.push('--tilde')
      } else {
        process.env.npm_config_save_prefix = '~'
      }
    }

    if (this.needsPeerDepsFix) {
      args.push('--legacy-peer-deps')
    }

    return await this.runCommand('add', [packageName, ...args])
  }

  async remove (packageName) {
    return await this.runCommand('remove', [packageName])
  }

  async upgrade (packageName) {
    // manage multiple packages separated by spaces
    const packageNamesArray = []

    for (const packname of packageName.split(' ')) {
      const realname = stripVersion(packname)
      if (
        isTestOrDebug &&
        (packname === '@vue/cli-service' || isOfficialPlugin(resolvePluginId(realname)))
      ) {
        // link packages in current repo for test
        const src = path.resolve(__dirname, `../../../../${realname}`)
        const dest = path.join(this.context, 'node_modules', realname)
        await fs.remove(dest)
        await fs.symlink(src, dest, 'dir')
      } else {
        packageNamesArray.push(packname)
      }
    }

    if (packageNamesArray.length) return await this.runCommand('add', packageNamesArray)
  }
}

这里主要分析PackageManager类的install方法。核心逻辑即使resolvePkg获取package.json对象。并依次获取dependenciesdevDependencies,devDependencies属性,用runCommand运行命令行下载对应依赖。runCommand方法依赖的是execanpm包。

Generator

生成package.json,安装相应依赖之后,就要生成模板文件。这一块逻辑都是有Generator类负责。会生成一个Generator实例,并调用generate方法来创建模板文件。这里不过多介绍,后续讲vue-cli插件时再分析。

const generator = new Generator(context, {
    pkg,
    plugins,
    afterInvokeCbs,
    afterAnyInvokeCbs
})
await generator.generate({
    extractConfigFiles: preset.useConfigFiles
})

最后就是执行所有回调数组。执行git初始化相关操作等,并打印输出相关运行命令。可以参考自定义vue-cli插件。

for (const cb of afterInvokeCbs) {
    await cb()
}
for (const cb of afterAnyInvokeCbs) {
    await cb()
}
// 创建结束时可以输出自定义的语句。与
generator.printExitLogs()

总结

本文我们了解了vue create <app-name>的整个过程。简单概况就是首先会判断命令行的--preset选项,是否选择预设的preset(存储在home目下的.vuerc)。或者选择手动模式,则是通过inquirer与用户的交互来获取完整的preset对象,再通过拿到的preset对象来是生成package.json。然后依据pageage.json的依赖性下载对应的node_modules。在最后就是获取模板文件并生成到目标文件夹。并输出结束语句退出。

参考文章


看见了
876 声望16 粉丝

前端开发,略懂后台;


引用和评论

0 条评论