橙某人

V1

2022/09/16阅读:7主题:橙心

硬刚VueCli3源码系列三 - 认识.vuerc文件、构建交互式问答题目列表

写在开头

VueCli3源码系列[1] 又开始更新啦,最近小编更新文章的频率有点下降了,也不知道跑了多少读者(ಥ_ಥ) ,可能也根本没啥读者吧,哈哈哈( ̄▽ ̄)~,不过想来也正常,源码系列都比较枯燥,能坚持的不多,小编也不求写的源码系列文章能有多少阅读量,反正就当写着玩吧,也希望偶尔能给某些有需求的小伙伴一些帮助。

然后回顾一下,为什么更新频率下降的原因,一方面是写源码系列文章还是有点压力的,很多时候小编自己也看得焦头烂额,更别提写文章了;而另一方面就是从三月份开始就忙于跳槽事宜,各种刷题,如面经、力扣等等,前前后后两个月时间整理了大概两万字的总结文档,后续也会把这些面经给整理出来。(✪ω✪)

image.png
image.png

当然,从三月份初到 2022-05-05 这天,工作的事情总算是尘埃落定了,后续如果工作上不忙的话,会加速更新完这系列文章。

预备知识

chalk模块

chalk 模块是一个可以美化 console.log 输出语句的包,能非常方便的输出多种颜色的 log,包括修改字体颜色,加粗字体,改变字体背景色等等。

这个模块的使用非常简单,我们直接来看一下例子就行。

安装:

npm install chalk

使用:

const chalk = require('chalk');

console.log(chalk.yellow('我是黄色字体'));
console.log(chalk.blue('我是蓝色字体'));
console.log(chalk.bgRed('红色背景'));
console.log(chalk.red.bold('红色加粗字体'));
console.log(chalk.underline('下划线'));
image.png
image.png

认识.vuerc文件

不知道你是否有这么一个疑问?当我们使用 vue create myProject 命令来快速初始化一个项目时,cmd 控制台会有一些交互式的选择,如下:

image.png
image.png
image.png
image.png

当我们选择 Manually select features 后,你就可以手动选择项目中需要安装的插件了。而当我们完成一系列的选择后,在最后 vue-cli 会询问我们是否需要保存此次操作结果,如果你选择了 Yes 则还会让你输入"名称"。

image.png
image.png
image.png
image.png

而当我们下次再次执行 vue create myProject2 命令初始化项目时,我们会看到多了一个上次选择结果的选项供我们选择。

image.png
image.png

那么,知道这么一个操作过程后,我们回到上面讲的疑问?

这个疑问就是 vue-cli 是如何存储我们之前选择的结果呢?它会存在什么地方呢?以什么形式储存呢?这个疑问在 vue-cli 文档上有大致的介绍,但我猜应该大部分人还不清楚,或者看过但却不知道实际的意义。

其实这里 vue-cli 会使用到一个 .vuerc 配置文件,它是自动创建的,存在于你系统下的 home 目录内(window系统在 C:\Users\当前登陆用户\.vuerc)。

image.png
image.png

打开这个文件后,你可以看到在 presets 属性下,会储存你之前选择的结果信息,这个文件还储存其他一些配置信息,如是否使用 cnpm 镜像,还有版本号等等信息。

注意这个presets,它非常重要,后续会围绕如何获取它来展开逻辑。

构建交互式问答题目列表

了解完 .vuerc 配置文件的作用后,我们就可以来继续完善 上一篇 文章写的 Creator.js 核心文件的逻辑了。

这里小编先大致讲一下 Creator.js 文件核心做的事情:

  • 构建交互问答题目列表;
  • 然后获取用户交互式选择后的结果,也就是 preset 参数的信息;
  • 然后根据 preset 信息去安装(npm install)相关的模块;
  • 最后去创建一些必要的模板文件,如 package.jsonindex.html 等等。

接下来,文章的主题是完成第一步和第二步的过程,我们需要先来构建相关的各种问答题目,然后获取用户选择问答后的结果 preset 信息。

构建第一和第二个问答题目

首先,我们先来看看如何构建第一和第二个交互问答题目的,这里还是会使用到 inquirer 模块,对它还不熟悉的小伙伴私下要努力努力了。

// Creator.js
const {defaults, loadOptions} = require('./options');
const {formatFeatures} = require('./util/features');

const isManualMode = answers => answers.preset === '__manual__'// 是否选择手动交互

module.exports = class Creator 
  constructor (name, context, promptModules) {
    this.name = name; // 项目名
    this.context = context; // 项目路径
    const { presetPrompt, featurePrompt } = this.resolveIntroPrompts(); // 第一,第二个问答
    this.presetPrompt = presetPrompt; 
    this.featurePrompt = featurePrompt; 
  } 
  async create(cliOptions = {}, preset = null) {}
  getPresets() {
    const savedOptions = loadOptions(); // 读取 .vuerc 文件的内容, 里面会保留 "上次选择的结果" 的数据
    return Object.assign({}, savedOptions.presets, defaults.presets); // defaults.presets 默认
  }
  resolveIntroPrompts() {
    const presets = this.getPresets();
    // 格式化插件名称,为了更好的展示效果: @vue/cli-plugin-babel -> babel  or  @vue/cli-plugin-eslint -> eslint
    const presetChoices = Object.keys(presets).map(name => {
      return {
        name`${name} (${formatFeatures(presets[name])})`,
        value: name
      }
    });
    // 第一个问答:选择默认安装还是手动选择安装
    const presetPrompt = {
      name'preset',
      type'list',
      message`请选择安装的交互模式:`,
      choices: [
        ...presetChoices, // .vuerc的preset与vue-cli默认的preset
        {
          name'手动选择',
          value'__manual__'
        }
      ]
    }
    // 第二个问答: 选择需要安装的插件列表
    const featurePrompt = {
      name'features',
      when: isManualMode, // 当选择了手动安装
      type'checkbox',
      message'请选择需要安装的插件,空格控制是否勾选:',
      choices: [], // 插件列表, 暂时为空, 后面会在constructor中注入
      pageSize10
    }
    return {
      presetPrompt,
      featurePrompt
    }
  }
}

上面代码中,小编添加了几个新方法,从 constructor() 入口出发,我们先看到resolveIntroPrompts() 方法,它用于构建第一和第二个交互问答信息。

不过在具体构建问答之前,我们应该先去读取 .vuerc 配置文件,看看有没有之前的操作结果保存下来,所以我们在 resolveIntroPrompts() 方法开头就去调用了 getPresets() 方法,这个方法会把 .vuerc 配置文件中的 presetvue-cli 默认的 preset 合并然后返回。

之所以要先获取 preset 是因为我们第一个问答题目会使用到它,可以看看如下图:

image.png
image.png

创建 options.js 文件:

const fs = require('fs');
const { error } = require('@vue/cli-shared-utils/lib/logger');
const { getRcPath } = require('./util/rcPath');

const rcPath = getRcPath('.vuerc'); // 它存在于你的 C:\Users\当前登陆用户\.vuerc

let cachedOptions;
exports.loadOptions = () => {
  // 如果有缓存则直接返回
  if (cachedOptions) return cachedOptions;
  if (fs.existsSync(rcPath)) {
    try {
      // 使用同步方式读取系统中的 .vuerc 文件出来
      cachedOptions = JSON.parse(fs.readFileSync(rcPath, 'utf-8'));
    } catch (e) {
      error('文件读取失败'); // vue-cli工具包中对console.error的封装, 并会上报相关错误
      exit(1); // 退出
    }
    return cachedOptions;
   } else {
     return {}; // 没有则返回一个空对象
   }
};
// 一个标准的preset形式, 默认安装时, preset就等于它
exports.defaultPreset = {
  routerfalse// 是否安装router
  vuexfalse// 是否安装vuex
  useConfigFilesfalse// 是否使用独立的配置文件
  cssPreprocessorundefined// css预处理器配置
  plugins: { // 插件相关, 默认安装会自动安装babel和eslint
    '@vue/cli-plugin-babel': {},
    '@vue/cli-plugin-eslint': {
      config'base',
      lintOn: ['save']
    }
  }
}
exports.defaults = {
  lastCheckedundefined// 是否检查版本
  latestVersionundefined// 版本号
  packageManagerundefined// 选择安装依赖时的下载源
  useTaobaoRegistryundefined// 是否使用cnpm
  presets: {
    'default': exports.defaultPreset
  }
}

新建 ./util/rcPath.js 文件:

const os = require("os");
const path = require("path");
exports.getRcPath = (file) => {
   // os.homedir() 能直接获取到系统当前用户的路径
   return path.join(os.homedir(), file);
};

getPresets() 方法主要是通过 options.js 文件和 rcPath.js 文件来配合完成的,loadOptions() 方法能帮助我们直接读取到 .vuerc 文件的内容并且序列化。

Creator.js 文件中,我们还引入 ./util/features.js 文件:

const chalk = require('chalk'); // 记得安装 chalk 模块:npm install chalk@2.4.1
const { toShortPluginId } = require('@vue/cli-shared-utils');

exports.getFeatures = (preset) => {
  const features = []
  if (preset.router) {
    features.push('vue-router')
  }
  if (preset.vuex) {
    features.push('vuex')
  }
  if (preset.cssPreprocessor) {
    features.push(preset.cssPreprocessor)
  }
  const plugins = Object.keys(preset.plugins).filter(dep => {
    return dep !== '@vue/cli-service'
  })
  features.push.apply(features, plugins)
  return features
}

// 返回: (\x1B[33mbabel\x1B[39m, \x1B[33meslint\x1B[39m) 
exports.formatFeatures = (preset, lead, joiner) => {
  const features = exports.getFeatures(preset)
  return features.map(dep => {
    dep = toShortPluginId(dep)
    return `${lead || ''}${chalk.yellow(dep)}`
  }).join(joiner || ', ')
}

./util/features.js 文件的虽然写得比较复杂,但是它的作用很简单,就是用来格式化名称的,为了更好的展示效果。之所以需要格式化名称,你可以看看下图,当我们选择安装 babelvue-routervuex 等时,在 vue-cli 中用 preset 来存储是以图下(@vue/cli-plugin-bale)全称这种形式的:

image.png
image.png

但是,展示给用户的是插件的简写形式:

image.png
image.png

到这里你可能会问,为什么在 preset 中是以 @vue/cli-plugin-插件名称 来存储呢?用户选择了 babel 直接就是 babel: true 不就可以了嘛?当然没那么简单啦。(✪ω✪)

这是因为当我们选择安装 vuex 插件时,我们不仅需要安装 vuex 这个包,还需要为项目生成一些 vuex 的基础模板,如 store/index.js 文件等等。而这些模板 vue-cli 都给每个插件单独写了一个包存储,如下: image.png
后面我们会根据 @vue/cli-plugin-插件名称 形式,把需要的包加载过来,获取到里面的模板信息。

上面小编截图使用的 vue-cli 版本是5.0.1,但是在3.x.x版本中,vue-routervuex 的模板是放置在 cli-service 包中的。 image.png

构建其他问答题目

我们接着来看看其他问答题目构建,比较简单,我们就直接来看代码了:

// Creator.js
...
const {hasYarn, hasPnpm3OrLater} = require('@vue/cli-shared-utils');

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

module.exports = class Creator 
  constructor (name, context, promptModules) {
    ...
    this.outroPrompts = this.resolveOutroPrompts(); // 其他问答
  } 
  async create(cliOptions = {}, preset = null) {}
  getPresets() { ... }
  resolveIntroPrompts() { ... }
  resolveOutroPrompts() {
    const outroPrompts = [
      // 对于eslint/postcss是否使用独立的配置文件
      {
        name'useConfigFiles',
        when: isManualMode,
        type'list',
        message'Where do you prefer placing config for Babel, PostCSS, ESLint, etc.?',
        choices: [
          {
            name'In dedicated config files',
            value'files'
          },
          {
            name'In package.json',
            value'pkg'
          }
        ]
      },
      {
        name'save',
        when: isManualMode,
        type'confirm',
        message'此次操作是否存储起来?',
        defaultfalse
      },
      {
        name'saveName',
        whenanswers => answers.save,
        type'input',
        message'请输入此次操作存储起来的名称:'
      }
    ]
    // 检测如果有 yarn/pnpm 等其他源, 会可以选择下载源题目
    const savedOptions = loadOptions()
    if (!savedOptions.packageManager && (hasYarn() || hasPnpm3OrLater())) {
      const packageManagerChoices = []
      if (hasYarn()) {
        packageManagerChoices.push({
          name'Use Yarn',
          value'yarn',
          short'Yarn'
        })
      }
      if (hasPnpm3OrLater()) {
        packageManagerChoices.push({
          name'Use PNPM',
          value'pnpm',
          short'PNPM'
        })
      }
      packageManagerChoices.push({
        name'Use NPM',
        value'npm',
        short'NPM'
      })
      outroPrompts.push({
        name'packageManager',
        type'list',
        message'Pick the package manager to use when installing dependencies:',
        choices: packageManagerChoices
      })
    }
    return outroPrompts
  }
}

resolveOutroPrompts() 方法主要用于构建"是否使用独立配置文件"、"是否保存此次选择结果"与"选择下载源"这三个题目。其中还使用到了检测下载源 hasYarn()hasPnpm3OrLater() 方法,这两个方法来源于 vue-cli 工具包 @vue/cli-shared-utilsenv.js 文件,感兴趣的小伙伴可以再去细看其中的实现逻辑。

image.png
image.png

更多的问答题目

上面我们大概构建了四五个问答题目,但这远远还没完呢,还有很多插件选择后的后续交互问答题目呢。比如,选择了 vue-router 插件,后续会继续询问你 vue-router 的模式是否使用 history 模式。

image.png
image.png

我们接着来看,还是在 constructor() 中做修改:

// Creator.js
...
const PromptModuleAPI = require('./PromptModuleAPI');

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

module.exports = class Creator 
  constructor (name, context, promptModules) {
    ...
    // 这三个存放的值来自于 ProptModuleAPI.js 文件 或者 promptModules 文件夹下的文件
    this.injectedPrompts = []; // 存储插件的后续问答题目, 如vue-router是否使用history模式
    this.promptCompleteCbs = []; // 存储插件的后续问题的 回调函数, 如选择了vue-router使用history模式, 会往其中放入一个回调, 这个回调会对用户的preset添加一个router: true
    
    // 这两句代码主要功能是完善第二个问答缺失的 手动安装的选择列表 信息
    const promptAPI = new PromptModuleAPI(this);
    // 遍历每一个文件, 每个文件的方法接收一个 promptAPI 实例, 实例上有各种操作方法
    promptModules.forEach(m => m(promptAPI)); // 执行 m() 方法
  } 
  async create(cliOptions = {}, preset = null) {}
  getPresets() { ... }
  resolveIntroPrompts() { ... }
  resolveOutroPrompts() { ... }
}

新建 PromptModuleAPI.js 文件:

module.exports = class PromptModuleAPI {
  constructor (creator) {
    this.creator = creator
  }
  // 往 第二道题目 添加选项
  injectFeature (feature) {
    this.creator.featurePrompt.choices.push(feature)
  }
  // 往injectedPrompts容器添加 插件的后续问答题目
  injectPrompt (prompt) {
    this.creator.injectedPrompts.push(prompt)
  }
  // 往injectedPrompts容器添加 插件的后续问答题目, 如果题目又是一个 列表 选择的话
  injectOptionForPrompt (name, option) {
    this.creator.injectedPrompts.find(f => {
      return f.name === name
    }).choices.push(option)
  }
  // 往promptCompleteCbs容器添加回调函数, 如使用vue-router的history模式, 那么后续要在用户的preset中作一个标识
  onPromptComplete (cb) {
    this.creator.promptCompleteCbs.push(cb)
  }
}

上面新增加的代码不多,但是会比较绕,需要你细细去体会。对于 constructor() 方法中的第三个参数 promptModules 我们在上一篇文章有大致提及了一下,当时还新建了 router.jsvuex.js 两个空文件,这次我们来把它们补全。

// router.js
const chalk = require('chalk')
module.exports = cli => {
  cli.injectFeature({
    name'Router',
    value'router',
    description'Structure the app with dynamic pages',
    link'https://router.vuejs.org/'
  })
  cli.injectPrompt({
    name'routerHistoryMode',
    whenanswers => answers.features.includes('router'), // answers为交互答题后的结果
    type'confirm',
    message`Use history mode for router? ${chalk.yellow(`(Requires proper server setup for index fallback in production)`)}`,
    description`By using the HTML5 History API, the URLs don't need the '#' character anymore.`,
    link'https://router.vuejs.org/guide/essentials/history-mode.html'
  })
  cli.onPromptComplete((answers, options) => {
    if (answers.features.includes('router')) {
      options.router = true
      options.routerHistoryMode = answers.routerHistoryMode
    }
  })
}
// vuex.js
module.exports = cli => {
  cli.injectFeature({
    name'Vuex',
    value'vuex',
    description'Manage the app state with a centralized store',
    link'https://vuex.vuejs.org/'
  })
  cli.onPromptComplete((answers, options) => {
    if (answers.features.includes('vuex')) {
      options.vuex = true
    }
  })
}

补全后,我们就总算是把所有题目都构建完成了。小编估计小伙伴们看到这里可能都晕掉了。(写的是啥啊?拉出来打)

image.gif
image.gif

运行交互问答题目

不要着急,虽然前面写了那么多铺垫,但都比较虚,下面小编让它跑起来,看看实际的效果会更生动一些。

回到 Creator.js 文件:

...
const inquirer = require('inquirer');

const isManualMode = answers => answers.preset === '__manual__'// 是否选择手动交互

module.exports = class Creator 
  constructor (name, context, promptModules) { ... }
  async create(cliOptions = {}, preset = null) {
    preset = await this.promptAndResolvePreset();
  }
  // 运行问答题目
  async promptAndResolvePreset (answers = null) {
    answers = await inquirer.prompt(this.resolveFinalPrompts());
    console.log('用户选择后粗略的preset:', answers)
  }
  // 返回所有的问答题目
  resolveFinalPrompts () {
    // 这段循环大致的意思是: 给存储在injectedPrompts容器中的后续问答的when属性添加一个 "是否选择手动交互" 的判断条件
    this.injectedPrompts.forEach(prompt => {
      // 获取原始的when结果
      const originalWhen = prompt.when || (() => true);
      // 重新设定when
      prompt.when = answers => {
        return isManualMode(answers) && originalWhen(answers)
      }
    })
    const prompts = [
      this.presetPrompt,
      this.featurePrompt,
      ...this.injectedPrompts,
      ...this.outroPrompts
    ]
    return prompts
  }
  resolveIntroPrompts() { ... }
  getPresets () { ... }
  resolveIntroPrompts () { ... }
  resolveOutroPrompts () { ... }
}

注意,前面我们构建题目都只是在 constructor 中初始化完成,而文件中的 create() 方法才是我们的入口核心方法。

下面我们执行 juejin-vue-cli create gg 命令:

image.png
image.png
image.png
image.png

这样我们就把问答的交互运行起来了,接下来我们只要根据选择的结果做后续的逻辑就可以了。

image.jpg
image.jpg



至此,本篇文章就写完啦,撒花撒花。

image.png
image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

参考资料

[1]

https://juejin.cn/column/7067350257609801759: https://juejin.cn/column/7067350257609801759

分类:

前端

标签:

Vue.js

作者介绍

橙某人
V1

打杂工 | 广州郊区建筑工地