
橙某人
2022/09/16阅读:52主题:橙心
硬刚VueCli3源码系列三 - 认识.vuerc文件、构建交互式问答题目列表
写在开头
VueCli3源码系列[1] 又开始更新啦,最近小编更新文章的频率有点下降了,也不知道跑了多少读者(ಥ_ಥ) ,可能也根本没啥读者吧,哈哈哈( ̄▽ ̄)~,不过想来也正常,源码系列都比较枯燥,能坚持的不多,小编也不求写的源码系列文章能有多少阅读量,反正就当写着玩吧,也希望偶尔能给某些有需求的小伙伴一些帮助。
然后回顾一下,为什么更新频率下降的原因,一方面是写源码系列文章还是有点压力的,很多时候小编自己也看得焦头烂额,更别提写文章了;而另一方面就是从三月份开始就忙于跳槽事宜,各种刷题,如面经、力扣等等,前前后后两个月时间整理了大概两万字的总结文档,后续也会把这些面经给整理出来。(✪ω✪)

当然,从三月份初到 2022-05-05 这天,工作的事情总算是尘埃落定了,后续如果工作上不忙的话,会加速更新完这系列文章。
预备知识
chalk模块
chalk 模块是一个可以美化 console.log
输出语句的包,能非常方便的输出多种颜色的 log
,包括修改字体颜色,加粗字体,改变字体背景色等等。
这个模块的使用非常简单,我们直接来看一下例子就行。
安装:
npm install chalk
使用:
const chalk = require('chalk');
console.log(chalk.yellow('我是黄色字体'));
console.log(chalk.blue('我是蓝色字体'));
console.log(chalk.bgRed('红色背景'));
console.log(chalk.red.bold('红色加粗字体'));
console.log(chalk.underline('下划线'));

认识.vuerc文件
不知道你是否有这么一个疑问?当我们使用 vue create myProject
命令来快速初始化一个项目时,cmd
控制台会有一些交互式的选择,如下:


当我们选择 Manually select features 后,你就可以手动选择项目中需要安装的插件了。而当我们完成一系列的选择后,在最后 vue-cli
会询问我们是否需要保存此次操作结果,如果你选择了 Yes 则还会让你输入"名称"。


而当我们下次再次执行 vue create myProject2
命令初始化项目时,我们会看到多了一个上次选择结果的选项供我们选择。

那么,知道这么一个操作过程后,我们回到上面讲的疑问?
这个疑问就是 vue-cli
是如何存储我们之前选择的结果呢?它会存在什么地方呢?以什么形式储存呢?这个疑问在 vue-cli 文档上有大致的介绍,但我猜应该大部分人还不清楚,或者看过但却不知道实际的意义。
其实这里 vue-cli
会使用到一个 .vuerc
配置文件,它是自动创建的,存在于你系统下的 home
目录内(window系统在 C:\Users\当前登陆用户\.vuerc
)。

打开这个文件后,你可以看到在 presets
属性下,会储存你之前选择的结果信息,这个文件还储存其他一些配置信息,如是否使用 cnpm
镜像,还有版本号等等信息。
注意这个presets,它非常重要,后续会围绕如何获取它来展开逻辑。
构建交互式问答题目列表
了解完 .vuerc
配置文件的作用后,我们就可以来继续完善 上一篇 文章写的 Creator.js
核心文件的逻辑了。
这里小编先大致讲一下 Creator.js
文件核心做的事情:
-
构建交互问答题目列表; -
然后获取用户交互式选择后的结果,也就是 preset
参数的信息; -
然后根据 preset
信息去安装(npm install
)相关的模块; -
最后去创建一些必要的模板文件,如 package.json
,index.html
等等。
接下来,文章的主题是完成第一步和第二步的过程,我们需要先来构建相关的各种问答题目,然后获取用户选择问答后的结果 preset
信息。
构建第一和第二个问答题目
首先,我们先来看看如何构建第一和第二个交互问答题目的,这里还是会使用到 inquirer 模块,对它还不熟悉的小伙伴私下要努力努力了。
// Creator.js
const {defaults, loadOptions} = require('./options');
const {formatFeatures} = require('./util/features');
const isManualMode = answers => answers.preset === '__manual__'; // 是否选择手动交互
module.exports = class Creator {
constructor (name, context, promptModules) {
this.name = name; // 项目名
this.context = context; // 项目路径
const { presetPrompt, featurePrompt } = this.resolveIntroPrompts(); // 第一,第二个问答
this.presetPrompt = presetPrompt;
this.featurePrompt = featurePrompt;
}
async create(cliOptions = {}, preset = null) {}
getPresets() {
const savedOptions = loadOptions(); // 读取 .vuerc 文件的内容, 里面会保留 "上次选择的结果" 的数据
return Object.assign({}, savedOptions.presets, defaults.presets); // defaults.presets 默认
}
resolveIntroPrompts() {
const presets = this.getPresets();
// 格式化插件名称,为了更好的展示效果: @vue/cli-plugin-babel -> babel or @vue/cli-plugin-eslint -> eslint
const presetChoices = Object.keys(presets).map(name => {
return {
name: `${name} (${formatFeatures(presets[name])})`,
value: name
}
});
// 第一个问答:选择默认安装还是手动选择安装
const presetPrompt = {
name: 'preset',
type: 'list',
message: `请选择安装的交互模式:`,
choices: [
...presetChoices, // .vuerc的preset与vue-cli默认的preset
{
name: '手动选择',
value: '__manual__'
}
]
}
// 第二个问答: 选择需要安装的插件列表
const featurePrompt = {
name: 'features',
when: isManualMode, // 当选择了手动安装
type: 'checkbox',
message: '请选择需要安装的插件,空格控制是否勾选:',
choices: [], // 插件列表, 暂时为空, 后面会在constructor中注入
pageSize: 10
}
return {
presetPrompt,
featurePrompt
}
}
}
上面代码中,小编添加了几个新方法,从 constructor()
入口出发,我们先看到resolveIntroPrompts()
方法,它用于构建第一和第二个交互问答信息。
不过在具体构建问答之前,我们应该先去读取 .vuerc
配置文件,看看有没有之前的操作结果保存下来,所以我们在 resolveIntroPrompts()
方法开头就去调用了 getPresets()
方法,这个方法会把 .vuerc
配置文件中的 preset
与 vue-cli
默认的 preset
合并然后返回。
之所以要先获取 preset
是因为我们第一个问答题目会使用到它,可以看看如下图:

创建 options.js
文件:
const fs = require('fs');
const { error } = require('@vue/cli-shared-utils/lib/logger');
const { getRcPath } = require('./util/rcPath');
const rcPath = getRcPath('.vuerc'); // 它存在于你的 C:\Users\当前登陆用户\.vuerc
let cachedOptions;
exports.loadOptions = () => {
// 如果有缓存则直接返回
if (cachedOptions) return cachedOptions;
if (fs.existsSync(rcPath)) {
try {
// 使用同步方式读取系统中的 .vuerc 文件出来
cachedOptions = JSON.parse(fs.readFileSync(rcPath, 'utf-8'));
} catch (e) {
error('文件读取失败'); // vue-cli工具包中对console.error的封装, 并会上报相关错误
exit(1); // 退出
}
return cachedOptions;
} else {
return {}; // 没有则返回一个空对象
}
};
// 一个标准的preset形式, 默认安装时, preset就等于它
exports.defaultPreset = {
router: false, // 是否安装router
vuex: false, // 是否安装vuex
useConfigFiles: false, // 是否使用独立的配置文件
cssPreprocessor: undefined, // css预处理器配置
plugins: { // 插件相关, 默认安装会自动安装babel和eslint
'@vue/cli-plugin-babel': {},
'@vue/cli-plugin-eslint': {
config: 'base',
lintOn: ['save']
}
}
}
exports.defaults = {
lastChecked: undefined, // 是否检查版本
latestVersion: undefined, // 版本号
packageManager: undefined, // 选择安装依赖时的下载源
useTaobaoRegistry: undefined, // 是否使用cnpm
presets: {
'default': exports.defaultPreset
}
}
新建 ./util/rcPath.js
文件:
const os = require("os");
const path = require("path");
exports.getRcPath = (file) => {
// os.homedir() 能直接获取到系统当前用户的路径
return path.join(os.homedir(), file);
};
getPresets()
方法主要是通过 options.js
文件和 rcPath.js
文件来配合完成的,loadOptions()
方法能帮助我们直接读取到 .vuerc
文件的内容并且序列化。
在Creator.js
文件中,我们还引入 ./util/features.js
文件:
const chalk = require('chalk'); // 记得安装 chalk 模块:npm install chalk@2.4.1
const { toShortPluginId } = require('@vue/cli-shared-utils');
exports.getFeatures = (preset) => {
const features = []
if (preset.router) {
features.push('vue-router')
}
if (preset.vuex) {
features.push('vuex')
}
if (preset.cssPreprocessor) {
features.push(preset.cssPreprocessor)
}
const plugins = Object.keys(preset.plugins).filter(dep => {
return dep !== '@vue/cli-service'
})
features.push.apply(features, plugins)
return features
}
// 返回: (\x1B[33mbabel\x1B[39m, \x1B[33meslint\x1B[39m)
exports.formatFeatures = (preset, lead, joiner) => {
const features = exports.getFeatures(preset)
return features.map(dep => {
dep = toShortPluginId(dep)
return `${lead || ''}${chalk.yellow(dep)}`
}).join(joiner || ', ')
}
./util/features.js
文件的虽然写得比较复杂,但是它的作用很简单,就是用来格式化名称的,为了更好的展示效果。之所以需要格式化名称,你可以看看下图,当我们选择安装 babel
、vue-router
与 vuex
等时,在 vue-cli
中用 preset
来存储是以图下(@vue/cli-plugin-bale
)全称这种形式的:

但是,展示给用户的是插件的简写形式:

到这里你可能会问,为什么在
preset
中是以@vue/cli-plugin-插件名称
来存储呢?用户选择了babel
直接就是babel: true
不就可以了嘛?当然没那么简单啦。(✪ω✪)
这是因为当我们选择安装vuex
插件时,我们不仅需要安装vuex
这个包,还需要为项目生成一些vuex
的基础模板,如store/index.js
文件等等。而这些模板vue-cli
都给每个插件单独写了一个包存储,如下:
后面我们会根据@vue/cli-plugin-插件名称
形式,把需要的包加载过来,获取到里面的模板信息。
上面小编截图使用的
vue-cli
版本是5.0.1,但是在3.x.x版本中,vue-router
与vuex
的模板是放置在cli-service
包中的。
构建其他问答题目
我们接着来看看其他问答题目构建,比较简单,我们就直接来看代码了:
// Creator.js
...
const {hasYarn, hasPnpm3OrLater} = require('@vue/cli-shared-utils');
const isManualMode = answers => answers.preset === '__manual__';
module.exports = class Creator {
constructor (name, context, promptModules) {
...
this.outroPrompts = this.resolveOutroPrompts(); // 其他问答
}
async create(cliOptions = {}, preset = null) {}
getPresets() { ... }
resolveIntroPrompts() { ... }
resolveOutroPrompts() {
const outroPrompts = [
// 对于eslint/postcss是否使用独立的配置文件
{
name: 'useConfigFiles',
when: isManualMode,
type: 'list',
message: 'Where do you prefer placing config for Babel, PostCSS, ESLint, etc.?',
choices: [
{
name: 'In dedicated config files',
value: 'files'
},
{
name: 'In package.json',
value: 'pkg'
}
]
},
{
name: 'save',
when: isManualMode,
type: 'confirm',
message: '此次操作是否存储起来?',
default: false
},
{
name: 'saveName',
when: answers => answers.save,
type: 'input',
message: '请输入此次操作存储起来的名称:'
}
]
// 检测如果有 yarn/pnpm 等其他源, 会可以选择下载源题目
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
}
}
resolveOutroPrompts()
方法主要用于构建"是否使用独立配置文件"、"是否保存此次选择结果"与"选择下载源"这三个题目。其中还使用到了检测下载源 hasYarn()
与 hasPnpm3OrLater()
方法,这两个方法来源于 vue-cli
工具包 @vue/cli-shared-utils
的 env.js
文件,感兴趣的小伙伴可以再去细看其中的实现逻辑。

更多的问答题目
上面我们大概构建了四五个问答题目,但这远远还没完呢,还有很多插件选择后的后续交互问答题目呢。比如,选择了 vue-router
插件,后续会继续询问你 vue-router
的模式是否使用 history
模式。

我们接着来看,还是在 constructor()
中做修改:
// Creator.js
...
const PromptModuleAPI = require('./PromptModuleAPI');
const isManualMode = answers => answers.preset === '__manual__';
module.exports = class Creator {
constructor (name, context, promptModules) {
...
// 这三个存放的值来自于 ProptModuleAPI.js 文件 或者 promptModules 文件夹下的文件
this.injectedPrompts = []; // 存储插件的后续问答题目, 如vue-router是否使用history模式
this.promptCompleteCbs = []; // 存储插件的后续问题的 回调函数, 如选择了vue-router使用history模式, 会往其中放入一个回调, 这个回调会对用户的preset添加一个router: true
// 这两句代码主要功能是完善第二个问答缺失的 手动安装的选择列表 信息
const promptAPI = new PromptModuleAPI(this);
// 遍历每一个文件, 每个文件的方法接收一个 promptAPI 实例, 实例上有各种操作方法
promptModules.forEach(m => m(promptAPI)); // 执行 m() 方法
}
async create(cliOptions = {}, preset = null) {}
getPresets() { ... }
resolveIntroPrompts() { ... }
resolveOutroPrompts() { ... }
}
新建 PromptModuleAPI.js
文件:
module.exports = class PromptModuleAPI {
constructor (creator) {
this.creator = creator
}
// 往 第二道题目 添加选项
injectFeature (feature) {
this.creator.featurePrompt.choices.push(feature)
}
// 往injectedPrompts容器添加 插件的后续问答题目
injectPrompt (prompt) {
this.creator.injectedPrompts.push(prompt)
}
// 往injectedPrompts容器添加 插件的后续问答题目, 如果题目又是一个 列表 选择的话
injectOptionForPrompt (name, option) {
this.creator.injectedPrompts.find(f => {
return f.name === name
}).choices.push(option)
}
// 往promptCompleteCbs容器添加回调函数, 如使用vue-router的history模式, 那么后续要在用户的preset中作一个标识
onPromptComplete (cb) {
this.creator.promptCompleteCbs.push(cb)
}
}
上面新增加的代码不多,但是会比较绕,需要你细细去体会。对于 constructor()
方法中的第三个参数 promptModules 我们在上一篇文章有大致提及了一下,当时还新建了 router.js
和 vuex.js
两个空文件,这次我们来把它们补全。
// router.js
const chalk = require('chalk')
module.exports = cli => {
cli.injectFeature({
name: 'Router',
value: 'router',
description: 'Structure the app with dynamic pages',
link: 'https://router.vuejs.org/'
})
cli.injectPrompt({
name: 'routerHistoryMode',
when: answers => answers.features.includes('router'), // answers为交互答题后的结果
type: 'confirm',
message: `Use history mode for router? ${chalk.yellow(`(Requires proper server setup for index fallback in production)`)}`,
description: `By using the HTML5 History API, the URLs don't need the '#' character anymore.`,
link: 'https://router.vuejs.org/guide/essentials/history-mode.html'
})
cli.onPromptComplete((answers, options) => {
if (answers.features.includes('router')) {
options.router = true
options.routerHistoryMode = answers.routerHistoryMode
}
})
}
// vuex.js
module.exports = cli => {
cli.injectFeature({
name: 'Vuex',
value: 'vuex',
description: 'Manage the app state with a centralized store',
link: 'https://vuex.vuejs.org/'
})
cli.onPromptComplete((answers, options) => {
if (answers.features.includes('vuex')) {
options.vuex = true
}
})
}
补全后,我们就总算是把所有题目都构建完成了。小编估计小伙伴们看到这里可能都晕掉了。(写的是啥啊?拉出来打)

运行交互问答题目
不要着急,虽然前面写了那么多铺垫,但都比较虚,下面小编让它跑起来,看看实际的效果会更生动一些。
回到 Creator.js
文件:
...
const inquirer = require('inquirer');
const isManualMode = answers => answers.preset === '__manual__'; // 是否选择手动交互
module.exports = class Creator {
constructor (name, context, promptModules) { ... }
async create(cliOptions = {}, preset = null) {
preset = await this.promptAndResolvePreset();
}
// 运行问答题目
async promptAndResolvePreset (answers = null) {
answers = await inquirer.prompt(this.resolveFinalPrompts());
console.log('用户选择后粗略的preset:', answers)
}
// 返回所有的问答题目
resolveFinalPrompts () {
// 这段循环大致的意思是: 给存储在injectedPrompts容器中的后续问答的when属性添加一个 "是否选择手动交互" 的判断条件
this.injectedPrompts.forEach(prompt => {
// 获取原始的when结果
const originalWhen = prompt.when || (() => true);
// 重新设定when
prompt.when = answers => {
return isManualMode(answers) && originalWhen(answers)
}
})
const prompts = [
this.presetPrompt,
this.featurePrompt,
...this.injectedPrompts,
...this.outroPrompts
]
return prompts
}
resolveIntroPrompts() { ... }
getPresets () { ... }
resolveIntroPrompts () { ... }
resolveOutroPrompts () { ... }
}
注意,前面我们构建题目都只是在 constructor
中初始化完成,而文件中的 create()
方法才是我们的入口核心方法。
下面我们执行 juejin-vue-cli create gg
命令:


这样我们就把问答的交互运行起来了,接下来我们只要根据选择的结果做后续的逻辑就可以了。

至此,本篇文章就写完啦,撒花撒花。

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。
参考资料
https://juejin.cn/column/7067350257609801759: https://juejin.cn/column/7067350257609801759
作者介绍

橙某人
打杂工 | 广州郊区建筑工地