vue-cli自定义实现
# 简介
最近在学习 vue-cli (opens new window) 的源码,获益良多。为了让自己理解得更加深刻,决定模仿它造一个轮子,争取尽可能多的实现原有的功能。
将这个轮子分成三个版本:
- 尽可能用最少的代码实现一个最简版本的脚手架。
- 在 1 的基础上添加一些辅助功能,例如选择包管理器、npm 源等等。
- 实现插件化,可以自由的进行扩展。在不影响内部源码的情况下,添加功能。
有人可能不懂脚手架是什么。按我的理解,脚手架就是帮助你把项目的基础架子搭好。例如项目依赖、模板、构建工具等等。让你不用从零开始配置一个项目,尽可能快的进行业务开发。
建议在阅读本文时,能够结合项目源码一起配合使用,效果更好。这是项目地址 mini-cli (opens new window)。项目中的每一个分支都对应一个版本,例如第一个版本对应的 git 分支为 v1。所以在阅读源码时,记得要切换到对应的分支。
# 第一个版本 v1
第一个版本的功能比较简单,大致为:
- 用户输入命令,准备创建项目。
- 脚手架解析用户命令,并弹出交互语句,询问用户创建项目需要哪些功能。
- 用户选择自己需要的功能。
- 脚手架根据用户的选择创建
package.json
文件,并添加对应的依赖项。 - 脚手架根据用户的选择渲染项目模板,生成文件(例如
index.html
、main.js
、App.vue
等文件)。 - 执行
npm install
命令安装依赖。
项目目录树:
├─.vscode
├─bin
│ ├─mvc.js # mvc 全局命令
├─lib
│ ├─generator # 各个功能的模板
│ │ ├─babel # babel 模板
│ │ ├─linter # eslint 模板
│ │ ├─router # vue-router 模板
│ │ ├─vue # vue 模板
│ │ ├─vuex # vuex 模板
│ │ └─webpack # webpack 模板
│ ├─promptModules # 各个模块的交互提示语
│ └─utils # 一系列工具函数
│ ├─create.js # create 命令处理函数
│ ├─Creator.js # 处理交互提示
│ ├─Generator.js # 渲染模板
│ ├─PromptModuleAPI.js # 将各个功能的提示语注入 Creator
└─scripts # commit message 验证脚本 和项目无关 不需关注
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 处理用户命令
脚手架第一个功能就是处理用户的命令,这需要使用 commander.js (opens new window)。这个库的功能就是解析用户的命令,提取出用户的输入交给脚手架。例如这段代码:
#!/usr/bin/env node
const program = require('commander')
const create = require('../lib/create')
program
.version('0.1.0')
.command('create <name>')
.description('create a new project')
.action(name => {
create(name)
})
program.parse()
2
3
4
5
6
7
8
9
10
11
12
13
它使用 commander 注册了一个 create
命令,并设置了脚手架的版本和描述。我将这段代码保存在项目下的 bin
目录,并命名为 mvc.js
。然后在 package.json
文件添加这段代码:
"bin": {
"mvc": "./bin/mvc.js"
},
2
3
再执行 npm link (opens new window),就可以将 mvc
注册成全局命令。这样在电脑上的任何地方都能使用 mvc
命令了。实际上,就是用 mvc
命令来代替执行 node ./bin/mvc.js
。
假设用户在命令行上输入 mvc create demo
(实际上执行的是 node ./bin/mvc.js create demo
),commander
解析到命令 create
和参数 demo
。然后脚手架可以在 action
回调里取到参数 name
(值为 demo)。
# 和用户交互
取到用户要创建的项目名称 demo
之后,就可以弹出交互选项,询问用户要创建的项目需要哪些功能。这需要用到 Inquirer.js (opens new window)。Inquirer.js
的功能就是弹出一个问题和一些选项,让用户选择。并且选项可以指定是多选、单选等等。
例如下面的代码:
const prompts = [
{
"name": "features", // 选项名称
"message": "Check the features needed for your project:", // 选项提示语
"pageSize": 10,
"type": "checkbox", // 选项类型 另外还有 confirm list 等
"choices": [ // 具体的选项
{
"name": "Babel",
"value": "babel",
"short": "Babel",
"description": "Transpile modern JavaScript to older versions (for compatibility)",
"link": "https://babeljs.io/",
"checked": true
},
{
"name": "Router",
"value": "router",
"description": "Structure the app with dynamic pages",
"link": "https://router.vuejs.org/"
},
]
}
]
inquirer.prompt(prompts)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
弹出的问题和选项如下:
问题的类型 "type": "checkbox"
是 checkbox
说明是多选。如果两个选项都进行选中的话,返回来的值为:
{ features: ['babel', 'router'] }
其中 features
是上面问题中的 name
属性。features
数组中的值则是每个选项中的 value
。
Inquirer.js
还可以提供具有相关性的问题,也就是上一个问题选择了指定的选项,下一个问题才会显示出来。例如下面的代码:
{
name: 'Router',
value: 'router',
description: 'Structure the app with dynamic pages',
link: 'https://router.vuejs.org/',
},
{
name: 'historyMode',
when: answers => answers.features.includes('router'),
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',
},
2
3
4
5
6
7
8
9
10
11
12
13
14
第二个问题中有一个属性 when
,它的值是一个函数 answers => answers.features.includes('router')
。当函数的执行结果为 true
,第二个问题才会显示出来。如果你在上一个问题中选择了 router
,它的结果就会变为 true
。弹出第二个问题:问你路由模式是否选择 history
模式。
大致了解 Inquirer.js
后,就可以明白这一步我们要做什么了。主要就是将脚手架支持的功能配合对应的问题、可选值在控制台上展示出来,供用户选择。获取到用户具体的选项值后,再渲染模板和依赖。
# 有哪些功能
先来看一下第一个版本支持哪些功能:
- vue
- vue-router
- vuex
- babel
- webpack
- linter(eslint)
由于这是一个 vue 相关的脚手架,所以 vue 是默认提供的,不需要用户选择。另外构建工具 webpack 提供了开发环境和打包的功能,也是必需的,不用用户进行选择。所以可供用户选择的功能只有 4 个:
- vue-router
- vuex
- babel
- linter
现在我们先来看一下这 4 个功能对应的交互提示语相关的文件。它们全部放在 lib/promptModules
目录下:
-babel.js
-linter.js
-router.js
-vuex.js
2
3
4
每个文件包含了和它相关的所有交互式问题。例如刚才的示例,说明 router
相关的问题有两个。下面再看一下 babel.js
的代码:
module.exports = (api) => {
api.injectFeature({
name: 'Babel',
value: 'babel',
short: 'Babel',
description: 'Transpile modern JavaScript to older versions (for compatibility)',
link: 'https://babeljs.io/',
checked: true,
})
}
2
3
4
5
6
7
8
9
10
只有一个问题,就是问下用户需不需要 babel
功能,默认为 checked: true
,也就是需要。
# 注入问题
用户使用 create
命令后,脚手架需要将所有功能的交互提示语句聚合在一起:
// craete.js
const creator = new Creator()
// 获取各个模块的交互提示语
const promptModules = getPromptModules()
const promptAPI = new PromptModuleAPI(creator)
promptModules.forEach(m => m(promptAPI))
// 清空控制台
clearConsole()
// 弹出交互提示语并获取用户的选择
const answers = await inquirer.prompt(creator.getFinalPrompts())
function getPromptModules() {
return [
'babel',
'router',
'vuex',
'linter',
].map(file => require(`./promptModules/${file}`))
}
// Creator.js
class Creator {
constructor() {
this.featurePrompt = {
name: 'features',
message: 'Check the features needed for your project:',
pageSize: 10,
type: 'checkbox',
choices: [],
}
this.injectedPrompts = []
}
getFinalPrompts() {
this.injectedPrompts.forEach(prompt => {
const originalWhen = prompt.when || (() => true)
prompt.when = answers => originalWhen(answers)
})
const prompts = [
this.featurePrompt,
...this.injectedPrompts,
]
return prompts
}
}
module.exports = Creator
// PromptModuleAPI.js
module.exports = class PromptModuleAPI {
constructor(creator) {
this.creator = creator
}
injectFeature(feature) {
this.creator.featurePrompt.choices.push(feature)
}
injectPrompt(prompt) {
this.creator.injectedPrompts.push(prompt)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
以上代码的逻辑如下:
- 创建
creator
对象 - 调用
getPromptModules()
获取所有功能的交互提示语 - 再调用
PromptModuleAPI
将所有交互提示语注入到creator
对象 - 通过
const answers = await inquirer.prompt(creator.getFinalPrompts())
在控制台弹出交互语句,并将用户选择结果赋值给answers
变量。
如果所有功能都选上,answers
的值为:
{
features: [ 'vue', 'webpack', 'babel', 'router', 'vuex', 'linter' ], // 项目具有的功能
historyMode: true, // 路由是否使用 history 模式
eslintConfig: 'airbnb', // esilnt 校验代码的默认规则,可被覆盖
lintOn: [ 'save' ] // 保存代码时进行校验
}
2
3
4
5
6
7
# 项目模板
获取用户的选项后就该开始渲染模板和生成 package.json
文件了。先来看一下如何生成 package.json
文件:
// package.json 文件内容
const pkg = {
name,
version: '0.1.0',
dependencies: {},
devDependencies: {},
}
2
3
4
5
6
7
8
先定义一个 pkg
变量来表示 package.json
文件,并设定一些默认值。
所有的项目模板都放在 lib/generator
目录下:
├─lib
│ ├─generator # 各个功能的模板
│ │ ├─babel # babel 模板
│ │ ├─linter # eslint 模板
│ │ ├─router # vue-router 模板
│ │ ├─vue # vue 模板
│ │ ├─vuex # vuex 模板
│ │ └─webpack # webpack 模板
2
3
4
5
6
7
8
9
每个模板的功能都差不多:
- 向
pkg
变量注入依赖项 - 提供模板文件
# 注入依赖
下面是 babel
相关的代码:
module.exports = (generator) => {
generator.extendPackage({
babel: {
presets: ['@babel/preset-env'],
},
dependencies: {
'core-js': '^3.8.3',
},
devDependencies: {
'@babel/core': '^7.12.13',
'@babel/preset-env': '^7.12.13',
'babel-loader': '^8.2.2',
},
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
可以看到,模板调用 generator
对象的 extendPackage()
方法向 pkg
变量注入了 babel
相关的所有依赖。
extendPackage(fields) {
const pkg = this.pkg
for (const key in fields) {
const value = fields[key]
const existing = pkg[key]
if (isObject(value) && (key === 'dependencies' || key === 'devDependencies' || key === 'scripts')) {
pkg[key] = Object.assign(existing || {}, value)
} else {
pkg[key] = value
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
注入依赖的过程就是遍历所有用户已选择的模板,并调用 extendPackage()
注入依赖。
# 渲染模板
脚手架是怎么渲染模板的呢?用 vuex
举例,先看一下它的代码:
module.exports = (generator) => {
// 向入口文件 `src/main.js` 注入代码 import store from './store'
generator.injectImports(generator.entryFile, `import store from './store'`)
// 向入口文件 `src/main.js` 的 new Vue() 注入选项 store
generator.injectRootOptions(generator.entryFile, `store`)
// 注入依赖
generator.extendPackage({
dependencies: {
vuex: '^3.6.2',
},
})
// 渲染模板
generator.render('./template', {})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
可以看到渲染的代码为 generator.render('./template', {})
。./template
是模板目录的路径:
所有的模板代码都放在 template
目录下,vuex
将会在用户创建的目录下的 src
目录生成 store
文件夹,里面有一个 index.js
文件。它的内容为:
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
},
mutations: {
},
actions: {
},
modules: {
},
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这里简单描述一下 generator.render()
的渲染过程。
第一步, 使用 globby (opens new window) 读取模板目录下的所有文件:
const _files = await globby(['**/*'], { cwd: source, dot: true })
2
第二步,遍历所有读取的文件。如果文件是二进制文件,则不作处理,渲染时直接生成文件。否则读取文件内容,再调用 ejs (opens new window) 进行渲染:
// 返回文件内容
const template = fs.readFileSync(name, 'utf-8')
return ejs.render(template, data, ejsOptions)
2
3
4
使用 ejs
的好处,就是可以结合变量来决定是否渲染某些代码。例如 webpack
的模板中有这样一段代码:
module: {
rules: [
<%_ if (hasBabel) { _%>
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/,
},
<%_ } _%>
],
},
2
3
4
5
6
7
8
9
10
11
12
ejs
可以根据用户是否选择了 babel
来决定是否渲染这段代码。如果 hasBabel
为 false
,则这段代码:
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/,
},
2
3
4
5
6
将不会被渲染出来。hasBabel
的值是调用 render()
时用参数传过去的:
generator.render('./template', {
hasBabel: options.features.includes('babel'),
lintOnSave: options.lintOn.includes('save'),
})
2
3
4
5
第三步,注入特定代码。回想一下刚才 vuex
中的:
// 向入口文件 `src/main.js` 注入代码 import store from './store'
generator.injectImports(generator.entryFile, `import store from './store'`)
// 向入口文件 `src/main.js` 的 new Vue() 注入选项 store
generator.injectRootOptions(generator.entryFile, `store`)
2
3
4
5
6
这两行代码的作用是:在项目入口文件 src/main.js
中注入特定的代码。
vuex
是 vue
的一个状态管理库,属于 vue
全家桶中的一员。如果创建的项目没有选择 vuex
和 vue-router
。则 src/main.js
的代码为:
import Vue from 'vue'
import App from './App.vue'
Vue.config.productionTip = false
new Vue({
render: (h) => h(App),
}).$mount('#app')
2
3
4
5
6
7
8
9
如果选择了 vuex
,它会注入上面所说的两行代码,现在 src/main.js
代码变为:
import Vue from 'vue'
import store from './store' // 注入的代码
import App from './App.vue'
Vue.config.productionTip = false
new Vue({
store, // 注入的代码
render: (h) => h(App),
}).$mount('#app')
2
3
4
5
6
7
8
9
10
11
这里简单描述一下代码的注入过程:
- 使用 vue-codemod (opens new window) 将代码解析成语法抽象树 AST。
- 然后将要插入的代码变成 AST 节点插入到上面所说的 AST 中。
- 最后将新的 AST 重新渲染成代码。
# 提取 package.json
的部分选项
一些第三方库的配置项可以放在 package.json
文件,也可以自己独立生成一份文件。例如 babel
在 package.json
中注入的配置为:
babel: {
presets: ['@babel/preset-env'],
}
2
3
4
我们可以调用 generator.extractConfigFiles()
将内容提取出来并生成 babel.config.js
文件:
module.exports = {
presets: ['@babel/preset-env'],
}
2
3
4
# 生成文件
渲染好的模板文件和 package.json
文件目前还是在内存中,并没有真正的在硬盘上创建。这时可以调用 writeFileTree()
将文件生成:
const fs = require('fs-extra')
const path = require('path')
module.exports = async function writeFileTree(dir, files) {
Object.keys(files).forEach((name) => {
const filePath = path.join(dir, name)
fs.ensureDirSync(path.dirname(filePath))
fs.writeFileSync(filePath, files[name])
})
}
2
3
4
5
6
7
8
9
10
11
这段代码的逻辑如下:
- 遍历所有渲染好的文件,逐一生成。
- 在生成一个文件时,确认它的父目录在不在,如果不在,就先生成父目录。
- 写入文件。
例如现在一个文件路径为 src/test.js
,第一次写入时,由于还没有 src
目录。所以会先生成 src
目录,再生成 test.js
文件。
# webpack
webpack 需要提供开发环境下的热加载、编译等服务,还需要提供打包服务。目前 webpack 的代码比较少,功能比较简单。而且生成的项目中,webpack 配置代码是暴露出来的。这留待 v3 版本再改进。
# 添加新功能
添加一个新功能,需要在两个地方添加代码:分别是 lib/promptModules
和 lib/generator
。在 lib/promptModules
中添加的是这个功能相关的交互提示语。在 lib/generator
中添加的是这个功能相关的依赖和模板代码。
不过不是所有的功能都需要添加模板代码的,例如 babel
就不需要。在添加新功能时,有可能会对已有的模板代码造成影响。例如我现在需要项目支持 ts
。除了添加 ts
相关的依赖,还得在 webpack
vue
vue-router
vuex
linter
等功能中修改原有的模板代码。
举个例子,在 vue-router
中,如果支持 ts
,则这段代码:
const routes = [ // ... ]
2
需要修改为:
<%_ if (hasTypeScript) { _%>
const routes: Array<RouteConfig> = [ // ... ]
<%_ } else { _%>
const routes = [ // ... ]
<%_ } _%>
2
3
4
5
6
因为 ts
的值有类型。
总之,添加的新功能越多,各个功能的模板代码也会越来越多。并且还需要考虑到各个功能之间的影响。
# 下载依赖
下载依赖需要使用 execa (opens new window),它可以调用子进程执行命令。
const execa = require('execa')
module.exports = function executeCommand(command, cwd) {
return new Promise((resolve, reject) => {
const child = execa(command, [], {
cwd,
stdio: ['inherit', 'pipe', 'inherit'],
})
child.stdout.on('data', buffer => {
process.stdout.write(buffer)
})
child.on('close', code => {
if (code !== 0) {
reject(new Error(`command failed: ${command}`))
return
}
resolve()
})
})
}
// create.js 文件
console.log('\n正在下载依赖...\n')
// 下载依赖
await executeCommand('npm install', path.join(process.cwd(), name))
console.log('\n依赖下载完成! 执行下列命令开始开发:\n')
console.log(`cd ${name}`)
console.log(`npm run dev`)
复制代码
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
调用 executeCommand()
开始下载依赖,参数为 npm install
和用户创建的项目路径。为了能让用户看到下载依赖的过程,我们需要使用下面的代码将子进程的输出传给主进程,也就是输出到控制台:
child.stdout.on('data', buffer => {
process.stdout.write(buffer)
})
2
3
4
下面我用动图演示一下 v1 版本的创建过程:
创建成功的项目截图:
# 第二个版本 v2
第二个版本在 v1 的基础上添加了一些辅助功能:
- 创建项目时判断该项目是否已存在,支持覆盖和合并创建。
- 选择功能时提供默认配置和手动选择两种模式。
- 如果用户的环境同时存在 yarn 和 npm,则会提示用户要使用哪个包管理器。
- 如果 npm 的默认源速度比较慢,则提示用户是否要切换到淘宝源。
- 如果用户是手动选择功能,在结束后会询问用户是否要将这次的选择保存为默认配置。
# 覆盖和合并
创建项目时,先提前判断一下该项目是否存在:
const targetDir = path.join(process.cwd(), name)
// 如果目标目录已存在,询问是覆盖还是合并
if (fs.existsSync(targetDir)) {
// 清空控制台
clearConsole()
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' },
],
},
])
if (action === 'overwrite') {
console.log(`\nRemoving ${chalk.cyan(targetDir)}...`)
await fs.remove(targetDir)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
如果选择 overwrite
,则进行移除 fs.remove(targetDir)
。
# 默认配置和手动模式
先在代码中提前把默认配置的代码写好:
exports.defaultPreset = {
features: ['babel', 'linter'],
historyMode: false,
eslintConfig: 'airbnb',
lintOn: ['save'],
}
2
3
4
5
6
这个配置默认使用 babel
和 eslint
。
然后生成交互提示语时,先调用 getDefaultPrompts()
方法获取默认配置。
getDefaultPrompts() {
const presets = this.getPresets()
const presetChoices = Object.entries(presets).map(([name, preset]) => {
let displayName = name
return {
name: `${displayName} (${preset.features})`,
value: name,
}
})
const presetPrompt = {
name: 'preset',
type: 'list',
message: `Please pick a preset:`,
choices: [
// 默认配置
...presetChoices,
// 这是手动模式提示语
{
name: 'Manually select features',
value: '__manual__',
},
],
}
const featurePrompt = {
name: 'features',
when: isManualMode,
type: 'checkbox',
message: 'Check the features needed for your project:',
choices: [],
pageSize: 10,
}
return {
presetPrompt,
featurePrompt,
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
这样配置后,在用户选择功能前会先弹出这样的提示语:
# 包管理器
在 vue-cli
创建项目时,会生成一个 .vuerc
文件,里面会记录一些关于项目的配置信息。例如使用哪个包管理器、npm 源是否使用淘宝源等等。为了避免和 vue-cli
冲突,本脚手架生成的配置文件为 .mvcrc
。
这个 .mvcrc
文件保存在用户的 home
目录下(不同操作系统目录不同)。我的是 win10 操作系统,保存目录为 C:\Users\bin
。获取用户的 home
目录可以通过以下代码获取:
const os = require('os')
os.homedir()
2
.mvcrc
文件还会保存用户创建项目的配置,这样当用户重新创建项目时,就可以直接选择以前创建过的配置,不用再一步步的选择项目功能。
在第一次创建项目时,.mvcrc
文件是不存在的。如果这时用户还安装了 yarn,脚手架就会提示用户要使用哪个包管理器:
// 读取 `.mvcrc` 文件
const savedOptions = loadOptions()
// 如果没有指定包管理器并且存在 yarn
if (!savedOptions.packageManager && hasYarn) {
const packageManagerChoices = []
if (hasYarn()) {
packageManagerChoices.push({
name: 'Use Yarn',
value: 'yarn',
short: 'Yarn',
})
}
packageManagerChoices.push({
name: 'Use NPM',
value: 'npm',
short: 'NPM',
})
otherPrompts.push({
name: 'packageManager',
type: 'list',
message: 'Pick the package manager to use when installing dependencies:',
choices: packageManagerChoices,
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
当用户选择 yarn 后,下载依赖的命令就会变为 yarn
;如果选择了 npm,下载命令则为 npm install
:
const PACKAGE_MANAGER_CONFIG = {
npm: {
install: ['install'],
},
yarn: {
install: [],
},
}
await executeCommand(
this.bin, // 'yarn' or 'npm'
[
...PACKAGE_MANAGER_CONFIG[this.bin][command],
...(args || []),
],
this.context,
)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 切换 npm 源
当用户选择了项目功能后,会先调用 shouldUseTaobao()
方法判断是否需要切换淘宝源:
const execa = require('execa')
const chalk = require('chalk')
const request = require('./request')
const { hasYarn } = require('./env')
const inquirer = require('inquirer')
const registries = require('./registries')
const { loadOptions, saveOptions } = require('./options')
async function ping(registry) {
await request.get(`${registry}/vue-cli-version-marker/latest`)
return registry
}
function removeSlash(url) {
return url.replace(/\/$/, '')
}
let checked
let result
module.exports = async function shouldUseTaobao(command) {
if (!command) {
command = hasYarn() ? 'yarn' : 'npm'
}
// ensure this only gets called once.
if (checked) return result
checked = true
// previously saved preference
const saved = loadOptions().useTaobaoRegistry
if (typeof saved === 'boolean') {
return (result = saved)
}
const save = val => {
result = val
saveOptions({ useTaobaoRegistry: val })
return val
}
let userCurrent
try {
userCurrent = (await execa(command, ['config', 'get', 'registry'])).stdout
} catch (registryError) {
try {
// Yarn 2 uses `npmRegistryServer` instead of `registry`
userCurrent = (await execa(command, ['config', 'get', 'npmRegistryServer'])).stdout
} catch (npmRegistryServerError) {
return save(false)
}
}
const defaultRegistry = registries[command]
if (removeSlash(userCurrent) !== removeSlash(defaultRegistry)) {
// user has configured custom registry, respect that
return save(false)
}
let faster
try {
faster = await Promise.race([
ping(defaultRegistry),
ping(registries.taobao),
])
} catch (e) {
return save(false)
}
if (faster !== registries.taobao) {
// default is already faster
return save(false)
}
if (process.env.VUE_CLI_API_MODE) {
return save(true)
}
// ask and save preference
const { useTaobaoRegistry } = await inquirer.prompt([
{
name: 'useTaobaoRegistry',
type: 'confirm',
message: chalk.yellow(
` Your connection to the default ${command} registry seems to be slow.\n`
+ ` Use ${chalk.cyan(registries.taobao)} for faster installation?`,
),
},
])
// 注册淘宝源
if (useTaobaoRegistry) {
await execa(command, ['config', 'set', 'registry', registries.taobao])
}
return save(useTaobaoRegistry)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
上面代码的逻辑为:
- 先判断默认配置文件
.mvcrc
是否有useTaobaoRegistry
选项。如果有,直接将结果返回,无需判断。 - 向 npm 默认源和淘宝源各发一个
get
请求,通过Promise.race()
来调用。这样更快的那个请求会先返回,从而知道是默认源还是淘宝源速度更快。 - 如果淘宝源速度更快,向用户提示是否切换到淘宝源。
- 如果用户选择淘宝源,则调用
await execa(command, ['config', 'set', 'registry', registries.taobao])
将当前 npm 的源改为淘宝源,即npm config set registry https://registry.npm.taobao.org
。如果是 yarn,则命令为yarn config set registry https://registry.npm.taobao.org
。
# 一点疑问
其实 vue-cli
是没有这段代码的:
// 注册淘宝源
if (useTaobaoRegistry) {
await execa(command, ['config', 'set', 'registry', registries.taobao])
}
2
3
4
5
这是我自己加的。主要是我没有在 vue-cli
中找到显式注册淘宝源的代码,它只是从配置文件读取出是否使用淘宝源,或者将是否使用淘宝源这个选项写入配置文件。另外 npm 的配置文件 .npmrc
是可以更改默认源的,如果在 .npmrc
文件直接写入淘宝的镜像地址,那 npm 就会使用淘宝源下载依赖。但 npm 肯定不会去读取 .vuerc
的配置来决定是否使用淘宝源。
对于这一点我没搞明白,所以在用户选择了淘宝源之后,手动调用命令注册一遍。
# 将项目功能保存为默认配置
如果用户创建项目时选择手动模式,在选择完一系列功能后,会弹出下面的提示语:
询问用户是否将这次的项目选择保存为默认配置,如果用户选择是,则弹出下一个提示语:
让用户输入保存配置的名称。
这两句提示语相关的代码为:
const otherPrompts = [
{
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:',
},
]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
保存配置的代码为:
exports.saveOptions = (toSave) => {
const options = Object.assign(cloneDeep(exports.loadOptions()), toSave)
for (const key in options) {
if (!(key in exports.defaults)) {
delete options[key]
}
}
cachedOptions = options
try {
fs.writeFileSync(rcPath, JSON.stringify(options, null, 2))
return true
} catch (e) {
error(
`Error saving preferences: `
+ `make sure you have write access to ${rcPath}.\n`
+ `(${e.message})`,
)
}
}
exports.savePreset = (name, preset) => {
const presets = cloneDeep(exports.loadOptions().presets || {})
presets[name] = preset
return exports.saveOptions({ presets })
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
以上代码直接将用户的配置保存到 .mvcrc
文件中。下面是我电脑上的 .mvcrc
的内容:
{
"packageManager": "npm",
"presets": {
"test": {
"features": [
"babel",
"linter"
],
"eslintConfig": "airbnb",
"lintOn": [
"save"
]
},
"demo": {
"features": [
"babel",
"linter"
],
"eslintConfig": "airbnb",
"lintOn": [
"save"
]
}
},
"useTaobaoRegistry": true
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
下次再创建项目时,脚手架就会先读取这个配置文件的内容,让用户决定是否使用已有的配置来创建项目。
至此,v2 版本的内容就介绍完了。
# 小结
由于 vue-cli
关于插件的源码我还没有看完,所以这篇文章只讲解前两个版本的源码。v3 版本等我看完 vue-cli
的源码再回来填坑,预计在 3 月初就可以完成。
# 参考链接
https://github.com/woai3c/mini-cli
手把手教你写一个脚手架 · Issue #22 · woai3c/Front-end-articles (github.com) (opens new window)
手把手教你写一个脚手架(二) · Issue #23 · woai3c/Front-end-articles (github.com) (opens new window)
# 参考链接
https://github.com/woai3c/mini-cli