橙某人

V1

2022/09/20阅读:15主题:橙心

硬刚VueCli3源码系列五 - 生成package.json文件、安装依赖

写在开头

上一篇 文章中,我们通过3000文字的介绍,终于介绍完 Preset 参数的"前世今生",其中我们也了解到它能通过好几个渠道来手动注入。 完全理解 Preset 参数是我们学习 vue-cli 源码的第一步,开头这一步请一定要走好,避免后面掉坑里了。✿(。◕ᴗ◕。)✿

预备知识

ora模块

相信网站的 Loading 效果大家估计都知道吧,那么 cmd 中的 Loading 效果应该如何来做呢?ora 模块就是来干这么一件事的,它能用于 cmd 控制台进度美化,它的使用也是非常的简单。

安装:

npm install ora@3.4.0

示例:

const ora = require('ora');
const spinner = ora('Loading...');

spinner.start(); 

setTimeout(() => {
  spinner.stop();
  console.log('loading stop...')
}, 3000)

效果如下:

4.gif
4.gif
  • org(string/options)
参数 类型 描述 默认值
text String 转轮后方的文字
color String 转轮的颜色,提供值:'black'、'red'、'green'、'yellow'、'blue'、'magenta'、'cyan'、'white'、'gray' 'cyan'
spinner String/Object 转轮的动画,可自定义:{interval: 80, frames: ['-', '+', '-']} 'dots'
hideCursor Boolean 隐藏鼠标指针 true
interval Number 转轮动画每帧之间的时间间隔,单位ms 100
  • methods
方法 描述 返回值
start(text) 运行转轮,text为指针后的文案 返回当前实例
stop() 停止转轮并清除 返回当前实例
succeed(text) 成功状态的转轮 返回当前实例
fail(text) 失败状态的转轮 返回当前实例
warn(text) 警告状态的转轮 返回当前实例
info(text) 提示状态的转轮 返回当前实例
isSpinning() 判断转轮当前是否在转 Boolean
stopAndPersist(options) 暂停转轮,替换设置 返回当前实例
clear() 清空转轮-跟stop很像 返回当前实例
render() 渲染帧 返回当前实例
frame() 获取下一帧 返回当前实例

fs-extra模块

fs-extra 模块是对原生的 fs 模块的封装,它继承原生 fs 模块,并对此进行扩展,提供了更多遍历的API,让用户让好的操作文件系统。

安装:

npm install fs-extra@7.0.1

它有以下这些方法,这些方法有对应的链接,这里小编就不多讲了,偷个懒。

异步方法:

  • copy:复制文件或文件夹。
  • emptyDir:清空文件夹(文件夹目录不删,内容清空)。
  • ensureFile:确保文件存在(文件目录结构没有会新建)。
  • ensureDir:确保文件夹存在(文件夹目录结构没有会新建)。
  • ensureLink:确保符号链接存在(目录结构没有会新建)。
  • ensureSymlink:同ensureDir。
  • mkdirp:同ensureDir。
  • mkdirs:同ensureDir。
  • move:移动文件或文件夹。
  • outputFile:同fs.writeFile(),写文件(目录结构没有会新建)。
  • outputJson:写json文件(目录结构没有会新建)。
  • pathExists:判断文件是否存在。
  • readJson:读取JSON文件,将其解析为对象。
  • remove:删除文件或文件夹,类似rm -rf。
  • writeJson:将对象写入JSON文件。

同步方法:

execa模块

execa 模块是一个能调用 shell 和本地外部程序的 JS 封装,它改进了 child_process 包的方法,它会启动子进程执行命令,支持多种操作系统,如果父进程退出,则生成的全部子进程都将被杀死。熟悉 Node 的小伙伴应该对它不陌生,不熟悉的小伙伴也没关系,你只要记得它能帮我们执行各种命令即可。

安装:

npm install execa@1.0.0

示例:

const execa = require('execa');

// 执行 npm -v 命令
const result = execa('npm', ['-v'], {}); 
// 监听命令执行结束
result.stdout.on('close', r => {
  console.log(r)
})

async function fn({
  // echo命令可用于cmd窗口中打印信息, 如 echo 'hello world' 命令可执行在cmd中执行
  const {stdout} = await execa('echo', ['你好']); 
  console.log(stdout); // 你好
}
fn()

async function fn1({
  // 相当于执行了 npm config get registry 命令
  const {stdout} = await execa('npm', ['config''get''registry']); 
  console.log(stdout); // https://packages.aliyun.com/5eb501ef3fd198000181afca/npm/npm-registry/
}
fn1()

更多使用方式可以自行查阅文档。传送门

生成package.json文件

前面我们讲完 Preset 参数的获取,接下来我们需要根据这个 Preset 参数的信息,来生成对应的一些模板的文件,首先我们来看看如何生成 package.json 文件:

// Creator.js
const {hasYarn, hasPnpm3OrLater, logWithSpinner} = require('@vue/cli-shared-utils');
...
const chalk = require('chalk');
const semver = require('semver');
const getVersions = require('./util/getVersions');
const writeFileTree = require('./util/writeFileTree');

module.exports = class Creator 
  constructor (name, context, promptModules) { ... }
  async create(cliOptions = {}, preset = null) { 
    ...
    preset = cloneDeep(preset);
    // name为项目名 context为项目路径 createCompleteCbs为模板文件创建完成的回调(可以先不管)
    const { name, context, createCompleteCbs } = this
    // preset.plugin格式调整
    preset.plugins['@vue/cli-service'] = Object.assign({
      projectName: name
    }, preset);
    // 下载源
    const packageManager = (cliOptions.packageManager ||
      loadOptions().packageManager || (hasYarn() ? 'yarn' : null) ||
      (hasPnpm3OrLater() ? 'pnpm' : 'npm'));
    // 清屏
    await clearConsole();
    // loading效果
    logWithSpinner(`✨``Creating project in ${chalk.yellow(context)}.`);
    // 确定脚手架的版本号
    const { current } = await getVersions();
    const currentMinor = `${semver.major(current)}.${semver.minor(current)}.0`;
    // 构建 package.json 文件内容
    const pkg = {
      name,
      version'0.1.0',
      privatetrue,
      devDependencies: {}
    }
    const deps = Object.keys(preset.plugins);
      deps.forEach(dep => {
      if (preset.plugins[dep]._isPreset) {
        return
      }
      pkg.devDependencies[dep] = (
        preset.plugins[dep].version ||
        ((/^@vue/.test(dep)) ? `^${currentMinor}` : `latest`)
      )
    });
    // 生成 package.json 文件
    await writeFileTree(context, { 'package.json'JSON.stringify(pkg, null2) });
  }
  async promptAndResolvePreset (answers = null) { ... }
  async resolvePreset (name, clone) { ... }
  resolveFinalPrompts () { ... }
  resolveIntroPrompts() { ... }
  getPresets () { ... }
  resolveIntroPrompts () { ... }
  resolveOutroPrompts () { ... }
}

上面主要在 create() 方法中增加了十几行代码,接下来我们依次来做一些解释。

  • preset.plugin格式调整:

    vue-cli 源码中,大部分插件(如vue-router/vuex等)的模板都是放置在 @vue/cli-service 这个包里面,但也不全是,像 labeleslint 就有独立的包存放模板,当然这指 vue-cli 的3版本,后续的版本 vue-cli 为所有插件都抽离了单独的包。

    所以 preset.plugins 的格式,我们需要调整到和 options.js 文件中的标准 preset 格式一样

    image.png
    image.png
  • 下载源 packageManager

    cliOptions.packageManager 来自于可选参数 -m,其他参数自行查看 文档image.png

    从上面代码中可以看到,我们的 packageManager 变量是直接从 loadOptions() 中取的,实际也就是从 .vuerc 文件中读取,这是为啥?为什么不从 Preset 参数中取?上上一篇 文章中不是有下载源相关的问答题目吗?

    这里需要注意哦,下载源的问答题目是有条件限定的,只有第一次会出现这个问答,后续都是直接就从 .vuerc 文件中读取了。

    image.png
    image.png
  • Loading效果:

    logWithSpinner() 是我们直接从 vue-cli 的工具包 @vue/cli-shared-utils 中导出的,它其实就是对在预备知识中提到的 ora 模块的封装而已。

  • JSON.stringify(pkg, null, 2)

    JSON.stringify() 相信大家都用得很熟了,但是它的第二、第三个参数你可知道是啥意思?小编这里就不展开聊了,给你准备 传送门

剩下的新增代码就是对 package.json 内容的组成了,也没啥好解释的,自己悟吧。(✪ω✪)

我们来新建 util/writeFileTree.js 文件:

const fs = require('fs-extra');
const path = require('path');

function deleteRemovedFiles (directory, newFiles, previousFiles{
  // 从 previousFiles 中获取不存在 newFiles 中的文件
  const filesToDelete = Object.keys(previousFiles)
    .filter(filename => !newFiles[filename])
  // 删除每个文件
  return Promise.all(filesToDelete.map(filename => {
    return fs.unlink(path.join(directory, filename))
  }))
}

/**
 * 生成真实的文件
 * @param { String } dir: 目录路径
 * @param { Object } files: 文件集合, key为文件名, value为文件内容
 * @param {*} previousFiles: 以前的文件集合
 */

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]); // 创建文件
  })
}

这个文件就比较简单,也写了注释,这里就不做过多解析了。

需要注意安装一下 fs-extra 模块:

npm install fs-extra@7.0.1

安装后,你可以尝试执行 juejin-vue-cli 命令,看看是否有相应的 package.json 文件生成。

16DB1FD1.jpg
16DB1FD1.jpg

安装依赖

既然 package.json 文件已经生成完毕,那么接下来需要根据这个文件来安装项目所需的依赖模块了,其实本质就是执行一下 npm install 命令就行了,我们来看看 vue-cli 内部是如何来做的。

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


module.exports = class Creator 
  constructor (name, context, promptModules) { ... }
  async create(cliOptions = {}, preset = null) { 
    ...
    log(`⚙  开始下载依赖`);
    // 下载 package.json 文件依赖
    await installDeps(context, packageManager, cliOptions.registry);
    stopSpinner();
    log(`依赖下载完成`);
  }
  ...
}

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

const execa = require('execa');
const registries = require('./registries');
const shouldUseTaobao = require('./shouldUseTaobao');

// 验证只能是这几个下载源
const supportPackageManagerList = ['npm''yarn''pnpm'];
function checkPackageManagerIsSupported (command{
  if (supportPackageManagerList.indexOf(command) === -1) {
    throw new Error(`Unknown package manager: ${command}`)
  }
}

// 记录每个源需要执行的命令
const packageManagerConfig = {
  npm: {
    installDeps: ['install''--loglevel''error'],
    installPackage: ['install''--loglevel''error'],
    uninstallPackage: ['uninstall''--loglevel''error'],
    updatePackage: ['update''--loglevel''error']
  },
  pnpm: {
    installDeps: ['install''--loglevel''error''--shamefully-flatten'],
    installPackage: ['install''--loglevel''error'],
    uninstallPackage: ['uninstall''--loglevel''error'],
    updatePackage: ['update''--loglevel''error']
  },
  yarn: {
    installDeps: [],
    installPackage: ['add'],
    uninstallPackage: ['remove'],
    updatePackage: ['upgrade']
  }
}

const taobaoDistURL = 'https://npm.taobao.org/dist';
/**
 * 给下载源添加registry地址
 * @param {String} command: npm/yarn/pnpm
 * @param {Array<String>} args: 被执行的命令列表, [install, ....]
 * @param {*} cliRegistry: registry地址, -r <url>
 */

async function addRegistryToArgs (command, args, cliRegistry{
  // cliRegistry来自于可选参数-r: vue create ProjectName -r --registry <url>
  const altRegistry = (cliRegistry ||
        ((await shouldUseTaobao(command)) ? registries.taobao: null));
  // 如果确定使用其他下载源的registry地址或者使用淘宝镜像, 则需要在被执行的命令列表中放入--registry与--disturl命令
  // --registry: 设置下载源的registry地址
  // --disturl: 设置node的国内镜像地址, 主要是解决依赖C++模块所带来的问题, 具体可以看看这篇文章的介绍.https://zhuanlan.zhihu.com/p/147005226
  if (altRegistry) {
    args.push(`--registry=${altRegistry}`)
    if (altRegistry === registries.taobao) {
      args.push(`--disturl=${taobaoDistURL}`)
    }
  }
}

/**
 * 执行命令
 * @param {String} command: npm/yarn/pnpm
 * @param {Array<String>} args: 被执行的命令列表
 * @param {String} targetDir: 项目目录地址
 * @returns
 */

function executeCommand (command, args, targetDir{
  return new Promise((resolve, reject) => {
    // 开始下载 - 通过 execa 模块去执行命令
    const child = execa(command, args, {
      cwd: targetDir, // 子进程的当前工作目录
      stdio: ['inherit'// 子 stdio 配置, 默认为 pipe
    })
    // 下载完成
    child.on('close', code => {
      if (code !== 0) {
        reject(`command failed: ${command} ${args.join(' ')}`)
        return
      }
      resolve()
    })
  })
}

// 下载依赖
exports.installDeps = async function installDeps (targetDir, command, cliRegistry{
  // 验证下载源
  checkPackageManagerIsSupported(command);
  // 获取需要执行的命令
  const args = packageManagerConfig[command].installDeps;
  // 添加registry地址
  await addRegistryToArgs(command, args, cliRegistry);
  // 执行命令
  await executeCommand(command, args, targetDir);
}

// 下载具体的包
exports.installPackage = async function (targetDir, command, cliRegistry, packageName, dev = true{}

// 卸载具体的包
exports.uninstallPackage = async function (targetDir, command, cliRegistry, packageName, dev = true{}

// 更新具体的包
exports.updatePackage = async function (targetDir, command, cliRegistry, packageName{}

这个文件内容有点多,但不要慌,内容实际也不复杂,主要看看 installDeps() 这个方法的过程就行,小编也都详细标明了注释,只要你看了就能懂。(@^▽^@)

上面代码中还有几个方法是省略的,它们的作用是针对单个依赖的操作,这里没涉及到就先省略掉了,感兴趣的小伙伴可以直接阅读 vue-cli 源码。传送门

下载 execa 模块:

npm install execa@1.0.0

继续新建 ./util/shouldUseTaobao.js 文件:

const execa = require('execa');
const inquirer = require('inquirer');
const { hasYarn, request } = require('@vue/cli-shared-utils');
const registries = require('./registries');
const { loadOptions, saveOptions } = require('../options');

// 发起请求
async function ping (registry{
  await request.get(`${registry}/vue-cli-version-marker/latest`);
  return registry;
}

// 替换斜杆为空字符
function removeSlash (url{
  return url.replace(/\/$/'')
}

let checked;
let result;

/**
 * 判断是否使用淘宝镜像
 * @param {String} command: npm/yarn/pnpm
 * @returns {Boolean}
 */

module.exports = async function shouldUseTaobao (command{
  if (!command) command = hasYarn() ? 'yarn' : 'npm';

  // 缓存处理
  if (checked) return result;
  checked = true;

  // 读取 .vuerc 文件中的useTaobaoRegistry属性
  const saved = loadOptions().useTaobaoRegistry
  if (typeof saved === 'boolean') {
    return (result = saved); // 如果在 .vuerc 文件中已经有储存, 下面的逻辑也就不用走了
  }

  const save = val => {
    result = val; // 缓存
    saveOptions({ useTaobaoRegistry: val }); // 将是否使用淘宝镜像记录到 .vuerc 文件
    return val;
  }
  
  // 获取你本地的下载源的registry地址
  const userCurrent = (await execa(command, ['config''get''registry'])).stdout; 
  const defaultRegistry = registries[command]; // 获取线上的registry地址
  // 判断用户是否使用了自定义注册表
  if (removeSlash(userCurrent) !== removeSlash(defaultRegistry)) {
    return save(false);
  }

  // 以下逻辑是vue-cli用于检查你当前使用的下载源速度与淘宝镜像的速度做比较
  let faster
  try {
    faster = await Promise.race([ // race: 同时发送请求, 一个请求结束, 则直接返回
      ping(defaultRegistry),
      ping(registries.taobao)
    ])
  } catch (e) {
    return save(false); // 如果对比过程中出现了错误, 则不使用淘宝镜像
  }
  if (faster !== registries.taobao) {
    return save(false); // 淘宝镜像速度比你当前本地使用的下载源速度慢
  }
  
  // 问答: 是否切换成淘宝镜像
  const { useTaobaoRegistry } = await inquirer.prompt([
    {
      name'useTaobaoRegistry',
      type'confirm',
      message'你当前本地使用的下载源速度比较慢, 是否切换成淘宝镜像?'
    }
  ])
  return save(useTaobaoRegistry);
}

最后新建 ./util/registries.js 文件:

const registries = {
  npm'https://registry.npmjs.org',
  yarn'https://registry.yarnpkg.com',
  taobao'https://registry.npm.taobao.org',
  pnpm'https://registry.npmjs.org'
}

module.exports = registries

这个文件存放一些下载源 registry 地址,相信有点前端经验的小伙伴可能见过这个命令:

npm config set registry https://registry.npm.taobao.org 

它的作用是将我们本地的下载源设置成 cnpm 下载源,也就是淘宝镜像。你也可以全局安装一下 nrm 模块来管理所有下载源,nrm 模块能很方便的切换下载源,这里就不作过多的解释了,感兴趣的小伙伴可以私下去了解了解。

image.png
image.png

接下来,我们来尝试执行 juejin-vue-cli create gg 命令试试:

5.gif
5.gif

如果执行完命令后,你也能正常下载完依赖,那么就说明你成功了(^m^)。
上面我们创建好了 package.json 文件,也安装完项目的依赖,接下来就是创建项目的目录结构了,但由于涉及的内容比较多,就放到下一篇文章再讲吧。



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

image.png
image.png

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

分类:

前端

标签:

Vue.js

作者介绍

橙某人
V1

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