Vue CLI 源码探索 [四]

1,207 阅读8分钟

本系列一共6篇文章


下面正文开始啦 ^_^

vue init template app-name

todo,暂时忽略该命令。一个是因为调试过程中出现报错,第二个是根据我的理解,以后该命令会被废弃。

调试配置

launch.json

{
  // 使用 IntelliSense 了解相关属性。 
  // 悬停以查看现有属性的描述。
  // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "启动程序",
      "skipFiles": [
        "<node_internals>/**"
      ],
      // "program": "${workspaceFolder}/packages/@vue/cli-service/bin/vue-cli-service",
      "program": "${workspaceFolder}/packages/@vue/cli/bin/vue",
      "args": [
        "init",
        "simple-webapck",
        "my-project"
      ]
    }
  ]
}

调试报错

vue-cli · Failed to download repo vuejs-templates/simple-webapck: Response code 404 (Not Found)

vue config [value]

官方介绍:

有些针对 @vue/cli 的全局配置,例如你惯用的包管理器和你本地保存的 preset,都保存在 home 目录下一个名叫 .vuerc 的 JSON 文件。你可以用编辑器直接编辑这个文件来更改已保存的选项。

你也可以使用 vue config 命令来审查或修改全局的 CLI 配置。

该命令是用来获取或者设置某个配置,这里的配置指的是 /Users/xxxx/.vuerc 这个文件的的配置

看下我当前的配置,记录了预设等等

{
  "useTaobaoRegistry": false,
  "latestVersion": "4.2.3",
  "lastChecked": 1583721075145,
  "packageManager": "yarn",
  "presets": {
    "llccing-default": {
      "useConfigFiles": true,
      "plugins": {
        "@vue/cli-plugin-babel": {},
        "@vue/cli-plugin-router": {
          "historyMode": false
        },
        "@vue/cli-plugin-eslint": {
          "config": "base",
          "lintOn": [
            "save"
          ]
        }
      },
      "cssPreprocessor": "stylus"
    }
  }
}

用处

这个config的作用是针对当前计算机中所有项目的通用的config,也就是说是跨项目的。实际开发过程中,感觉使用场景还是比较少。

当然我们现在公司的处理方式是用了远程 preset 的方式,然后统一管理这个preset,达到组内所有人都公用一个配置的目的。

所以我觉得在企业开发中,.vuerc 中的配置用处还是比较小。

调试配置

{
  // 保留主要部分,其他和前面一致
  "program": "${workspaceFolder}/packages/@vucli/bin/vue",
  "args": [
    "config",
  ]
}

源码探索

主要的代码逻辑都在 @vue/cli/lib/config.js 中,也很清晰

  • 首先读取 .vuerc 文件
  • 如果 vue config 后面没有其他参数,则打印当前的 .vuerc 文件内容
  • 然后根据参数类型:get/delete/edit/set 分别操作对应的值

vue config edit

在执行这个命令的时候,作者还针对编辑器编辑 .vuerc 文件单独抽象了一个库launch-editor出来,合理的抽象,确实能够降低复杂度,且代码职责解耦,利于维护。

解决 edit命令报错的问题将code命令加入 PATH 中

有意思的地方

// 看了一下这个 os 是node.js内置模块,提供了操作系统相关的使用方法和属性
// homedir 返回当前用户的胡目录的字符串格式路径
const homedir = require('os').homedir()

下面再看一段代码,这段代码来自 @vue/cli-shared-utils/lib/object.js,也就是工具库中对象操作的方法,这个方法厉害之处是:如果你想给取得 const obj = {a: {b: {c: { d: 123123, e: '我是eee' } } } },这个对象中 d 的值,只要执行get(obj, 'a.b.c.d') 即可。

假设这样调用get(obj, 'a.b.c.d'),下面分析下逻辑:


exports.get = function (target, path) {
  // fields = ['a', 'b', 'c', 'd']
  const fields = path.split('.')
  // obj = {a: {b: {c: { d: 123123, e: '我是eee' } } } }
  let obj = target
  // l = 4
  const l = fields.length
  // 通过循环,逐层深入,这里i最大是2
  for (let i = 0; i < l - 1; i++) {
    const key = fields[i]
    if (!obj[key]) {
      return undefined
    }
    obj = obj[key]
  }
  // obj = { d: 123123, e: '我是eee' }
  // fields[l - 1] = d
  // 所以 obj[fields[l - 1]] = obj[d] = 123123
  return obj[fields[l - 1]]
}

这个写法在vue.js的源码中也能够看到。

vue outdated

'(experimental) check for outdated vue cli service / plugins'

上面是代码中的功能描述原文,我理解这个是说:实验性的功能,用来检查 服务(@vue/cli-service) 或者 插件(官方插件@vue/cli-plugin-* 自定义插件vue-cli-plugin-*) 是否过期

调试配置

{
  "program": "${workspaceFolder}/packages/@vue/cli/bin/vue",
  "args": [
    "outdated"
  ]
}

上面的这个调试配置是能够正常执行的,但是有一点缺憾的是,我现在还没有发现如何能能够在某个项目中执行 vue outdated 命令,然后能够在当前的 vue-cli 项目中打断点的方式。

所以其中的某些代码逻辑就不能实际执行到,当然这个对我们查看代码会有影响,但是没那么大。

源码探索

命令注册

program
  .command('outdated')
  .description('(experimental) check for outdated vue cli service / plugins')
  .option('--next', 'Also check for alpha / beta / rc versions when upgrading')
  .action((cmd) => {
    require('../lib/outdated')(cleanArgs(cmd))
  })

主要逻辑

@vue/cli/lib/outdated.js 这个文件中也很清晰,加载 ./Upgrader 类,实例化,调用 checkForUpdates 方法。

const Upgrader = require('./Upgrader')

async function outdated (options, context = process.cwd()) {
  const upgrader = new Upgrader(context)
  return upgrader.checkForUpdates(options.next)
}

首先 Upgrader 实例化时,构造函数中初始了两个属性,并且又实例化了 PackageManager 类。

this.pkg = getPkg(this.context)
this.pm = new PackageManager({ context })

这个 PackageManager 类我们可以通过他的方法来大致了解他的作用

class PackageManager {
  async runCommand (command, args) {...}
  async install () {
    ...
    return await this.runCommand('install')
  }
  async add () {
    ...
    return await this.runCommand('add', [packageName, ...args])
  }
  async remove (packageName) {
    return await this.runCommand('remove', [packageName])
  }
  async upgrade (packageName) {
    ...
    return await this.runCommand('add', [packageName])
  }
}

主要是针对依赖包的处理,安装、添加、移除、升级,版本比较等。

下面看下这个方法,因为要做版本比较,所以需要有比较的源 -- 也就是最新版本。getRemoteVersion 这个方法是用来获取远程版本的

async getRemoteVersion (packageName, versionRange = 'latest') {
  const metadata = await this.getMetadata(packageName)
  if (Object.keys(metadata['dist-tags']).includes(versionRange)) {
    // 这里判断如果版本匹配,则和就直接返回版本号
    return metadata['dist-tags'][versionRange]
  }
  const versions = Array.isArray(metadata.versions) ? metadata.versions : Object.keys(metadata.versions)
  // 然后这里返回所有匹配的版本中,最大的版本。
  return semver.maxSatisfying(versions, versionRange)
}

其中的 this.getMetadata(packageName) 这个方法的内容可以看下,

const url = `${registry.replace(/\/$/g, '')}/${packageName}`
try {
  // 请求url,url类似这样 http://npm.intra.xiaojukeji.com/@vue/cli
  metadata = (await request.get(url, { headers })).body
  metadataCache.set(metadataKey, metadata)
  return metadata
} catch (e) {
  error(`Failed to get response from ${url}`)
  throw e
}

我通过curl的方式模仿request请求,curl http://npm.intra.xiaojukeji.com/@vue/cli 拿到的返回结果如下

"_id" : "@vue/cli",
"_rev" : "45453609",
"name" : "@vue/cli",
"description" : "Command line interface for rapid Vue.js development",
"dist-tags" : {
  "next" : "4.1.0",
  "latest" : "4.2.3"
},
"versions" : {
  "4.1.1" : {
    "name" : "@vue/cli",
    "description" : "Command line interface for rapid Vue.js development",
    "version" : "4.1.1",
    "author" : {
      "name" : "Evan You"
    },
  },
  "4.1.2": {},
  ...
}

接下来再看 checkForUpdates 方法,首先是获取当前项目依赖中的 if (name !== '@vue/cli-service' && !isPlugin(name)) 主要是找服务和插件,其中 isPlugin 这个方法尤其值得好好品一下:

const pluginRE = /^(@vue\/|vue-|@[\w-]+(\.)?[\w-]+\/vue-)cli-plugin-/
exports.isPlugin = id => pluginRE.test(id)

看到这个正则我只能空叹:“厉害!”,当然我们大致可以理解是匹配了名字来判断是否为插件。

获取到需要过期的插件后,通过控制台输出,从而提示用户进行升级。

vue upgrade [plugin-name]

引用原文:(experimental) upgrade vue cli service / plugins

这个是用来升级 cli-service 和 plugins 的。

源码探索

调试脚本

{
  "program": "${workspaceFolder}/packages/@vue/cli/bin/vue",
  "args": [
    "upgrade",
    "@vue/cli-service"
  ]
}

命令注册

program
  .command('upgrade [plugin-name]')
  // 这也是个实验性的功能
  .description('(experimental) upgrade vue cli service / plugins')
  .option('-t, --to <version>', 'Upgrade <package-name> to a version that is not latest')
  .option('-f, --from <version>', 'Skip probing installed plugin, assuming it is upgraded from the designated version')
  .option('-r, --registry <url>', 'Use specified npm registry when installing dependencies')
  .option('--all', 'Upgrade all plugins')
  .option('--next', 'Also check for alpha / beta / rc versions when upgrading')
  .action((packageName, cmd) => {
    require('../lib/upgrade')(packageName, cleanArgs(cmd))
  })

代码的主要逻辑在 @vue/cli/lib/upgrade.js 这个文件中

async function upgrade (packageName, options, context = process.cwd()) {
  // 首先校验了项目目录下是否有代码没有提交
  if (!(await confirmIfGitDirty(context))) {
    return
  }

  // 然后实例化了 `Upgrader` 类
  const upgrader = new Upgrader(context)
  // 如果没指定具体的升级包
  if (!packageName) {
    if (options.to) {
      error(`Must specify a package name to upgrade to ${options.to}`)
      process.exit(1)
    }

    if (options.all) {
      return upgrader.upgradeAll(options.next)
    }

    // 通过 `const upgradable = await upgrader.checkForUpdates(options.next)` 检查是否有更新,
    const upgradable = await upgrader.checkForUpdates(options.next)
    if (upgradable) {
      const { ok } = await inquirer.prompt([
        {
          name: 'ok',
          type: 'confirm',
          message: 'Continue to upgrade these plugins?',
          default: true
        }
      ])

      if (ok) {
        return upgrader.upgradeAll(options.next)
      }
    }

    return
  }
  // 如果指定了升级的包,那么直接升级就好
  return upgrader.upgrade(packageName, options)
}

通过 git status --porcelain 命令检查当前仓库中是否有未提交的内容(也就是看git的工作区和暂存区是否有新的内容)

const { stdout } = await execa('git', ['status', '--porcelain'], { cwd: context })
  if (!stdout) {
    return true
  }

@vue/cli/lib/Upgrader.js 这里我们看下 Upgrader 类的 upgrade 方法:

async upgrade (pluginId, options) {
  ...

  // 这里是说如果要升级的包存在 migrator(升级器)则调用它
  const pluginMigrator = loadModule(`${packageName}/migrator`, this.context) || noop

  await runMigrator(
      this.context,
      {
        id: packageName,
        apply: pluginMigrator,
        baseVersion: installed
      },
      this.pkg
    )
}

runMigrator 来自 @vue/cli/lib/migrate.js

async function runMigrator (context, plugin, pkg = getPkg(context)) {
  const afterInvokeCbs = []
  // 这里实例化了迁移器
  const migrator = new Migrator(context, {
    plugin,
    pkg,
    files: await readFiles(context),
    afterInvokeCbs
  })
  
  await migrator.generate({
    extractConfigFiles: true,
    checkExisting: true
  })
}

看下 Migrator 的真面目,@vue/cli/lib/Migrator.js

// 这里 Migrator 继承了 Generator,所以可以使用 Generator 的方法
module.exports = class Migrator extends Generator {
  ...
  // 这里是对原有方法的覆盖
  async generate (...args) {
    const plugin = this.migratorPlugin

    // apply migrators from plugins
    // 调用插件的 migrator
    const api = new MigratorAPI(
      plugin.id,
      plugin.baseVersion,
      this,
      plugin.options,
      this.rootOptions
    )

    // 这里是调用了 插件中的 migrator 方法
    await plugin.apply(api, plugin.options, this.rootOptions, this.invoking)

    // 这里仍然调用父类的方法
    await super.generate(...args)
  }
}

我们看上面实例化了 MigratorAPI,我们看下他的本来面目 @vue/cli/lib/MigratorAPI

class MigratorAPI extends GeneratorAPI {
  /**
   * @param {string} id - Id of the owner plugin
   * @param {Migrator} migrator - The invoking Migrator instance
   * @param {object} options - options passed to this plugin
   * @param {object} rootOptions - root options (the entire preset)
   */
  constructor (id, baseVersion, migrator, options, rootOptions) {
    super(id, migrator, options, rootOptions)

    this.baseVersion = baseVersion
    this.migrator = this.generator
  }

  fromVersion (range) {
    return semver.satisfies(this.baseVersion, range)
  }
}

MigratorAPI 继承了 GeneratorAPI,所以她就拥有了 GeneratorAPI 所有的方法

vue migrate [plugin-name]

(experimental) run migrator for an already-installed cli plugin

实验的功能,用于迁移已经安装的插件

源码探索

命令注册

program
  .command('migrate [plugin-name]')
  .description('(experimental) run migrator for an already-installed cli plugin')
  // TODO: use `requiredOption` after upgrading to commander 4.x
  .option('-f, --from <version>', 'The base version for the migrator to migrate from')
  .action((packageName, cmd) => {
    require('../lib/migrate')(packageName, cleanArgs(cmd))
  })

调试配置

{
  "program": "${workspaceFolder}/packages/@vue/cli/bin/vue",
  "args": [
    "migrate",
    "@vue/cli-service",
    "--from=4.2.1"
  ]
}

逻辑

主要的逻辑从 @vue/cli/lib/migrate.js 开始

async function migrate (pluginId, { from }, context = process.cwd()) {
  // TODO: remove this after upgrading to commander 4.x
  // 这里会强制设置迁移的基础版本
  if (!from) {
    throw new Error(`Required option 'from' not specified`)
  }

  const pluginName = resolvePluginId(pluginId)
  // 这里是载入了插件的 migrator 文件,留给插件开发者迁移入口,具体的迁移逻辑有插件开发者实现。
  const pluginMigrator = loadModule(`${pluginName}/migrator`, context)
  if (!pluginMigrator) {
    log(`There's no migrator in ${pluginName}`)
    return
  }
  await runMigrator(context, {
    id: pluginName,
    apply: pluginMigrator,
    baseVersion: from
  })
}

再看下 runMigrator 方法,实例化了 Migrator 类,而 Migtrator 类则继承了 Generator 类并且重写了 generate 方法

  async generate (...args) {
    const plugin = this.migratorPlugin

    // apply migrators from plugins
    const api = new MigratorAPI(
      plugin.id,
      plugin.baseVersion,
      this,
      plugin.options,
      this.rootOptions
    )

    await plugin.apply(api, plugin.options, this.rootOptions, this.invoking)

    await super.generate(...args)
  }

其中 MigratorAPI 是继承了 GeneratorAPI

TODO 这一段的代码是的设计是很精巧的,Generator/GeneratorAPI/Migrator/MigratorAPI,并且 Migrator 继承 GeneratorMigratorAPI 继承 GeneratorAPI,总觉这里是应用了一种或者多多种设计模式,但是现在还没想清晰,先留个疑问在此。

我们再看 runMigrator 的后续逻辑,执行 migrator 实例的 generate 方法,同时如果有新的依赖则通过 pm(PackageManager 实例)安装,如果有钩子函数则执行钩子函数,然后通过 git ls-files -o --exclude-standard --full-name 命令识别出本次迁移变化的内容,提醒用户关注这些文件变化。

vue info

print debugging information about your environment

打印当前环境的调试信息

源码探索

program
  .command('info')
  .description('print debugging information about your environment')
  .action((cmd) => {
    console.log(chalk.bold('\nEnvironment Info:'))
    require('envinfo').run(
      {
        System: ['OS', 'CPU'],
        Binaries: ['Node', 'Yarn', 'npm'],
        Browsers: ['Chrome', 'Edge', 'Firefox', 'Safari'],
        npmPackages: '/**/{typescript,*vue*,@vue/*/}',
        npmGlobalPackages: ['@vue/cli']
      },
      {
        showNotFound: true,
        duplicates: true,
        fullTree: true
      }
    ).then(console.log)
  })

这里主要是依赖了 envinfo 的能力。

这里看一下我当前环境的信息,只所以 @vue/cli: Not Found 这个是因为我当前是调试模式,在@vue/cli目录执行了 yarn link,所以这里的 @vue/cli 是 Not Found

Environment Info:

  System:
    OS: macOS Sierra 10.12.6
    CPU: (4) x64 Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
  Binaries:
    Node: 12.13.0 - ~/.nvm/versions/node/v12.13.0/bin/node
    Yarn: 1.22.0 - ~/.yarn/bin/yarn
    npm: 6.12.0 - ~/.nvm/versions/node/v12.13.0/bin/npm
  Browsers:
    Chrome: 80.0.3987.149
    Firefox: 72.0.1
    Safari: 12.1.2
  npmPackages:
    @vue/babel-helper-vue-jsx-merge-props:  1.0.0
    @vue/babel-plugin-transform-vue-jsx:  1.0.0
    @vue/babel-preset-app:  3.11.0
    @vue/babel-preset-jsx:  1.1.0
    @vue/babel-sugar-functional-vue:  1.0.0
    @vue/babel-sugar-inject-h:  1.0.0
    @vue/babel-sugar-v-model:  1.0.0
    @vue/babel-sugar-v-on:  1.1.0
    @vue/component-compiler-utils:  3.0.0
    vue:  2.6.10
    vue-hot-reload-api:  2.3.4
    vue-loader:  15.7.1
    vue-router:  3.1.3
    vue-server-renderer:  2.6.10
    vue-style-loader:  4.1.2
    vue-template-compiler:  2.6.10
    vue-template-es2015-compiler:  1.9.1
    vuepress: ^1.1.0 => 1.1.0
    vuepress-html-webpack-plugin:  3.2.0
    vuepress-plugin-container:  2.0.2
  npmGlobalPackages:
    @vue/cli: Not Found

vue <command>

output help information on unknown commands

主要是针对没有设置的命令,输出帮助信息

program
  .arguments('<command>')
  .action((cmd) => {
    program.outputHelp()
    console.log(`  ` + chalk.red(`Unknown command ${chalk.yellow(cmd)}.`))
    console.log()
    // 这个建议命令是个人性化的功能,如果你不小心输错一个字符,他会提示你正确的
    suggestCommands(cmd)
  })

我们看下 suggestCommands 的代码


function suggestCommands (unknownCommand) {
  // 取得所有注册命令
  const availableCommands = program.commands.map(cmd => cmd._name)

  let suggestion

  availableCommands.forEach(cmd => {
    /*
    举个拼配的例子
    实际输入值 bast
    ''   leven 4
    计划输入值 best leven 1
    这样的情况下,isBestMatch = true
    */
    const isBestMatch = leven(cmd, unknownCommand) < leven(suggestion || '', unknownCommand)
    // 如果拼配不上的字符数 < 3 并且 isBestMatch=true 则说明找到了 “建议命令”
    if (leven(cmd, unknownCommand) < 3 && isBestMatch) {
      suggestion = cmd
    }
  })

  if (suggestion) {
    console.log(`  ` + chalk.red(`Did you mean ${chalk.yellow(suggestion)}?`))
  }
}

leven README中作者这样描述:测量两个不同的字符串的差别,实现莱温斯坦距离算法最快的js实现,没有之一...,说明很有信心的!

vue --help

help 主要是为了输出帮助信息

// add some useful info on help
program.on('--help', () => {
  console.log()
  console.log(`  Run ${chalk.cyan(`vue <command> --help`)} for detailed usage of given command.`)
  console.log()
})

// 这个循环是给每一个命令都自定义了帮助信息
program.commands.forEach(c => c.on('--help', () => console.log()))

至此,@vue/cli/bin 中涉及的15个命令就都过了一遍,相信你和我一样都有收获。

感谢阅读

原文地址

感谢你阅读到这里,翻译的不好的地方,还请指点。希望我的内容能让你受用,再次感谢。by llccing 千里