Vue CLI 是如何實現的 -- 終端命令行工具篇

原文連接 http://axuebin.com/articles/fe-solution/cli/vuecli.html 轉載請聯繫。

image

Vue CLI 是一個基於 Vue.js 進行快速開發的完整系統,提供了終端命令行工具、零配置腳手架、插件體系、圖形化管理界面等。本文暫且只分析項目初始化部分,也就是終端命令行工具的實現。javascript

我的原創技術文章會發在公衆號玩相機的程序員上,用鍵盤和相機記錄生活的公衆號。css

0. 用法

用法很簡單,每一個 CLI 都大同小異:html

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

目前 Vue CLI 同時支持 Vue 2 和 Vue 3 項目的建立(默認配置)。vue

上面是 Vue CLI 提供的默認配置,能夠快速地建立一個項目。除此以外,也能夠根據本身的項目需求(是否使用 Babel、是否使用 TS 等)來自定義項目工程配置,這樣會更加的靈活。java

選擇完成以後,敲下回車,就開始執行安裝依賴、拷貝模板等命令...node

看到 Successfully 就是項目初始化成功了。react

vue create  命令支持一些參數配置,能夠經過 vue create --help  獲取詳細的文檔:webpack

用法: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                      輸出使用幫助信息

具體的用法你們感興趣的能夠嘗試一下,這裏就不展開了,後續在源碼分析中會有相應的部分提到。git

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 年 四、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]];
}

我的原創技術文章會發在公衆號玩相機的程序員上,用鍵盤和相機記錄生活的公衆號。

相關文章
相關標籤/搜索