乐趣区

关于javascript:Vue-CLI-是如何实现的-终端命令行工具篇

原文链接 http://axuebin.com/articles/fe-solution/cli/vuecli.html 转载请分割。

Vue CLI 是一个基于 Vue.js 进行疾速开发的残缺零碎,提供了终端命令行工具、零配置脚手架、插件体系、图形化治理界面等。本文暂且只剖析 我的项目初始化 局部,也就是终端命令行工具的实现。

集体原创技术文章会发在公众号 玩相机的程序员 上,用键盘和相机记录生存的公众号。

0. 用法

用法很简略,每个 CLI 都大同小异:

npm install -g @vue/cli
vue create vue-cli-test

目前 Vue CLI 同时反对 Vue 2 和 Vue 3 我的项目的创立(默认配置)。

下面是 Vue CLI 提供的默认配置,能够疾速地创立一个我的项目。除此之外,也能够依据本人的我的项目需要(是否应用 Babel、是否应用 TS 等)来自定义我的项目工程配置,这样会更加的灵便。

抉择实现之后,敲下回车,就开始执行装置依赖、拷贝模板等命令 …

看到 Successfully 就是我的项目初始化胜利了。

vue create  命令反对一些参数配置,能够通过 vue create --help  获取具体的文档:

用法:create [options] <app-name>

选项:-p, --preset <presetName>       疏忽提示符并应用已保留的或近程的预设选项
  -d, --default                   疏忽提示符并应用默认预设选项
  -i, --inlinePreset <json>       疏忽提示符并应用内联的 JSON 字符串预设选项
  -m, --packageManager <command>  在装置依赖时应用指定的 npm 客户端
  -r, --registry <url>            在装置依赖时应用指定的 npm registry
  -g, --git [message]             强制 / 跳过 git 初始化,并可选的指定初始化提交信息
  -n, --no-git                    跳过 git 初始化
  -f, --force                     覆写目标目录可能存在的配置
  -c, --clone                     应用 git clone 获取近程预设选项
  -x, --proxy                     应用指定的代理创立我的项目
  -b, --bare                      创立我的项目时省略默认组件中的老手领导信息
  -h, --help                      输入应用帮忙信息

具体的用法大家感兴趣的能够尝试一下,这里就不开展了,后续在源码剖析中会有相应的局部提到。

1. 入口文件

本文中的 vue cli 版本为 4.5.9。若浏览本文时存在 break change,可能就须要本人了解一下啦

依照失常逻辑,咱们在 package.json 里找到了入口文件:

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

bin/vue.js 里的代码不少,无非就是在 vue  上注册了 create / add / ui  等命令,本文只剖析 create  局部,找到这部分代码(删除主流程无关的代码后):

// 查看 node 版本
checkNodeVersion(requiredVersion, '@vue/cli');

// 挂载 create 命令
program.command('create <app-name>').action((name, cmd) => {
  // 获取额定参数
  const options = cleanArgs(cmd);
  // 执行 create 办法
  require('../lib/create')(name, options);
});

cleanArgs  是获取 vue create  前面通过 -  传入的参数,通过 vue create --help 能够获取执行的参数列表。

获取参数之后就是执行真正的 create  办法了,等等认真开展。

不得不说,Vue CLI 对于代码模块的治理十分细,每个模块基本上都是 繁多功能模块,能够任意地拼装和应用。每个文件的代码行数也都不会很多,浏览起来十分难受。

2. 输出命令有误,猜想用户用意

Vue CLI 中比拟有意思的一个中央,如果用户在终端中输出 vue creat xxx  而不是 vue create xxx,会怎么样呢?实践上应该是报错了。

如果只是报错,那我就不提了。看看后果:

终端上输入了一行很要害的信息 Did you mean create,Vue CLI 仿佛晓得用户是想应用 create  然而手速太快打错单词了。

这是如何做到的呢?咱们在源代码中寻找答案:

const leven = require('leven');

// 如果不是以后已挂载的命令,会猜想用户用意
program.arguments('<command>').action(cmd => {suggestCommands(cmd);
});

// 猜想用户用意
function suggestCommands(unknownCommand) {const availableCommands = program.commands.map(cmd => cmd._name);

  let suggestion;

  availableCommands.forEach(cmd => {
    const isBestMatch =
      leven(cmd, unknownCommand) < leven(suggestion || '', unknownCommand);
    if (leven(cmd, unknownCommand) < 3 && isBestMatch) {suggestion = cmd;}
  });

  if (suggestion) {console.log(`  ` + chalk.red(`Did you mean ${chalk.yellow(suggestion)}?`));
  }
}

代码中应用了 leven 了这个包,这是用于计算字符串编辑间隔算法的 JS 实现,Vue CLI 这里应用了这个包,来别离计算输出的命令和以后已挂载的所有命令的编辑举例,从而猜想用户理论想输出的命令是哪个。

小而美的一个性能,用户体验极大晋升。

3. Node 版本相关检查

3.1 Node 冀望版本

create-react-app  相似,Vue CLI 也是先查看了一下以后 Node 版本是否符合要求:

  • 以后 Node 版本:process.version
  • 冀望的 Node 版本:require("../package.json").engines.node

比方我目前在用的是 Node v10.20.1 而 @vue/cli 4.5.9  要求的 Node 版本是 >=8.9,所以是符合要求的。

3.2 举荐 Node LTS 版本

bin/vue.js  中有这样一段代码,看上去也是在查看 Node 版本:

const EOL_NODE_MAJORS = ['8.x', '9.x', '11.x', '13.x'];
for (const major of EOL_NODE_MAJORS) {if (semver.satisfies(process.version, major)) {
    console.log(
      chalk.red(`You are using Node ${process.version}.\n` +
          `Node.js ${major} has already reached end-of-life and will not be supported in future major releases.\n` +
          `It's strongly recommended to use an active LTS version instead.`
      )
    );
  }
}

可能并不是所有人都理解它的作用,在这里略微科普一下。

简略来说,Node 的主版本分为 奇数版本 偶数版本 。每个版本公布之后会继续六个月的工夫,六个月之后,奇数版本将变为 EOL 状态,而偶数版本变为 Active LTS  状态并且长期反对。所以咱们在生产环境应用 Node 的时候,应该尽量应用它的 LTS 版本,而不是 EOL 的版本。

EOL 版本:A End-Of-Life version of Node
LTS 版本: A long-term supported version of Node

这是目前常见的 Node 版本的一个状况:

解释一下图中几个状态:

  • CURRENT:会修复 bug,减少新个性,一直改善
  • ACTIVE:长期稳固版本
  • MAINTENANCE:只会修复 bug,不会再有新的个性减少
  • EOL:当进度条走完,这个版本也就不再保护和反对了

通过下面那张图,咱们能够看到,Node 8.x 在 2020 年曾经 EOL,Node 12.x 在 2021 年的时候也会进入 MAINTENANCE 状态,而 Node 10.x 在 2021 年 4、5 月的时候就会变成 EOL

Vue CLI 中对以后的 Node 版本进行判断,如果你用的是 EOL 版本,会举荐你应用 LTS 版本。也就是说,在不久之后,这里的应该判断会多出一个 10.x,还不快去给 Vue CLI 提个 PR(手动狗头)。

4. 判断是否在以后门路

在执行 vue create  的时候,是必须指定一个 app-name,否则会报错:Missing required argument <app-name>

那如果用户曾经本人创立了一个目录,想在以后这个空目录下创立一个我的项目呢?当然,Vue CLI 也是反对的,执行 vue create .  就 OK 了。

lib/create.js  中就有相干代码是在解决这个逻辑的。

async function create(projectName, options) {
  // 判断传入的 projectName 是否是 .
  const inCurrent = projectName === '.';
  // path.relative 会返回第一个参数到第二个参数的相对路径
  // 这里就是用来获取当前目录的目录名
  const name = inCurrent ? path.relative('../', cwd) : projectName;
  // 最终初始化我的项目的门路
  const targetDir = path.resolve(cwd, projectName || '.');
}

如果你须要实现一个 CLI,这个逻辑是能够拿来即用的。

5. 查看利用名

Vue CLI 会通过 validate-npm-package-name  这个包来查看输出的 projectName 是否符合规范。

const result = validateProjectName(name);
if (!result.validForNewPackages) {console.error(chalk.red(`Invalid project name: "${name}"`));
  exit(1);
}

对应的 npm 命名标准能够见:Naming Rules

6. 若指标文件夹已存在,是否笼罩

这段代码比较简单,就是判断 target  目录是否存在,而后通过交互询问用户是否笼罩(对应的是操作是删除原目录):

// 是否 vue create -m
if (fs.existsSync(targetDir) && !options.merge) {
  // 是否 vue create -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 {
      // 如果有目标目录,则询问如何解决:Overwrite / Merge / Cancel
      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},
          ],
        },
      ]);
      // 如果抉择 Cancel,则间接停止
      // 如果抉择 Overwrite,则先删除原目录
      // 如果抉择 Merge,不必预处理啥
      if (!action) {return;} else if (action === 'overwrite') {console.log(`\nRemoving ${chalk.cyan(targetDir)}...`);
        await fs.remove(targetDir);
      }
    }
  }
}

7. 整体谬误捕捉

create  办法的最外层,放了一个 catch  办法,捕捉外部所有抛出的谬误,将以后的 spinner  状态进行,退出过程。

module.exports = (...args) => {return create(...args).catch(err => {stopSpinner(false); // do not persist
    error(err);
    if (!process.env.VUE_CLI_TEST) {process.exit(1);
    }
  });
};

8. Creator 类

lib/create.js  办法的最初,执行了这样两行代码:

const creator = new Creator(name, targetDir, getPromptModules());
await creator.create(options);

看来最重要的代码还是在 Creator  这个类中。

关上 Creator.js  文件,好家伙,500+ 行代码,并且引入了 12 个模块。当然,这篇文章不会把这 500 行代码和 12 个模块都理一遍,没必要,感兴趣的本人去看看好了。

本文还是梳理主流程和一些有意思的性能。

8.1 constructor 构造函数

先看一下 Creator  类的的构造函数:

module.exports = class Creator extends EventEmitter {constructor(name, context, promptModules) {super();

    this.name = name;
    this.context = process.env.VUE_CLI_CONTEXT = context;
    // 获取了 preset 和 feature 的 交互抉择列表,在 vue create 的时候提供抉择
    const {presetPrompt, featurePrompt} = this.resolveIntroPrompts();
    this.presetPrompt = presetPrompt;
    this.featurePrompt = featurePrompt;

    // 交互抉择列表:是否输入一些文件
    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));
  }
};

构造函数嘛,次要就是初始化一些变量。这里次要将逻辑都封装在 resolveIntroPrompts / resolveOutroPrompts  和 PromptModuleAPI  这几个办法中。

次要看一下 PromptModuleAPI 这个类是干什么的。

module.exports = class PromptModuleAPI {constructor(creator) {this.creator = creator;}
  // 在 promptModules 里用
  injectFeature(feature) {this.creator.featurePrompt.choices.push(feature);
  }
  // 在 promptModules 里用
  injectPrompt(prompt) {this.creator.injectedPrompts.push(prompt);
  }
  // 在 promptModules 里用
  injectOptionForPrompt(name, option) {
    this.creator.injectedPrompts
      .find(f => {return f.name === name;})
      .choices.push(option);
  }
  // 在 promptModules 里用
  onPromptComplete(cb) {this.creator.promptCompleteCbs.push(cb);
  }
};

这里咱们也简略说一下,promptModules  返回的是所有用于终端交互的模块,其中会调用 injectFeatureinjectPrompt 来将交互配置插入进去,并且会通过 onPromptComplete  注册一个回调。

onPromptComplete 注册回调的模式是往 promptCompleteCbs 这个数组中 push 了传入的办法,能够猜想在所有交互实现之后应该会通过以下模式来调用回调:

this.promptCompleteCbs.forEach(cb => cb(answers, preset));

回过来看这段代码:

module.exports = class Creator extends EventEmitter {constructor(name, context, promptModules) {const promptAPI = new PromptModuleAPI(this);
    promptModules.forEach(m => m(promptAPI));
  }
};

Creator  的构造函数中,实例化了一个 promptAPI  对象,并遍历 prmptModules  把这个对象传入了 promptModules  中,阐明在实例化 Creator  的时候时候就会把所有用于交互的配置注册好了。

这里咱们留神到,在构造函数中呈现了四种 promptpresetPromptfeaturePromptinjectedPromptsoutroPrompts,具体有什么区别呢?下文有有具体开展。

8.2 EventEmitter 事件模块

首先,Creator  类是继承于 Node.js 的 EventEmitter 类。家喻户晓,events  是 Node.js 中最重要的一个模块,而 EventEmitter 类就是其根底,是 Node.js 中事件触发与事件监听等性能的封装。

在这里,Creator  继承自 EventEmitter , 应该就是为了不便在 create  过程中 emit  一些事件,整顿了一下,次要就是以下 8 个事件:

this.emit('creation', { event: 'creating'}); // 创立
this.emit('creation', { event: 'git-init'}); // 初始化 git
this.emit('creation', { event: 'plugins-install'}); // 装置插件
this.emit('creation', { event: 'invoking-generators'}); // 调用 generator
this.emit('creation', { event: 'deps-install'}); // 装置额定的依赖
this.emit('creation', { event: 'completion-hooks'}); // 实现之后的回调
this.emit('creation', { event: 'done'}); // create 流程完结
this.emit('creation', { event: 'fetch-remote-preset'}); // 拉取近程 preset

咱们晓得事件 emit  肯定会有 on  的中央,是哪呢?搜了一下源码,是在 @vue/cli-ui 这个包里,也就是说在终端命令行工具的场景下,不会触发到这些事件,这里简略理解一下即可:

const creator = new Creator('', cwd.get(), getPromptModules());
onCreationEvent = ({event}) => {progress.set({ id: PROGRESS_ID, status: event, info: null}, context);
};
creator.on('creation', onCreationEvent);

简略来说,就是通过 vue ui  启动一个图形化界面来初始化我的项目时,会启动一个 server 端,和终端之间是存在通信的。server 端挂载了一些事件,在 create 的每个阶段,会从 cli 中的办法触发这些事件。

9. Preset(预设)

Creator  类的实例办法 create  承受两个参数:

  • cliOptions:终端命令行传入的参数
  • preset:Vue CLI 的预设

9.1 什么是 Preset(预设)

Preset 是什么呢?官网解释 是一个蕴含创立新我的项目所需预约义选项和插件的 JSON 对象,让用户无需在命令提醒中抉择它们。比方:

{
  "useConfigFiles": true,
  "cssPreprocessor": "sass",
  "plugins": {"@vue/cli-plugin-babel": {},
    "@vue/cli-plugin-eslint": {
      "config": "airbnb",
      "lintOn": ["save", "commit"]
    }
  },
  "configs": {"vue": {...},
    "postcss": {...},
    "eslintConfig": {...},
    "jest": {...}
  }
}

在 CLI 中容许应用本地的 preset 和近程的 preset。

9.2 prompt

用过 inquirer 的敌人的对 prompt 这个单词肯定不生疏,它有 input / checkbox 等类型,是用户和终端的交互。

咱们回过头来看一下在 Creator 中的一个办法 getPromptModules,依照字面意思,这个办法是获取了一些用于交互的模块,具体来看一下:

exports.getPromptModules = () => {
  return [
    'vueVersion',
    'babel',
    'typescript',
    'pwa',
    'router',
    'vuex',
    'cssPreprocessors',
    'linter',
    'unit',
    'e2e',
  ].map(file => require(`../promptModules/${file}`));
};

看样子是获取了一系列的模块,返回了一个数组。我看了一下这里列的几个模块,代码格局根本都是对立的::

module.exports = cli => {
  cli.injectFeature({
    name: '',
    value: '',
    short: '',
    description: '',
    link: '',
    checked: true,
  });

  cli.injectPrompt({
    name: '',
    when: answers => answers.features.includes(''),
    message: '',
    type: 'list',
    choices: [],
    default: '2',
  });

  cli.onPromptComplete((answers, options) => {});
};

独自看 injectFeatureinjectPrompt 的对象是不是和 inquirer 有那么一点神似?是的,他们就是用户交互的一些配置选项。那 Feature  和 Prompt  有什么区别呢?

Feature:Vue CLI 在抉择自定义配置时的顶层选项:

Prompt:抉择具体 Feature 对应的二级选项,比方抉择了 Choose Vue version 这个 Feature,会要求用户抉择是 2.x 还是 3.x:

onPromptComplete 注册了一个回调办法,在实现交互之后执行。

看来咱们的猜想是对的,getPromptModules 办法就是获取一些 用于和用户交互的模块,比方:

  • babel:抉择是否应用 Babel
  • cssPreprocessors:抉择 CSS 的预处理器(Sass、Less、Stylus)

先说到这里,前面在自定义配置加载的章节里会开展介绍 Vue CLI 用到的所有 prompt

9.3 获取预设

咱们具体来看一下获取预设相干的逻辑。这部分代码在 create  实例办法中:

// Creator.js
module.exports = class Creator extends EventEmitter {async create(cliOptions = {}, preset = null) {
    const isTestOrDebug = process.env.VUE_CLI_TEST || process.env.VUE_CLI_DEBUG;
    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;
      } 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();
      }
    }
  }
};

能够看到,代码中别离针对几种状况作了解决:

  • cli 参数配了 –preset
  • cli 参数配了 –default
  • cli 参数配了 –inlinePreset
  • cli 没配相干参数,默认获取 Preset 的行为

前三种状况就不开展说了,咱们来看一下第四种状况,也就是默认通过交互 prompt  来获取 Preset 的逻辑,也就是 promptAndResolvePreset  办法。

先看一下理论用的时候是什么样的:

咱们能够猜想这里就是一段 const answers = await inquirer.prompt([])  代码。

 async promptAndResolvePreset(answers = null) {
    // prompt
    if (!answers) {await clearConsole(true);
      answers = await inquirer.prompt(this.resolveFinalPrompts());
    }
    debug("vue-cli:answers")(answers);
 }

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

    const prompts = [
      this.presetPrompt,
      this.featurePrompt,
      ...this.injectedPrompts,
      ...this.outroPrompts,
    ];
    debug("vue-cli:prompts")(prompts);
    return prompts;
 }

是的,咱们猜的没错,将 this.resolveFinalPrompts  里的配置进行交互,而 this.resolveFinalPrompts  办法其实就是将在 Creator  的构造函数里初始化的那些 prompts  合到一起了。上文也提到了有这四种 prompt,在下一节开展介绍。
**

9.4 保留预设

在 Vue CLI 的最初,会让用户抉择 save this as a preset for future?,如果用户抉择了 Yes,就会执行相干逻辑将这次的交互后果保留下来。这部分逻辑也是在 promptAndResolvePreset 中。

async promptAndResolvePreset(answers = null)  {
  if (
    answers.save &&
    answers.saveName &&
    savePreset(answers.saveName, preset)
  ) {log();
    log(`????  Preset ${chalk.yellow(answers.saveName)} saved in ${chalk.yellow(rcPath)}`
    );
  }
}

在调用 savePreset 之前还会对预设进行解析、校验等,就不开展了,间接来看一下 savePreset 办法:

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});
};

代码很简略,先深拷贝一份 Preset(这里间接用的 lodash 的 clonedeep),而后进过一些 merge 的操作之后就 writeFileSync 到上文有提到的 .vuerc 文件了。

10. 自定义配置加载

这四种 prompt  别离对应的是预设选项、自定义 feature 抉择、具体 feature 选项和其它选项,它们之间存在相互关联、层层递进的关系。联合这四种 prompt,就是 Vue CLI 展示开用户背后的所有交互了,其中也蕴含自定义配置的加载。

10.1 presetPrompt: 预设选项

也就是最后截图里看到的哪三个选项,抉择 Vue2 还是 Vue3 还是自定义 feature

如果抉择了 Vue2  或者 Vue3,则后续对于 preset  所有的 prompt  都会终止。

10.2 featurePrompt: 自定义 feature 选项

**
如果在 presetPrompt  中抉择了 Manually,则会持续抉择 feature

featurePrompt  就是存储的这个列表,对应的代码是这样的:

const isManualMode = answers => answers.preset === '__manual__';

const featurePrompt = {
  name: 'features',
  when: isManualMode,
  type: 'checkbox',
  message: 'Check the features needed for your project:',
  choices: [],
  pageSize: 10,
};

在代码中能够看到,在 isManualMode  的时候才会弹出这个交互。

10.3 injectedPrompts: 具体 feature 选项

featurePrompt  只是提供了一个一级列表,当用户抉择了 Vue Version / Babel / TypeScript  等选项之后,会弹出新的交互,比方 Choose Vue version

injectedPrompts  就是存储的这些具体选项的列表,也就是上文有提到通过 getPromptModules 办法在 promptModules  目录获取到的那些 prompt  模块:

对应的代码能够再回顾一下:

cli.injectPrompt({
  name: 'vueVersion',
  when: answers => answers.features.includes('vueVersion'),
  message: 'Choose a version of Vue.js that you want to start the project with',
  type: 'list',
  choices: [
    {
      name: '2.x',
      value: '2',
    },
    {name: '3.x (Preview)',
      value: '3',
    },
  ],
  default: '2',
});

能够看到,在 answers => answers.features.includes('vueVersion'),也就是 featurePrompt 的交互后果中如果蕴含 vueVersion  就会弹出具体抉择 Vue Version  的交互。

10.4 outroPrompts: 其它选项

**
这里存储的就是一些除了上述三类选项之外的选项目前蕴含三个:

Where do you prefer placing config for Babel, ESLint, etc.? Babel,ESLint 等配置文件如何存储?

  • In dedicated config files。独自保留在各自的配置文件中。
  • In package.json。对立存储在 package.json 中。

Save this as a preset for future projects? 是否保留这次 Preset 以便之后间接应用。

如果你抉择了 Yes,则会再进去一个交互:Save preset as 输出 Preset 的名称

10.5 总结:Vue CLI 交互流程

这里总结一下 Vue CLI 的整体交互,也就是 prompt  的实现。

也就是文章最开始的时候提到,Vue CLI 反对默认配置之外,也反对自定义配置(Babel、TS 等),这样一个交互流程是如何实现的。

Vue CLI 将所有交互分为四大类:

从预设选项到具体 feature 选项,它们是一个 层层递进 的关系,不同的机会和抉择会触发不同的交互。

Vue CLI 这里在代码架构上的设计值得学习,将各个交互保护在不同的模块中,通过对立的一个 prmoptAPI  实例在 Creator  实例初始化的时候,插入到不同的 prompt  中,并且注册各自的回调函数。这样设计对于 prompt  而言是齐全解耦的,删除某一项 prompt  对于上下文的影响能够忽略不计。

好了,对于预设(Preset)和交互(Prompt)到这里根本剖析完了,剩下的一些细节问题就不再开展了。

这里波及到的相干源码文件有,大家能够自行看一下:

  • Creator.js
  • PromptModuleAPI.js
  • utils/createTools.js
  • promptModules

11. 初始化我的项目根底文件

当用户选完所有交互之后,CLI 的下一步职责就是依据用户的选项去生成对应的代码了,这也是 CLI 的外围性能之一。

11.1 初始化 package.json 文件

依据用户的选项会挂载相干的 vue-cli-plugin,而后用于生成 package.json  的依赖 devDependencies,比方 @vue/cli-service / @vue/cli-plugin-babel / @vue/cli-plugin-eslint  等。

Vue CLI 会当初创立目录下写入一个根底的 package.json

{
  "name": "a",
  "version": "0.1.0",
  "private": true,
  "devDependencies": {
    "@vue/cli-plugin-babel": "~4.5.0",
    "@vue/cli-plugin-eslint": "~4.5.0",
    "@vue/cli-service": "~4.5.0"
  }
}

11.2 初始化 Git

依据传入的参数和一系列的判断,会在目标目录下初始化 Git 环境,简略来说就是执行一下 git init

await run('git init');

具体是否初始化 Git 环境是这样判断的:

shouldInitGit(cliOptions) {
  // 如果全局没装置 Git,则不初始化
  if (!hasGit()) {return false;}
  // 如果 CLI 有传入 --git 参数,则初始化
  if (cliOptions.forceGit) {return true;}
  // 如果 CLI 有传入 --no-git,则不初始化
  if (cliOptions.git === false || cliOptions.git === "false") {return false;}
  // 如果当前目录下曾经有 Git 环境,就不初始化
  return !hasProjectGit(this.context);
}

11.3 初始化 README.md

我的项目的 README.md  会依据上下文动静生成,而不是写死的一个文档:

function generateReadme(pkg, packageManager) {
  return [`# ${pkg.name}\n`,
    '## Project setup',
    '```',
    `${packageManager} install`,
    '```',
    printScripts(pkg, packageManager),
    '### Customize configuration',
    'See [Configuration Reference](https://cli.vuejs.org/config/).',
    '',
  ].join('\n');
}

Vue CLI 创立的 README.md  会告知用户如何应用这个我的项目,除了 npm install  之外,会依据 package.json  里的 scripts  参数来动静生成应用文档,比方如何开发、构建和测试:

const descriptions = {
  build: 'Compiles and minifies for production',
  serve: 'Compiles and hot-reloads for development',
  lint: 'Lints and fixes files',
  'test:e2e': 'Run your end-to-end tests',
  'test:unit': 'Run your unit tests',
};

function printScripts(pkg, packageManager) {return Object.keys(pkg.scripts || {})
    .map(key => {if (!descriptions[key]) return '';
      return [`\n### ${descriptions[key]}`,
        '```',
        `${packageManager} ${packageManager !== 'yarn' ? 'run' : ''}${key}`,'```','',
      ].join('\n');
    })
    .join('');
}

这里可能会有读者问,为什么不间接拷贝一个 README.md  文件过来呢?

  • 第一,Vue CLI 反对不同的包治理,对应装置、启动和构建脚本都是不一样的,这个是须要动静生成的;
  • 第二,动静生成自由性更强,能够依据用户的选项去生成对应的文档,而不是大家都一样。

11.4 装置依赖

调用 ProjectManageinstall 办法装置依赖,代码不简单:

 async install () {if (this.needsNpmInstallFix) {
     // 读取 package.json
     const pkg = resolvePkg(this.context)
     // 装置 dependencies
     if (pkg.dependencies) {const deps = Object.entries(pkg.dependencies).map(([dep, range]) => `${dep}@${range}`)
       await this.runCommand('install', deps)
     }
     // 装置 devDependencies
     if (pkg.devDependencies) {const devDeps = Object.entries(pkg.devDependencies).map(([dep, range]) => `${dep}@${range}`)
       await this.runCommand('install', [...devDeps, '--save-dev'])
     }
     // 装置 optionalDependencies
     if (pkg.optionalDependencies) {const devDeps = Object.entries(pkg.devDependencies).map(([dep, range]) => `${dep}@${range}`)
       await this.runCommand('install', [...devDeps, '--save-optional'])
     }
     return
   }
   return await this.runCommand('install', this.needsPeerDepsFix ? ['--legacy-peer-deps'] : [])
 }

简略来说就是读取 package.json 而后别离装置 npm 的不同依赖。

这里的逻辑深刻进去感觉还是挺简单的,我也没认真深刻看,就不开展说了。。。

11.4.1 主动判断 NPM 源

这里有一个有意思的点,对于装置依赖时应用的 npm 仓库源。如果用户没有指定装置源,Vue CLI 会主动判断是否应用淘宝的 NPM 装置源,猜猜是如何实现的?

function shouldUseTaobao() {
  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)
  }

  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?`
      )
    }
  ])
  return save(useTaobaoRegistry);
}

Vue CLI 中会通过 Promise.race 去申请 默认装置源 淘宝装置源:
**

  • 如果先返回的是淘宝装置源,就会让用户确认一次,是否应用淘宝装置源
  • 如果先返回的是默认装置源,就会间接应用默认装置源

一般来说,必定都是应用默认装置源,然而思考国内用户。。咳咳。。为这个设计点赞。

12. Generator 生成代码

除了 Creator  外,整个 Vue CLI 的第二大重要的类是 Generator,负责我的项目代码的生成,来具体看看干了啥。

12.1 初始化插件

generate  办法中,最先执行的是一个 initPlugins  办法,代码如下:

async 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)
    }
  }
}

在这里会给每一个 package.json  里的插件初始化一个 GeneratorAPI  实例,将实例传入对应插件的 generator  办法并执行,比方 @vue/cli-plugin-babel/generator.js

12.2 GeneratorAPI 类

Vue CLI 应用了一套基于插件的架构。如果你查阅一个新创建我的项目的 package.json,就会发现依赖都是以 @vue/cli-plugin- 结尾的。插件能够批改 webpack 的外部配置,也能够向 vue-cli-service 注入命令。在我的项目创立的过程中,绝大部分列出的个性都是通过插件来实现的。

刚刚提到,会往每一个插件的 generator  中传入 GeneratorAPI  的实例,看看这个类提供了什么。

12.2.1 例子:@vue/cli-plugin-babel

为了不那么形象,咱们先拿 @vue/cli-plugin-babel 来看,这个插件比较简单:

module.exports = api => {delete api.generator.files['babel.config.js'];

  api.extendPackage({
    babel: {presets: ['@vue/cli-plugin-babel/preset'],
    },
    dependencies: {'core-js': '^3.6.5',},
  });
};

这里 api  就是一个 GeneratorAPI 实例,这里用到了一个 extendPackage  办法:

// GeneratorAPI.js
// 删减局部代码,只针对 @vue/cli-plugin-babel 剖析
extendPackage (fields, options = {}) {
  const pkg = this.generator.pkg
  const toMerge = isFunction(fields) ? fields(pkg) : fields
  // 遍历传入的参数,这里是 babel 和 dependencies 两个对象
  for (const key in toMerge) {const value = toMerge[key]
    const existing = pkg[key]
    // 如果 key 的名称是 dependencies 和 devDependencies
    // 就通过 mergeDeps 办法往 package.json 合并依赖
    if (isObject(value) && (key === 'dependencies' || key === 'devDependencies')) {pkg[key] = mergeDeps(
        this.id,
        existing || {},
        value,
        this.generator.depSources,
        extendOptions
      )
    } else if (!extendOptions.merge || !(key in pkg)) {pkg[key] = value
    }
  }
}

这时候,默认的 package.json  就变成:

{
  "babel": {"presets": ["@vue/cli-plugin-babel/preset"]
  },
  "dependencies": {"core-js": "^3.6.5"},
  "devDependencies": {},
  "name": "test",
  "private": true,
  "version": "0.1.0"
}

看完这个例子,对于 GeneratorAPI  的实例做什么可能有些理解了,咱们就来具体看看这个类的实例吧。

12.2.2 重要的几个实例办法

先介绍几个 GeneratorAPI  重要的实例办法,这里就只介绍性能,具体代码就不看了,等等会用到。

  • extendPackage:拓展 package.json 配置
  • render:通过 ejs 渲染模板文件
  • onCreateComplete: 注册文件写入硬盘之后的回调
  • genJSConfig: 将 json 文件输入成 js 文件
  • injectImports: 向文件中退出 import

13. @vue/cli-service

上文曾经看过一个 @vue/cli-plugin-babel  插件,对于 Vue CLI 的插件架构是不是有点感觉?也理解到一个比拟重要的 GeneratorAPI  类,插件中的一些批改配置的性能都是这个类的实例办法。

接下来看一个比拟重要的插件 @vue/cli-service,这个插件是 Vue CLI 的外围插件,和 create react app  的 react-scripts  相似,借助这个插件,咱们应该可能更粗浅地了解 GeneratorAPI 以及 Vue CLI 的插件架构是如何实现的。

来看一下 @vue/cli-service  这个包下的 generator/index.js  文件,这里为了剖析不便,将源码拆解成多段,其实也就是别离调用了 GeneratorAPI  实例的不同办法:

13.1 渲染 template

api.render('./template', {doesCompile: api.hasPlugin('babel') || api.hasPlugin('typescript'),
});

template  目录下的文件通过 render  渲染到内存中,这里用的是 ejs  作为模板渲染引擎。

13.2 写 package.json

通过 extendPackagepacakge.json 中写入 Vue   的相干依赖:

if (options.vueVersion === '3') {
  api.extendPackage({
    dependencies: {vue: '^3.0.0',},
    devDependencies: {'@vue/compiler-sfc': '^3.0.0',},
  });
} else {
  api.extendPackage({
    dependencies: {vue: '^2.6.11',},
    devDependencies: {'vue-template-compiler': '^2.6.11',},
  });
}

通过 extendPackagepacakge.json 中写入 scripts

api.extendPackage({
  scripts: {
    serve: 'vue-cli-service serve',
    build: 'vue-cli-service build',
  },
  browserslist: ['> 1%', 'last 2 versions', 'not dead'],
});

通过 extendPackagepacakge.json 中写入 CSS 预处理参数:

if (options.cssPreprocessor) {
  const deps = {
    sass: {
      sass: '^1.26.5',
      'sass-loader': '^8.0.2',
    },
    'node-sass': {
      'node-sass': '^4.12.0',
      'sass-loader': '^8.0.2',
    },
    'dart-sass': {
      sass: '^1.26.5',
      'sass-loader': '^8.0.2',
    },
    less: {
      less: '^3.0.4',
      'less-loader': '^5.0.0',
    },
    stylus: {
      stylus: '^0.54.7',
      'stylus-loader': '^3.0.2',
    },
  };

  api.extendPackage({devDependencies: deps[options.cssPreprocessor],
  });
}

13.3 调用 router 插件和 vuex 插件

// for v3 compatibility
if (options.router && !api.hasPlugin('router')) {require('./router')(api, options, options);
}

// for v3 compatibility
if (options.vuex && !api.hasPlugin('vuex')) {require('./vuex')(api, options, options);
}

是不是很简略,通过 GeneratorAPI  提供的实例办法,能够在插件中十分不便地对我的项目进行批改和自定义。

14. 抽取独自配置文件

上文提到,通过 extendPackage  回往 package.json  中写入一些配置。然而,上文也提到有一个交互是 Where do you prefer placing config for Babel, ESLint, etc.? 也就是会将配置抽取成独自的文件。generate  里的 extractConfigFiles  办法就是执行了这个逻辑。

extractConfigFiles(extractAll, checkExisting) {
  const configTransforms = Object.assign({},
    defaultConfigTransforms,
    this.configTransforms,
    reservedConfigTransforms
  );
  const extract = (key) => {
    if (configTransforms[key] &&
      this.pkg[key] &&
      !this.originalPkg[key]
    ) {const value = this.pkg[key];
      const configTransform = configTransforms[key];
      const res = configTransform.transform(
        value,
        checkExisting,
        this.files,
        this.context
      );
      const {content, filename} = res;
      this.files[filename] = ensureEOL(content);
      delete this.pkg[key];
    }
  };
  if (extractAll) {for (const key in this.pkg) {extract(key);
    }
  } else {extract("babel");
  }
}

这里的 configTransforms  就是一些会须要抽取的配置:

如果 extractAll  是 true,也就是在下面的交互当选了 Yes,就会将 package.json  里的所有 key configTransforms 比拟,如果都存在,就将配置抽取到独立的文件中。

15. 将内存中的文件输入到硬盘

上文有提到,api.render  会通过 EJS 将模板文件渲染成字符串放在内存中。执行了 generate  的所有逻辑之后,内存中曾经有了须要输入的各种文件,放在 this.files  里。generate  的最初一步就是调用 writeFileTree  将内存中的所有文件写入到硬盘。

到这里 generate  的逻辑就根本都讲完了,Vue CLI 生成代码的局部也就讲完了。

16. 总结

整体看下来,Vue CLI 的代码还是比较复杂的,整体架构条理还是比较清楚的,其中有两点印象最深:

第一,整体的交互流程的挂载。将各个模块的交互逻辑通过一个类的实例保护起来,执行机会和胜利回调等也是设计的比拟好。

第二,插件机制很重要。插件机制将性能和脚手架进行解耦。

看来,无论是 create-react-app 还是 Vue CLI,在设计的时候都会尽量思考 插件机制,将能力凋谢进来再将性能集成进来,无论是对于 Vue CLI 自身的外围性能,还是对于社区开发者来说,都具备了足够的开放性和扩展性。

整体代码看下来,最重要的就是两个概念:

  • Preset:预设,包含整体的交互流程(Prompt)
  • Plugin:插件,整体的插件零碎

围绕这两个概念,代码中的这几个类:CreatorPromptModuleAPIGeneratorGeneratorAPI 就是外围。

简略总结一下流程:

  1. 执行 vue create
  2. 初始化 Creator 实例 creator,挂载所有交互配置
  3. 调用 creator 的实例办法 create
  4. 询问用户自定义配置
  5. 初始化 Generator 实例 generator
  6. 初始化各种插件
  7. 执行插件的 generator 逻辑,写 package.json、渲染模板等
  8. 将文件写入到硬盘

这样一个 CLI 的生命周期就走完了,我的项目曾经初始化好了。

附:Vue CLI 中能够间接拿来用的工具办法

看完 Vue CLI 的源码,除了感叹这简单的设计之外,也发现很多工具办法,在咱们实现本人的 CLI 时,都是能够拿来即用的,在这里总结一下。

获取 CLI 参数

解析 CLI 通过 -- 传入的参数。

const program = require('commander');

function camelize(str) {return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : ''));
}

function cleanArgs(cmd) {const args = {};
  cmd.options.forEach(o => {const key = camelize(o.long.replace(/^--/, ''));
    // if an option is not present and Command has a method with the same name
    // it should not be copied
    if (typeof cmd[key] !== 'function' && typeof cmd[key] !== 'undefined') {args[key] = cmd[key];
    }
  });
  return args;
}

查看 Node 版本

通过 semver.satisfies 比拟两个 Node 版本:

  • process.version: 以后运行环境的 Node 版本
  • wanted: package.json 里配置的 Node 版本
const requiredVersion = require('../package.json').engines.node;

function checkNodeVersion(wanted, id) {if (!semver.satisfies(process.version, wanted, { includePrerelease: true})) {
    console.log(
      chalk.red(
        'You are using Node' +
          process.version +
          ', but this version of' +
          id +
          'requires Node' +
          wanted +
          '.\nPlease upgrade your Node version.'
      )
    );
    process.exit(1);
  }
}

checkNodeVersion(requiredVersion, '@vue/cli');

读取 package.json

const fs = require('fs');
const path = require('path');

function getPackageJson(cwd) {const packagePath = path.join(cwd, 'package.json');

  let packageJson;
  try {packageJson = fs.readFileSync(packagePath, 'utf-8');
  } catch (err) {throw new Error(`The package.json file at '${packagePath}' does not exist`);
  }

  try {packageJson = JSON.parse(packageJson);
  } catch (err) {throw new Error('The package.json is malformed');
  }

  return packageJson;
}

对象排序

这里次要是在输入 package.json 的时候能够对输入的对象先进行排序,更好看一些。。

module.exports = function sortObject(obj, keyOrder, dontSortByUnicode) {if (!obj) return;
  const res = {};

  if (keyOrder) {
    keyOrder.forEach(key => {if (obj.hasOwnProperty(key)) {res[key] = obj[key];
        delete obj[key];
      }
    });
  }

  const keys = Object.keys(obj);

  !dontSortByUnicode && keys.sort();
  keys.forEach(key => {res[key] = obj[key];
  });

  return res;
};

输入文件到硬盘

这个其实没啥,就是三步:

  • fs.unlink 删除文件
  • fs.ensureDirSync 创立目录
  • fs.writeFileSync 写文件
const fs = require('fs-extra');
const path = require('path');

// 删除曾经存在的文件
function deleteRemovedFiles(directory, newFiles, previousFiles) {
  // get all files that are not in the new filesystem and are still existing
  const filesToDelete = Object.keys(previousFiles).filter(filename => !newFiles[filename]
  );

  // delete each of these files
  return Promise.all(
    filesToDelete.map(filename => {return fs.unlink(path.join(directory, filename));
    })
  );
}

// 输入文件到硬盘
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]);
  });
};

判断我的项目是否初始化 git

其实就是在目录下执行 git status 看是否报错。

const hasProjectGit = cwd => {
  let result;
  try {execSync('git status', { stdio: 'ignore', cwd});
    result = true;
  } catch (e) {result = false;}
  return result;
};

对象的 get 办法

能够用 lodash,当初能够间接用 a?.b?.c 就好了

function get(target, path) {const fields = path.split('.');
  let obj = target;
  const l = fields.length;
  for (let i = 0; i < l - 1; i++) {const key = fields[i];
    if (!obj[key]) {return undefined;}
    obj = obj[key];
  }
  return obj[fields[l - 1]];
}

集体原创技术文章会发在公众号 玩相机的程序员 上,用键盘和相机记录生存的公众号。

退出移动版