3-3 Vue cli 详解

156 阅读6分钟

原文链接(格式更好):《3-3 Vue cli 详解》

前端脚手架:快速创建项目的基础代码框架和配置的工具

Vue cli 目前处于维护状态:

使用介绍:介绍 | Vue CLI

官方源码:github.com/vuejs/vue-c…

Vue3.0 推荐使用 create-vue 来创建项目

Vue cli 使用流程

创建项目 => 选择配置 => 安装依赖

// 创建项目
$ vue create yourProjectName

// 选择模板
Vue CLI v5.0.8
? Please pick a preset: (Use arrow keys)
❯ Default ([Vue 3] babel, eslint) 
  Default ([Vue 2] babel, eslint) 
  Manually select features 

// 手动配置
Vue CLI v5.0.8
? Please pick a preset: Manually select features
? Check the features needed for your project: (Press <space> to select, <a> to 
toggle all, <i> to invert selection, and <enter> to proceed)
❯◉ BabelTypeScriptProgressive Web App (PWA) SupportRouterVuexCSS Pre-processors
 ◉ Linter / FormatterUnit TestingE2E Testing

// 拉取项目并安装依赖
Vue CLI v5.0.8Creating project in /Users/hzq/code/mianshi/3-1 Vue 基础/v2-project.
🗃  Initializing git repository...
⚙️  Installing CLI plugins. This might take a while...

🚀  Invoking generators...
📦  Installing additional dependencies...

⚓  Running completion hooks...

📄  Generating README.md...

🎉  Successfully created project v2-project.
👉  Get started with the following commands:

 $ cd v2-project
 $ pnpm run serve

// 运行项目
 $ pnpm run serve 

Vue cli 基础原理

vue 指令是如何提供的?

如何实现与用户交互的?

如何生成代码文件的?

Vue cli 2.x 版本

项目目录:

首先vue命令是在哪里注入到全局的呢?

package.json代码:

{
  // ...
  "bin": {
    "vue": "bin/vue", // 这里将 vue 注入到全局
    "vue-init": "bin/vue-init",
    "vue-list": "bin/vue-list"
  },
}

所以我们才可以直接用vue命令,比如vue -h、vue -V

其次bin/vue里面定义了更多的子命令,代码如下:

#!/usr/bin/env node

require('commander')
  .version(require('../package').version)
  .usage('<command> [options]')
  .command('init', 'generate a new project from a template')
  .command('list', 'list available official templates')
  .command('build', 'prototype a new project')
  .parse(process.argv)

bin/vue代码解析:

#!/user/bin/env node // 特殊注释,告诉操作系统使用 node 来执行该文件
require('commander') // 引入 commander 库,用于解析命令行参数
	.version(require('../package').version) // 设置版本
	.usage('<command> [options]') // 定义默认信息,用户输入 -h 时显示这个信息
	.commnad('init', 'generate a new project from a template') // 定义 init 子命令,后面的是它的描述
	.command('list', 'list available official templates') // 定义 list 子命令,后面的是它的描述
  .command('build', 'prototype a new project') // 定义 build 子命令,后面的是它的描述
	.parse(process.argv) // 使用 parse 解析命令行参数,process.argv 是包含命令行参数的数组
// 最后 commander 会设置全局变量来反映这些参数

// 总的来说,这段代码定义了一个简单的命令行工具,它有三个子命令:init, list, 和 build,
// 并为每个子命令提供了简短的描述。用户可以通过这些子命令和相关的选项与工具进行交互。

主流程

主流程代码是在bin/vue-init里面

对应代码如下:

#!/usr/bin/env node
// npm 包依赖项 -- start
// download: 下载 git 仓库代码工具,类似于 git clone xxx
const download = require("download-git-repo");
// program: 命令行工具: 定义命令和选项,并自动生成帮助和用法信息
const program = require("commander");
// fs: 文件系统(File System)内置模块,可操作本地文件的读取、写入、创建、删除、复制等
const exists = require("fs").existsSync;
// path: 内置模块,处理文件路径,可拼接、解析、转换和格式化文件路径
const path = require("path");
// ora: 在命令行界面(CLI)中显示加载动画的
const ora = require("ora");
// user-home: 用于获取用户家目录
const home = require("user-home");
// tildify: 用于将绝对路径转换为使用波浪符(~)表示的相对路径。
const tildify = require("tildify");
// chalk: 命令行高亮工具
const chalk = require("chalk");
// inquirer: 命令行交互式问答工具,可以用来向用户提问并获取输入。
const inquirer = require("inquirer");
// rm: 用于删除文件和目录
const rm = require("rimraf").sync;
// npm 包依赖项 -- end

// 本地依赖项 --  start
// logger: 打印工具
const logger = require("../lib/logger");
// generate: 代码生成工具: 基于模板生成本地代码/文件
const generate = require("../lib/generate");
// check-version: 检测版本工具: 1、检查 node 版本;2、提示更新 cli 版本(通过拉取npm包的版本对比得出)
const checkVersion = require("../lib/check-version");
// warnings: 警告提示工具
const warnings = require("../lib/warnings");
// local-path: 路径工具
const localPath = require("../lib/local-path");
// 是否为本地路径方法
const isLocalPath = localPath.isLocalPath;
// 获取本地模板路径方法
const getTemplatePath = localPath.getTemplatePath;
// 本地依赖项 -- end
/**
 * Usage.
 */

// 定义该命令的使用格式,若用户只输入 vue init
// 则会提示:
// Usage: vue init <template-name> [project-name]
// Commands:
// 	-c, --clone			use git clone
// 	--offline				use cached template
program
  .usage("<template-name> [project-name]")
  .option("-c, --clone", "use git clone")
  .option("--offline", "use cached template");

/**
 * Help.
 */

program.on("--help", () => {
  console.log("  Examples:");
  console.log();
  console.log(
    chalk.gray("    # create a new project with an official template")
  );
  console.log("    $ vue init webpack my-project");
  console.log();
  console.log(
    chalk.gray("    # create a new project straight from a github template")
  );
  console.log("    $ vue init username/repo my-project");
  console.log();
});

/**
 * Help.
 */

function help() {
  program.parse(process.argv);
  if (program.args.length < 1) return program.help();
}
help();

/**
 * Settings.
 */
// 根据输入的命令行,进行模板名称、文件名称等边缘检测与处理
// program.args 返回命令行输入的参数数组
// 模板名称
let template = program.args[0];
// hasSlash: 是否包含斜杠 => 模板名称是否包含路径层级 => 本质是判断是否为 github 的第三方模板
// 因为 github 的第三方模板名称格式为:username/repo
const hasSlash = template.indexOf("/") > -1;
// 项目名称
const rawName = program.args[1];
// 输入空的项目名称 => 表明创建的文件需要平铺在当前文件夹下
const inPlace = !rawName || rawName === ".";
// 文件夹名称:如果 inPlace 为 true,则 ../ 回退一级,否则继续使用 rawName
const name = inPlace ? path.relative("../", process.cwd()) : rawName;
// 生成的文件夹路径
const to = path.resolve(rawName || ".");
const clone = program.clone || false;

// 拼接本地模板的存放路径,都放在 本地根路径下的 .vue-templates 文件夹内
const tmp = path.join(home, ".vue-templates", template.replace(///g, "-"));
if (program.offline) {
  console.log(`> Use cached template at ${chalk.yellow(tildify(tmp))}`);
  template = tmp;
}

/**
 * Padding.
 */

console.log();
process.on("exit", () => {
  console.log();
});
if (exists(to)) {
  // 要生成的项目名已存在时:
  inquirer
    .prompt([
      {
        type: "confirm",
        message: inPlace
          ? "Generate project in current directory?"
          : "Target directory exists. Continue?",
        name: "ok",
      },
    ])
    .then((answers) => {
      if (answers.ok) {
        run();
      }
    })
    .catch(logger.fatal);
} else {
  run();
}

/**
 * Check, download and generate the project.
 */
// 核心代码:处理使用本地还是远程模板
function run() {
  // isLocalPath:通过输入的模板名称来判断是否为本地的模板
  // 比如输入的完整命令为:vue init ../.local/code/mianshi/vue-template vue2Project,则就是用本地的模板
  if (isLocalPath(template)) {
    // 根据模板名称获取完整的模板地址
    const templatePath = getTemplatePath(template);
    if (exists(templatePath)) {
      // 存在该地址,则使用它去生成代码文件
      // name: 命令行输入的项目名称
      // templatePath: 本地模板的地址
      // to: 项目生成的地址
      generate(name, templatePath, to, (err) => {
        if (err) logger.fatal(err);
        console.log();
        logger.success('Generated "%s".', name);
      });
    } else {
      // 本地模板没找到
      logger.fatal('Local template "%s" not found.', template);
    }
  } else {
    // 非本地模板时
  	// 比如输入的完整命令为:vue init vue-template vue2Project,则就是用远程的模板
    checkVersion(() => {
      if (!hasSlash) {
        // 模板名不带/时,则代表使用的是官方模板
        // 使用官方模板名称:vuejs-templates 开头
        const officialTemplate = "vuejs-templates/" + template;
        if (template.indexOf("#") !== -1) {
          // 模板名带#时,表明模板可用
          downloadAndGenerate(officialTemplate);
        } else {
          // 模板名不带#时,可能是老版本、不可用、废弃等
          if (template.indexOf("-2.0") !== -1) {
            // 模板名带-2.0时,会显示警告,该版本已被废弃等....
            warnings.v2SuffixTemplatesDeprecated(template, inPlace ? "" : name);
            return;
          }

          // warnings.v2BranchIsNowDefault(template, inPlace ? '' : name)
          // 表明不是废弃版本,即可能是老版本,就继续下载&生成
          downloadAndGenerate(officialTemplate); 
        }
      } else {
        // 模板名带/时,则代表使用的是 github 的第三方模板
        downloadAndGenerate(template);
      }
    });
  }
}

/**
 * Download a generate from a template repo.
 *
 * @param {String} template
 */
// 核心代码:使用远程模板生成代码文件
function downloadAndGenerate(template) {
  // 命令行 loading
  const spinner = ora("downloading template");
  spinner.start();
  // Remove if local template exists
  if (exists(tmp)) rm(tmp);
  // download():下载远程模板代码
  // template:远程的模板名称
  // tmp:官方模板本地存储位置
  download(template, tmp, { clone }, (err) => {
    spinner.stop();
    if (err)
      logger.fatal(
        "Failed to download repo " + template + ": " + err.message.trim()
      );
    // generate():生成代码文件
    // name: 命令行输入的项目名称
    // tmp: 刚刚下载的官方模板的本地地址
    // to: 项目生成的地址
    generate(name, tmp, to, (err) => {
      if (err) logger.fatal(err);
      console.log();
      logger.success('Generated "%s".', name);
    });
  });
}

流程图

关键点

脚手架的版本检测

vue cli 的版本检测代码如下:

const request = require('request')
const semver = require('semver') // 用于处理和解析语义化版本号
const chalk = require('chalk')
const packageConfig = require('../package.json')

module.exports = done => {
  // --- 系统的 nodejs 版本与脚手架指定的版本比较 ---(标准版)
  // semver.satisfies(): 是否满足
  // process.version: 当前运行的 node 版本
  // packageConfig.engines.node: cli 指定的 node 版本
  if (!semver.satisfies(process.version, packageConfig.engines.node)) {
    return console.log(chalk.red(
      '  You must upgrade node to >=' + packageConfig.engines.node + '.x to use vue-cli'
    ))
  }

  // --- 系统的脚手架版本与最新脚手架的版本比较 ---
  // 请求远程 npmjs 上的 vue-cli 包的最新版本号
  // 								vs
  // 当前系统已安装的 vue-cli 包源代码的 package.json 的版本
  request({
    url: 'https://registry.npmjs.org/vue-cli',
    timeout: 1000
  }, (err, res, body) => {
    if (!err && res.statusCode === 200) {
      const latestVersion = JSON.parse(body)['dist-tags'].latest
      const localVersion = packageConfig.version
      // semver.lt(): 是否低于
      if (semver.lt(localVersion, latestVersion)) {
        console.log(chalk.yellow('  A newer version of vue-cli is available.'))
        console.log()
        console.log('  latest:    ' + chalk.green(latestVersion))
        console.log('  installed: ' + chalk.red(localVersion))
        console.log()
      }
    }
    done()
  })
}

vue list 命令

vue-list.js对应代码如下:

#!/usr/bin/env node

const logger = require("../lib/logger"); // 自定义打印方法
const request = require("request"); // HTTP 请求库
const chalk = require("chalk"); // 命令行高亮工具

/**
 * Padding.
 */

console.log();
process.on("exit", () => {
  console.log();
});

/**
 * List repos.
 */

// 通过 request 库,去拉取 github 上面的官方模板数据,不会展示本地缓存的模板数据
request(
  {
    url: "https://api.github.com/users/vuejs-templates/repos",
    headers: {
      "User-Agent": "vue-cli",
    },
  },
  (err, res, body) => {
    if (err) logger.fatal(err); // 请求有错误时,打印展示错误
    const requestBody = JSON.parse(body);
    if (Array.isArray(requestBody)) {
      console.log("  Available official templates:");
      console.log();
      requestBody.forEach((repo) => {
        // 请求的模板数据循环,然后展示到命令行内
        console.log(
          "  " +
          chalk.yellow("★") +
          "  " +
          chalk.blue(repo.name) +
          " - " +
          repo.description
        );
      });
    } else {
      console.error(requestBody.message);
    }
  }
);

Vue cli 进阶知识

代码生成逻辑

generate.js文件里面是最核心的生成逻辑,代码如下:

// npm 包依赖项 -- start
// chalk: 命令行高亮工具
const chalk = require("chalk");
// metalsmith: 一个静态内容生成器,用于处理文件和目录。
const Metalsmith = require("metalsmith");
// handlebars: 基于 JavaScript 的模板引擎,用于生成动态 HTML 或其他格式的文本文件。
const Handlebars = require("handlebars");
// async: 一个提供了许多异步操作辅助函数的库,如 eachSeries、waterfall 等。
const async = require("async");
// consolidate: 是一个用于在 Express 中使用多种模板引擎的库。导入了 consolidate 库中的 handlebars 渲染函数。
const render = require("consolidate").handlebars.render;
// path: 内置模块,处理文件路径,可拼接、解析、转换和格式化文件路径
const path = require("path");
// multimatch: 多条件匹配工具
const multimatch = require("multimatch");
// npm 包依赖项 -- end

// 本地依赖项 --  start
// getOptions: 用于获取或解析命令行选项
const getOptions = require("./options");
// ask: 用于向用户提问并获取输入
const ask = require("./ask");
// filter: 过滤或处理文件列表
const filter = require("./filter");
// logger: 打印工具
const logger = require("./logger");
// 本地依赖项 -- end

// register handlebars helper
Handlebars.registerHelper("if_eq", function (a, b, opts) {
  return a === b ? opts.fn(this) : opts.inverse(this);
});

Handlebars.registerHelper("unless_eq", function (a, b, opts) {
  return a === b ? opts.inverse(this) : opts.fn(this);
});

/**
 * Generate a template given a `src` and `dest`.
 *
 * @param {String} name 命令行输入的项目名称
 * @param {String} src 下载的官方模板的本地地址(某个模板的线上地址: https://github.com/vuejs-templates/webpack)
 * @param {String} dest 项目生成的地址
 * @param {Function} done 完成的回调函数
 */
module.exports = function generate(name, src, dest, done) {
  // 1. 读取配置项,读取模板内的 meta.json || meta.js
  const opts = getOptions(name, src);
  // 2. 使用 Metalsmith 初始化数据,拿到模板里面的 template 文件(里面的内容就是生成出来的代码文件)
  const metalsmith = Metalsmith(path.join(src, "template"));
  // 3. 配置项合并: metalsmith.metadata 的元数据 与 手动定义的数据 进行合并
  const data = Object.assign(metalsmith.metadata(), {
    destDirName: name,
    inPlace: dest === process.cwd(),
    noEscape: true,
  });
  // 4. 注册 handlebars 的 helper
  opts.helpers &&
    Object.keys(opts.helpers).map((key) => {
      Handlebars.registerHelper(key, opts.helpers[key]);
    });

  const helpers = { chalk, logger };

  // 5. 调用 before 钩子函数
  if (opts.metalsmith && typeof opts.metalsmith.before === "function") {
    opts.metalsmith.before(metalsmith, opts, helpers);
  }

  metalsmith
    .use(askQuestions(opts.prompts)) // 问询主流程: name|description|author|router|lint ...
    .use(filterFiles(opts.filters)) // 根据问询结果过滤掉不需要的文件
    .use(renderTemplateFiles(opts.skipInterpolation)); // 最后生成模板文件

  if (typeof opts.metalsmith === "function") {
    // 执行
    opts.metalsmith(metalsmith, opts, helpers);
  } else if (opts.metalsmith && typeof opts.metalsmith.after === "function") {
    // 调用 after 钩子函数
    opts.metalsmith.after(metalsmith, opts, helpers);
  }

  // 结尾
  metalsmith
    .clean(false)
    .source(".") // start from template root instead of `./src` which is Metalsmith's default for `source`
    .destination(dest)
    .build((err, files) => {
      done(err);
      // 调用 complete 钩子函数: 依赖排序、安装依赖、运行 lint、打印最终信息
      if (typeof opts.complete === "function") {
        const helpers = { chalk, logger, files };
        opts.complete(data, helpers);
      } else {
        logMessage(opts.completeMessage, data);
      }
    });

  return data;
};

/**
 * Create a middleware for asking questions.
 *
 * @param {Object} prompts
 * @return {Function}
 */

function askQuestions(prompts) {
  return (files, metalsmith, done) => {
    ask(prompts, metalsmith.metadata(), done);
  };
}

/**
 * Create a middleware for filtering files.
 *
 * @param {Object} filters
 * @return {Function}
 */

function filterFiles(filters) {
  return (files, metalsmith, done) => {
    filter();
  };
}

/**
 * Template in place plugin.
 *
 * @param {Object} files
 * @param {Metalsmith} metalsmith
 * @param {Function} done
 */

function renderTemplateFiles(skipInterpolation) {
  skipInterpolation =
    typeof skipInterpolation === "string"
      ? [skipInterpolation]
      : skipInterpolation;
  return (files, metalsmith, done) => {
    const keys = Object.keys(files);
    const metalsmithMetadata = metalsmith.metadata();
    // 异步处理[模板/template]下的每一个文件
    async.each(
      keys,
      (file, next) => {
        // skipping files with skipInterpolation option
        if (
          skipInterpolation &&
          multimatch([file], skipInterpolation, { dot: true }).length // 多重匹配,满足 [file], skipInterpolation, { dot: true } 时
        ) {
          return next();
        }
        // 获取文件内容字符串
        const str = files[file].contents.toString();
        // do not attempt to render files that do not have mustaches
        if (!/{{([^{}]+)}}/g.test(str)) {
          // 当文件内容字符串里面没有 {{}} 时,直接跳过
          return next();
        }

        // 结合模板文件内容、元数据 完成自定义的改造渲染
        render(str, metalsmithMetadata, (err, res) => {
          if (err) {
            err.message = `[${file}] ${err.message}`;
            return next(err);
          }
          files[file].contents = new Buffer(res);
          next();
        });
      },
      done
    );
  };
}

/**
 * Display template complete message.
 *
 * @param {String} message
 * @param {Object} data
 */

function logMessage(message, data) {
  if (!message) return;
  render(message, data, (err, res) => {
    if (err) {
      console.error(
        "\n   Error when rendering template complete message: " +
          err.message.trim()
      );
    } else {
      console.log(
        "\n" +
          res
            .split(/\r?\n/g)
            .map((line) => "   " + line)
            .join("\n")
      );
    }
  });
}

流程图

扩展知识

语义化版本号

语义化版本号:Semantic Versioning,简称 SemVer,是一种版本控制规范,它定义了版本号的格式和版本间的兼容性规则。

SemVer 版本号通常由三部分组成:主版本号、次版本号和补丁版本号,格式为MAJOR.MINOR.PATCH

semver 库提供了一系列函数和方法来比较、解析、验证和操作 SemVer 版本号。

面试点

命令配置

主命令:在package.jsonbin配置

{
  "name": "vue-cli",
  // ....
  
  "bin": {
    "vue-init": "./bin/vue-init.js"
  }
}

副命令:在对应的 JS 代码里面通过commander 库实现

/bin/vue-init.js代码

#! /usr/bin/env node
const program = require('commander')
program.version('1.0')
  .usage("<template-name> [project-name]")
	.option('-c, --clone', 'use git clone"')
	.parse(program.argv)

vue-init vue-template vue2Project --clone:将进行初始化项目

命令行参数获取

命令行输入:vue-init vue-template vue2Project --clone

  • option定义命令行参数
    • 可以通过program.key获取
    • 比如:
      • program.clone返回为boolean 的 true,判断是否输入了clone
  • option定义命令行参数
    • 可以通过program.args[index]获取,index为数组下标
    • 比如:
      • program.args[0]返回vue-init后的第一个,即vue-template
      • program.args[1]返回vue-init后的第二个,即vue2Project

命令行交互

可以通过inquirer 库实现命令行的各种问询、选择等交互

const inquirer = require('inquirer')


const promatList = [
  {
    type: 'input', // 输入类型的交互
    messgae: '项目名称', // 提示信息
    name: 'name', // 输入的值存的 key
    default: 'project' // 默认值
  },
  {
    type: 'list', //  单选选择类型的交互
    messgae: '构建工具', // 提示信息
    name: 'name', // 输入的值存的 key
    choices: ['cli2', 'cli3'], // 可选项
    default: 'cli2' // 默认值
  }
]
inquirer.promat(promatList) // 进行交互
  .then(answerObj => {
  // answerObj:返回用户输入的答案对象,其中的 key 为 promatList 里面的 name
})

脚手架版本检测

// 脚手架与 nodejs 的版本检测,一般用满足(类似于>=)
const semver = reqiure('semver')

const baseNodejsVersion = ">=6.0"

if(!semver.satisfies(process.version, baseNodejsVersion)) {
  consoe.log('当前 nodejs 的版本不满足该脚手架,最低版本为' + baseNodejsVersion)
}