前言
正文
文件路径:/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)
}
})
}
执行文件定义create
async函数,其中...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
,通过creator
的create
方法来创建新的项目。
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}`))
}
其中presetPrompt
与featurePrompt
由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', '[email protected]'])
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
}