Vue CLI 源码探索 [六]

2,314 阅读20分钟

本系列一共6篇文章


下面正文开始啦 ^_^

plugin 插件

组件化:

台式电脑可以分为三部分,显示器、主机、键鼠,主机,内部再次拆分为主板、电源、硬盘、内存条等等部分。每一个部分是自闭和的整体,我理解这就是一种组件化的方式。

插件化:

主板上有很多PCI,这些插槽可以查很多东西,来丰富电脑的功能,比如:网卡、声卡、电视卡、硬盘控制器等等许多东西,那么你说没有拆件电脑能够启动吗,当然只不过有些功能不能实现,比如上网、听音与。插件化就是这种道理,通过丰富的插件简化Vue开发,是你专注于业务逻辑,同时通过官方插件构建的项目也是最佳实践。当然也支持自定义插件,按照统一的插件开发方式写出的插件就能够适配所有 Vue/cli 创建的项目。

插件组成

首先我们头脑中需要有一个插件的整体概念,由哪些部分组成:

.
├── README.md
├── generator.js  # generator(可选)
├── index.js      # service 插件
├── package.json
├── prompts.js    # prompt 文件(可选)
└── ui.js         # Vue UI 集成(可选)

安装并执行插件

vue add [plugin],这个命令我们已经在前面讲过了。

插件包函几部分中,generatorprompts 是在 vue add 命令执行的时候执行的。

service 插件的执行时机则是在运行 vue-cli-service xxx 命令时,如 vue-cli-service serve,得出这个结论可以看下 @vue/cli-service/lib/Service.jsinit 方法:

    // apply plugins.
    // this.plugins 就是当前项目中的全部 Vue CLI 插件
    this.plugins.forEach(({ id, apply }) => {
      if (this.pluginsToSkip.has(id)) return
      // apply 方法就是 插件中 Service 默认导出的函数
      apply(new PluginAPI(id, this), this.projectOptions)
    })

至于为什么 vue-cli-service serve 最终会走到上面的 init 方法,我们在之前在 探索 vue inspect 时提到过的。

插件列表

官方插件

  • @vue/cli-plugin-vuex
  • @vue/cli-plugin-router
  • @vue/cli-plugin-typescript
  • @vue/cli-plugin-eslint
  • @vue/cli-plugin-babel
  • @vue/cli-plugin-pwa
  • @vue/cli-plugin-unit-jest
  • @vue/cli-plugin-unit-mocha
  • @vue/cli-plugin-e2e-cypress
  • @vue/cli-plugin-e2e-nightwatch

TODO

  • 翻译 plugin-dev 文档
  • @vue/cli-plugin-vuex
  • @vue/cli-plugin-router
  • @vue/cli-plugin-babel
  • @vue/cli-plugin-typescript
  • @vue/cli-plugin-eslint

使用Markdown语法来写 todo list 还有个小插曲:不展示 todo list的原因见此

插件开发

插件开发部分的文档可以见我翻译的内容,相信会对你有所帮助。

webpack loader

webpack plugin

@vue/cli-plugin-vuex

这个插件是从 @vue/cli@4.x 开始增加的,规范化 vuex 的使用,同时提供更加完美的默认配置。

源码探索

cli-plugin-vuex 插件由两个部分组成,Service 和 Generator。

Service 部分

一是必须有的 Service 部分:从 package.json 文件中可以看到,主文件是根目录的 index.js,文件内容如下

module.exports = (api, options = {}) => {}

可以看到这里返回了一个空函数,这个是根据这个文档来的。

Generator 部分

还有一部分是 Generator,就是 /generator/index.js

// 这里的 api 指的是 GeneratorAPI 实例
module.exports = (api, options = {}) => {
  // 这里 api.entryFile 指的是 main.js 文件
  api.injectImports(api.entryFile, `import store from './store'`)
  api.injectRootOptions(api.entryFile, `store`)

  api.extendPackage({
    dependencies: {
      vuex: '^3.1.2'
    }
  })

  api.render('./template', {
  })

  // 这里指的是 GeneratorAPI 被调用的过程中,如果是 typescript 项目的需要做转换
  if (api.invoking && api.hasPlugin('typescript')) {
    /* eslint-disable-next-line node/no-extraneous-require */
    const convertFiles = require('@vue/cli-plugin-typescript/generator/convert')
    convertFiles(api)
  }
}

api.entryFile 你可能好奇他到底只的是哪个文件,我们看这里:

@vue/cli/lib/GeneratorAPI.js

  /**
   * Get the entry file taking into account typescript.
   *
   * @readonly
   */
  get entryFile () {
    if (this._entryFile) return this._entryFile
    // 从这里可以看到,它就是指的 主文件
    return (this._entryFile = fs.existsSync(this.resolve('src/main.ts')) ? 'src/main.ts' : 'src/main.js')
  }

再看下 injectImports 这个方法:

  /**
   * 添加导入语句到文件中
   * 在这里 file 指的是主文件
   * imports 就是导入语句
   */
  injectImports (file, imports) {
    const _imports = (
      this.generator.imports[file] ||
      (this.generator.imports[file] = new Set())
    )
    // imports 这里是支持数组的,非数组也会转为数组处理
    ;(Array.isArray(imports) ? imports : [imports]).forEach(imp => {
      _imports.add(imp)
    })
  }

injectRootOptions 方法:

  /**
   * Add options to the root Vue instance (detected by `new Vue`).
   */
  injectRootOptions (file, options) {
    const _options = (
      this.generator.rootOptions[file] ||
      (this.generator.rootOptions[file] = new Set())
    )
    // 支持数组,处理同上
    ;(Array.isArray(options) ? options : [options]).forEach(opt => {
      _options.add(opt)
    })
  }

injectRootOptions 执行后,store 会加入到下面的代码中。

new Vue({
  el: '#app',
  router,
  store,
  render: h => h(App)
})

extendPackage 方法是用来扩展项目的 package.json 文件,

/**
   * 扩展项目的 package.json 文件
   * 也解决不同插件之间的依赖冲突
   * 工具配置字段可能在提取之前被提取到独立文件中
   * 文件将写入磁盘
   *
   * @param {object | () => object} fields - 合并的字段
   * @param {object} [options] - 用来扩展/合并的选项
   * @param {boolean} [options.prune=false] - 在合并之后从对象中移除所有 null/undefined 字段
   * @param {boolean} [options.merge=true] 深度合并嵌套字段
   *    无论次选项如何依赖字段始终会深度合并
   * @param {boolean} [options.warnIncompatibleVersions=true] 如果依赖版本没有相交,将输出警告
   */
  extendPackage (fields, options = {}) {
    const extendOptions = {
      prune: false,
      merge: true,
      warnIncompatibleVersions: true
    }

    // 这是为了兼容性
    // 版本 4.0.0 到 4.1.2, 没有 `options` 对象, 只有 `forceNewVersion` 标志
    if (typeof options === 'boolean') {
      extendOptions.warnIncompatibleVersions = !options
    } else {
      Object.assign(extendOptions, options)
    }

    const pkg = this.generator.pkg
    // 我们传入的是个对象,所以这里走 else 选项
    const toMerge = isFunction(fields) ? fields(pkg) : fields
    for (const key in toMerge) {
      // value = { vuex: '^3.1.2' }
      const value = toMerge[key]
      // existing = { xxx } 现有依赖
      const existing = pkg[key]
      // key = dependencies
      if (isObject(value) && (key === 'dependencies' || key === 'devDependencies')) {
        // 使用特定版本解决冲突
        pkg[key] = mergeDeps(
          this.id,
          existing || {},
          value,
          this.generator.depSources,
          extendOptions
        )
      } else if (!extendOptions.merge || !(key in pkg)) {
        pkg[key] = value
      } else if (Array.isArray(value) && Array.isArray(existing)) {
        pkg[key] = mergeArrayWithDedupe(existing, value)
      } else if (isObject(value) && isObject(existing)) {
        pkg[key] = deepmerge(existing, value, { arrayMerge: mergeArrayWithDedupe })
      } else {
        pkg[key] = value
      }
    }

    if (extendOptions.prune) {
      pruneObject(pkg)
    }
  }

再看 api.render('./template', {}) 这句话,我们找到 GeneratorAPI 对应的 render 方法:

因为我们第一个参数是字符串类型,所以这里仅截取了部分走的到的逻辑

  /**
   * Render template files into the virtual files tree object.
   * 渲染模板文件到虚拟文件树对象
   * @param {string | object | FileMiddleware} source -
   *   参数可以是下面几种
   *   - 相对路径;
   *   - { 模板源:目标文件 } 的哈希对象映射;
   *   - 自定义的文件中间件函数
   * @param {object} [additionalData] - 模板能够获得的额外数据
   * @param {object} [ejsOptions] - ejs 的配置信息
   */
  render (source, additionalData = {}, ejsOptions = {}) {
    const baseDir = extractCallDir()
    if (isString(source)) {
      source = path.resolve(baseDir, source)
      // 这里传入 _injectFileMiddleware 的函数是个参数,所以并不会马上执行
      this._injectFileMiddleware(async (files) => {
        const data = this._resolveData(additionalData)
        const globby = require('globby')
        const _files = await globby(['**/*'], { cwd: source })
        for (const rawPath of _files) {
          const targetPath = rawPath.split('/').map(filename => {
            // 以点开头的文件当发布到 npm 上会被忽略,所以在模板中我们需要用下划线取代(例如,"_gitignore")
            // 这里则是将 下划线 转回 点
            if (filename.charAt(0) === '_' && filename.charAt(1) !== '_') {
              return `.${filename.slice(1)}`
            }
            // 对于两个下划线的文件名,则截取第二个下划线开始的字符串名字
            if (filename.charAt(0) === '_' && filename.charAt(1) === '_') {
              return `${filename.slice(1)}`
            }
            return filename
          }).join('/')
          const sourcePath = path.resolve(source, rawPath)
          const content = renderFile(sourcePath, data, ejsOptions)
          // 对于二进制文件或者非空白的文件才设置,否则就过滤了
          if (Buffer.isBuffer(content) || /[^\s]/.test(content)) {
            files[targetPath] = content
          }
        }
      })

上面的方法中,调用了 _injectFileMiddleware 方法:

  /**
   * 注入一个文件处理中间件
   *
   * @private 私有的,通过名字的 下划线可以知道
   * @param {FileMiddleware} middleware - 一个中间件函数
   *   他接受虚拟文件树对象,和 ejs 渲染函数。可以是异步的
   */
  _injectFileMiddleware (middleware) {
    this.generator.fileMiddlewares.push(middleware)
  }

上面出入 _injectFileMiddleware 的参数,的执行是在 Generator.js 中的 resolveFiles() 方法中

  async resolveFiles () {
    const files = this.files
    for (const middleware of this.fileMiddlewares) {
      // 这里将 files 传入,作为文件树的根节点
      await middleware(files, ejs.render)
    }
  ...

这里我们看下 Typescript 的转换方式:

// 我们调用的时候传入的仅仅是 GeneratorAPI
module.exports = (api, { tsLint = false, convertJsToTs = true } = {}) => {
  const jsRE = /\.js$/
  const excludeRE = /^tests\/e2e\/|(\.config|rc)\.js$/
  const convertLintFlags = require('../lib/convertLintFlags')
  // 这里使用了 GeneratorAPI 的 postProcessFiles 方法
  api.postProcessFiles(files => {
    // 这里默认值是 true
    if (convertJsToTs) {
      // 删除所有的有同名 ts 文件的 js 文件
      // 简单的将其他 js 文件重命名为 ts 文件
      for (const file in files) {
        // 这个时候我们操作的还是虚拟文件树 files
        if (jsRE.test(file) && !excludeRE.test(file)) {
          const tsFile = file.replace(jsRE, '.ts')
          if (!files[tsFile]) {
            let content = files[file]
            if (tsLint) {
              content = convertLintFlags(content)
            }
            files[tsFile] = content
          }
          delete files[file]
        }
      }
    }

这里我们看下 postProcessFiles 方法:

/**
   * push 一个文件中间件,它将在所有普通中间件都执行完成后再执行
   * @param {FileMiddleware} cb 参数是一个回调函数
   */
  postProcessFiles (cb) {
    this.generator.postProcessFilesCbs.push(cb)
  }

那么 postProcessFilesCbs 将在哪里执行呢,我们再次回到了 Generator.js 文件的 resolveFiles 方法:

  // 这个我们已经在前面讲到了
  const files = this.files
  for (const middleware of this.fileMiddlewares) {
    await middleware(files, ejs.render)
  }
  ...

  for (const postProcess of this.postProcessFilesCbs) {
    // 这里我们刚刚 push 进去的 中间件将会执行
    await postProcess(files)
  }

最终的文件写入则在 Generator.jsgenerate 方法中:

  ...
  // 载入文件树
  await this.resolveFiles()
  ...
  // 将虚拟文件树写入磁盘
  await writeFileTree(this.context, this.files, initialFiles)

@vue/cli-plugin-router

这个插件同 @vue/cli-plugin-vuex 也是从 @vue/cli@4.x 开始有的,目的也是规范化 router 的使用,同时添加更完美的默认配置。

源码探索

Service 部分

@vue/cli-plugin-vuex 一致,因为是必须项,所以也是导出空函数

module.exports = (api, options = {}) => {}

Generator 部分

module.exports = (api, options = {}) => {
  // 增加入口
  api.injectImports(api.entryFile, `import router from './router'`)
  // 增加 router 选项
  api.injectRootOptions(api.entryFile, `router`)

  // 扩展项目的 package.json 文件中的依赖
  api.extendPackage({
    dependencies: {
      'vue-router': '^3.1.5'
    }
  })

  // 渲染模板
  api.render('./template', {
    historyMode: options.historyMode,
    doesCompile: api.hasPlugin('babel') || api.hasPlugin('typescript'),
    hasTypeScript: api.hasPlugin('typescript')
  })

  if (api.invoking) {
    if (api.hasPlugin('typescript')) {
      /* eslint-disable-next-line node/no-extraneous-require */
      const convertFiles = require('@vue/cli-plugin-typescript/generator/convert')
      convertFiles(api)
    }
  }
}

前面的部分和 @vue/cli-plugin-vuex 是一致的,这里有区别的地方是,render 方法传了参数:

api.render('./template', {
    historyMode: options.historyMode,
    doesCompile: api.hasPlugin('babel') || api.hasPlugin('typescript'),
    hasTypeScript: api.hasPlugin('typescript')
  })
// additionalData 这个参数就是上面传入的
render (source, additionalData = {}, ejsOptions = {}) {
    ...
    this._injectFileMiddleware(async (files) => {
      // 传入 _resolveData 方法中
      const data = this._resolveData(additionalData)
      for (const rawPath of _files) {
          ...
          // 处理文件时,作为参数传入
          const content = renderFile(sourcePath, data, ejsOptions)
          ...
        }
    ...

render 方法的第二个参数传入到了 _resolveData 方法中:

/**
   * 渲染模板时解析数据
   *
   * @private
   */
  _resolveData (additionalData) {
    return Object.assign({
      options: this.options,
      rootOptions: this.rootOptions,
      plugins: this.pluginsData
    }, additionalData)
  }

  /**
   * 所以最终返回的对象结构如下
    {
      options: {},
      rootOptions: {},
      plugins: {},
      historyMode: '',
      doesCompile: '',
      hasTypeScript: ''
    }
   */

然后我们看下 renderFile 方法:

function renderFile (name, data, ejsOptions) {
  ...
  const template = fs.readFileSync(name, 'utf-8')

  // custom template inheritance via yaml front matter.
  // ---
  // extend: 'source-file'
  // replace: !!js/regexp /some-regex/
  // OR
  // replace:
  //   - !!js/regexp /foo/
  //   - !!js/regexp /bar/
  // ---
  // https://github.com/dworthen/js-yaml-front-matter
  const yaml = require('yaml-front-matter')
  const parsed = yaml.loadFront(template)
  // content 就是文件内容
  const content = parsed.__content
  let finalTemplate = content.trim() + `\n`
  
  ...

  // data 最终传到 ejs 的 render 方法中
  return ejs.render(finalTemplate, data, ejsOptions)

ejs.redner() 这个方法的第一个参数,是模板,第二个参数是传入模板中的变量,第三个则是ejs模板的配置项。所以我们的 data 会模板渲染的时候使用到,那么我们看下模板中是如何使用的:

@vue/cli-plugin-router/generator/template/src/App.vue,首先看这个模板文件:

---
extend: '@vue/cli-service/generator/template/src/App.vue'
replace:
  - !!js/regexp /<template>[^]*?<\/template>/
  - !!js/regexp /\n<script>[^]*?<\/script>\n/
  - !!js/regexp /  margin-top[^]*?<\/style>/
---

上面这段是 yaml 语法,首先它继承了 @vue/cli-service/generator/template/src/App.vue 文件(这个是原始的模板),然后替换了3部分内容:

  • 首先是模板部分:
<%# REPLACE %>
<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </div>
    <router-view/>
  </div>
</template>
<%# END_REPLACE %>

然后 script 脚本,替换为空:

<%# REPLACE %>
<%# END_REPLACE %>

最后是样式部分


<%# REPLACE %>
// 这里的括号是为了承接继承的内容
}

// 这里可以看到是用到 data 中的 rootOptions 属性
<%_ if (rootOptions.cssPreprocessor !== 'stylus') { _%>
...
<%# END_REPLACE %>

@vue/cli-plugin-router/generator/template/src/router/index.js 这个文件中用到了通过插件传入过来的参数:hasTypeScriptdoesCompilehistoryMode

区分 Typescript,使用不同的导入方式

<%_ if (hasTypeScript) { _%>
import VueRouter, { RouteConfig } from 'vue-router'
<%_ } else { _%>
import VueRouter from 'vue-router'
<%_ } _%>

通过 doescCompile,来区分是否需要编译

    <%_ if (doesCompile) { _%>
    component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
    <%_ } else { _%>
    component: function () {
      return import(/* webpackChunkName: "about" */ '../views/About.vue')
    }
    <%_ } _%>

通过 historyMode 控制路由模式

const router = new VueRouter({
  <%_ if (historyMode) { _%>
  mode: 'history',
  base: process.env.BASE_URL,
  <%_ } _%>
  routes
})

Prompts 部分

对话这里只有一个问题,就是路由类型。这个问题的答案在 historyMode: options.historyMode, 这里就用到了。

module.exports = [
  {
    name: 'historyMode',
    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.`
  }
]

这里我们看下上面的 prompts 是如何被执行的,我们添加插件是通过 vue add @vue/cli-plugin-router 的方式,然后会执行到 @vue/cli/lib/invoke.js 中的 invoke 方法,我们看下 invoke 方法中处理 prompts 的逻辑:

...
  } else if (!Object.keys(pluginOptions).length) {
    // 这里就载入了我们定义在插件中的 对话
    let pluginPrompts = loadModule(`${id}/prompts`, context)
    if (pluginPrompts) {
      if (typeof pluginPrompts === 'function') {
        pluginPrompts = pluginPrompts(pkg)
      }
      if (typeof pluginPrompts.getPrompts === 'function') {
        pluginPrompts = pluginPrompts.getPrompts(pkg)
      }
      // 因为我们的插件中返回的是数组,所有就直接执行了(开始对话)
      pluginOptions = await inquirer.prompt(pluginPrompts)
    }
  }

@vue/cli-plugin-babel

babel 用来做语法转换

Service

module.exports = (api, options) => {
  // 如果是 生产环境 并且 开启了 parallel(并行打包)则为 true
  const useThreads = process.env.NODE_ENV === 'production' && !!options.parallel
  const cliServicePath = path.dirname(require.resolve('@vue/cli-service'))
  // 载入需要额外使用 babel-loader 进行转化的目录
  const transpileDepRegex = genTranspileDepRegex(options.transpileDependencies)

  // try to load the project babel config;
  // if the default preset is used,
  // there will be a VUE_CLI_TRANSPILE_BABEL_RUNTIME env var set.
  // the `filename` field is required
  // in case there're filename-related options like `ignore` in the user config
  babel.loadPartialConfigSync({ filename: api.resolve('src/main.js') })

  api.chainWebpack(webpackConfig => {
    webpackConfig.resolveLoader.modules.prepend(path.join(__dirname, 'node_modules'))

    const jsRule = webpackConfig.module
      .rule('js')
        .test(/\.m?jsx?$/)
        .exclude
          .add(filepath => {
            // 总是转译 vue 文件中的 js 文件
            if (/\.vue\.jsx?$/.test(filepath)) {
              return false
            }
            // 排除了 cli-service 中的动态入口
            if (filepath.startsWith(cliServicePath)) {
              return true
            }

            // 仅仅当 @vue/babel-preset-app 预设使用时,引入 @babel/runtime
            if (
              process.env.VUE_CLI_TRANSPILE_BABEL_RUNTIME &&
              filepath.includes(path.join('@babel', 'runtime'))
            ) {
              return false
            }

            // check if this is something the user explicitly wants to transpile
            // 查看用户配置的需要转译的文件不能排除
            if (transpileDepRegex && transpileDepRegex.test(filepath)) {
              return false
            }
            // 不转译 node_modules 下的文件
            return /node_modules/.test(filepath)
          })
          .end()
        .use('cache-loader')
          .loader(require.resolve('cache-loader'))
          // api.genCacheConfig 这个方法我们看下
          .options(api.genCacheConfig('babel-loader', {
            '@babel/core': require('@babel/core/package.json').version,
            '@vue/babel-preset-app': require('@vue/babel-preset-app/package.json').version,
            'babel-loader': require('babel-loader/package.json').version,
            modern: !!process.env.VUE_CLI_MODERN_BUILD,
            browserslist: api.service.pkg.browserslist
          }, [
            'babel.config.js',
            '.browserslistrc'
          ]))
          .end()
    // 如果使用并行处理,则使用 thread-loader      
    if (useThreads) {
      const threadLoaderConfig = jsRule
        .use('thread-loader')
          .loader(require.resolve('thread-loader'))

      if (typeof options.parallel === 'number') {
        threadLoaderConfig.options({ workers: options.parallel })
      }
    }

    // 重点,应用babel-loader
    jsRule
      .use('babel-loader')
        .loader(require.resolve('babel-loader'))
  })
}

@vue/cli-service/lib/PluginAPI.js,我们看下 genCacheConfig 方法:

  /**
   * 通过大量的变量生成一个缓存标志
   */
  // 根据前面的调用 id = babel-laoder
  // partialIdentifier = { '@babel/core': 'x.x.x', '@vue/babel-preset-app': 'x.x.x', 'babel-loader': 'x.x.x' }
  // configFiles = ['babel.config.js', '.browserslistrc']
  genCacheConfig (id, partialIdentifier, configFiles = []) {
    const fs = require('fs')
    // 这里可以看到 缓存目录是 项目的 node_modules/.cache/
    const cacheDirectory = this.resolve(`node_modules/.cache/${id}`)

    // 这是所有版本相关的变量集合
    const variables = {
      partialIdentifier,
      'cli-service': require('../package.json').version,
      'cache-loader': require('cache-loader/package.json').version,
      env: process.env.NODE_ENV,
      test: !!process.env.VUE_CLI_TEST,
      config: [
        fmtFunc(this.service.projectOptions.chainWebpack),
        fmtFunc(this.service.projectOptions.configureWebpack)
      ]
    }

    // 所有的配置文件
    if (!Array.isArray(configFiles)) {
      configFiles = [configFiles]
    }
    configFiles = configFiles.concat([
      'package-lock.json',
      'yarn.lock',
      'pnpm-lock.yaml'
    ])

    // 将配置文件也添加到 variables 变量上,保证唯一
    variables.configFiles = configFiles.map(file => {
      const content = readConfig(file)
      return content && content.replace(/\r\n?/g, '\n')
    })

    // 这里使用了 hash-sum 哈希生成器来生成唯一标志
    const cacheIdentifier = hash(variables)
    // 返回的对象,则是 cache-loader 需要的配置
    return { cacheDirectory, cacheIdentifier }

Generator

module.exports = api => {
  // 你很可能希望覆盖整个配置以确保他没有冲突的正常工作,例如,对于一个使用了 Jest 但是没有使用 Babel 的项目。
  // 它对于使用自己的特殊 babel 配置而没有使用 Babel 插件已有的配置很少见。
  delete api.generator.files['babel.config.js']


  // 这里修改 package.json 文件中的 babel 配置项;增加了 core.js@3 的依赖。
  api.extendPackage({
    babel: {
      // 我们看到 presets 来自 @vue/cli-plugin-babel/preset
      presets: ['@vue/cli-plugin-babel/preset']
    },
    dependencies: {
      'core-js': '^3.6.4'
    }
  })
}

这里我们就再看下 @vue/cli-plugin-babel/preset

module.exports = require('@vue/babel-preset-app')

这里就一句引用,内容来自 @vue/babel-preset-app

module.exports = (context, options = {}) => {
  ...
  return {
    sourceType: 'unambiguous',
    overrides: [{
      exclude: [/@babel[\/|\\\\]runtime/, /core-js/],
      presets,
      plugins
    }, {
      // there are some untranspiled code in @babel/runtime
      // https://github.com/babel/babel/issues/9903
      include: [/@babel[\/|\\\\]runtime/],
      presets: [
        [require('@babel/preset-env'), {
          useBuiltIns,
          corejs: useBuiltIns ? 3 : false
        }]
      ]
    }]
  }
}

@vue/babel-preset-app 经过处理之后导出的 presets 最终赋值给了 package.json 文件中 babel.presets 选项,至于其中的细节,我们将其放在 Babel 的后续分析中。

这里有一点需要注意的地方,虽然我们这里看到 babel.presets 的配置应该在 package.json 文件中,那么为啥有的项目并不是这样呢,这里要看下这个 prompt:

useConfigFiles

{
  name: 'useConfigFiles',
  when: isManualMode,
  type: 'list',
  //  Babel, ESLint 等等这些配置文件放在哪里?
  message: 'Where do you prefer placing config for Babel, ESLint, etc.?',
  choices: [
    {
      // 放在专用的配置文件中
      name: 'In dedicated config files',
      value: 'files'
    },
    {
      // 放在 package.json 中
      name: 'In package.json',
      value: 'pkg'
    }
  ]
}

这个 prompt 的结果决定了配置文件放的位置,如果你这里的 useConfigFiles 选择 In dedicated config files,那么 再看这里,在 Creator 的 create 方法中:

await generator.generate({
  extractConfigFiles: preset.useConfigFiles
})

这里将用户的选择传入了 generator.generate 方法:

...
// extract configs from package.json into dedicated files.
this.extractConfigFiles(extractConfigFiles, checkExisting)

这里我们看下 extractConfigFiles 方法的主要逻辑:

  extractConfigFiles (extractAll, checkExisting) {
    ...
    // 这里定义了一个提取方法
    const extract = key => {
      if (
        configTransforms[key] &&
        this.pkg[key] &&
        // do not extract if the field exists in original package.json
        // 如果 字段 存在于原始的 package.json 文件中,则不提取
        !this.originalPkg[key]
      ) {
        const value = this.pkg[key]
        const configTransform = configTransforms[key]
        const res = configTransform.transform(
          value,
          checkExisting,
          this.files,
          this.context
        )
        const { content, filename } = res
        // 因为操作的都是虚拟文件树,所以这里相当于创建单独的配置文件
        this.files[filename] = ensureEOL(content)
        // 这里删除提取的字段
        delete this.pkg[key]
      }
    }
    if (extractAll) {
      // 这里会循环 package.json 下的每一个字段,看是否需要提取
      for (const key in this.pkg) {
        extract(key)
      }
    }
  }

migrator

这个工具主要是为了更加方便的升级,在前面讲 vue upgrade 命令时我们已经提到了他是如何被调用的。

我们来看下 @vue/cli-plugin-babel 的 migrator 的内部逻辑:

module.exports = api => {
  // 这句话是个深坑,来,我们往里跳!
  api.transformScript(
    'babel.config.js',
    require('../codemods/usePluginPreset')
  )

  // 这里判断若是从 3.x 的版本升级,则增加 `core.js` 的依赖项
  if (api.fromVersion('^3')) {
    api.extendPackage(
      {
        dependencies: {
          'core-js': '^3.6.4'
        }
      },
      { warnIncompatibleVersions: false }
    )

    // TODO: implement a codemod to migrate polyfills
    // 这里是作者留下的待实现的内容,可以看到是计划在增加 migrator 增加自动化迁移
    api.exitLog(`core-js has been upgraded from v2 to v3.
If you have any custom polyfills defined in ${chalk.yellow('babel.config.js')}, please be aware their names may have been changed.
For more complete changelog, see https://github.com/zloirock/core-js/blob/master/CHANGELOG.md#300---20190319`)
  }
}

api.transformScript 这个方法是 MigratorAPI 继承自 GeneratorAPI,这里我们看下逻辑:

/**
   * 针对 script 脚本 或者 .vue 文件中 script 部分执行 codemod
   * @param {string} file the path to the file to transform
   * @param {Codemod} codemod the codemod module to run
   * @param {object} options additional options for the codemod
   */
  transformScript (file, codemod, options) {
    this._injectFileMiddleware(files => {
      // 这里调用了 runCodemod 方法
      files[file] = runCodemod(
        codemod,
        { path: this.resolve(file), source: files[file] },
        options
      )
    })
  }

顺藤摸瓜我们再看下 runCodemod 方法:

// 这里因引入了两个重要插件
const adapt = require('vue-jscodeshift-adapter')
let jscodeshift = require('jscodeshift')

module.exports = function runCodemod (transformModule, fileInfo, options = {}) {
  ...

  if (parser) {
    jscodeshift = jscodeshift.withParser(parser)
  }

  return adapt(transform)(fileInfo, api, options)
}

这里是借助了 jscodeshift 来处理js文件内容。

我们看下 require('../codemods/usePluginPreset') 这里是如何处理js文件的:

  // 这里都是在进行内容的替换
  root
    .find(j.Literal, { value: '@vue/app' })
    .replaceWith(j.stringLiteral('@vue/cli-plugin-babel/preset'))
  root
    .find(j.Literal, { value: '@vue/babel-preset-app' })
    .replaceWith(j.stringLiteral('@vue/cli-plugin-babel/preset'))

可以看到主要是替换某些关键词,这里的具体的语法我们暂时不深究,只需知道他是做了内容替换即可。

至此,@vue/cli-plugin-babel的解析也就结束了。

@vue/cli-plugin-typescirpt

对于这个插件的基本内容,可以看下我翻译的 README ,相信这样你应该对这个插件有个基本的了解了。

Service

Service 的内容大部分和 @vue/cli-plugin-babel 的内容重复,不赘述,几个不一样的地方:

// 这里判断如果不是多页应用,则进行以下处理
if (!projectOptions.pages) {
  config.entry('app')
    .clear()
    .add('./src/main.ts')
}

这里的 projectOptions 是项目默认配置 + vue.config.js 两部分内容合并起来的。

执行时机,不知道你会不会有这样的疑问,那么这个 Service 插件到底是什么时候执行的呢。其实我们前面也提到过,比如我们调试项目时,会执行 yarn serve 那么其实是执行了 vue-cli-service serve,所以在这个时机,如果我们安装 @vue/cli-plugin-typescript 插件,那么它的 Service 部分则会执行。

还有个之前没有碰到的写法是,这里注册了新的命令:

// 判断如果没有安装 eslint 插件
if (!api.hasPlugin('eslint')) {
  // 注册 lint 命令
  api.registerCommand('lint', {
    description: 'lint source files with TSLint',
    usage: 'vue-cli-service lint [options] [...files]',
    options: {
      '--format [formatter]': 'specify formatter (default: codeFrame)',
      '--no-fix': 'do not fix errors',
      '--formatters-dir [dir]': 'formatter directory',
      '--rules-dir [dir]': 'rules directory'
    }
  }, args => {
    return require('./lib/tslint')(args, api)
  })
}

我们看到 lint 命令的处理逻辑在 ./lib/tslint

// 可以看到这里用到了 tslint
const tslint = require('tslint')
const ts = require('typescript')
...

// 这里其实有新的发现,vue-template-compiler原来能够解析 vue 文件的部分内容
const vueCompiler = require('vue-template-compiler')
const { script } = vueCompiler.parseComponent(content, { pad: 'line' })

vue-template-compiler 这个就是 Vue 中的代码了。

Prompts

这里我们先看下 Prompts,为啥呢,因为 Generator 中的配置信息部分就来自 Prompts

// these prompts are used if the plugin is late-installed into an existing
// project and invoked by `vue invoke`.

// 如果这个插件使用 `vue invoke`命令,后安装到一个已经存在的项目中时,这些对话才会被使用。
const prompts = module.exports = [
  {
    // 是否使用 classComponent
    name: `classComponent`,
    type: `confirm`,
    message: `Use class-style component syntax?`,
    default: true
  },
  {
    name: `useTsWithBabel`,
    type: `confirm`,
    message: 'Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)?'
  },
  {
    name: `lint`,
    type: `confirm`,
    message: `Use TSLint?`
  },
  {
    name: `lintOn`,
    type: `checkbox`,
    when: answers => answers.lint,
    message: `Pick lint features:`,
    choices: [
      {
        name: 'Lint on save',
        value: 'save',
        checked: true
      },
      {
        name: 'Lint and fix on commit' + (hasGit() ? '' : chalk.red(' (requires Git)')),
        value: 'commit'
      }
    ]
  },
  {
    // 将 js 转换为 ts
    name: `convertJsToTs`,
    type: `confirm`,
    message: `Convert all .js files to .ts?`,
    default: true
  },
  {
    name: `allowJs`,
    type: `confirm`,
    message: `Allow .js files to be compiled?`,
    default: false
  }
]

默认的 typescript 对话在这里

Generator

了解了 Prompts 后,再看 Generator 就会清晰一些,基本的内容和前面一致,不赘述,看下新的东西:

// 这里将插件自身的 `package.json` 文件引入了
const pluginDevDeps = require('../package.json').devDependencies

module.exports = (api, {
  classComponent,
  tsLint,
  lintOn = [],
  convertJsToTs,
  allowJs
}, _, invoking) => {
  ...
  api.extendPackage({
    devDependencies: {
      // 这里使用插件的 typescript 版本来作为安装依赖的版本
      // 达到了维护一致性的目的
      // 不用同时分别维护插件 `package.json` 的版本和此处的版本
      typescript: pluginDevDeps.typescript
    }
  })

  ...
  // 在创建完成后 执行 lint 修复文件
  api.onCreateComplete(() => {
    return require('../lib/tslint')({}, api, true)
  })

  ...
  // 这里执行转换
  require('./convert')(api, { tsLint, convertJsToTs })
}

这里看下 onCreateComplete 方法:

  /**
   * push 一个当文件被写入磁盘之后才会调用的回调函数。
   *
   * @param {function} cb
   */
  onCreateComplete (cb) {
    this.afterInvoke(cb)
  }

  afterInvoke (cb) {
    this.generator.afterInvokeCbs.push(cb)
  }

所以 onCreateComplete 其实是 push 了一个回调函数,待之后执行。

然后再看下 require('./convert') 这里的 convert 函数:

api.postProcessFiles(files => {
    if (convertJsToTs) {
      // 删除所有有同名 ts 文件的js文件,然后简单的将其他 js 文件重命名为 ts 文件
      ...
    } else {
      // rename only main file to main.ts
      // 仅仅重命名 main.js 为 main.ts
      const tsFile = api.entryFile.replace(jsRE, '.ts')
      let content = files[api.entryFile]
      if (tsLint) {
        // 这个函数的逻辑在下面解析
        content = convertLintFlags(content)
      }
      files[tsFile] = content
      delete files[api.entryFile]
    }
  })

然后了解下 api.postProcessFiles 方法:

  /**
   * push 一个文件中间件 -- 它将在所有普通文件中间件执行后再执行
   *
   * @param {FileMiddleware} cb
   */
  postProcessFiles (cb) {
    this.generator.postProcessFilesCbs.push(cb)
  }

convertLintFlags 这个方法我们也看下:

module.exports = function convertLintFlags (file) {
  return file
    // 主要是将 eslint 的规则,转成 tslint 的规则
    .replace(/\/\*\s?eslint-(enable|disable)([^*]+)?\*\//g, (_, $1, $2) => {
      if ($2) $2 = $2.trim()
      return `/* tslint:${$1}${$2 ? `:${$2}` : ``} */`
    })
    .replace(/\/\/\s?eslint-disable-(next-)?line(.+)?/g, (_, $1, $2) => {
      if ($2) $2 = $2.trim()
      return `// tslint:disable-${$1 || ''}line${$2 ? `:${$2}` : ``}`
    })
}

同时 Generator 中也有模板,模板的写法大量使用了 yaml-front-matter 从字符串或者文件中解析 YAML 来进行继承和替换。

Migrator

Migrator 中的逻辑很简单:

module.exports = api => {
  // 这个升级的迁移方式,暂时来看升级一下 typescirpt 依赖的版本就可以了。
  api.extendPackage(
    {
      devDependencies: {
        typescript: require('../package.json').devDependencies.typescript
      }
    },
    { warnIncompatibleVersions: false }
  )
}

@vue/cli-plugin-eslint

对于这个插件的基本内容,可以看下我翻译的 README ,相信这样你应该对这个插件有个基本的了解了。

Service

Service 服务中主要是增加 webpack 的配置和 注册了 lint 命令:

module.exports = (api, options) => {
  if (options.lintOnSave) {
    const extensions = require('./eslintOptions').extensions(api)
    // 这里使用 loadModule 方法,允许用户自定义 ESLint 依赖版本。
    const { resolveModule, loadModule } = require('@vue/cli-shared-utils')
    const cwd = api.getCwd()
    const eslintPkg =
      // 这里在下文分析下
      loadModule('eslint/package.json', cwd, true) ||
      loadModule('eslint/package.json', __dirname, true)

    // eslint-loader 在 eslint 配置改变时不会破会缓存,所以我们需要手动地生成一个缓存标志将配置考虑在内。
    const { cacheIdentifier } = api.genCacheConfig(
      'eslint-loader',
      {
        'eslint-loader': require('eslint-loader/package.json').version,
        eslint: eslintPkg.version
      },
      [
        '.eslintrc.js',
        '.eslintrc.yaml',
        '.eslintrc.yml',
        '.eslintrc.json',
        '.eslintrc',
        'package.json'
      ]
    )
    ...
    // 接下来是 webpack 的配置部分,暂时省略。
  }

  // 这里注册了新的命令 `vue-cli-service lint`
  api.registerCommand(
    'lint',
    {
      description: 'lint and fix source files',
      usage: 'vue-cli-service lint [options] [...files]',
      options: {
        '--format [formatter]': 'specify formatter (default: codeframe)',
        '--no-fix': 'do not fix errors or warnings',
        '--no-fix-warnings': 'fix errors, but do not fix warnings',
        '--max-errors [limit]':
          'specify number of errors to make build failed (default: 0)',
        '--max-warnings [limit]':
          'specify number of warnings to make build failed (default: Infinity)'
      },
      details:
        'For more options, see https://eslint.org/docs/user-guide/command-line-interface#options'
    },
    args => {
      require('./lint')(args, api)
    }
  )

这里看下如果实现了 eslint 自定义和预置同时存在的:

const cwd = api.getCwd()
const eslintPkg = 
  loadModule('eslint/package.json', cwd, true) ||
  loadModule('eslint/package.json', __dirname, true)

再看下 api.getCwd() 这个方法

 /**
   * 当前工作目录(其实也就是项目目录,因为 Service 脚本执行是在项目根目录)
   */
  getCwd () {
    return this.service.context
  }

loadModule 的区别主要是第二个参数不同,这里有个关于 __dirname 和 process.cwd() 区别的文档可以看下,所以这里会先在 项目目录下找用户定义的 ESLint 如果没有找到的情况下,再在 "源代码所在目录" -- 也就是 @vue/cli-plugin-eslint 这个插件中的 eslint。

const { cacheIdentifier } = api.genCacheConfig(
  ...

缓存标志的生成和我们在 @vue/cli-plugin-babel 的 Service 部分生成标志用的同一个方法。

关于命令注册,它是引用了 lint.js

...
require('./lint')(args, api)

再看先 lint.js 中部分内容:

  // 这里可以看到,还是兼容前面提到用户自定义 eslint 的原则
  const { CLIEngine } = loadModule('eslint', cwd, true) || require('eslint')
  ...
  // 这里进行了 eslint 的初始化
  const engine = new CLIEngine(config)
  ...
  // 这里应该是进行了 lint 操作
  const report = engine.executeOnFiles(files)
  ...
  // 控制自动修复的逻辑
  if (config.fix) {
    CLIEngine.outputFixes(report)
  }

到此,主要的 Service 逻辑我们就过了一遍。

Pormpts

module.exports = [
  {
    name: 'config',
    type: 'list',
    message: `Pick an ESLint config:`,
    choices: [
      {
        name: 'Error prevention only',
        value: 'base',
        short: 'Basic'
      },
      ...
  },
  {
    name: 'lintOn',
    type: 'checkbox',
    message: 'Pick additional lint features:',
    choices: [
      {
        name: 'Lint on save',
        value: 'save',
        checked: true
      },
      {
        name: 'Lint and fix on commit' + (hasGit() ? '' : chalk.red(' (requires Git)')),
        value: 'commit'
      }
    ]
  }

对话一共两个,第一个是选择一种 ESLint 配置,第二个是选择 lint 的执行时机,默认是在文件保存的时候执行 lint。

Generator

直接看代码:

// 这里的 config、lintOn 参数,其实就是 对话中的两个问题
module.exports = (api, { config, lintOn = [] }, _, invoking) => {
  // 这里载入了默认的 eslint 配置
  const eslintConfig = require('../eslintOptions').config(api, config)
  // 这里的依赖是根据 config 选项决定的,不同的 eslint 规则对应不同的依赖
  const devDependencies = require('../eslintDeps').getDeps(api, config)

  const pkg = {
    scripts: {
      lint: 'vue-cli-service lint'
    },
    eslintConfig,
    devDependencies
  }

  ...

  // lint & fix after create to ensure files adhere to chosen config
  // for older versions that do not support the `hooks` feature
  // lint 和 修复了 vue-cli 老版本不支持 `hooks` 功能
  try {
    // 这里的这个写法,对于我们自己做版本处理相关,也很有用
    api.assertCliVersion('^4.0.0-beta.0')
  } catch (e) {
    if (config && config !== 'base') {
      api.onCreateComplete(() => {
        require('../lint')({ silent: true }, api)
      })
    }
  }

Migrator

这里来看下 eslint 插件升级的逻辑:

module.exports = async (api) => {
  // 首先获取项目的 package.json 文件
  const pkg = require(api.resolve('package.json'))
  
  // 取得本地 eslint 版本
  let localESLintRange = pkg.devDependencies.eslint

  // 如果项目是通过 Vue CLI 3.0 或者更早版本构建的,ESLint 依赖(ESLint v4)将在 @vue/cli-plugin-eslint 插件内部;
  // 在 Vue CLI v4 中他应该被提取到项目依赖中
  // 这里判断如果项目当前 Vue CLI 是3.x版本,并且项目没有单独安装 ESLint 时
  if (api.fromVersion('^3') && !localESLintRange) {
    localESLintRange = '^4.19.1'
    // 这里 针对你从 增加了相应的依赖
    api.extendPackage({
      devDependencies: {
        eslint: localESLintRange,
        'babel-eslint': '^8.2.5',
        'eslint-plugin-vue': '^4.5.0'
      }
    })
  }

  // 这里获得 eslint 的主版本号
  const localESLintMajor = semver.major(
    semver.maxSatisfying(
      ['4.99.0', '5.99.0', '6.99.0'],
      localESLintRange
    )
  )

  // 如果 主版本 已经是 6,说明是最新的,则直接返回
  if (localESLintMajor === 6) {
    return
  }

  // 如果 主版本 不是 6,则进行对话
  const { confirmUpgrade } = await inquirer.prompt([{
    name: 'confirmUpgrade',
    type: 'confirm',
    message:
    `Your current ESLint version is v${localESLintMajor}.\n` +
    `The lastest major version is v6.\n` +
    `Do you want to upgrade? (May contain breaking changes)\n`
  }])

  // 如果用户的答案是 true
  if (confirmUpgrade) {
    const { getDeps } = require('../eslintDeps')

    const newDeps = getDeps(api)
    // 这里根据用户已经选择 eslint 规则来设置
    if (pkg.devDependencies['@vue/eslint-config-airbnb']) {
      Object.assign(newDeps, getDeps(api, 'airbnb'))
    }
    if (pkg.devDependencies['@vue/eslint-config-standard']) {
      Object.assign(newDeps, getDeps(api, 'standard'))
    }
    if (pkg.devDependencies['@vue/eslint-config-prettier']) {
      Object.assign(newDeps, getDeps(api, 'prettier'))
    }

    api.extendPackage({ devDependencies: newDeps }, { warnIncompatibleVersions: false })

    ...
  }
}

感谢阅读

原文地址

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