Vue CLI 源码探索 [二]

1,970 阅读9分钟

本系列一共6篇文章


下面正文开始啦 ^_^

vue add plugin [pluginOptions]

上一节,我们完成了 vue create <app-name> 命令的探索,接下来我们看 vue add plugin [pluginOptions] 命令。

源码探索

add 命令的注册从下面的代码开始

program
  .command('add plugin [pluginOptions]')
  .description('install a plugin and invoke its generator in an already created project')
  .option('--registry <url>', 'Use specified npm registry when installing dependencies (only for npm)')
  // 这个表示未定的选项,也能够被解析。这样就给自定义插件提供了自由发挥的空间
  // https://github.com/tj/commander.js/blob/master/tests/command.allowUnknownOptions.test.js
  .allowUnknownOption()
  .action((plugin) => {
    // vue add pluginX -a 123 -bc
    const args = minimist(process.argv.slice(3))
    // { _: [ 'pluginX' ], a: 123, b: true, c: true }
    console.log(args)
    // pluginX
    console.log(plugin)
    require('../lib/add')(plugin, minimist(process.argv.slice(3)))
  })

可以看到,add 命令需要一个 plugin 参数,插件的配置 [pluginOptions] 是可选项。这里我们 debug 一下,在 action 的回调中打印下参数的结构。通过图中的注释,可以看见参数都已经被解析成功了。

@vue/cli/lib/add.js

整体结构
// add 命令的最后是调用这个js文件,传入两个参数,一个是 plugin 名字,字符串类型,一个是 `{ _: [ 'pluginX' ], a: 123, b: true, c: true }` 这个结构的参数对象。
async function add(pluginName, options= {}, context = process.cwd()) {
  ......
}

// 这里是 add.js 文件的返回,其他的 js 文件的结构也是类似,可以看到这里都进行了异常处理。
module.exports = (...args) => {
  return add(...args).catch(err => {
    error(err)
    if (!process.env.VUE_CLI_TEST) {
      process.exit(1)
    }
  })
}
逐个捋一下
  if (!(await confirmIfGitDirty(context))) {
    return
  }

add 函数开始的时候会判断当前项目是否是干净的 git 工作区,这么做的目的其实是避免意外导致用户的代码丢失。

  // for `vue add` command in 3.x projects
  const servicePkg = loadModule('@vue/cli-service/package.json',  context)
  if (servicePkg && semver.satisfies(servicePkg.version, '3.x')) {
    ......
  }

这段代码的逻辑主要是针对 @vue/cli 4.0之后增加了 router 和 vuex 插件,但是 3.x 并没有这个插件,所以这里做了处理,相当于 polyfill。

  const pm = new PackageManager({ context })
  const cliVersion = require('../package.json').version
  if (isOfficialPlugin(packageName) && semver.prerelease(cliVersion)) {
    await pm.add(`${packageName}@^${cliVersion}`)
  } else {
    await pm.add(packageName)
  }

这里初始化了 PackageManager 实例,来自 @vue/cli/lib/util/ProjectPackageManager.js。构造函数的逻辑比较简单,就是确定了够包管理工具,pm.add() 方法的作用是安装依赖,到此依赖已经安装完成了,接下来就要执行依赖中的逻辑。


  // 这里会载入 plugin 对应的 generator.js 或 generator/index.js 的路径
  const generatorPath = resolveModule(`${packageName}/generator`, context)
  // 如果对应的 generator 存在则执行 invoke 方法
  if (generatorPath) {
    invoke(pluginName, options, context)
  } else {
    log(`Plugin ${packageName} does not have a generator to invoke`)
  }
@vue/cli/lib/invoke.js

invoke() 方法提供 3 个参数:pluginName(插件名字),options(配置项),context(路径)。主要的逻辑如下

  1. 判断如果当前项目目录 git 工作区是否有修改单未提交的内容,有则暂停。
  2. 载入当前项目目录的 package.json 文件,从而取得 devDependenciesdependecies 两个依赖列表,从中判断当前插件是否已经通过包管理工具安装成功。
  3. 载入插件的 generator.js 或者 generator/index.js,然后拼一个 plugin 的结构 { id, apply: pluginGenerator, options: {...} },这里重点是 pluginGenerator,他是一个函数,就是plugin中默认返回的函数。
  4. 执行 runGenerator() 函数,将 plugin 传入,这里会再次生成 Generator 实例,然后执行 Generator 实例的 generate 方法完成文件的写入
  5. 如果插件中定义了 callback,那么再次执行callback,从而完成整个插件的添加。

留有疑问

github.com/cnpm/binary… 这个库是干啥的?

有啥感想

慢慢了解这个项目,发现它拆分做的很彻底,拆的足够的细,才能做到逻辑复用。

还有就是大量了使用了 Class 的方式,好处是足够集中,相关的问题都在某个 Class 的中。

vue invoke plugin [pluginOptions]

上一篇我们介绍了 vue add plugin 命令,这次的 vue invoke 其实做的事就是在已经安装了插件的基础上再次执行插件中定义的方法。

源码探索

program
  .command('invoke plugin [pluginOptions]')
  .description('invoke the generator of a plugin in an already created project')
  .option('--registry <url>', 'Use specified npm registry when installing dependencies (only for npm)')
  .allowUnknownOption()
  .action((plugin) => {
    require('../lib/invoke')(plugin, minimist(process.argv.slice(3)))
  })

代码很清晰,注册了 invoke 命令,解析 plugin 参数和其他参数,传入到 ../lib/invoke 方法中。

invoke的逻辑在上一节中有详细说明,可以参考上一节。

啥感想

文件的拆分足够合理,函数的参数设置的足够合理,才能够做到合理的复用。当然,这个是需要足够的经验,和一点点的优化慢慢形成的合理的代码组织形式。

vue inspect [path...]

这个命令是将webpack的配置打印出来,inspect 这个单词的意思是“检查、检验”。

源码探索

program
  .command('inspect [paths...]')
  // 通过 vue-cli-service 检查项目中的 webpack 配置
  .description('inspect the webpack config in a project with vue-cli-service')
  // 这里对应 webpack 的 mode
  .option('--mode <mode>')
  .option('--rule <ruleName>', 'inspect a specific module rule')
  .option('--plugin <pluginName>', 'inspect a specific plugin')
  .option('--rules', 'list all module rule names')
  .option('--plugins', 'list all plugin names')
  .option('-v --verbose', 'Show full function definitions in output')
  .action((paths, cmd) => {
    require('../lib/inspect')(paths, cleanArgs(cmd))
  })

参数解析

  • --mode mode,打印测试或者正式环境下的 webpack 配置,例如:vue inspect --mode development 或者 vue inspect --mode production
  • --rule ruleName,打印某个模块的规则,例如:vue inspect --rule stylus
  • --plugin pluginName,打印某个插件的配置,例如:vue inspect --plugin transform-modules
  • --rules,打印所有的规则的名字列表
  • --plugins,打印所有插件的名字列表
  • -v --verbose,展示全部的配置
servicePath = resolve.sync('@vue/cli-service', { basedir: cwd })
const binPath = path.resolve(servicePath, '../../bin/vue-cli-service.js')
if (fs.existsSync(binPath)) {
    execa('node', [
      binPath,
      'inspect',
      ...(args.mode ? ['--mode', args.mode] : []),
      ...(args.rule ? ['--rule', args.rule] : []),
      ...(args.plugin ? ['--plugin', args.plugin] : []),
      ...(args.rules ? ['--rules'] : []),
      ...(args.plugins ? ['--plugins'] : []),
      ...(args.verbose ? ['--verbose'] : []),
      ...paths
    ], { cwd, stdio: 'inherit' })
  }

从上面可以看到,其实这个inspect命令主要是用到了 @vue/cli-service 的命令,然后参数都传了过去。

@vue/cli-service/bin/vue-cli-service.js

顺藤摸瓜我们看下这个文件的内容,首先载入 ../lib/Service 这个类,然后实例化,并且执行 service 实例的 run() 方法。

const Service = require('../lib/Service')
const service = new Service(process.env.VUE_CLI_CONTEXT || process.cwd())

const rawArgv = process.argv.slice(2)
const args = require('minimist')(rawArgv, {
  boolean: [
    // build
    'modern',
    'report',
    'report-json',
    'inline-vue',
    'watch',
    // serve
    'open',
    'copy',
    'https',
    // inspect
    'verbose'
  ]
})
const command = args._[0]

/**
 * args = {
 *  _: ["inspect"],
 *  copey: false,
 *  https: false
 *  ....等9个属性,都为 false
 * }
 * command = "inspect"
 * rawArgv = ["inspect"]
 */
service.run(command, args, rawArgv).catch(err => {
  error(err)
  process.exit(1)
})

Service

再看下 Service 类的构造函数,捋一下代码主要做了几个事

  1. 通过package.json生成pkg对象
  2. 取得所有的plugin
  3. 得到项目的模式

这里是说下,取得插件,通过 this.plugins = this.resolvePlugins(plugins, useBuiltIn) 这行代码实现。

然后 resolvePlugins 方法中先取得内部插件,包含 serve,build,inspect,help 这4个命令,对应每个文件都会返回一个函数和一个对象,这个函数我们前面提到过,且所有插件都会返回这个函数,module.exports = (api, options) => {},这里的 api 参数表示的是 PluginAPI 实例, options 表示项目配置,会将项目的默认配置 @vue/cli-service/lib/options 和用户项目根目录中的配置合并/vue.config.js。一个对象是 module.exports.defaultModes = { serve: 'development'| 'production' },告知该插件的使用场景。

  const idToPlugin = id => ({
    id: id.replace(/^.\//, 'built-in:'),
    apply: require(id)
  })

  let plugins

  const builtInPlugins = [
    './commands/serve',
    './commands/build',
    './commands/inspect',
    './commands/help',
    // config plugins are order sensitive
    // config 中的配置,主要是 webpack 的配置。
    './config/base',
    './config/css',
    './config/prod',
    './config/app'
  ].map(idToPlugin)

resolvePlugins 最后返回的 plugins 我们打印一下:

Array(8) [
  {id: "built-in:commands/serve", apply: (api, options) => { … }}, 
  {id: "built-in:commands/build", apply: (api, options) => { … }}, {id: "built-in:commands/inspect", apply: (api, options) => { … }}
  {id: "built-in:config/app", apply: (api, options) => { … }}
  ...
  ]

这里因为这里没有其他的 vuePlugin 所以 plugin的个数就是默认初始化的8个插件。

下面这个reduce执行后,会返回一个对象 { serve: 'development'| 'production' } (todo, 这里有个疑问,这会根据每个插件的模式进行覆盖,那么最后一个插件的模式决定所有模式吗?)

    // resolve the default mode to use for each command
    // this is provided by plugins as module.exports.defaultModes
    // so we can get the information without actually applying the plugin.
    // 在不需要插件执行(apply)的情况下,通过 `module.exports.defaultModes` 配置,载入每个命令的默认模式。
    this.modes = this.plugins.reduce((modes, { apply: { defaultModes }}) => {
      return Object.assign(modes, defaultModes)
    }, {})

所以最后得到的 modes 的结构是这样的,展示每个插件的执行模式

{
  serve: "development", 
  build: "production", 
  inspect: "development"
}

接下来看 Service 类的 run 方法接收3个参数,根据前面的内容,这个三个参数为:"inspect"{ _: ["inspect"], copey: false, https: false ....等9个属性,都为 false }["inspect"]

run 函数中首先执行的是 init 方法载入环境变量,用户配置和执行插件方法。 这里因为我们是在 vue-cli 项目中,所以 环境变量和用户配置(vue.config.js)都是空的。但是会载入默认的配置,这个配置包函webpack配置。

接下来这段代码就是执行插件的apply(就是插件的默认导出方法)方法,将默认的项目配置传入 apply方法中,同时api是指 PluginAPI。

// apply plugins.
this.plugins.forEach(({ id, apply }) => {
  if (this.pluginsToSkip.has(id)) return
  apply(new PluginAPI(id, this), this.projectOptions)
})

同时我们通过 @vue/cli-service/lib/commands/serve.js 可以看到,插件的默认导出函数中的逻辑也很清晰,调用了 registerCommand方法,传入了3个参数:命令名字、配置、回调函数

api.registerCommand('serve', {
  description: 'start development server',
  ...
}, async function serve (args) {
  ...
})

registerCommand 函数的逻辑在这个 @vue/cli-service/lib/PluginAPI.js 文件中也能够清晰看见,其中这个 this.service 就是指 Service 实例。

registerCommand (name, opts, fn) {
  if (typeof opts === 'function') {
    fn = opts
    opts = null
  }
  // 注意这里fn,后面用到了。
  this.service.commands[name] = { fn, opts: opts || {}}
}

我们再回来看 init 方法,最后就是载入项目的webpack配置。

再次回到 run 方法中,

// 在一切都初始化之后,检验下本次传入的命令是否存在
let command = this.commands[name]
if (!command && name) {
  error(`command "${name}" does not exist.`)
  process.exit(1)
}

if (!command || args.help || args.h) {
  command = this.commands.help
} else {
  args._.shift() // 移除命令本身
  rawArgv.shift()
}

// 这里是新的开始了,从command中取得fn,这个fn就是 `@vue/cli-service/lib/commands/inspect.js` 在这个插件的默认导出函数中执行 api.registerCommand 函数传入的第三个参数
const { fn } = command
return fn(args, rawArgv)

所以,我们接下里看下 fn 的逻辑

args => {
  // 取得 全部的 webpack 配置
  const config = api.resolveWebpackConfig()
  // 取得上一步传来的参数
  const { _: paths, verbose } = args
  // 通过判断来参数是否为true,返回对应的信息
  if (args.rule) {
    res = config.module.rules.find(r => r.__ruleNames[0] === args.rule)
  } else if (args.plugin) {
    res = config.plugins.find(p => p.__pluginName === argplugin)
  }
  ...
  // 如果以上都没匹配到,那么则返回默认的全部 webpack 配置
  else {
    res = config
  }
}

至此, vue inspect 命令执行结束。

感谢阅读

原文地址

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