写在开头
上一篇 文章中,我们通过3000文字的介绍,终于介绍完 Preset 参数的"前世今生",其中我们也了解到它能通过好几个渠道来手动注入。 完全理解 Preset 参数是我们学习 vue-cli 源码的第一步,开头这一步请一定要走好,避免后面掉坑里了。✿(。◕ᴗ◕。)✿
预备知识
ora模块
相信网站的 Loading 效果大家估计都知道吧,那么 cmd 中的 Loading 效果应该如何来做呢?ora 模块就是来干这么一件事的,它能用于 cmd 控制台进度美化,它的使用也是非常的简单。
安装:
npm install ora@3.4.0
示例:
const ora = require('ora');
const spinner = ora('Loading...');
spinner.start();
setTimeout(() => {
spinner.stop();
console.log('loading stop...')
}, 3000)
效果如下:
org(string/options):
| 参数 | 类型 | 描述 | 默认值 |
|---|---|---|---|
| text | String | 转轮后方的文字 | |
| color | String | 转轮的颜色,提供值:'black'、'red'、'green'、'yellow'、'blue'、'magenta'、'cyan'、'white'、'gray' | 'cyan' |
| spinner | String/Object | 转轮的动画,可自定义:{interval: 80, frames: ['-', '+', '-']} | 'dots' |
| hideCursor | Boolean | 隐藏鼠标指针 | true |
| interval | Number | 转轮动画每帧之间的时间间隔,单位ms | 100 |
methods:
| 方法 | 描述 | 返回值 |
|---|---|---|
| start(text) | 运行转轮,text为指针后的文案 | 返回当前实例 |
| stop() | 停止转轮并清除 | 返回当前实例 |
| succeed(text) | 成功状态的转轮 | 返回当前实例 |
| fail(text) | 失败状态的转轮 | 返回当前实例 |
| warn(text) | 警告状态的转轮 | 返回当前实例 |
| info(text) | 提示状态的转轮 | 返回当前实例 |
| isSpinning() | 判断转轮当前是否在转 | Boolean |
| stopAndPersist(options) | 暂停转轮,替换设置 | 返回当前实例 |
| clear() | 清空转轮-跟stop很像 | 返回当前实例 |
| render() | 渲染帧 | 返回当前实例 |
| frame() | 获取下一帧 | 返回当前实例 |
fs-extra模块
fs-extra 模块是对原生的 fs 模块的封装,它继承原生 fs 模块,并对此进行扩展,提供了更多遍历的API,让用户让好的操作文件系统。
安装:
npm install fs-extra@7.0.1
它有以下这些方法,这些方法有对应的链接,这里小编就不多讲了,偷个懒。
异步方法:
- copy:复制文件或文件夹。
- emptyDir:清空文件夹(文件夹目录不删,内容清空)。
- ensureFile:确保文件存在(文件目录结构没有会新建)。
- ensureDir:确保文件夹存在(文件夹目录结构没有会新建)。
- ensureLink:确保符号链接存在(目录结构没有会新建)。
- ensureSymlink:同ensureDir。
- mkdirp:同ensureDir。
- mkdirs:同ensureDir。
- move:移动文件或文件夹。
- outputFile:同fs.writeFile(),写文件(目录结构没有会新建)。
- outputJson:写json文件(目录结构没有会新建)。
- pathExists:判断文件是否存在。
- readJson:读取JSON文件,将其解析为对象。
- remove:删除文件或文件夹,类似rm -rf。
- writeJson:将对象写入JSON文件。
同步方法:
- copySync
- emptyDirSync
- ensureFileSync
- ensureDirSync
- ensureLinkSync
- ensureSymlinkSync
- mkdirpSync
- mkdirsSync
- moveSync
- outputFileSync
- outputJsonSync
- pathExistsSync
- readJsonSync
- removeSync
- writeJsonSync
execa模块
execa 模块是一个能调用 shell 和本地外部程序的 JS 封装,它改进了 child_process 包的方法,它会启动子进程执行命令,支持多种操作系统,如果父进程退出,则生成的全部子进程都将被杀死。熟悉 Node 的小伙伴应该对它不陌生,不熟悉的小伙伴也没关系,你只要记得它能帮我们执行各种命令即可。
安装:
npm install execa@1.0.0
示例:
const execa = require('execa');
// 执行 npm -v 命令
const result = execa('npm', ['-v'], {});
// 监听命令执行结束
result.stdout.on('close', r => {
console.log(r)
})
async function fn() {
// echo命令可用于cmd窗口中打印信息, 如 echo 'hello world' 命令可执行在cmd中执行
const {stdout} = await execa('echo', ['你好']);
console.log(stdout); // 你好
}
fn()
async function fn1() {
// 相当于执行了 npm config get registry 命令
const {stdout} = await execa('npm', ['config', 'get', 'registry']);
console.log(stdout); // https://packages.aliyun.com/5eb501ef3fd198000181afca/npm/npm-registry/
}
fn1()
更多使用方式可以自行查阅文档。传送门
生成package.json文件
前面我们讲完 Preset 参数的获取,接下来我们需要根据这个 Preset 参数的信息,来生成对应的一些模板的文件,首先我们来看看如何生成 package.json 文件:
// Creator.js
const {hasYarn, hasPnpm3OrLater, logWithSpinner} = require('@vue/cli-shared-utils');
...
const chalk = require('chalk');
const semver = require('semver');
const getVersions = require('./util/getVersions');
const writeFileTree = require('./util/writeFileTree');
module.exports = class Creator {
constructor (name, context, promptModules) { ... }
async create(cliOptions = {}, preset = null) {
...
preset = cloneDeep(preset);
// name为项目名 context为项目路径 createCompleteCbs为模板文件创建完成的回调(可以先不管)
const { name, context, createCompleteCbs } = this;
// preset.plugin格式调整
preset.plugins['@vue/cli-service'] = Object.assign({
projectName: name
}, preset);
// 下载源
const packageManager = (cliOptions.packageManager ||
loadOptions().packageManager || (hasYarn() ? 'yarn' : null) ||
(hasPnpm3OrLater() ? 'pnpm' : 'npm'));
// 清屏
await clearConsole();
// loading效果
logWithSpinner(`✨`, `Creating project in ${chalk.yellow(context)}.`);
// 确定脚手架的版本号
const { current } = await getVersions();
const currentMinor = `${semver.major(current)}.${semver.minor(current)}.0`;
// 构建 package.json 文件内容
const pkg = {
name,
version: '0.1.0',
private: true,
devDependencies: {}
}
const deps = Object.keys(preset.plugins);
deps.forEach(dep => {
if (preset.plugins[dep]._isPreset) {
return
}
pkg.devDependencies[dep] = (
preset.plugins[dep].version ||
((/^@vue/.test(dep)) ? `^${currentMinor}` : `latest`)
)
});
// 生成 package.json 文件
await writeFileTree(context, { 'package.json': JSON.stringify(pkg, null, 2) });
}
async promptAndResolvePreset (answers = null) { ... }
async resolvePreset (name, clone) { ... }
resolveFinalPrompts () { ... }
resolveIntroPrompts() { ... }
getPresets () { ... }
resolveIntroPrompts () { ... }
resolveOutroPrompts () { ... }
}
上面主要在 create() 方法中增加了十几行代码,接下来我们依次来做一些解释。
-
preset.plugin格式调整:在
vue-cli源码中,大部分插件(如vue-router/vuex等)的模板都是放置在@vue/cli-service这个包里面,但也不全是,像label和eslint就有独立的包存放模板,当然这指vue-cli的3版本,后续的版本vue-cli为所有插件都抽离了单独的包。所以
preset.plugins的格式,我们需要调整到和options.js文件中的标准preset格式一样 -
下载源
packageManager:cliOptions.packageManager来自于可选参数-m,其他参数自行查看 文档。从上面代码中可以看到,我们的
packageManager变量是直接从loadOptions()中取的,实际也就是从.vuerc文件中读取,这是为啥?为什么不从Preset参数中取?上上一篇 文章中不是有下载源相关的问答题目吗?这里需要注意哦,下载源的问答题目是有条件限定的,只有第一次会出现这个问答,后续都是直接就从
.vuerc文件中读取了。 -
Loading效果:
logWithSpinner()是我们直接从vue-cli的工具包 @vue/cli-shared-utils 中导出的,它其实就是对在预备知识中提到的 ora 模块的封装而已。 -
JSON.stringify(pkg, null, 2):JSON.stringify()相信大家都用得很熟了,但是它的第二、第三个参数你可知道是啥意思?小编这里就不展开聊了,给你准备 传送门 。
剩下的新增代码就是对 package.json 内容的组成了,也没啥好解释的,自己悟吧。(✪ω✪)
我们来新建 util/writeFileTree.js 文件:
const fs = require('fs-extra');
const path = require('path');
function deleteRemovedFiles (directory, newFiles, previousFiles) {
// 从 previousFiles 中获取不存在 newFiles 中的文件
const filesToDelete = Object.keys(previousFiles)
.filter(filename => !newFiles[filename])
// 删除每个文件
return Promise.all(filesToDelete.map(filename => {
return fs.unlink(path.join(directory, filename))
}))
}
/**
* 生成真实的文件
* @param { String } dir: 目录路径
* @param { Object } files: 文件集合, key为文件名, value为文件内容
* @param {*} previousFiles: 以前的文件集合
*/
module.exports = async function writeFileTree (dir, files, previousFiles) {
if (previousFiles) {
await deleteRemovedFiles(dir, files, previousFiles);
}
Object.keys(files).forEach((name) => {
const filePath = path.join(dir, name); // 拼接文件全路径
fs.ensureDirSync(path.dirname(filePath)); // 创建目录
fs.writeFileSync(filePath, files[name]); // 创建文件
})
}
这个文件就比较简单,也写了注释,这里就不做过多解析了。
需要注意安装一下 fs-extra 模块:
npm install fs-extra@7.0.1
安装后,你可以尝试执行 juejin-vue-cli 命令,看看是否有相应的 package.json 文件生成。
安装依赖
既然 package.json 文件已经生成完毕,那么接下来需要根据这个文件来安装项目所需的依赖模块了,其实本质就是执行一下 npm install 命令就行了,我们来看看 vue-cli 内部是如何来做的。
// Creator.js
const {hasYarn, hasPnpm3OrLater, logWithSpinner, log} = require('@vue/cli-shared-utils');
...
const {installDeps} = require('./util/installDeps');
module.exports = class Creator {
constructor (name, context, promptModules) { ... }
async create(cliOptions = {}, preset = null) {
...
log(`⚙ 开始下载依赖`);
// 下载 package.json 文件依赖
await installDeps(context, packageManager, cliOptions.registry);
stopSpinner();
log(`依赖下载完成`);
}
...
}
新建 ./util/installDeps.js 文件:
const execa = require('execa');
const registries = require('./registries');
const shouldUseTaobao = require('./shouldUseTaobao');
// 验证只能是这几个下载源
const supportPackageManagerList = ['npm', 'yarn', 'pnpm'];
function checkPackageManagerIsSupported (command) {
if (supportPackageManagerList.indexOf(command) === -1) {
throw new Error(`Unknown package manager: ${command}`)
}
}
// 记录每个源需要执行的命令
const packageManagerConfig = {
npm: {
installDeps: ['install', '--loglevel', 'error'],
installPackage: ['install', '--loglevel', 'error'],
uninstallPackage: ['uninstall', '--loglevel', 'error'],
updatePackage: ['update', '--loglevel', 'error']
},
pnpm: {
installDeps: ['install', '--loglevel', 'error', '--shamefully-flatten'],
installPackage: ['install', '--loglevel', 'error'],
uninstallPackage: ['uninstall', '--loglevel', 'error'],
updatePackage: ['update', '--loglevel', 'error']
},
yarn: {
installDeps: [],
installPackage: ['add'],
uninstallPackage: ['remove'],
updatePackage: ['upgrade']
}
}
const taobaoDistURL = 'https://npm.taobao.org/dist';
/**
* 给下载源添加registry地址
* @param {String} command: npm/yarn/pnpm
* @param {Array<String>} args: 被执行的命令列表, [install, ....]
* @param {*} cliRegistry: registry地址, -r <url>
*/
async function addRegistryToArgs (command, args, cliRegistry) {
// cliRegistry来自于可选参数-r: vue create ProjectName -r --registry <url>
const altRegistry = (cliRegistry ||
((await shouldUseTaobao(command)) ? registries.taobao: null));
// 如果确定使用其他下载源的registry地址或者使用淘宝镜像, 则需要在被执行的命令列表中放入--registry与--disturl命令
// --registry: 设置下载源的registry地址
// --disturl: 设置node的国内镜像地址, 主要是解决依赖C++模块所带来的问题, 具体可以看看这篇文章的介绍.https://zhuanlan.zhihu.com/p/147005226
if (altRegistry) {
args.push(`--registry=${altRegistry}`)
if (altRegistry === registries.taobao) {
args.push(`--disturl=${taobaoDistURL}`)
}
}
}
/**
* 执行命令
* @param {String} command: npm/yarn/pnpm
* @param {Array<String>} args: 被执行的命令列表
* @param {String} targetDir: 项目目录地址
* @returns
*/
function executeCommand (command, args, targetDir) {
return new Promise((resolve, reject) => {
// 开始下载 - 通过 execa 模块去执行命令
const child = execa(command, args, {
cwd: targetDir, // 子进程的当前工作目录
stdio: ['inherit'] // 子 stdio 配置, 默认为 pipe
})
// 下载完成
child.on('close', code => {
if (code !== 0) {
reject(`command failed: ${command} ${args.join(' ')}`)
return
}
resolve()
})
})
}
// 下载依赖
exports.installDeps = async function installDeps (targetDir, command, cliRegistry) {
// 验证下载源
checkPackageManagerIsSupported(command);
// 获取需要执行的命令
const args = packageManagerConfig[command].installDeps;
// 添加registry地址
await addRegistryToArgs(command, args, cliRegistry);
// 执行命令
await executeCommand(command, args, targetDir);
}
// 下载具体的包
exports.installPackage = async function (targetDir, command, cliRegistry, packageName, dev = true) {}
// 卸载具体的包
exports.uninstallPackage = async function (targetDir, command, cliRegistry, packageName, dev = true) {}
// 更新具体的包
exports.updatePackage = async function (targetDir, command, cliRegistry, packageName) {}
这个文件内容有点多,但不要慌,内容实际也不复杂,主要看看 installDeps() 这个方法的过程就行,小编也都详细标明了注释,只要你看了就能懂。(@^▽^@)
上面代码中还有几个方法是省略的,它们的作用是针对单个依赖的操作,这里没涉及到就先省略掉了,感兴趣的小伙伴可以直接阅读 vue-cli 源码。传送门
下载 execa 模块:
npm install execa@1.0.0
继续新建 ./util/shouldUseTaobao.js 文件:
const execa = require('execa');
const inquirer = require('inquirer');
const { hasYarn, request } = require('@vue/cli-shared-utils');
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;
/**
* 判断是否使用淘宝镜像
* @param {String} command: npm/yarn/pnpm
* @returns {Boolean}
*/
module.exports = async function shouldUseTaobao (command) {
if (!command) command = hasYarn() ? 'yarn' : 'npm';
// 缓存处理
if (checked) return result;
checked = true;
// 读取 .vuerc 文件中的useTaobaoRegistry属性
const saved = loadOptions().useTaobaoRegistry
if (typeof saved === 'boolean') {
return (result = saved); // 如果在 .vuerc 文件中已经有储存, 下面的逻辑也就不用走了
}
const save = val => {
result = val; // 缓存
saveOptions({ useTaobaoRegistry: val }); // 将是否使用淘宝镜像记录到 .vuerc 文件
return val;
}
// 获取你本地的下载源的registry地址
const userCurrent = (await execa(command, ['config', 'get', 'registry'])).stdout;
const defaultRegistry = registries[command]; // 获取线上的registry地址
// 判断用户是否使用了自定义注册表
if (removeSlash(userCurrent) !== removeSlash(defaultRegistry)) {
return save(false);
}
// 以下逻辑是vue-cli用于检查你当前使用的下载源速度与淘宝镜像的速度做比较
let faster
try {
faster = await Promise.race([ // race: 同时发送请求, 一个请求结束, 则直接返回
ping(defaultRegistry),
ping(registries.taobao)
])
} catch (e) {
return save(false); // 如果对比过程中出现了错误, 则不使用淘宝镜像
}
if (faster !== registries.taobao) {
return save(false); // 淘宝镜像速度比你当前本地使用的下载源速度慢
}
// 问答: 是否切换成淘宝镜像
const { useTaobaoRegistry } = await inquirer.prompt([
{
name: 'useTaobaoRegistry',
type: 'confirm',
message: '你当前本地使用的下载源速度比较慢, 是否切换成淘宝镜像?'
}
])
return save(useTaobaoRegistry);
}
最后新建 ./util/registries.js 文件:
const registries = {
npm: 'https://registry.npmjs.org',
yarn: 'https://registry.yarnpkg.com',
taobao: 'https://registry.npm.taobao.org',
pnpm: 'https://registry.npmjs.org'
}
module.exports = registries
这个文件存放一些下载源 registry 地址,相信有点前端经验的小伙伴可能见过这个命令:
npm config set registry https://registry.npm.taobao.org
它的作用是将我们本地的下载源设置成 cnpm 下载源,也就是淘宝镜像。你也可以全局安装一下 nrm 模块来管理所有下载源,nrm 模块能很方便的切换下载源,这里就不作过多的解释了,感兴趣的小伙伴可以私下去了解了解。
接下来,我们来尝试执行 juejin-vue-cli create gg 命令试试:
如果执行完命令后,你也能正常下载完依赖,那么就说明你成功了(^m^)。
上面我们创建好了 package.json 文件,也安装完项目的依赖,接下来就是创建项目的目录结构了,但由于涉及的内容比较多,就放到下一篇文章再讲吧。
至此,本篇文章就写完啦,撒花撒花。
希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。