vue create project 是如何实现的

前端名狮

共 15545字,需浏览 32分钟

 · 2021-02-08

前言

Vue CLI 是一个基于 Vue.js 进行快速开发的完整系统,提供了终端命令行工具、零配置脚手架、插件体系、图形化管理界面等能力。大多前端项目都用到了Vue CLI,都会使用 vue create 来创建项目,我们发现一个问题 , 只会去用而不明白它的里面的东西,很难去开发适配业务的脚手架,因此我们还是要借鉴优秀框架的思想,才能够让自己更上层楼。

由于Vue CLI逻辑复杂,本文暂且只分析项目初始化部分。


入门须知

基础依赖库:

  • commander:是一款重量轻,表现力和强大的命令行框架,提供了用户命令行输入和参数解析的功能。

  • Inquirer:交互式命令行工具。

  • execa:是可以调用 shell 和本地外部程序的 javascript 封装,在 Node.js 内置的 child_process.exec 基础上进行了提升,比如更好地支持 windows 平台,以及提供Promise的接口等等。

  • chalk:修改控制台字符串的样式,包括字体样式(加粗),颜色以及背景颜色等。

  • download-git-repo:用于从GitHub, GitLab, Bitbucket  下载一个git仓库。

  • semver规范版本号

  • validate-npm-package-name: 验证是否为有效npm包名

  • minimist:解析命令行参数


正文

vue create的执行过程,大致分为以下五个部分:

  • 基础验证

  • 获取预设选项

  • 初始化项目基础文件

  • Generator生成项目代码

  • 提交项目代码

从入口一步一步来分析, vue脚手架使用的 lerna + yarn 分包,在 package.json 文件中可以看到 packages/@vue/* 是一个子包

"workspaces": [
"packages/@vue/*",
"packages/test/*",
"packages/vue-cli-version-marker"
],

所以直接去看 packages/@vue 目录,最后来到 cli 目录, 在packages/@vue/cli/package.json 文件中可以看到bin字段到底执行的是什么文件。

 "bin": {
"vue": "bin/vue.js"
},

在执行 vue 命令时,实际上是在执行 bin/vue.js ,顺着 bin/vue.js 文件即可看到 vue create   是如何实现的。

vue create 命令的定义:

const program = require('commander')
program
.command('create ')
.description('create a new project powered by vue-cli-service')
.option('-p, --preset ', 'Skip prompts and use saved or remote preset')
.option('-d, --default', 'Skip prompts and use default preset')
.option('-i, --inlinePreset ', 'Skip prompts and use inline JSON string as preset')
.option('-m, --packageManager ', 'Use specified npm client when installing dependencies')
.option('-r, --registry ', 'Use specified npm registry when installing dependencies (only for npm)')
.option('-g, --git [message]', 'Force git initialization with initial commit message')
.option('-n, --no-git', 'Skip git initialization')
.option('-f, --force', 'Overwrite target directory if it exists')
.option('--merge', 'Merge target directory if it exists')
.option('-c, --clone', 'Use git clone when fetching remote preset')
.option('-x, --proxy ', 'Use specified proxy when creating project')
.option('-b, --bare', 'Scaffold project without beginner instructions')
.option('--skipGetStarted', 'Skip displaying "Get started" instructions')
.action((name, cmd) => {
// 提取出用户输入的optionsconst options = cleanArgs(cmd)
// 判断是否提供了多个不带option的参数// 是则提示用户只将第一个用作项目名称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 trueif (process.argv.includes('-g') || process.argv.includes('--git')) {
options.forceGit = true
}
// 调用create方法,进行基础验证require('../lib/create')(name, options)
})

在命令行输入 vue create --help 可以看到在create命令中定义的指令:


按照命令描述信息,用户可自定义 preset 等配置。在命令行输入以下命令:

vue create -p ../outro_preset -m yarn -r https://registry.npm.taobao.org -g 'init project' -f myproject

可得到如下参数信息,这些参数在构建项目的时候都会用到。

{
preset: '../outro_preset',
packageManager: 'yarn',
registry: 'https://registry.npm.taobao.org',
git: 'init project',
force: true
}


基础验证

验证是否为有效的npm包名

const validateProjectName = require('validate-npm-package-name');
if (!result.validForNewPackages) {
console.error(chalk.red(`Invalid project name: "${name}"`))
}

判断当前项目是否已存在, 存在则根据命令行输入的参数进行如下处理:

if 命令行参数中传入了  -f  : 删除已有目录

else if 当为 '.' 时,询问是否在当前目录下生成项目:"否"则退出

else if 询问 "覆盖、合并、取消" 当前项目

// 获取当前项目所在目录
const targetDir = path.resolve(cwd, projectName || '.')
const inCurrent = projectName === '.';
if (fs.existsSync(targetDir) && !options.merge) {
//命令行参数中传入了`-f`: 删除已有目录if (options.force) {await fs.remove(targetDir)
} else {
await clearConsole()
// 当为'.'时,询问是否在当前目录下生成项目if (inCurrent) { const { ok } = await inquirer.prompt([
{
name: 'ok',
type: 'confirm',
message: `Generate project in current directory?`
}
])
// "否"则退出if (!ok) {return
}
} else {
// 询问 "覆盖、合并、取消" 当前项目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 }
]
}
])
if (!action) {
return
} else if (action === 'overwrite') {
console.log(`\nRemoving ${chalk.cyan(targetDir)}...`)
await fs.remove(targetDir)
}
}
}
}


获取预设选项

创建creator实例

用来初始化一些变量。主要逻辑都封装在 resolveIntroPrompts   PromptModuleAPI   resolveOutroPrompts 这三个方法中。


resolveIntroPrompts

获取了 presetPrompt list,在初始化项目的时候提供选择。

const { presetPrompt, featurePrompt } = this.resolveIntroPrompts()

getPresets 获取默认预设选项:defaults.presets + ~/.vuerc

defaults.presets = {
'default': {
vueVersion: '2',
// useConfigFiles 用来决定是否将文件放到单独的配置文件中
useConfigFiles: false,
cssPreprocessor: undefined,
plugins: {
'@vue/cli-plugin-babel': {},
'@vue/cli-plugin-eslint': {
config: 'base',
lintOn: ['save']
}
}
},
'__default_vue_3__': {
useConfigFiles: false,
cssPreprocessor: undefined,
plugins: {
'@vue/cli-plugin-babel': {},
'@vue/cli-plugin-eslint': {
config: 'base',
lintOn: ['save']
}
}
}
}


presetPrompt: 在getPresets的基础上添加一个自定义选项(Manually select features), 可以根据自己的项目需求(是否使用 Babel、TS 等)来自定义项目工程配置,这样会更加的灵活



featurePrompt: 当用户选择的preset为 manual 时,在交互命令行显示 featurePrompt

const featurePrompt = {
name: 'features',
when: answers => answers.preset === '__manual__',
type: 'checkbox',
message: 'Check the features needed for your project:',
choices: [],
pageSize: 10
}


PromptModuleAPI

featurePrompt 默认是空列表,通过 PromptModuleAPI 类创建的实例来给当前creator实例注入

  • featurePrompt

  • injectedPrompts             // 被注入的prompts,是选择对应的feature后需要显示的prompt

  • promptCompleteCbs      // 注入每个feature对应的回调,当所有prompt选择完毕再调用


遍历 promptModules 中的每个函数,注入feature到 featurePrompt 

/**
promptModules为以下文件的导出的函数:
'vueVersion',
'babel',
'typescript',
'pwa',
'router',
'vuex',
'cssPreprocessors',
'linter',
'unit',
'e2e'
**/

promptModules.forEach(m => m(promptAPI))

将每个feature对应的prompt数据都初始化完成(注入到对应的数据中)。


以 cssPreprocessors 为例:

module.exports = cli => {
cli.injectFeature({
name: 'CSS Pre-processors',
value: 'css-preprocessor',
description: 'Add support for CSS pre-processors like Sass, Less or Stylus',
link: 'https://cli.vuejs.org/guide/css.html'
})

const notice = 'PostCSS, Autoprefixer and CSS Modules are supported by default'
// 选择css-preprocessor后需要显示的prompt
cli.injectPrompt({
name: 'cssPreprocessor',
when: answers => answers.features.includes('css-preprocessor'),
type: 'list',
message: `Pick a CSS pre-processor${process.env.VUE_CLI_API_MODE ? '' : ` (${notice})`}:`,
description: `${notice}.`,
choices: [
{
name: 'Sass/SCSS (with dart-sass)',
value: 'dart-sass'
},
{
name: 'Less',
value: 'less'
},
{
name: 'Stylus',
value: 'stylus'
}
]
})
// 所有prompt选择完成后,调用回调函数
cli.onPromptComplete((answers, options) => {
if (answers.cssPreprocessor) {
options.cssPreprocessor = answers.cssPreprocessor
}
})
}



resolveOutroPrompts

是否使用外部配置文件(babel.config.js 等)?

是否保存当前自定义的预设值? 保存自定义预设值名称为?

选择哪个包管理器(yarn pnpm npm)?



调用create实例方法

获取preset

  • if  vue  create  参数中有  -p , 调用 resolvePreset 方法

  • else if 参数中是否带有 -d, 有则使用默认提供的 default preset

  • else if 参数中是否带有 inlinePreset, 有则通过JSON.parse解析命令行中的preset

  • else 调用 promptAndResolvePreset 方法,通过交互 prompt 来获取 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()
}


根据用户选择的preset, 注入一些核心插件:

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
}
}
/**
preset: {
useConfigFiles: true,
plugins: {
'@vue/cli-plugin-babel': {},
'@vue/cli-plugin-typescript': {
classComponent: true,
useTsWithBabel: true
},
'@vue/cli-plugin-eslint': {
config: 'base',
lintOn: [Array]
},
'@vue/cli-service': {
projectName: 'myproject',
useConfigFiles: true,
plugins: [Circular],
vueVersion: '2'
}
},
vueVersion: '2'
}
**/

获取官方插件的最新版本,循环遍历preset.plugins的每个插件放到package.json devDependencies

deps.forEach(dep => {
let { version } = preset.plugins[dep]
if (!version) {
if (isOfficialPlugin(dep) || dep === '@vue/cli-service' || dep === '@vue/babel-preset-env') {
version = isTestOrDebug ? `latest` : `~${latestMinor}`
} else {
version = 'latest'
}
}
pkg.devDependencies[dep] = version
})

将package.json写入磁盘


将各个交互维护在不同的模块中,通过统一的一个 prmoptAPI 实例在  prmoptAPI 实例初始化的时候,插入到不同的prompt 中,并且注册各自的回调函数。这样设计对于  prompt 而言是完全解耦的,删除某一项 prompt 对于上下文的影响可以忽略不计。


初始化项目基础文件

如果项目需要使用 git 初始化,则运行 git init ,生成 .git 文件夹

安装package.json中引入的依赖, 选择合适的包管理器, 如果是npm包管理器则执行 npm install  yarn则使用 yarn


Generator生成项目代码

创建generator实例

const plugins = await this.resolvePlugins(preset.plugins, pkg)
const generator = new Generator(context, {
pkg,
plugins,
afterInvokeCbs,
afterAnyInvokeCbs
})

实例属性 allPluginIds 是 package.json 中 dependencies devDependencies 中所有的社区插件+内部插件

this.plugins = plugins;
this.allPluginIds = Object.keys(this.pkg.dependencies || {})
.concat(Object.keys(this.pkg.devDependencies || {}))
.filter(isPlugin)


调用generate实例方法

initPlugins

extractConfigFiles: 将一些配置信息从package.json提取到对应的配置文件中, 比如 babel.config.js

resolveFiles: 等待文件解析

sortPkg 给package.json中的属性进行排序

writeFileTree:将排序后的pkg对象写入磁盘



创建generatorAPI实例

generatorAPI的几个重要实例方法

  • hasPlugin                 // 判断项目中是否有某个插件

  • extendPackage        // 扩展package.json 配置

  • render                      // 通过 ejs 渲染模板文件

  • onCreateComplete  // 注册文件写入硬盘之后的回调

  • genJSConfig           // 将 json 文件输出成 js 文件

  • injectImports           // 向文件中加入 import

  • exitLog                   // generator 退出的时候输出的信息

  • injectRootOptions  // 向 Vue 根实例中添加选项

  • ...


vue-cli 3.0 是基于插件架构的,当插件需要自定义项目模板、修改模板中的一些文件或者添加一些依赖时,可通过插件中的generator向外暴露一个函数,接收的第一个参数 api,通过该 api 提供的方法去完成应用的拓展。


提交项目代码

判断是否有README.md文件,没有则生成一个README.md写入磁盘

当需要使用 git 则运行:

> git add -A
> git commit -m --no-verify

成功则 gitCommitFailed = false;

命令行参数中不带有 skipGetStarted 则在交互命令行给出运行提示:



其他

resolvePreset 方法具体逻辑

  • 判断-p传入的preset是否存在于~/.vuerc || 默认配置, 是则返回对应的preset

  • 看传入的preset是否为一个相对/绝对路径,是则读取本地的preset文件配置

  • 是否以参数是否包含"/",包含则认为是远程库,拉取远程preset设置

  • 否则给出用户提示,未找到可用preset,并告知其当前可选用的preset有哪些


promptAndResolvePreset 方法具体逻辑

/**
inquirer.prompt的Answers是一个包含有用户客户端输入的每一个问题的答案的对象
键:问题对象的name属性
值:取决于问题的类型,confirm类型为Boolean,Input类型为用户输入的字符串,rawlist和list类型为选中的值,也为字符串类型。
**/

answers = await inquirer.prompt(this.resolveFinalPrompts()) //给出交互提示


prompt的最后一步是选择包管理器,如果选择了就会写入~/.vuerc文件中,下次就不用再选择了。

# ~/.vuerc
{
"useTaobaoRegistry": true,
"latestVersion": "5.0.0-alpha.3",
"lastChecked": 1612085399136,
"packageManager": "yarn",
"presets": {
"my-preset": {
"useConfigFiles": false,
"plugins": {},
"vueVersion": "2"
}
}
}


answers.features = answers.features || []
// run cb registered by prompt modules to finalize the preset
// 调用feature中注入的回调函数,给preset添加对应配置项
this.promptCompleteCbs.forEach(cb => cb(answers, preset))


resolveFinalPrompts方法具体逻辑

遍历 PromptModuleAPI 给 feature 注入的 injectedPrompts ,重写 injectedPrompts 的 when,每一项都多加一个 isManualMode 的判定。只有在选择__manual__模式下才能显示 feature 下的 prompt 。

this.injectedPrompts.forEach(prompt => {
const originalWhen = prompt.when || (() => true)
prompt.when = answers => {
return isManualMode(answers) && originalWhen(answers)
}
})


presetPrompt,   featurePrompt, injectedPrompts, outroPrompts合并

const prompts = [
// 预设选项this.presetPrompt, // 自定义feature选项this.featurePrompt,// 具体feature选项
...this.injectedPrompts,
// 其他选项
...this.outroPrompts
]


shouldInitGit方法具体逻辑

是否git命令可用(执行git--version看是否报错)

命令行里是否带有'-g'

命令行里是否带有'-n’

项目是否已使用Git管理 (执行git status看是否报错)


resolvePlugins方法具体逻辑

cli-plugin-babel/generator/index.js

module.exports = api => {
// Most likely you want to overwrite the whole config to ensure it's working// without conflicts, e.g. for a project that used Jest without Babel.// It should be rare for the user to have their own special babel config// without using the Babel plugin already.delete api.generator.files['babel.config.js']// 扩展package.json 配置
api.extendPackage({
babel: {
presets: ['@vue/cli-plugin-babel/preset']
},
dependencies: {
'core-js': '^3.8.1'
}
})
}



// 例:
plugins: {
'@vue/cli-plugin-eslint': {
config: 'base',
lintOn: ['save'],
}
}
/************************* 处理后 **************************/
plugins: [
{
id: '@vue/cli-plugin-eslint',
// apply: generator回调函数
apply: [Function] { hooks: [Function], applyTS: [Function] },
options: {
config: 'base',
lintOn: ['save'],
}
}
]

遍历preset中的每个plugin配置,对每个plugin进行loadModule,加载每个plugin的generator回调函数,如果plugin中包含prompt属性,则加载plugin/prompts,而后在命令行执行这个prompt, 生成一个排序后添加了apply(generator回调函数) 以及prompts处理后的plugins


isPlugin方法具体逻辑

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

@vue/cli-plugin-(内建插件)

Vue CLI-plugin- (社区插件)



initPlugins具体具体逻辑

for (const id of this.allPluginIds) {
const api = new GeneratorAPI(id, this, {}, rootOptions)
const pluginGenerator = loadModule(`${id}/generator`, this.context)

if (pluginGenerator && pluginGenerator.hooks) {
await pluginGenerator.hooks(api, {}, rootOptions, pluginIds)
}
}

// .....

for (const plugin of this.plugins) {
const { id, apply, options } = plugin
const api = new GeneratorAPI(id, this, options, rootOptions)
await apply(api, options, rootOptions, invoking)

if (apply.hooks) {
// while we execute the entire `hooks` function,// only the `afterInvoke` hook is respected// because `afterAnyHooks` is already determined by the `allPluginIds` loop aboveawait apply.hooks(api, options, rootOptions, pluginIds)
}
}

遍历package.json 里的插件初始化GeneratorAPI实例,对应插件的 generator方法中含有hooks则执行hooks方法。

再遍历preset中的插件初始化GeneratorAPI实例,将实例传入到对应插件的 generator 回调函数执行,如果有hooks再执行hooks方法。


sortObject方法具体逻辑

将object中的属性按照unicode的顺序排序, 使object中的属性看起来更整洁


extractConfigFiles方法具体逻辑

合并全部的configTransForms

const configTransforms = Object.assign({},
defaultConfigTransforms,
this.configTransforms,
reservedConfigTransforms
)

如果extractConfigFiles为true,则遍历pkg中的所有key,找到configTransforms匹配的key,

如果字段存在于package.json文件中则不提取

否则,将相应代码删除,将提取出的代码放在this.files中

当extractConfigFiles为false,提取vue、babel这两个config


### sortPkg方法具体逻辑

给dependencies,  devependencies, scripts, 及最外层都进行Unicode排序


最后

genrator部分写得略为粗糙,可能有逻辑不通的地方,如有问题还望大佬们多多指正 ( ̄▽ ̄)~*  

相关链接

https://kuangpf.com/Vue CLI-analysis/

https://mp.weixin.qq.com/s/DlN0qbJ3rAtUiP_BUe-EZQ

https://juejin.cn/post/6844903807919325192


推荐阅读
  1. Vue scoped与深度选择器deep的原理

  2. Vue.js 组件复用和扩展之道

  3. 【深入vue】为什么Vue3.0不再使用defineProperty实现数据监听?

  4. 带你五步学会Vue SSR

  5. Vue3 新增API

  6. Vue Router history模式的配置方法及其原理

❤️爱心三连击

1.看到这里了就点个在看支持下吧,你的点赞在看是我创作的动力。

2.关注公众号前端名狮,回复「1」加入前端交流群,一起学习进步!

3.也可添加微信【qq1248351595】,一起成长。


“在看转发”是最大的支持

浏览 79
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报