Vue 技术探索:深入理解 VueCli 2.x

458 阅读8分钟

文章主题: 全面掌握 VueCli 的使用和原理

一、关于脚手架

Vue CLI 是一套基于 Node.js 实现的标准化开发工具,用于快速构建 Vue.js 项目。它主要包括以下命令文件:vue、vue-build、vue-create、vue-init 和 vue-list,这些命令分别用于管理项目、构建项目、创建新项目、初始化项目模板以及列出可用模板。通过 Vue CLI,开发者可以更加高效地创建和维护 Vue.js 应用。

二、架构图

总体架构

vue-init 实现

三、使用说明

Vue-cli 2.9.6

安装、初始化模板、安装依赖、运行项目

# 安装脚手架
npm install -g vue-cli
# 初始化模板
vue init <template-name> <project-name>
# Ask 询问配置项 ······
# 进入项目目录安装依赖
cd <project-name> npm install
# 运行项目
npm run dev

四、工作原理

Vue CLI 是一套基于 Node.js 实现的标准化开发工具,用于快速构建 Vue.js 项目。它主要包括以下命令文件:vue、vue-build、vue-create、vue-init 和 vue-list,这些命令分别用于管理项目、构建项目、创建新项目、初始化项目模板以及列出可用模板。通过 Vue CLI,开发者可以更加高效地创建和维护 Vue.js 应用。其中,vue-init 是 Vue CLI 的核心,用于通过模板快速初始化新的 Vue.js 项目,使开发者能够更加高效地创建和维护应用。

目录分析 | 文件分析 | 源码分析

目录结构分析

.
├── CONTRIBUTING.md            # 贡献指南,介绍如何参与项目贡献
├── LICENSE                    # 许可证文件,定义项目的开源协议
├── README.md                  # 项目说明文件,介绍项目概述、安装和使用方法
├── appveyor.yml               # AppVeyor CI 配置文件,用于 Windows 平台的持续集成
├── bin                        # 可执行文件目录
│   ├── vue                    # Vue CLI 主命令文件
│   ├── vue-build              # 构建项目的命令文件
│   ├── vue-create             # 创建新项目的命令文件
│   ├── vue-init               # 初始化新项目的命令文件
│   └── vue-list               # 列出可用模板的命令文件
├── circle.yml                 # CircleCI 配置文件,用于持续集成
├── docs                       # 文档目录
│   └── build.md               # 构建相关的文档
├── issue_template.md          # GitHub issue 模板,用于规范提交问题
├── lib                        # 库文件目录,包含核心逻辑和工具函数
│   ├── ask.js                 # 用户交互和询问逻辑
│   ├── check-version.js       # 版本检查逻辑
│   ├── eval.js                # 代码评估和执行逻辑
│   ├── filter.js              # 模板过滤逻辑
│   ├── generate.js            # 项目生成逻辑
│   ├── git-user.js            # 获取 Git 用户信息
│   ├── local-path.js          # 本地路径处理逻辑
│   ├── logger.js              # 日志记录逻辑
│   ├── options.js             # 选项处理逻辑
│   └── warnings.js            # 警告处理逻辑
├── package-lock.json          # npm 的锁定文件,记录当前安装的具体版本
├── package.json               # 项目的配置文件,包含依赖信息和脚本命令
└── pnpm-lock.yaml             # pnpm 的锁定文件,记录当前安装的具体版本

源码分析

package.json

{
  "name": "vue-cli", // 项目名称
  "version": "2.9.6", // 项目版本号
  "description": "A simple CLI for scaffolding Vue.js projects.", // 项目描述
  "preferGlobal": true, // 指定是否首选全局安装
  "bin": { 
    "vue": "bin/vue", // 将 bin/vue 文件映射为 vue 命令
    "vue-init": "bin/vue-init", // 将 bin/vue-init 文件映射为 vue-init 命令
    "vue-list": "bin/vue-list" // 将 bin/vue-list 文件映射为 vue-list 命令
  },
  "repository": { 
    "type": "git", // 版本控制类型
    "url": "git+https://github.com/vuejs/vue-cli.git" // 代码仓库 URL
  },
  "keywords": [ 
    "vue", // 关键字,用于 npm 搜索
    "cli", // 关键字,用于 npm 搜索
    "spa" // 关键字,用于 npm 搜索
  ],
  "author": "Evan You", // 项目作者
  "license": "MIT", // 项目许可证
  "bugs": { 
    "url": "https://github.com/vuejs/vue-cli/issues" // 报告问题的 URL
  },
  "homepage": "https://github.com/vuejs/vue-cli#readme", // 项目主页
  "scripts": { 
    "test": "npm run lint && npm run e2e", // 执行测试,包括 lint 和 e2e 测试
    "lint": "eslint test/e2e/test*.js lib bin/* --env mocha", // 运行 eslint 检查代码
    "e2e": "rimraf test/e2e/mock-template-build && mocha test/e2e/test.js --slow 1000" // 运行端到端测试,先清理,再运行 mocha 测试
  },
  "dependencies": { // 项目运行时依赖的包
    "async": "^2.4.0", // 异步控制流库
    "chalk": "^2.1.0", // 终端字符串样式库
    "commander": "^2.9.0", // 命令行界面框架
    "consolidate": "^0.14.0", // 模板引擎整合库
    "download-git-repo": "^1.0.1", // 下载并提取 git 仓库
    "handlebars": "^4.0.5", // 模板引擎
    "inquirer": "^6.0.0", // 命令行交互库
    "metalsmith": "^2.1.0", // 静态网站生成器
    "minimatch": "^3.0.0", // 路径匹配库
    "multimatch": "^2.1.0", // 多模式路径匹配库
    "ora": "^1.3.0", // 命令行加载器
    "read-metadata": "^1.0.0", // 读取元数据
    "request": "^2.67.0", // HTTP 请求库
    "rimraf": "^2.5.0", // 文件和文件夹删除工具
    "semver": "^5.1.0", // 语义化版本控制库
    "tildify": "^1.2.0", // 将路径转换为用户主目录路径
    "uid": "0.0.2", // 生成唯一 ID
    "user-home": "^2.0.0", // 获取用户主目录
    "validate-npm-package-name": "^3.0.0", // 验证 npm 包名
    "coffee-script": "1.12.7" // CoffeeScript 编译器
  },
  "devDependencies": { // 项目开发时依赖的包
    "chai": "^4.1.2", // 断言库
    "eslint": "^3.19.0", // JavaScript 代码检查工具
    "eslint-plugin-vue-libs": "^1.2.1", // Vue.js 项目特定的 eslint 插件
    "execa": "^0.8.0", // 运行外部命令的库
    "mocha": "^3.5.3" // 测试框架
  },
  "engines": { 
    "node": ">=6.0.0" // 指定 Node.js 的最低版本要求
  }
}

vue-cli/bin/vue

基于 Node.js 的命令行工具的入口文件,使用了 commander 库来定义和解析命令行命令。这段代码是一个典型的命令行应用程序主文件,通常用于定义 CLI(命令行界面)程序的可用命令和选项。

#!/usr/bin/env node
const program = require('commander') // 命令行
program
  .version(require('../package').version)
  .usage('<command> [options]')
  .command('init', 'generate a new project from a template')
  .command('test', 'test')
  .command('list', 'list available official templates')
  .command('build', 'prototype a new project')
  .command('create', '(for v3 warning only)')

program.parse(process.argv)

为什么运行 vue init 命令可以执行 vue-init 文件?

根据 commander 的约定,当用户运行 vue init 时,CLI 会去执行与该子命令关联的脚本文件,通常是 vue-init

扩展

commander 的使用和说明

完整的 node.js 命令行解决方案。

NPM: www.npmjs.com/package/com…

vue-cli/bin/vue-build

快速生成模版功能, 已下架。

vue build 在 Vue CLI 2.x 中提供了一种方便的方式来快速构建 Vue.js 项目,特别适用于小型项目或需要快速原型开发的场景。然而,由于功能相对简单,Vue CLI 3.x 及以后的版本移除了这个命令,并建议使用更灵活和功能强大的构建工具和配置。

vue-cli/bin/vue-create

提示用户 vue create 命令是 Vue CLI 3 专有的,并提供升级到 Vue CLI 3 的指引。

#!/usr/bin/env node

const chalk = require('chalk') // 引入 chalk 库,用于终端字符串样式

console.log() // 输出一个空行
console.log(
  `  ` + 
  chalk.yellow(`vue create`) + // 将 `vue create` 显示为黄色
  ' is a Vue CLI 3 only command and you are using Vue CLI ' + 
  require('../package.json').version + '.' // 获取当前使用的 Vue CLI 版本,并显示在提示信息中
)
console.log(`  You may want to run the following to upgrade to Vue CLI 3:`) // 提示用户可能需要运行以下命令来升级到 Vue CLI 3
console.log() // 输出一个空行
console.log(chalk.cyan(`  npm uninstall -g vue-cli`)) // 以青色字体显示卸载旧版本 Vue CLI 的命令
console.log(chalk.cyan(`  npm install -g @vue/cli`)) // 以青色字体显示安装新版本 Vue CLI 的命令
console.log() // 输出一个空行

vue-cli/bin/vue-init

vue-cli 2.x 中 vue init 命令的核心实现文件。vue init 命令的主要功能是从指定的模板仓库中下载模板,并基于该模板生成一个新的 Vue.js 项目,因此,这个文件实现了 vue init 命令的具体操作步骤,包括命令行参数解析、模板下载、项目生成等,是 vue-cli 2.x 用于快速构建 Vue.js 项目的关键组件。

#!/usr/bin/env node

// 引入必要的模块
const download = require('download-git-repo') // 用于从 Git 仓库下载代码
const program = require('commander') // 用于处理命令行参数
const exists = require('fs').existsSync // 判断文件是否存在
const path = require('path') // 处理文件路径
const ora = require('ora') // 提供命令行加载效果
const home = require('user-home') // 获取用户的 home 目录
const tildify = require('tildify') // 将绝对路径转换成波浪号路径
const chalk = require('chalk') // 用于美化命令行输出
const inquirer = require('inquirer') // 用于与用户进行交互
const rm = require('rimraf').sync // 同步删除文件
const logger = require('../lib/logger') // 日志记录工具
const generate = require('../lib/generate') // 生成项目工具
const checkVersion = require('../lib/check-version') // 检查版本工具
const warnings = require('../lib/warnings') // 告警工具
const localPath = require('../lib/local-path') // 本地路径处理工具

/**
 * 设置命令行参数
 */
program
  .usage('<template-name> [project-name]') // 设置命令行的使用说明
  .option('-c, --clone', 'use git clone') // 设置命令行选项,是否使用 git clone
  .option('--offline', 'use cached template') // 设置命令行选项,是否使用缓存的模板

/**
 * 显示帮助信息
 */
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()
})

/**
 * 显示帮助或退出
 */
function help () {
  program.parse(process.argv)
  if (program.args.length < 1) return program.help()
}
help()

/**
 * 设置参数和路径
 */
let template = program.args[0] // 模板名称
const hasSlash = template.indexOf('/') > -1 // 是否包含斜杠,用于判断是否是自定义模板
const rawName = program.args[1] // 项目名称
const inPlace = !rawName || rawName === '.' // 是否在当前目录创建项目
const name = inPlace ? path.relative('../', process.cwd()) : rawName // 项目名称
const to = path.resolve(rawName || '.') // 项目生成的目录
const clone = program.clone || false // 是否使用 git clone
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
}

/**
 * 提示用户并根据用户选择执行相应操作
 */
console.log()
process.on('exit', () => {
  console.log()
})
if (inPlace || 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() // 执行项目生成流程
}

/**
 * 检查版本,下载模板并生成项目
 */
function run () {
  // 如果模板是本地路径,则直接生成项目
  if (isLocalPath(template)) {
    // 获取本地模板路径
    const templatePath = localPath.getTemplatePath(template)
    // 检查模板路径是否存在
    if (exists(templatePath)) {
      // 调用 generate 方法生成项目
      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 {
    // 检查版本号
    checkVersion(() => {
      if (!hasSlash) {
        // 使用官方模板
        const officialTemplate = 'vuejs-templates/' + template
        // 判断模板名中是否包含 '#'
        if (template.indexOf('#') !== -1) {
          downloadAndGenerate(officialTemplate) // 下载并生成项目
        } else {
          // 如果模板名包含 '-2.0'
          if (template.indexOf('-2.0') !== -1) {
            warnings.v2SuffixTemplatesDeprecated(template, inPlace ? '' : name) // 提示模板名已弃用
            return
          }
          downloadAndGenerate(officialTemplate) // 下载并生成项目
        }
      } else {
        // 如果模板名包含 '/'
        downloadAndGenerate(template) // 下载并生成项目
      }
    })
  }
}

/**
 * 从模板仓库下载并生成项目
 *
 * @param {String} template 模板名称或仓库路径
 */
function downloadAndGenerate (template) {
  console.log('Downloading template', template) // 打印正在下载的模板名称
  const spinner = ora('downloading template') // 初始化下载进度指示器
  spinner.start() // 开始显示进度指示器

  // 如果本地已存在模板缓存,删除它
  if (exists(tmp)) rm(tmp)

  // 下载模板到本地缓存
  download(template, tmp, { clone }, err => {
    spinner.stop() // 停止显示进度指示器

    // 如果下载过程中发生错误,记录错误信息并终止
    if (err) logger.fatal('Failed to download repo ' + template + ': ' + err.message.trim())

    // 下载成功后生成项目
    generate(name, tmp, to, err => {
      if (err) logger.fatal(err) // 如果生成项目过程中出现错误,记录错误信息并终止
      console.log()
      logger.success('Generated "%s".', name) // 成功生成项目,打印成功信息
    })
  })
}

vue-cli/lib/generate.js

const chalk = require('chalk')
// 静态网页项目生成器
const Metalsmith = require('metalsmith')
// 制作模板引擎
const Handlebars = require('handlebars')
// 异步处理工具
const async = require('async')
// 模板引擎中的解析渲染器单独提取
const render = require('consolidate').handlebars.render
const path = require('path')
// 多条件匹配: 异或 且或
const multimatch = require('multimatch')
// 本地工具
// 获取元数据中的所有配置信息:对象 => 配置项
// 配置项 & set 默认值

// 元数据的处理和加工 模板的meta.js
/**
 * 在 vue-cli 2.x 中,getOptions 这个模块的作用是获取和处理初始化项目所需的选项配置。
 * 这个模块通常会从预定义的模板、用户输入或者配置文件中加载选项,并进行必要的处理,
 * 以便在后续的项目生成过程中使用。
 */
const getOptions = require('./options')
/**
 * 向用户提问并获取用户输入。
 * 在项目初始化过程中,ask 模块可能会展示一系列的问题(例如项目名称、描述、使用的工具等),
 * 然后根据用户的回答生成相应的配置。
 */
const ask = require('./ask')
/**
 * 该模块通常用于根据某些条件过滤模板文件。在创建新项目时,
 * 可能会有一些文件或配置项只在特定条件下才需要生成。
 * filter 模块可以根据用户在 ask 模块中提供的答案,
 * 来决定哪些文件或配置需要包含在项目中,哪些不需要。
 * 比如,用户选择使用 TypeScript,那么只有在这种情况下才会生成相关的 TypeScript 配置文件。
 */
const filter = require('./filter')
/**
 * 该模块用于日志记录和输出信息。
 */
const logger = require('./logger')

// 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 存储模板的源
 * @param {String} dest 目的地
 * @param {Function} done
 */

module.exports = function generate (name, src, dest, done) {
  // 读取模板转换成元数据
  // 传入项目名称 和 源地址(本地模板存储地址)
  const opts = getOptions(name, src)

  // TODO:: Metalsmith 的用法
  // 创建了一个 Metalsmith 实例,并设置其工作目录为模板路径。
  // Metalsmith 是一个静态网站生成器,这个实例会在指定的模板目录中处理文件和生成项目。
  // 初始化加载模板
  const metalsmith = Metalsmith(path.join(src, 'template'))

  // 初始化赋值, 配置项完整合并
  const data = Object.assign(metalsmith.metadata(), {
    destDirName: name,
    inPlace: dest === process.cwd(),
    noEscape: true
  })

  // 注册配置对象: 元数据中的 helpers
  opts.helpers && Object.keys(opts.helpers).map(key => {
    Handlebars.registerHelper(key, opts.helpers[key])
  })

  const helpers = { chalk, logger }

  // 阶段性钩子: before
  if (opts.metalsmith && typeof opts.metalsmith.before === 'function') {
    opts.metalsmith.before(metalsmith, opts, helpers)
  }
  // 问询的使用
  // 加载三个插件: 问询 | 过滤 | 渲染
  metalsmith.use(askQuestions(opts.prompts))
    .use(filterFiles(opts.filters))
    .use(renderTemplateFiles(opts.skipInterpolation))

  // 阶段性钩子: after
  if (typeof opts.metalsmith === 'function') {
    opts.metalsmith(metalsmith, opts, helpers)
  } else if (opts.metalsmith && typeof opts.metalsmith.after === 'function') {
    opts.metalsmith.after(metalsmith, opts, helpers)
  }
  // 收尾工作: build 完成之后将complete(后置钩子)执行
  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)
      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(files, filters, metalsmith.metadata(), done)
  }
}

/**
 * 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) => {
    // 通过索引来 map 文件
    const keys = Object.keys(files)
    // 获取 sm 元数据
    const metalsmithMetadata = metalsmith.metadata()
    // 异步处理每一个文件
    async.each(keys, (file, next) => {
      // skipping files with skipInterpolation option
      // multimatch 多条件匹配: 有文件 且被跳过了 且 dot = true 则继续下一个, 不做配置了
      if (skipInterpolation && multimatch([file], skipInterpolation, { dot: true }).length) {
        return next()
      }
      const str = files[file].contents.toString()
      // do not attempt to render files that do not have mustaches(变量替换符)
      // 当前的文件中没有变量 mustaches, 非变量替换组, 不用处理当前文件 => 静态文件
      if (!/{{([^{}]+)}}/g.test(str)) {
        return next()
      }
      // 上面 不要处理的 | 跳过的
      // 渲染文件 render 通过 handleBars的渲染器 渲染模板
      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'))
    }
  })
}

引入必要的依赖库

  • chalk: 用于命令行高亮输出。
  • Metalsmith: 静态网页生成器。
  • Handlebars: 模板引擎。
  • async: 异步处理工具。
  • consolidate: 提供模板引擎的统一接口。
  • path: Node.js内置的路径处理模块。
  • multimatch: 多条件匹配工具。
  • 本地工具(options, ask, filter, logger): 包括获取配置、询问用户、文件过滤和日志记录等功能。

主生成函数 generate

  • 读取模板并转换为元数据。
  • 初始化Metalsmith实例。
  • 合并和初始化配置项。
  • 注册Handlebars助手函数。
  • 设置Metalsmith的阶段性钩子(beforeafter)。
  • 使用中间件处理用户提问、文件过滤和模板文件渲染。
  • 构建项目并处理完成后的操作。

注册Handlebars助手函数

  • if_eq: 判断两个值是否相等。
  • unless_eq: 判断两个值是否不相等。

辅助函数

  • logMessage: 显示模板完成消息。

中间件函数

  • askQuestions: 创建一个中间件,用于向用户提问。

  • filterFiles: 创建一个中间件,用于过滤文件。

  • renderTemplateFiles: 创建一个中间件,用于渲染模板文件。