vue-cli的运行命令为vue create [projectName]
在vue-cli项目v3分支中,找到package/@vue/cli/bin/vue.js
,这个文件里定义了vue-cli的相关命令:
program
.command('create <app-name>')
.description('create a new project powered by vue-cli-service')
// ...
.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)
})
可以看到这个命令最后加载了package/@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 function create (projectName, options) {
// 项目存放地址校验,项目名校验
// getPromptModules() 获取需要确认的模块
// 'babel','typescript'等模块
const creator = new Creator(name, targetDir, getPromptModules())
await creator.create(options)
}
Creator的构造方法是:
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.createCompleteCbs = []
this.run = this.run.bind(this)
const promptAPI = new PromptModuleAPI(this)
promptModules.forEach(m => m(promptAPI))
}
这一段初始化代码主要做的事情是将一些需要用户确认的模块push到对应的成员变量里,PromptModuleAPI
的构造方法是:
injectFeature (feature) {
this.creator.featurePrompt.choices.push(feature)
}
injectPrompt (prompt) {
this.creator.injectedPrompts.push(prompt)
}
injectOptionForPrompt (name, option) {
this.creator.injectedPrompts.find(f => {
return f.name === name
}).choices.push(option)
}
onPromptComplete (cb) {
this.creator.promptCompleteCbs.push(cb)
}
需要确认的模块在package/@vue/cli/lib/util/createTools.js
中定义:
exports.getPromptModules = () => {
return [
'babel',
'typescript',
'pwa',
'router',
'vuex',
'cssPreprocessors',
'linter',
'unit',
'e2e'
].map(file => require(`../promptModules/${file}`))
}
接下来看create方法,这个方法很长,用文字代替
async create (cliOptions = {}, preset = null) {
// 1. 对项目预设配置进行初始化
// 2. 给preset添加plugin @vue/cli-service
// 3. 确定依赖管理工具 yarn pnpm npm
// 4. 初始化package.json内容,检查预制依赖的版本
// 5. 是否使用git init
// 6. 安装依赖
// 7. generator
const generator = new Generator(context, {
pkg, // package.json的内容
plugins, // preset.plugin
completeCbs: createCompleteCbs
})
await generator.generate({
extractConfigFiles: preset.useConfigFiles
})
// 8. 创建README.md文件
// 9. 根据命令行参数决定是否执行git commit -m"init"
}
在第七步的源码中,我们可以看到创建了一个Generator
对象,并执行了它的generate
方法。构造参数中的plugins
为:
async resolvePlugins (rawPlugins) {
// ensure cli-service is invoked first
rawPlugins = sortObject(rawPlugins, ['@vue/cli-service'], true)
const plugins = []
for (const id of Object.keys(rawPlugins)) {
// 注意这里的apply,加载了@vue/cli-service/generator模块
const apply = loadModule(`${id}/generator`, this.context) || (() => {})
let options = rawPlugins[id] || {}
if (options.prompts) {
const prompts = loadModule(`${id}/prompts`, this.context)
if (prompts) {
log()
log(`${chalk.cyan(options._isPreset ? `Preset options:` : id)}`)
options = await inquirer.prompt(prompts)
}
}
plugins.push({ id, apply, options })
}
return plugins
}
loadModule
方法定义在packages/@vue/cli-shared-utils/lib/module.js
exports.loadModule = function (request, context, force = false) {
const resolvedPath = exports.resolveModule(request, context)
if (resolvedPath) {
if (force) {
clearRequireCache(resolvedPath)
}
return require(resolvedPath)
}
}
在Generator
构造方法中对传进来的plugins
进行了处理,调用了apply
方法。
plugins.forEach(({ id, apply, options }) => {
const api = new GeneratorAPI(id, this, options, rootOptions)
apply(api, options, rootOptions, invoking)
})
因此我们回过头去看@vue/cli-service/generator
这个模块做了什么,这个模块的文件列表如下:
├── index.js
├── router
│ ├── index.js
│ └── template
│ └── src
├── template // 代码模板
│ ├── _gitignore
│ ├── public
│ │ ├── favicon.ico
│ │ └── index.html
│ └── src
│ ├── App.vue
│ ├── assets
│ ├── components
│ └── main.js
└── vuex
├── index.js
└── template
└── src
index.js
module.exports = (api, options) => {
api.render('./template', {
doesCompile: api.hasPlugin('babel') || api.hasPlugin('typescript')
})
// 下面的代码是,根据命令行参数扩展package.json的内容
// 如增加router依赖 vuex依赖 less sass 等等依赖
}
可以看到执行apply(api, options, rootOptions, invoking)
这句代码实际上就是执行了api.render
方法,因为api
是GeneratorAPI
的实例,找到它的render方法:
/**
* Render template files into the virtual files tree object.
*
* @param {string | object | FileMiddleware} source -
* Can be one of:
* - relative path to a directory;
* - Object hash of { sourceTemplate: targetFile } mappings;
* - a custom file middleware function.
* @param {object} [additionalData] - additional data available to templates.
* @param {object} [ejsOptions] - options for ejs.
*/
render (source, additionalData = {}, ejsOptions = {}) {
const baseDir = extractCallDir()
if (isString(source)) {
source = path.resolve(baseDir, source)
// _injectFileMiddleware 方法定义为
// this.generator.fileMiddlewares.push(middleware)
this._injectFileMiddleware(async (files) => {
const data = this._resolveData(additionalData)
const globby = require('globby')
const _files = await globby(['**/*'], { cwd: source })
for (const rawPath of _files) {
const targetPath = rawPath.split('/').map(filename => {
// dotfiles are ignored when published to npm, therefore in templates
// we need to use underscore instead (e.g. "_gitignore")
if (filename.charAt(0) === '_' && filename.charAt(1) !== '_') {
return `.${filename.slice(1)}`
}
if (filename.charAt(0) === '_' && filename.charAt(1) === '_') {
return `${filename.slice(1)}`
}
return filename
}).join('/')
const sourcePath = path.resolve(source, rawPath)
const content = renderFile(sourcePath, data, ejsOptions) // 注意这里
// only set file if it's not all whitespace, or is a Buffer (binary files)
if (Buffer.isBuffer(content) || /[^\s]/.test(content)) {
files[targetPath] = content
}
}
})
}
// 其他对于source类型的判断
实际上是将一个箭头函数push
到了generator
的fileMiddlewares
数组里,generator
实际上就是new GeneratorAPI
时传进去的this
。
generate
方法定义为:
async generate ({
extractConfigFiles = false,
checkExisting = false
} = {}) {
// save the file system before applying plugin for comparison
const initialFiles = Object.assign({}, this.files)
// extract configs from package.json into dedicated files.
this.extractConfigFiles(extractConfigFiles, checkExisting)
// wait for file resolve
await this.resolveFiles() // 此处执行了fileMiddlewares内部的方法
// set package.json
this.sortPkg()
this.files['package.json'] = JSON.stringify(this.pkg, null, 2) + '\n'
// write/update file tree to disk
await writeFileTree(this.context, this.files, initialFiles)
}
在resolveFiles
中执行了数组中存放的箭头函数:
async resolveFiles () {
const files = this.files
for (const middleware of this.fileMiddlewares) {
await middleware(files, ejs.render) // 此处执行之前push进去的箭头函数
}
// normalize file paths on windows
// all paths are converted to use / instead of \
normalizeFilePaths(files)
// handle imports and root option injections
Object.keys(files).forEach(file => {
let imports = this.imports[file]
imports = imports instanceof Set ? Array.from(imports) : imports
if (imports && imports.length > 0) {
files[file] = runCodemod(
require('./util/codemods/injectImports'),
{ path: file, source: files[file] },
{ imports }
)
}
let injections = this.rootOptions[file]
injections = injections instanceof Set ? Array.from(injections) : injections
if (injections && injections.length > 0) {
files[file] = runCodemod(
require('./util/codemods/injectOptions'),
{ path: file, source: files[file] },
{ injections }
)
}
})
for (const postProcess of this.postProcessFilesCbs) {
await postProcess(files)
}
debug('vue:cli-files')(this.files)
}
回到之前的箭头函数,方法体中执行了renderFile
方法,
function renderFile (name, data, ejsOptions) {
if (isBinaryFileSync(name)) {
return fs.readFileSync(name) // return buffer
}
const template = fs.readFileSync(name, 'utf-8')
// custom template inheritance via yaml front matter.
// ---
// extend: 'source-file'
// replace: !!js/regexp /some-regex/
// OR
// replace:
// - !!js/regexp /foo/
// - !!js/regexp /bar/
// ---
const yaml = require('yaml-front-matter')
const parsed = yaml.loadFront(template)
const content = parsed.__content
let finalTemplate = content.trim() + `\n`
if (parsed.when) {
finalTemplate = (
`<%_ if (${parsed.when}) { _%>` +
finalTemplate +
`<%_ } _%>`
)
// use ejs.render to test the conditional expression
// if evaluated to falsy value, return early to avoid extra cost for extend expression
const result = ejs.render(finalTemplate, data, ejsOptions)
if (!result) {
return ''
}
}
if (parsed.extend) {
const extendPath = path.isAbsolute(parsed.extend)
? parsed.extend
: resolve.sync(parsed.extend, { basedir: path.dirname(name) })
finalTemplate = fs.readFileSync(extendPath, 'utf-8')
if (parsed.replace) {
if (Array.isArray(parsed.replace)) {
const replaceMatch = content.match(replaceBlockRE)
if (replaceMatch) {
const replaces = replaceMatch.map(m => {
return m.replace(replaceBlockRE, '$1').trim()
})
parsed.replace.forEach((r, i) => {
finalTemplate = finalTemplate.replace(r, replaces[i])
})
}
} else {
finalTemplate = finalTemplate.replace(parsed.replace, content.trim())
}
}
}
return ejs.render(finalTemplate, data, ejsOptions)
}
最后是调用了ejs.render方法来将代码模版输出成正常的代码文件.
preset
(项目预设配置)可以使用默认的vue
配置,也可以通过命令行参数preset
来指定远程github或者其他类git
库preset
存放地址,相关代码如下
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()
}
}
resolvePreset
部分源码:
if (name in savedPresets) {
preset = savedPresets[name]
} else if (name.endsWith('.json') || /^\./.test(name) || path.isAbsolute(name)) {
preset = await loadLocalPreset(path.resolve(name))
} else if (name.includes('/')) {
logWithSpinner(`Fetching remote preset ${chalk.cyan(name)}...`)
this.emit('creation', { event: 'fetch-remote-preset' })
try {
// 拉取远程预设
preset = await loadRemotePreset(name, clone)
stopSpinner()
} catch (e) {
stopSpinner()
error(`Failed fetching remote preset ${chalk.cyan(name)}:`)
throw e
}
}
loadRemotePreset
:
const fs = require('fs-extra')
const loadPresetFromDir = require('./loadPresetFromDir')
module.exports = async function fetchRemotePreset (name, clone) {
const os = require('os')
const path = require('path')
const download = require('download-git-repo')
const tmpdir = path.join(os.tmpdir(), 'vue-cli')
// clone will fail if tmpdir already exists
// https://github.com/flipxfx/download-git-repo/issues/41
if (clone) {
await fs.remove(tmpdir)
}
await new Promise((resolve, reject) => {
download(name, tmpdir, { clone }, err => {
if (err) return reject(err)
resolve()
})
})
return await loadPresetFromDir(tmpdir)
}
create-react-app
命令入口:/packages/create-react-app/index.js
对当前版本信息进行了判断,然后引入createReactApp
文件
'use strict';
var currentNodeVersion = process.versions.node;
var semver = currentNodeVersion.split('.');
var major = semver[0];
if (major < 8) {
console.error(
'You are running Node ' +
currentNodeVersion +
'.\n' +
'Create React App requires Node 8 or higher. \n' +
'Please update your version of Node.'
);
process.exit(1);
}
require('./createReactApp');
文件开头定义了create-react-app启动命令: create-react-app
let projectName;
const program = new commander.Command(packageJson.name)
.version(packageJson.version)
.arguments('<project-directory>')
.usage(`${chalk.green('<project-directory>')} [options]`)
.action(name => {
projectName = name;
})
// ... 其他log
当projectName
不为空的时候,执行createApp
方法
createApp(
projectName,
program.verbose, // --verbose
program.scriptsVersion, // --scripts-version 用于指定react-script版本
program.template, // 代码模板 ts or js
program.useNpm, // --use-npm
program.usePnp, // --use-pnp
program.typescript // --typescript 是否启用typescript模板
);
看一下createApp方法定义:
function createApp(
name,
verbose,
version,
template,
useNpm,
usePnp,
useTypeScript
) {
// ...
// 检查node版本并设置react-scripts初始版本为@0.9.x
// ...
const root = path.resolve(name); // 初始化的项目目录路径
const appName = path.basename(root); // 看起来似乎appName === name...
checkAppName(appName); // 检查app名称是否符合规范
fs.ensureDirSync(name); // 确保当前目录存在,不存在则创建此目录
// 判断目录下是否存在一些隐藏文件 比如:.DS_Store .git等等 存在即返回false
if (!isSafeToCreateProjectIn(root, name)) {
process.exit(1);
}
console.log(`Creating a new React app in ${chalk.green(root)}.`);
console.log();
const packageJson = {
name: appName,
version: '0.1.0',
private: true,
};
// 在当前目录下创建package.json文件
fs.writeFileSync(
path.join(root, 'package.json'),
JSON.stringify(packageJson, null, 2) + os.EOL // EOL \n => POSIX \r\n -> windows
);
const useYarn = useNpm ? false : shouldUseYarn();
// 当前工作目录,即项目目录的父级
const originalDirectory = process.cwd();
// 改变cwd -> 项目目录
process.chdir(root);
// 判断当前工作目录是否可以执行npm
if (!useYarn && !checkThatNpmCanReadCwd()) {
process.exit(1);
}
// ...
// 检查yarn pnp的版本支持
// ...
// 兼容之前的版本命令 --typescript
if (useTypeScript) {
console.log(
chalk.yellow(
'The --typescript option has been deprecated and will be removed in a future release.'
)
);
console.log(
chalk.yellow(
`In future, please use ${chalk.cyan('--template typescript')}.`
)
);
console.log();
if (!template) {
template = 'typescript';
}
}
// 设置yarn的依赖仓库
run(
root, // 项目目录
appName, // 项目目录名 -> 项目名
version, // scripts-version
verbose, // --verbose
originalDirectory, // 项目目录的父级目录
template, // 代码模板 如果使用了--typescript参数 则template为typescript
useYarn, // boolean 是否使用yarn作为包管理工具
usePnp // boolean 是否使用pnpm作为包管理工具
);
}
run
方法是一系列的promise链式调用,方法比较多也比较复杂:
function run(
root,
appName,
version,
verbose,
originalDirectory,
template,
useYarn,
usePnp
) {
Promise.all([
// 返回正确的react-script版本依赖字符串,如react-scripts@3.2.0
getInstallPackage(version, originalDirectory),
// 返回代码模板地址 template为空时返回cra-template template为typescript时返回cra-template-typescript
// 其他根据正则匹配返回本地文件或网络文件地址
getTemplateInstallPackage(template, originalDirectory),
]).then(([packageToInstall, templateToInstall]) => {
const allDependencies = ['react', 'react-dom', packageToInstall];
console.log('Installing packages. This might take a couple of minutes.');
Promise.all([
// 对于网络地址的解析(.tar等压缩文件,git+等代码库文件)
// 不是以上地址的直接返回原地址,如cra-template
// 对于react-scripts返回对象{name: 'react-scripts', version: '3.2.0'}
getPackageInfo(packageToInstall),
getPackageInfo(templateToInstall),
])
.then(([packageInfo, templateInfo]) =>
// 对yarn离线模式的处理
checkIfOnline(useYarn).then(isOnline => ({
isOnline,
packageInfo,
templateInfo,
}))
)
.then(({ isOnline, packageInfo, templateInfo }) => {
// coerce 将字符串转换为semver格式的对象,比如'v2' -> 2.0.0
let packageVersion = semver.coerce(packageInfo.version);
// This environment variable can be removed post-release.
const templatesVersionMinimum = process.env.CRA_INTERNAL_TEST
? '3.2.0'
: '3.3.0';
// Assume compatibility if we can't test the version.
// 兼容semver解析错误的情况
if (!semver.valid(packageVersion)) {
packageVersion = templatesVersionMinimum;
}
// Only support templates when used alongside new react-scripts versions.
// packageVersion是否大于等于templatesVersionMinimum
const supportsTemplates = semver.gte(
packageVersion,
templatesVersionMinimum
);
// 检查template模板和react-script版本是否兼容
if (supportsTemplates) {
allDependencies.push(templateToInstall);
} else if (template) {
console.log('');
console.log(
`The ${chalk.cyan(packageInfo.name)} version you're using ${
packageInfo.name === 'react-scripts' ? 'is not' : 'may not be'
} compatible with the ${chalk.cyan('--template')} option.`
);
console.log('');
}
// TODO: Remove with next major release.
// 如果template包含typescript 则添加相应的类型声明及依赖
if (!supportsTemplates && (template || '').includes('typescript')) {
allDependencies.push(
'@types/node',
'@types/react',
'@types/react-dom',
'@types/jest',
'typescript'
);
}
// log...
// 根据allDependencies的内容执行相关的包管理器依赖下载命令
return install(
root,
useYarn,
usePnp,
allDependencies,
verbose,
isOnline
).then(() => ({
packageInfo, // {name: 'react-scripts', version: '3.2.0'}
supportsTemplates, // packageVersion是否大于等于templatesVersionMinimum 3.2.0或3.3.0
templateInfo, // cra-template 或 cra-template-typescript 或 远程压缩包 或 .git
}));
})
.then(async ({ packageInfo, supportsTemplates, templateInfo }) => {
const packageName = packageInfo.name;
const templateName = supportsTemplates ? templateInfo.name : undefined;
checkNodeVersion(packageName);
setCaretRangeForRuntimeDeps(packageName);
const pnpPath = path.resolve(process.cwd(), '.pnp.js');
const nodeArgs = fs.existsSync(pnpPath) ? ['--require', pnpPath] : [];
// 执行了react-scripts/scripts/init.js
await executeNodeScript(
{
cwd: process.cwd(),
args: nodeArgs,
},
[root, appName, verbose, originalDirectory, templateName],
`
var init = require('${packageName}/scripts/init.js');
init.apply(null, JSON.parse(process.argv[1]));
`
);
// ... 低版本react-scripts的兼容log
})
.catch(reason => {
// 安装失败的log处理说明
// ...
// On 'exit' we will delete these files from target directory.
const knownGeneratedFiles = [
'package.json',
'yarn.lock',
'node_modules',
];
// 删除以上文件..
// 对剩余文件的清理,如果还有遗留文件默认不处理,如果没有则删除当前创建的文件目录
const remainingFiles = fs.readdirSync(path.join(root));
if (!remainingFiles.length) {
// Delete target folder if empty
console.log(
`Deleting ${chalk.cyan(`${appName}/`)} from ${chalk.cyan(
path.resolve(root, '..')
)}`
);
process.chdir(path.resolve(root, '..'));
fs.removeSync(path.join(root));
}
console.log('Done.');
process.exit(1);
});
});
}
在最后的promise
中通过executeNodeScript
方法执行了/package/react-scripts/scripts/init.js
的module.exports
方法:
function executeNodeScript({ cwd, args }, data, source) {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[...args, '-e', source, '--', JSON.stringify(data)],
{ cwd, stdio: 'inherit' }
);
child.on('close', code => {
if (code !== 0) {
reject({
command: `node ${args.join(' ')}`,
});
return;
}
resolve();
});
});
}
init文件中的方法比较长,这里删除了一些log及一些不影响程序流的判断分支
module.exports = function(
appPath,
appName,
verbose,
originalDirectory,
templateName
) {
// 创建出来的package.json文件对象
const appPackage = require(path.join(appPath, 'package.json'));
const useYarn = fs.existsSync(path.join(appPath, 'yarn.lock'));
// ...
// templateName为空的情况退出程序
// ...
const templatePath = path.join(
require.resolve(templateName, { paths: [appPath] }),
'..'
);
// Copy over some of the devDependencies
appPackage.dependencies = appPackage.dependencies || {};
// Setup the script rules
appPackage.scripts = {
start: 'react-scripts start',
build: 'react-scripts build',
test: 'react-scripts test',
eject: 'react-scripts eject',
};
// Setup the eslint config
appPackage.eslintConfig = {
extends: 'react-app',
};
// Setup the browsers list
appPackage.browserslist = defaultBrowsers;
// 重写package.json文件内容
fs.writeFileSync(
path.join(appPath, 'package.json'),
JSON.stringify(appPackage, null, 2) + os.EOL
);
const readmeExists = fs.existsSync(path.join(appPath, 'README.md'));
if (readmeExists) {
fs.renameSync(
path.join(appPath, 'README.md'),
path.join(appPath, 'README.old.md')
);
}
// Copy the files for the user
const templateDir = path.join(templatePath, 'template');
if (fs.existsSync(templateDir)) {
// 注意这里,这里将template代码模板拷贝到生成的项目目录中
fs.copySync(templateDir, appPath);
} else {
console.error(
`Could not locate supplied template: ${chalk.green(templateDir)}`
);
return;
}
// 对yarn的一些处理
// Rename gitignore after the fact to prevent npm from renaming it to .npmignore
// See: https://github.com/npm/npm/issues/1862
// 处理.gitignore文件
let command;
let remove;
let args;
// 对yarn和npm两种情况进行判断并进行以上命令变量赋值
// Install additional template dependencies, if present
// 对旧版本的一些依赖兼容及终端log输出
};
与vue-cli类似,代码模板支持使用cli本地的代码模板也支持网络资源.
两个cli工具基本大同小异,甚至连cli源码组织方式都差不多。他们的基本工作流程是:(顺序可能有不同,但基本过程都差不多)
都依赖外部依赖来进行项目(创建的新项目)启动,create-react-app使用react-scripts,vue-cli使用cli-service,通过package.json依赖加载。
两个项目都是通过lerna来管理package的,这个需要学习一下…