VueCLI Generator 源码分析

830 阅读5分钟

VueCLI Generator 源码分析

使用 Vue CLI 的 vue create 创建项目后,新项目里会有一些初始文件,这些文件是怎么来的呢?它们都是通过 Vue CLI 的 Generator 生成的。

本篇文章接下来就会介绍 Vue CLI 的 Generator 的相关知识。

Generator 和 插件

Vue CLI 使用了一套基于插件的架构,每个插件的目录结构如下:

image-20211009163244718

通常,一个需要操作文件的插件会有 generator.js 或者 generator/index.js ,这两个文件就是每个插件操作项目文件的 Generator 逻辑。

Generator 和 vue create

Generator 在 vue create 的过程中会被调用,我们先看看 vue create 的基本流程。

vue create 运行过程分析

  1. vue create 命令在 create.js 中生成 Creator 实例
  2. 调用 creator.create() 开始创建
    1. resolvePreset 加载预设文件
    2. promptAndResolvePreset 交互式配置预设选项
    3. 初始化 package.json : 如果没有就创建默认的,已存在就更新特定的几个插件(dependence)
    4. npm install
    5. resolvePlugins:遍历插件
      1. generator 方法设置为插件的 apply 方法
    6. 创建 Generator 实例
    7. 调用 generator 的 generate 方法
    8. npm install
    9. 执行 Generator 的 afterInvokeCbs、afterAnyInvokeCbs 的钩子函数
    10. 检查 readme,若没有则生成
    11. 初始化 git (如果需要的话)

通过上面的流程发现,generator 的逻辑主要在上面列举的 6、7 两步。

Generator 初始化

我们需要重点关注 Genrator 实例初始化的以下变量

  1. pkg: package.json 内容
  2. files: 记录 Generator 需要生成的文件 (文件目录 -> 文件文本内容 的映射)
  3. fileMiddlewares:保存 VueCLI 插件中的 api.render 生成的文件操作
  4. postProcessFilesCbs:保存 VueCLI 插件中的 api.postProcessFiles 生成的文件操作
  5. afterInvokeCbs:保存 api.afterInvoke 生成的钩子函数
  6. afterAnyInvokeCbs:保存 api.afterAnyInvoke 生成的钩子函数
// https://github1s.com/vuejs/vue-cli/blob/HEAD/packages/@vue/cli/lib/Generator.js#L105-L149
class Generator {
  constructor () {
    // 插件
    this.plugins = sortPlugins(plugins)
    
    // package.json
    this.pkg = Object.assign({}, pkg)
    
    // generator 钩子
    this.afterInvokeCbs = afterInvokeCbs
    this.afterAnyInvokeCbs = afterAnyInvokeCbs
    
    // virtual file tree, 虚拟文件树
    this.files = Object.keys(files).length
      // when execute `vue add/invoke`, only created/modified files are written to disk
      ? watchFiles(files, this.filesModifyRecord = new Set())
      // all files need to be written to disk
      : files
    this.fileMiddlewares = []
    this.postProcessFilesCbs = []

    // load all the other plugins
    this.allPlugins = this.resolveAllPlugins()

   }
}

Generator 的 generate 方法

  1. initPlugins: 遍历插件,调用 GeneratorAPI 更改初始化的插件
  2. extractConfigFiles:根据配置把 package.json 中 babel、postcss、eslintConfig、jest、browserslist、browserslist 的配置抽取为独立的配置文件
  3. resolveFiles: 1. 遍历执行 fileMiddlewares ,修改 files 变量 2. 文件生成后,遍历执行 postProcessFilesCbs
  4. 添加 pkg 内容到 files
  5. writeFileTree:保存 files 变量内的内容到硬盘
// https://github1s.com/vuejs/vue-cli/blob/HEAD/packages/@vue/cli/lib/Generator.js#L193-L211
class Generator {
  async generate ({
    extractConfigFiles = false,
    checkExisting = false
  } = {}) {
    await this.initPlugins()

    // save the file system before applying plugin for comparison
    const initialFiles = Object.assign({}, this.files)
    // extract configs from package.json into dedicated files.
    this.extractConfigFiles(extractConfigFiles, checkExisting)
    // wait for file resolve
    await this.resolveFiles()
    // set package.json
    this.sortPkg()
    this.files['package.json'] = JSON.stringify(this.pkg, null, 2) + '\n'
    // write/update file tree to disk
    await writeFileTree(this.context, this.files, initialFiles, this.filesModifyRecord)
  }
}

GeneratorAPI 介绍

GeneratorAPI 是暴露给每个 VueCLI 插件用来生成/删除/修改项目文件的。

  1. api.render :生成一个 fileMiddleware,用来把目录内的文件全部写到 files 变量
  2. api.extendPackage: 更改 package.json 内容
  3. api.postProcessFiles: 添加 postProcess 钩子,在所有的 fileMiddleware 执行完再执行。在这个钩子可以获取所有 fileMiddleware 都执行完后的 file 变量。
  4. api.afterInvoke: 添加 afterInvoke 钩子函数,在文件写入硬盘后调用
  5. api.afterAnyInvoke :添加 api.afterAnyInvoke 钩子函数,在文件写入硬盘后调用

小技巧

通过源码可知,我们可以直接更改 files 变量在增/减/修改文件:

  1. 删除文件 delete api.generator.files.xxx
  2. 删除 dependenciesdelete this.pkg.dependencies.xxx

Hooks

查看 Generator.initPlugins 会发现,hooks 会被反复执行。

  1. 所有插件的 generator 调用前会执行一次,收集 hooks 内的 afterAnyHooks (忽略 afterInvokeCbs
  2. 当前插件的 generator 调用后会执行一次,收集 hooks 内的 afterInvokeCbs (忽略 afterAnyHooks
  3. 注意:generator 中调用 api.afterAnyInvoke
  4. afterInvokeCbsafterAnyInvokeCbs 都会在文件写入硬盘后执行, afterInvokeCbs 会在 afterAnyInvokeCbs 前执行
/**
* https://github1s.com/vuejs/vue-cli/blob/HEAD/packages/@vue/cli/lib/Generator.js#L150
* Generator.initPlugins
*/ 
async initPlugins () {
    const { rootOptions, invoking } = this
    const pluginIds = this.plugins.map(p => p.id)

    // avoid modifying the passed afterInvokes, because we want to ignore them from other plugins
    const passedAfterInvokeCbs = this.afterInvokeCbs
    this.afterInvokeCbs = []
    // apply hooks from all plugins to collect 'afterAnyHooks'
    for (const plugin of this.allPlugins) {
      const { id, apply } = plugin
      const api = new GeneratorAPI(id, this, {}, rootOptions)

      if (apply.hooks) {
        await apply.hooks(api, {}, rootOptions, pluginIds)
      }
    }

    // We are doing save/load to make the hook order deterministic
    // save "any" hooks
    const afterAnyInvokeCbsFromPlugins = this.afterAnyInvokeCbs

    // reset hooks
    this.afterInvokeCbs = passedAfterInvokeCbs
    this.afterAnyInvokeCbs = []
    this.postProcessFilesCbs = []

    // apply generators from plugins
    for (const plugin of this.plugins) {
      const { id, apply, options } = plugin
      const api = new GeneratorAPI(id, this, options, rootOptions)
      await apply(api, options, rootOptions, invoking)

      if (apply.hooks) {
        // while we execute the entire `hooks` function,
        // only the `afterInvoke` hook is respected
        // because `afterAnyHooks` is already determined by the `allPlugins` loop above
        await apply.hooks(api, options, rootOptions, pluginIds)
      }
    }
    // restore "any" hooks
    this.afterAnyInvokeCbs = afterAnyInvokeCbsFromPlugins
  }

// https://github1s.com/vuejs/vue-cli/blob/HEAD/packages/@vue/cli/lib/Creator.js#L223-L230
class Creator extends EventEmitter {
  async create (cliOptions = {}, preset = null) {
    await generator.generate({
      extractConfigFiles: preset.useConfigFiles
  	})
    
    for (const cb of afterInvokeCbs) {
       await cb()
     }
    for (const cb of afterAnyInvokeCbs) {
      await cb()
    }
  }
}

GeneratorAPI 使用实例

// 自定义插件的 generator.js 或者 generator/index.js
module.exports = (api, options) => {
  api.render('./template')

  api.extendPackage({
    dependencies: {
      'vue-router-layout': '^0.1.2'
    }
  })
  
  // afterInvoke 钩子,这个钩子将在文件被写入硬盘之后被调用
  api.afterInvoke(() => {})
  
}

// 自定义插件的 generator.js 或者 generator/index.js
module.exports.hooks = (api) => {
  // hooks 函数会被反复执行
  api.afterAnyInvoke(() => {
  // 文件操作
  })
  // afterInvoke 钩子,这个钩子将在文件被写入硬盘之后被调用
  api.afterInvoke(() => {
    
  })
}
    

总结

本文从 Vue Create 命令入手,着重分析了 Vue CLI Generator 部分的的总体流程。总结下来,Generator 源码部分有下面几点可以借鉴:

  1. 用 files 变量保存文件信息,即减少文件的 IO 操作,也能方便统一管理文件结构和内容。
  2. fileMiddlewares 插件和 Hooks 钩子的设计,可以管理文件操作的顺序和方便的在各个时机添加新的处理逻辑。
  3. GeneratorAPI 将对外 API 独立成一个文件,功能组织更加清晰。

TLDR

vue create 用到的插件

执行 vue create 时,Vue CLI 首先会读取预设默认预设 信息如下所示:

exports.defaultPreset = {
  useConfigFiles: false,
  cssPreprocessor: undefined,
  plugins: {
    '@vue/cli-plugin-babel': {},
    '@vue/cli-plugin-eslint': {
      config: 'base',
      lintOn: ['save']
    }
  }
}

可以看到,预设一般会包含一个或多个插件。在 creator 的源码中不难发现, @vue/cli-service 插件每次都会被加载。

// clone before mutating
preset = cloneDeep(preset)
// inject core service
preset.plugins['@vue/cli-service'] = Object.assign({
projectName: name
}, preset)

结合 默认预设creator 的源码 分析,默认情况下 vue create 会用到如下插件(Vue CLI 会确保 @vue/cli-service 插件第一个加载执行):

  1. @vue/cli-service
  2. @vue/cli-plugin-babel
  3. @vue/cli-plugin-eslint

参考资料

  1. vue-cli-analysis
  2. Vue CLI 源码探索 [六]
  3. 【cli】这是看过最优秀的Vue-cli源码分析,绝对受益匪浅
  4. 浅谈 vue-cli 扩展性和插件设计