阅读Vue Cli源码,搭建自己的脚手架

928 阅读5分钟

前言

自从Vite2的诞生,Vue Cli 就宣布处于维护状态,对于新的 Vue 3 项目,使用 create-vue 搭建基于 Vite 的项目。尤大当然也希望开发者日后使用Vite这款构建工具,毕竟Vite的功能以及性能确实值得我们去学习和使用。

image.png

那么我们为什么还需要去阅读Vue Cli的源码呢?

答:阅读Vue Cli源码最终的目的:就是了解如何搭建属于自己的脚手架提供一种思路。拜托,搭建自己的脚手架是一件很酷的事情唉,也是为了更好提升个人和团队的开发效率。

介绍脚手架的概念和Vue Cli

脚手架工具

脚手架工具分为了两种:专用脚手架、通用脚手架。

对于 Vue 官方提供了 **vue-cli** 

对于 React 官方提供了 **create-react-app**

对于 Angular 官方也提供了自己的脚手架 **angular-cli**

专用脚手架:就是针对于某款特定的框架,搭建的脚手架

通用脚手架典型代表就是:Yeoman

Yeopman脚手架最重要的概念就是:针对不同技术栈的项目,你可以创建不同的生成器。在使用Yeopman创建项目时,你只需要选择对应的生成器即可。

脚手架工具最基本的作用:自动创建一个项目的基础框架。毕竟项目的最初搭建项目是一件比较耗时费力的活。

Vue Cli 源码简单介绍

直接去 Github 拉去源码,阅读项目的 package.json 文件,可以看到 workspaces 一共有三个包,一眼看过去就能确定 packages/@vue 一定是最核心的包了。

"workspaces": [
    "packages/@vue/*",
    "packages/test/*",
    "packages/vue-cli-version-marker"
]

点击进去可以看到 packages/@vue/cli 阅读里面的 package.json 文件 可以看到一个 bin字段。所以 bin/vue.js 文件是脚手架的入口文件了。

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

再自己的命令行中输入 vue --help,可以看到脚手架一共提供了14种命令

image.png

阅读 vue create <app-name> 命令的源码

第一步:进入到 bin/vue.js 可以看到

`const program = require("commander"); // commander 定义相关命令`
program
  .command("create <app-name>")
  .description("create a new project powered by vue-cli-service")
  .option(
    "-p, --preset <presetName>",
    "Skip prompts and use saved or remote preset"
  )
  .option("-d, --default", "Skip prompts and use default preset")
  // ...其它的options
  .action((name, options) => {
    // 输入 vue create <app-name> 后续做的事

    // 这个判断 可能就是判断了你输入了多个 app-name 会提示你
    if (minimist(process.argv.slice(3))._.length > 1) {
      console.log(
        chalk.yellow(
          "\n Info: You provided more than one argument. The first one will be used as the app's name, the rest are ignored."
        )
      );
    }

    // 判断你输入的命令 参数中是否包含 -g 或者是 包含--git。如果包含默认就使用git
    // --git makes commander to default git to true
    if (process.argv.includes("-g") || process.argv.includes("--git")) {
      options.forceGit = true;
    }
    
    // 进入 lib/create 文件
    require("../lib/create")(name, options);
});

第二步:进入到 lib/create 文件中可以看到

主要是执行了一个 create() 方法

async function create(projectName, options) {
  if (options.proxy) {
    process.env.HTTP_PROXY = options.proxy;
  }
  /**
   * 获取当前目录,如果没有传,就获取你当前进程的工作目录
   * process.cwd() 方法返回 Node.js 进程的当前工作目录
   */
  const cwd = options.cwd || process.cwd();
  const inCurrent = projectName === ".";
  // path.relative() 方法根据当前工作目录返回从 from 到 to 的相对路径
  // path.relative("../", cwd) 可以获取到当前所在的文件夹的名称
  const name = inCurrent ? path.relative("../", cwd) : projectName;
  // path.resolve() 方法将路径或路径片段的序列解析为绝对路径
  // 获取 当前所在的文件夹路径+projectName 的绝对路径 targetDir:最终创建项目后的项目绝对路径
  const targetDir = path.resolve(cwd, projectName || ".");
  // 验证项目名称的有效性,如果发现输入的项目名有问题就会进行 if语句中,并且退出程序 exit(1)
  const result = validateProjectName(name);
  if (!result.validForNewPackages) {
    console.error(chalk.red(`Invalid project name: "${name}"`));
    result.errors &&
      result.errors.forEach((err) => {
        console.error(chalk.red.dim("Error: " + err));
      });
    result.warnings &&
      result.warnings.forEach((warn) => {
        console.error(chalk.red.dim("Warning: " + warn));
      });
    exit(1);
  }

  // fs.existsSync(targetDir) 如果路径存在则返回 true,否则返回 false。
  // 判断下这个 项目所在的地方 是否存在了。if条件语句里的内容都是去处理 项目文件夹已经存在的情况
  if (fs.existsSync(targetDir) && !options.merge) {
    // 如果存在并且不打算合并
    if (options.force) {
      // 如果用户输入了 -f 这个option,则直接删除原有的文件夹
      await fs.remove(targetDir);
    } else {
      // await clearConsole(); 会去清除 命令窗口的内容
      await clearConsole();
      if (inCurrent) {
        const { ok } = await inquirer.prompt([
          {
            name: "ok",
            type: "confirm",
            message: `Generate project in current directory?`,
          },
        ]);
        if (!ok) {
          return;
        }
      } else {
        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 }, // 取消操作
            ],
          },
        ]);
        if (!action) {
          return;
        } else if (action === "overwrite") {
          console.log(`\nRemoving ${chalk.cyan(targetDir)}...`);
          await fs.remove(targetDir);
        }
      }
    }
  }
  // 上面的操作,都是对项目路径上的处理,真正的创建项目的步骤在下面
  
  // getPromptModules() 方法 其实就是去获取 准备好的 modules,以交互式的方式来用户去选择自己需要的模板
  const creator = new Creator(name, targetDir, getPromptModules());
  // 真正去创建一个项目的方法
  await creator.create(options);
}

第三步:简单介绍 Creator 类

可以看到真正去创建项目模板的是执行了 await creator.create(options);,然后再去看 .create() 方法。

constructor(name, context, promptModules) {
    // name 项目名称
    // context 项目的绝对路径
    // promptModules 准备好的模块交互(都是一个个Function)
    super();

    this.name = name; // 项目名称
    this.context = process.env.VUE_CLI_CONTEXT = context; // 项目的绝对路径
    // this.resolveOutroPrompts() 都是用来处理预设选项的。
    // 我们在创建vue工程的时候弹出的交互窗口会让我们选择vue2工程还是vue3工程,是否包含vuex,vue-route 等feature
    const { presetPrompt, featurePrompt } = this.resolveIntroPrompts();
    // 总之:presetPrompt、featurePrompt、outroPrompts、injectedPrompts 都是一些预测好的问题
    this.presetPrompt = presetPrompt; // presetPrompt 对应着 Please pick a preset: 这个问题
    this.featurePrompt = featurePrompt; // featurePrompt 对应着 Check the features needed for your project: 这个问题
    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));
  }

第四步:进入 .create() 方法,创建一个Vue的项目

async create(cliOptions = {}, preset = null) {
    // 获取这个实例creator相关属性
    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 (Vue 3)"];
      } 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();
      }
    }

    // 这里是对预设进行一次深拷贝
    preset = cloneDeep(preset);

    // 第一步:获取预设配置。这块就是注入一些核心服务,根据用户的选择,调整 preset 预设
    preset.plugins["@vue/cli-service"] = Object.assign(
      {
        projectName: name,
      },
      preset
    );

    if (cliOptions.bare) {
      preset.plugins["@vue/cli-service"].bare = true;
    }

    // legacy support for router
    if (preset.router) {
      preset.plugins["@vue/cli-plugin-router"] = {};

      if (preset.routerHistoryMode) {
        preset.plugins["@vue/cli-plugin-router"].historyMode = true;
      }
    }

    // legacy support for vuex
    if (preset.vuex) {
      preset.plugins["@vue/cli-plugin-vuex"] = {};
    }

    // 第二步:确实使用什么包管理工具 (默认直接是npm)
    const packageManager =
      cliOptions.packageManager ||
      loadOptions().packageManager ||
      (hasYarn() ? "yarn" : null) ||
      (hasPnpm3OrLater() ? "pnpm" : "npm");

    // 清空命令行  
    await clearConsole();
    
    const pm = new PackageManager({
      context,
      forcePackageManager: packageManager,
    });
    log(`✨  Creating project in ${chalk.yellow(context)}.`);
    this.emit("creation", { event: "creating" });

    // get latest CLI plugin version
    const { latestMinor } = await getVersions();

    // 第三步:创建package,json文件 
    const pkg = {
      name,
      version: "0.1.0",
      private: true,
      devDependencies: {},
      ...resolvePkg(context),
    };
    // 获取预设需要的相关插件名称
    const deps = Object.keys(preset.plugins);

    deps.forEach((dep) => {
      if (preset.plugins[dep]._isPreset) {
        return;
      }

      let { version } = preset.plugins[dep];

      if (!version) {
        if (
          isOfficialPlugin(dep) ||
          dep === "@vue/cli-service" ||
          dep === "@vue/babel-preset-env"
        ) {
          version = isTestOrDebug ? `latest` : `~${latestMinor}`;
        } else {
          version = "latest";
        }
      }
      // 将 插件:version 写入package.json 的 devDependencies 中  
      pkg.devDependencies[dep] = version;
    });
    
    
    // 编写package.json文件 - writeFileTree()
    await writeFileTree(context, {
      "package.json": JSON.stringify(pkg, null, 2),
    });

    // 第四步:初始化 git 仓库
    const shouldInitGit = this.shouldInitGit(cliOptions);
    console.log("是否应该初始化init:", shouldInitGit);
    if (shouldInitGit) {
      log(`🗃  Initializing git repository...`);
      this.emit("creation", { event: "git-init" });
      // 执行 git init 命令
      await run("git init");
    }

    // 第五步:安装插件
    // install plugins
    log(`⚙\u{fe0f}  Installing CLI plugins. This might take a while...`);
    log();
    this.emit("creation", { event: "plugins-install" });

    if (isTestOrDebug && !process.env.VUE_CLI_TEST_DO_INSTALL_PLUGIN) {
      // in development, avoid installation process
      await require("./util/setupDevProject")(context);
    } else {
      // 执行了 npm install
      await pm.install();
    }

    // 第六步:安装完依赖后,帮助我们去生成了基础的文件夹和文件:public 、src、.gitignore、babel.config.js、vue.config.js
    // 在这一块其实就已经把一个vue项目需要的基本文件帮助我们生成了
    // run generator
    log(`🚀  Invoking generators...`);
    this.emit("creation", { event: "invoking-generators" });
    const plugins = await this.resolvePlugins(preset.plugins, pkg);
    const generator = new Generator(context, {
      pkg,
      plugins,
      afterInvokeCbs,
      afterAnyInvokeCbs,
    });
    await generator.generate({
      extractConfigFiles: preset.useConfigFiles,
    });

    // install additional deps (injected by generators)
    log(`📦  Installing additional dependencies...`);
    this.emit("creation", { event: "deps-install" });
    log();
    if (!isTestOrDebug || process.env.VUE_CLI_TEST_DO_INSTALL_PLUGIN) {
      await pm.install();
    }

    // run complete cbs if any (injected by generators)
    log(`⚓  Running completion hooks...`);
    this.emit("creation", { event: "completion-hooks" });
    for (const cb of afterInvokeCbs) {
      await cb();
    }
    for (const cb of afterAnyInvokeCbs) {
      await cb();
    }

    // 第七步:生成一个README.md文件
    if (!generator.files["README.md"]) {
      // generate README.md
      log();
      log("📄  Generating README.md...");
      await writeFileTree(context, {
        "README.md": generateReadme(generator.pkg, packageManager),
      });
    }

    // .... 进行上面的这些程序后,一个基础的Vue项目就生成了,后面的就是收尾工作。

总结

vue create <app-name> 执行的大致流程就是,通过命令行的方式询问用户,然后根据用户的生成对应的预设,根据预设去设置好package.json、安装node_modules、初始化git、安装readme.me 文件等工作,生成一个基础的项目文件结构。