前言
自从Vite2的诞生,Vue Cli 就宣布处于维护状态,对于新的 Vue 3 项目,使用 create-vue 搭建基于 Vite 的项目。尤大当然也希望开发者日后使用Vite这款构建工具,毕竟Vite的功能以及性能确实值得我们去学习和使用。
那么我们为什么还需要去阅读Vue Cli的源码呢?
答:阅读Vue Cli源码最终的目的:就是了解如何搭建属于自己的脚手架提供一种思路。拜托,搭建自己的脚手架是一件很酷的事情唉,也是为了更好提升个人和团队的开发效率。
介绍脚手架的概念和Vue Cli
脚手架工具
脚手架工具分为了两种:专用脚手架、通用脚手架。
对于 Vue 官方提供了 **vue-cli**
对于 React 官方提供了 **create-react-app**
对于 Angular 官方也提供了自己的脚手架 **angular-cli**
专用脚手架:就是针对于某款特定的框架,搭建的脚手架
通用脚手架典型代表就是:Yeoman
Yeopman脚手架最重要的概念就是:针对不同技术栈的项目,你可以创建不同的生成器。在使用Yeopman创建项目时,你只需要选择对应的生成器即可。
脚手架工具最基本的作用:自动创建一个项目的基础框架。毕竟项目的最初搭建项目是一件比较耗时费力的活。
Vue Cli 源码简单介绍
直接去 Github 拉去源码,阅读项目的 package.json 文件,可以看到 workspaces 一共有三个包,一眼看过去就能确定 packages/@vue 一定是最核心的包了。
"workspaces": [
"packages/@vue/*",
"packages/test/*",
"packages/vue-cli-version-marker"
]
点击进去可以看到 packages/@vue/cli 阅读里面的 package.json 文件 可以看到一个 bin字段。所以 bin/vue.js 文件是脚手架的入口文件了。
"bin": {
"vue": "bin/vue.js"
}
再自己的命令行中输入 vue --help,可以看到脚手架一共提供了14种命令
阅读 vue create <app-name> 命令的源码
第一步:进入到 bin/vue.js 可以看到
`const program = require("commander"); // commander 定义相关命令`
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("-d, --default", "Skip prompts and use default preset")
// ...其它的options
.action((name, options) => {
// 输入 vue create <app-name> 后续做的事
// 这个判断 可能就是判断了你输入了多个 app-name 会提示你
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."
)
);
}
// 判断你输入的命令 参数中是否包含 -g 或者是 包含--git。如果包含默认就使用git
// --git makes commander to default git to true
if (process.argv.includes("-g") || process.argv.includes("--git")) {
options.forceGit = true;
}
// 进入 lib/create 文件
require("../lib/create")(name, options);
});
第二步:进入到 lib/create 文件中可以看到
主要是执行了一个 create() 方法
async function create(projectName, options) {
if (options.proxy) {
process.env.HTTP_PROXY = options.proxy;
}
/**
* 获取当前目录,如果没有传,就获取你当前进程的工作目录
* process.cwd() 方法返回 Node.js 进程的当前工作目录
*/
const cwd = options.cwd || process.cwd();
const inCurrent = projectName === ".";
// path.relative() 方法根据当前工作目录返回从 from 到 to 的相对路径
// path.relative("../", cwd) 可以获取到当前所在的文件夹的名称
const name = inCurrent ? path.relative("../", cwd) : projectName;
// path.resolve() 方法将路径或路径片段的序列解析为绝对路径
// 获取 当前所在的文件夹路径+projectName 的绝对路径 targetDir:最终创建项目后的项目绝对路径
const targetDir = path.resolve(cwd, projectName || ".");
// 验证项目名称的有效性,如果发现输入的项目名有问题就会进行 if语句中,并且退出程序 exit(1)
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);
}
// fs.existsSync(targetDir) 如果路径存在则返回 true,否则返回 false。
// 判断下这个 项目所在的地方 是否存在了。if条件语句里的内容都是去处理 项目文件夹已经存在的情况
if (fs.existsSync(targetDir) && !options.merge) {
// 如果存在并且不打算合并
if (options.force) {
// 如果用户输入了 -f 这个option,则直接删除原有的文件夹
await fs.remove(targetDir);
} else {
// await clearConsole(); 会去清除 命令窗口的内容
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);
}
}
}
}
// 上面的操作,都是对项目路径上的处理,真正的创建项目的步骤在下面
// getPromptModules() 方法 其实就是去获取 准备好的 modules,以交互式的方式来用户去选择自己需要的模板
const creator = new Creator(name, targetDir, getPromptModules());
// 真正去创建一个项目的方法
await creator.create(options);
}
第三步:简单介绍 Creator 类
可以看到真正去创建项目模板的是执行了 await creator.create(options);,然后再去看 .create() 方法。
constructor(name, context, promptModules) {
// name 项目名称
// context 项目的绝对路径
// promptModules 准备好的模块交互(都是一个个Function)
super();
this.name = name; // 项目名称
this.context = process.env.VUE_CLI_CONTEXT = context; // 项目的绝对路径
// this.resolveOutroPrompts() 都是用来处理预设选项的。
// 我们在创建vue工程的时候弹出的交互窗口会让我们选择vue2工程还是vue3工程,是否包含vuex,vue-route 等feature
const { presetPrompt, featurePrompt } = this.resolveIntroPrompts();
// 总之:presetPrompt、featurePrompt、outroPrompts、injectedPrompts 都是一些预测好的问题
this.presetPrompt = presetPrompt; // presetPrompt 对应着 Please pick a preset: 这个问题
this.featurePrompt = featurePrompt; // featurePrompt 对应着 Check the features needed for your project: 这个问题
this.outroPrompts = this.resolveOutroPrompts();
this.injectedPrompts = [];
this.promptCompleteCbs = [];
this.afterInvokeCbs = [];
this.afterAnyInvokeCbs = [];
this.run = this.run.bind(this);
const promptAPI = new PromptModuleAPI(this);
promptModules.forEach((m) => m(promptAPI));
}
第四步:进入 .create() 方法,创建一个Vue的项目
async create(cliOptions = {}, preset = null) {
// 获取这个实例creator相关属性
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 (Vue 3)"];
} 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 = cloneDeep(preset);
// 第一步:获取预设配置。这块就是注入一些核心服务,根据用户的选择,调整 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;
}
}
// legacy support for vuex
if (preset.vuex) {
preset.plugins["@vue/cli-plugin-vuex"] = {};
}
// 第二步:确实使用什么包管理工具 (默认直接是npm)
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();
// 第三步:创建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 ? `latest` : `~${latestMinor}`;
} else {
version = "latest";
}
}
// 将 插件:version 写入package.json 的 devDependencies 中
pkg.devDependencies[dep] = version;
});
// 编写package.json文件 - writeFileTree()
await writeFileTree(context, {
"package.json": JSON.stringify(pkg, null, 2),
});
// 第四步:初始化 git 仓库
const shouldInitGit = this.shouldInitGit(cliOptions);
console.log("是否应该初始化init:", shouldInitGit);
if (shouldInitGit) {
log(`🗃 Initializing git repository...`);
this.emit("creation", { event: "git-init" });
// 执行 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 {
// 执行了 npm install
await pm.install();
}
// 第六步:安装完依赖后,帮助我们去生成了基础的文件夹和文件:public 、src、.gitignore、babel.config.js、vue.config.js
// 在这一块其实就已经把一个vue项目需要的基本文件帮助我们生成了
// 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();
}
// 第七步:生成一个README.md文件
if (!generator.files["README.md"]) {
// generate README.md
log();
log("📄 Generating README.md...");
await writeFileTree(context, {
"README.md": generateReadme(generator.pkg, packageManager),
});
}
// .... 进行上面的这些程序后,一个基础的Vue项目就生成了,后面的就是收尾工作。
总结
vue create <app-name> 执行的大致流程就是,通过命令行的方式询问用户,然后根据用户的生成对应的预设,根据预设去设置好package.json、安装node_modules、初始化git、安装readme.me 文件等工作,生成一个基础的项目文件结构。