likes
comments
collection
share

万字技术分享——从零开始 实现一个Vue-CLI

作者站长头像
站长
· 阅读数 19

背景

大家周五好,正好填一下之前的坑我准备了很久的学习如何开发一个 Vue CLI 工具的学习的内容输出成文章,我觉得学习的最终过程是需要输出成果的,这也是我的一个学习过程的记录。文章内容比较长,需要慢慢学习,全文超三万字,希望帮到大家学习和总结

本质上就是一个 Node 项目,Vue CLI 是一个基于 Node.js 的命令行工具,用于快速搭建和管理 Vue.js 项目的脚手架。其核心原理是通过命令行接口获取用户的配置选项,然后根据这些选项生成项目文件和目录结构。以下是一个实现简易版 Vue CLI 的基本步骤和示例代码。

分析过程

Vue-CLI 工具中有很多的功能,今天我们这里最主要学习使用的是 create , 最后输出的文件是长这样的

万字技术分享——从零开始 实现一个Vue-CLI

create command 命令

create 命令我们在使用 Vue-CLI 工具的时候一般是构建项目,快速搭建脚手架,自定义配置需求点,生成对应的文件工程模版,这对于我们快速开发作用很大。我们不妨看一看源码,其实与 init 指令也是雷同的这里就不一一描述了

const program = require('commander')

program
  .command('create <app-name>')
  .description('create a new project powered by vue-cli-service')
  .option('-p, --preset <presetName>', 'Skip prompts and use saved or remote preset')
  .option('-d, --default', 'Skip prompts and use default preset')
  .option('-i, --inlinePreset <json>', 'Skip prompts and use inline JSON string as preset')
  .option('-m, --packageManager <command>', 'Use specified npm client when installing dependencies')
  .option('-r, --registry <url>', 'Use specified npm registry when installing dependencies (only for npm)')
  .option('-g, --git [message]', 'Force git initialization with initial commit message')
  .option('-n, --no-git', 'Skip git initialization')
  .option('-f, --force', 'Overwrite target directory if it exists')
  .option('--merge', 'Merge target directory if it exists')
  .option('-c, --clone', 'Use git clone when fetching remote preset')
  .option('-x, --proxy <proxyUrl>', 'Use specified proxy when creating project')
  .option('-b, --bare', 'Scaffold project without beginner instructions')
  .option('--skipGetStarted', 'Skip displaying "Get started" instructions')
  .action((name, options) => {
    if (minimist(process.argv.slice(3))._.length > 1) {
      console.log(chalk.yellow('\n Info: You provided more than one argument. The first one will be used as the app\'s name, the rest are ignored.'))
    }
    // --git makes commander to default git to true
    if (process.argv.includes('-g') || process.argv.includes('--git')) {
      options.forceGit = true
    }
    require('../lib/create')(name, options)
  })

这里对应官方网站上的文档介绍内容参数填写相关的部分

用法:create [options] <app-name>
创建一个由 `vue-cli-service` 提供支持的新项目

选项:

  -p, --preset <presetName>       忽略提示符并使用已保存的或远程的预设选项
  -d, --default                   忽略提示符并使用默认预设选项
  -i, --inlinePreset <json>       忽略提示符并使用内联的 JSON 字符串预设选项
  -m, --packageManager <command>  在安装依赖时使用指定的 npm 客户端
  -r, --registry <url>            在安装依赖时使用指定的 npm registry
  -g, --git [message]             强制 / 跳过 git 初始化,并可选的指定初始化提交信息
  -n, --no-git                    跳过 git 初始化
  -f, --force                     覆写目标目录可能存在的配置
  -c, --clone                     使用 git clone 获取远程预设选项
  -x, --proxy                     使用指定的代理创建项目
  -b, --bare                      创建项目时省略默认组件中的新手指导信息
  -h, --help                      输出使用帮助信息

这里可以看到这个 Create 命令中附带了一个参数 然后传递到里面的 一个 create 方法,附带上传入的 projectName 参数数据

万字技术分享——从零开始 实现一个Vue-CLI

create & Creator 实例对象

我们先看看 create 方法,里面的方法很简单,详细看下图,里面的内容点互相嵌套,实际上是分开几步来实现完整的功能的

万字技术分享——从零开始 实现一个Vue-CLI

实际上是将部分的参数,做格式化的处理和边界的逻辑判断,然后将它们传入到创建对象的实例方法中 Creator,最终调用 creator.create 方法。

接下来我们看看 Creator 实例对象,它是属于最核心的功能点,可以看到优质的代码一般都是面向对象开发的。

这部分代码比较的长,我们将代码收起来一个个拆分来看

万字技术分享——从零开始 实现一个Vue-CLI


1. 初始化 生成 Prompt & Preset

这里做了很多的验证和边界处理,这里就不一一阐述了,本文的初衷是让大家去阅读源码,然后实现一个功能

constructor (name, context, promptModules) {
    super(); // 继承 EventEmitter 

    this.name = name; // ProjectName
    this.context = process.env.VUE_CLI_CONTEXT = context; // 默认的上下文,一般是传入目标CWD
    const { presetPrompt, featurePrompt } = this.resolveIntroPrompts(); // 就是我们选择的 Prompts

    this.presetPrompt = presetPrompt; // 选择 preset Vue2 或者 Vue3
    this.featurePrompt = featurePrompt; // 选择 Feature 的分支
    this.outroPrompts = this.resolveOutroPrompts(); // 使用 manual 模式的时候用到 Prompts
    this.injectedPrompts = []; // 注入的 Prompts
    this.promptCompleteCbs = [];
    this.afterInvokeCbs = [];
    this.afterAnyInvokeCbs = [];

    this.run = this.run.bind(this)

    const promptAPI = new PromptModuleAPI(this); // 对应管理整个 Feature 的选项或者 Preset V2或V3
    promptModules.forEach(m => m(promptAPI)); // 传入
  }

万字技术分享——从零开始 实现一个Vue-CLI

这里有三个分支,一个是选择版本,一个手动选择 Feature. PromptModuleAPI 对应的就是这个功能点,这里贴的代码是只做参考,详细的还是需要到 Github 上研究的

exports.getPromptModules = () => {
  return [
    'vueVersion',
    'babel',
    'typescript',
    'pwa',
    'router',
    'vuex',
    'cssPreprocessors',
    'linter',
    'unit',
    'e2e'
  ].map(file => require(`../promptModules/${file}`))
}

PromptModuleAPI 这个实例 实际上是负责管理整个 PromptModule 选择和注入的

module.exports = class PromptModuleAPI {
  constructor (creator) {
    this.creator = creator
  }

  // 注入设置的 Feature  
  injectFeature (feature) {
    this.creator.featurePrompt.choices.push(feature)
  }

  // // 注入设置的 prompt  
  injectPrompt (prompt) {
    this.creator.injectedPrompts.push(prompt)
  }
  
  
  injectOptionForPrompt (name, option) {
    this.creator.injectedPrompts.find(f => {
      return f.name === name
    }).choices.push(option)
  }

  onPromptComplete (cb) {
    this.creator.promptCompleteCbs.push(cb)
  }
}

单独的文件负责管理,每一个对应的配置,用到的时候在 实例对象中管理中遍历出来使用 这里是举例其中的一个文件,进行说明,总共有很多个,详细可以看这个文件夹

万字技术分享——从零开始 实现一个Vue-CLI

module.exports = cli => {
  cli.injectFeature({
    name: 'Babel',
    value: 'babel',
    short: 'Babel',
    description: 'Transpile modern JavaScript to older versions (for compatibility)',
    link: 'https://babeljs.io/',
    checked: true
  })

  cli.onPromptComplete((answers, options) => {
    if (answers.features.includes('ts')) {
      if (!answers.useTsWithBabel) {
        return
      }
    } else if (!answers.features.includes('babel')) {
      return
    }
    options.plugins['@vue/cli-plugin-babel'] = {}
  })
}

这里用到 配置设置,实际看起来有点绕,其实是值得我们学习如何进行管理这些配置文件的,更加系统和优雅的处理文件,把需要经常改动的静态资源和配置功能点分开,从而实现在命令行中,输入选择,获取对应功能点的功能。

2. 生成 Package.json 文件 & install

设置 Preset 对象,这里就是根据我们前面输入的,动态设置到 Preset Plugin 中

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

    if (cliOptions.bare) {
      preset.plugins['@vue/cli-service'].bare = true
    }

    // legacy support for router
    if (preset.router) {
      preset.plugins['@vue/cli-plugin-router'] = {}

      if (preset.routerHistoryMode) {
        preset.plugins['@vue/cli-plugin-router'].historyMode = true
      }
    }

    // legacy support for vuex
    if (preset.vuex) {
      preset.plugins['@vue/cli-plugin-vuex'] = {}
    }

初始化 Package 对象

按照选择 Preset 需求, 在 plugins 里面添加,例如 vuex,vue-router,是否为 history-mode 等等

然后开始生成 Package.json 并且下载依赖

const packageManager = (
  cliOptions.packageManager ||
  loadOptions().packageManager ||
  (hasYarn() ? 'yarn' : null) ||
  (hasPnpm3OrLater() ? 'pnpm' : 'npm')
); // 定义包管理器,按照优先级来确定

await clearConsole();
// pm 就是整个负责下载依赖的 包管理器
const pm = new PackageManager({ context, forcePackageManager: packageManager })

log(`✨  Creating project in ${chalk.yellow(context)}.`); // 上下文 context 一般是 cwd
this.emit('creation', { event: 'creating' }); // 用到了 EventEmitter 类的方法 向所有订阅发通知

// 目的是通过这个方法同步当前的任务状态

// get latest CLI plugin version
const { latestMinor } = await getVersions(); // 获取插件的最新版本

// 生成 依赖插件 这里就是就最最最基础的 Package 对象
const pkg = {
  name,
  version: '0.1.0',
  private: true,
  devDependencies: {},
  ...resolvePkg(context)
}

const deps = Object.keys(preset.plugins)
deps.forEach(dep => {
  if (preset.plugins[dep]._isPreset) {
    return
  }

  let { version } = preset.plugins[dep]

  if (!version) {
    if (isOfficialPlugin(dep) || dep === '@vue/cli-service' || dep === '@vue/babel-preset-env') {
      version = isTestOrDebug ? `latest` : `~${latestMinor}`
    } else {
      version = 'latest'
    }
  }

  pkg.devDependencies[dep] = version
})

// write package.json 写出来 package.json
await writeFileTree(context, {
  'package.json': JSON.stringify(pkg, null, 2)
})

// generate a .npmrc file for pnpm, to persist the `shamefully-flatten` flag
// 这里省略一部分 关于 npmrc 是 pnpm的情况

// intilaize git repository before installing deps
// so that vue-cli-service can setup git hooks.
// 省略一部分 关于 GIT 配置相关的

// install plugins
log(`⚙\u{fe0f}  Installing CLI plugins. This might take a while...`)
log()
this.emit('creation', { event: 'plugins-install' })

if (isTestOrDebug && !process.env.VUE_CLI_TEST_DO_INSTALL_PLUGIN) {
  // in development, avoid installation process
  await require('./util/setupDevProject')(context)
} else {
  await pm.install(); // 下载 node_module 依赖内容
}

生成 Package.json 文件举例

{
  "name": "vue-project-demo",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "core-js": "^3.6.5",
    "vue": "^3.0.0"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "~4.5.4",
    "@vue/cli-plugin-eslint": "~4.5.4",
    "@vue/cli-service": "~4.5.4",
    "@vue/compiler-sfc": "^3.0.0",
  },
  "eslintConfig": {
    "root": true,
    "env": {
      "node": true
    },
    "extends": [
      "plugin:vue/vue3-essential",
      "eslint:recommended"
    ],
    "parserOptions": {
      "parser": "babel-eslint"
    },
    "rules": {}
  },
}

3. Generator 生成模版 拓展 Package

    // run generator
    log(`🚀  Invoking generators...`)
    this.emit('creation', { event: 'invoking-generators' })
    const plugins = await this.resolvePlugins(preset.plugins, pkg)
    const generator = new Generator(context, {
      pkg,
      plugins,
      afterInvokeCbs,
      afterAnyInvokeCbs
    })
    
    // 这里调用 Generate 方法
    await generator.generate({
      extractConfigFiles: preset.useConfigFiles
    })

    // install additional deps (injected by generators) Genrerator 中拓展依赖 deps install
    log(`📦  Installing additional dependencies...`)
    this.emit('creation', { event: 'deps-install' })
    log()
    if (!isTestOrDebug || process.env.VUE_CLI_TEST_DO_INSTALL_PLUGIN) {
      await pm.install()
    }

    // run complete cbs if any (injected by generators)
    log(`⚓  Running completion hooks...`)
    this.emit('creation', { event: 'completion-hooks' }); // 创建 Hooks
    for (const cb of afterInvokeCbs) {
      await cb()
    }
    for (const cb of afterAnyInvokeCbs) {
      await cb()
    }

我们看看 Generator 实例方法

module.exports = class Generator {
  constructor (context, {
    pkg = {},
    plugins = [],
    afterInvokeCbs = [],
    afterAnyInvokeCbs = [],
    files = {},
    invoking = false
  } = {}) {
    this.context = context
    this.plugins = sortPlugins(plugins)
    this.originalPkg = pkg
    this.pkg = Object.assign({}, pkg)
    this.pm = new PackageManager({ context })
    this.imports = {}
    this.rootOptions = {}
    this.afterInvokeCbs = afterInvokeCbs
    this.afterAnyInvokeCbs = afterAnyInvokeCbs
    this.configTransforms = {} // 插件通过 GeneratorAPI 暴露的 addConfigTransform 方法添加如何提取配置文件
    this.defaultConfigTransforms = defaultConfigTransforms // 默认的配置文件
    this.reservedConfigTransforms = reservedConfigTransforms // 保留配置的 vue config 文件 配置
    this.invoking = invoking
    // for conflict resolution
    this.depSources = {}
    // 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 = []
    // exit messages
    this.exitLogs = []

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

    const cliService = plugins.find(p => p.id === '@vue/cli-service')
    const rootOptions = cliService
      ? cliService.options
      : inferRootOptions(pkg)

    this.rootOptions = rootOptions
   }
   
   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) // 每个插件对应生成一个 GeneratorAPI 实例,并将实例 api 传入插件暴露出来的 generator 函数

      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
   }
  }

GeneratorAPI

  • hasPlugin:判断项目中是否有某个插件
  • extendPackage:拓展 package.json 配置
  • render:利用 ejs 渲染模板文件
  • onCreateComplete:内存中保存的文件字符串全部被写入文件后的回调函数
  • exitLog:当 generator 退出的时候输出的信息
  • genJSConfig:将 json 文件生成为 js 配置文件
  • injectImports:向文件当中注入import语法的方法
  • injectRootOptions:向 Vue 根实例中添加选项
module.exports = (api, options) => {
  // 渲染 ejs 模版
  api.render('./template', {
    doesCompile: api.hasPlugin('babel') || api.hasPlugin('typescript'),
    useBabel: api.hasPlugin('babel')
  })

  if (options.vueVersion === '3') {
    // 拓展 package.json
    api.extendPackage({
      dependencies: {
        'vue': '^3.2.13'
      }
    })
  } else {
  
     // 拓展 package.json
    api.extendPackage({
      dependencies: {
        'vue': '^2.6.14'
      },
      devDependencies: {
        'vue-template-compiler': '^2.6.14'
      }
    })
  }
  
  // 拓展 package.json
  api.extendPackage({
    scripts: {
      'serve': 'vue-cli-service serve',
      'build': 'vue-cli-service build'
    },
    browserslist: [
      '> 1%',
      'last 2 versions',
      'not dead',
      ...(options.vueVersion === '3' ? ['not ie 11'] : [])
    ]
  })

  // 是否使用 css 预编译
  if (options.cssPreprocessor) {
    const deps = {
      sass: {
        sass: '^1.32.7',
        'sass-loader': '^12.0.0'
      },
      'dart-sass': {
        sass: '^1.32.7',
        'sass-loader': '^12.0.0'
      },
      less: {
        'less': '^4.0.0',
        'less-loader': '^8.0.0'
      },
      stylus: {
        'stylus': '^0.55.0',
        'stylus-loader': '^6.1.0'
      }
    }

    api.extendPackage({
      devDependencies: deps[options.cssPreprocessor]
    })
  }

  // 引入路由
  if (options.router && !api.hasPlugin('router')) {
    require('./router')(api, options, options)
  }

  // 引入 vuex 
  if (options.vuex && !api.hasPlugin('vuex')) {
    require('./vuex')(api, options, options)
  }

  // 拓展 Package
  if (options.configs) {
    api.extendPackage(options.configs)
  }

  // 如果使用 ts 就删除 jsconfig.json
  if (api.hasPlugin('typescript')) {
    api.render((files) => delete files['jsconfig.json'])
  }
}

resolveFiles

fileMiddlewares 里面包含了 ejs render 函数,所有插件调用 api.render 时候只是把对应的渲染函数 push 到了 fileMiddlewares 中,等所有的 插件执行完以后才会遍历执行 fileMiddlewares 里面的所有函数,即在内存中生成模板文件字符串。

injectImportsAndOptions 就是将 generator 注入的 import 和 rootOption 解析到对应的文件中,比如选择了 vuex, 会在 src/main.js 中添加 import store from './store',以及在 vue 根实例中添加 router 选项。

postProcessFilesCbs 是在所有普通文件在内存中渲染成字符串完成之后要执行的遍历回调。

async resolveFiles () {
    const files = this.files // 存放 files 文件的对象,用来保存文件的,最后用来生成文件
    for (const middleware of this.fileMiddlewares) {
      await middleware(files, ejs.render)
    }

    // normalize file paths on windows
    // all paths are converted to use / instead of \
    normalizeFilePaths(files)

    // handle imports and root option injections
    Object.keys(files).forEach(file => {
      let imports = this.imports[file]
      imports = imports instanceof Set ? Array.from(imports) : imports
      if (imports && imports.length > 0) {
        files[file] = runTransformation(
          { path: file, source: files[file] },
          require('./util/codemods/injectImports'),
          { imports }
        )
      }

      let injections = this.rootOptions[file]
      injections = injections instanceof Set ? Array.from(injections) : injections
      if (injections && injections.length > 0) {
        files[file] = runTransformation(
          { path: file, source: files[file] },
          require('./util/codemods/injectOptions'),
          { injections }
        )
      }
    })

    for (const postProcess of this.postProcessFilesCbs) {
      await postProcess(files)
    }
    debug('vue:cli-files')(this.files)
  }

writeFileTree

在提取了配置文件和模板渲染之后调用了 sortPkg 对 package.json 的字段进行了排序并将 package.json 转化为 json 字符串添加到项目的 files 中。 此时整个项目的文件已经在内存中生成好了(在源码中就是对应的 this.files),接下来就调用 writeFileTree 方法将内存中的字符串模板文件生成在磁盘中。

4. 安装额外依赖 & 生成 REAME.md & 输出日志

    // 下载依赖
    log(`📦  Installing additional dependencies...`)
    this.emit('creation', { event: 'deps-install' })
    log()
    if (!isTestOrDebug || process.env.VUE_CLI_TEST_DO_INSTALL_PLUGIN) {
      await pm.install()
    }

    // 执行完成以后的 Callback
    log(`⚓  Running completion hooks...`)
    this.emit('creation', { event: 'completion-hooks' })
    for (const cb of afterInvokeCbs) {
      await cb()
    }
    for (const cb of afterAnyInvokeCbs) {
      await cb()
    }

    if (!generator.files['README.md']) {
      // 生成 README.md 文件
      log()
      log('📄  Generating README.md...')
      await writeFileTree(context, {
        'README.md': generateReadme(generator.pkg, packageManager)
      })
    }

    // GIT 相关
    let gitCommitFailed = false
    if (shouldInitGit) {
      await run('git add -A')
      if (isTestOrDebug) {
        await run('git', ['config', 'user.name', 'test'])
        await run('git', ['config', 'user.email', 'test@test.com'])
        await run('git', ['config', 'commit.gpgSign', 'false'])
      }
      const msg = typeof cliOptions.git === 'string' ? cliOptions.git : 'init'
      try {
        await run('git', ['commit', '-m', msg, '--no-verify'])
      } catch (e) {
        gitCommitFailed = true
      }
    }

    
    log()
    log(`🎉  Successfully created project ${chalk.yellow(name)}.`)
    if (!cliOptions.skipGetStarted) {
      log(
        `👉  Get started with the following commands:\n\n` +
        (this.context === process.cwd() ? `` : chalk.cyan(` ${chalk.gray('$')} cd ${name}\n`)) +
        chalk.cyan(` ${chalk.gray('$')} ${packageManager === 'yarn' ? 'yarn serve' : packageManager === 'pnpm' ? 'pnpm run serve' : 'npm run serve'}`)
      )
    }
    log()
    this.emit('creation', { event: 'done' })

    if (gitCommitFailed) {
      warn(
        `Skipped git commit due to missing username and email in git config, or failed to sign commit.\n` +
        `You will need to perform the initial commit yourself.\n`
      )
    }
    // // 最后打印日志输出日志
    generator.printExitLogs()

5. 总结

可以看到这个过程是十分的复杂的,其中我们重点看 Creator & Generator 这两个构造函数。 调用 create 方法的时候会调用创建一个 Creator 实例,然后在 Creatorcreate 方法中又调用创建 Generator 实例,再在这个实例里面再调用 Generator 方法。这里是比较复杂和绕的。

在这个过程中,总的来说是收集了用户选择的信息,在对象中保存需要加载的插件等等,然后将数据保存在内存对象中,到最后再生成对应的文件,这样实现我们的最后的目的

实现过程

说了一大堆我们终于开始初始化整一个 Node 项目用来生成 template 了。

1. 初始化一个 Node.js 项目

在你的工作目录下,运行以下命令来初始化一个新的 Node.js 项目:

mkdir my-vue-cli
cd my-vue-cli
npm init -y

这将创建一个 package.json 文件。

2. 安装必要的包

你需要安装一些包来帮助你创建 CLI 工具:

  • inquirer: 用于在命令行中与用户交互。inquirer
  • fs-extra: 扩展了 fs 模块,提供了更多文件操作的方法。fs-extra
npm install inquirer fs-extra -D

3. 创建 CLI 脚本

在项目根目录下,创建一个名为 cli.js 的文件,并写入以下代码:

#!/usr/bin/env node

const inquirer = require('@inquirer/prompts');
const fs = require('fs-extra');
const path = require('path');


async function init() {
  const answers = await inquirer.input(
    {
      type: 'input',
      name: 'projectName',
      message: 'What is the name of your project?',
      default: 'my-vue-project'
    }
  );

  console.log(answers)

  const projectPath = path.join(process.cwd(), answers);
  await fs.ensureDir(projectPath);
  await fs.writeFile(path.join(projectPath, 'index.html'), '<h1>Hello Vue!</h1>');
  console.log('Project created successfully!');
}

init();

这段代码会询问用户项目的名称,并在当前目录下创建一个新的文件夹,里面包含一个简单的 index.html 文件。

万字技术分享——从零开始 实现一个Vue-CLI

万字技术分享——从零开始 实现一个Vue-CLI

<h1>Hello Vue!</h1>

4. 设置命令行接口

package.json 文件中,添加一个 bin 字段来指定 CLI 的入口文件:

"bin": {
  "my-vue-cli": "./cli.js"
}

5. 全局安装你的 CLI 工具

在项目目录下运行:

npm link

这会创建一个全局的符号链接,指向你的 CLI 脚本。

6. 使用你的 CLI 工具

现在你可以在命令行中运行你的 CLI 工具了:

my-vue-cli

跟随提示输入项目名称,CLI 工具将会在当前目录下创建一个包含 index.html 文件的新文件夹。相信这里的功能实质上是不能满足我们的需求的,我们需要很多个性化自定义的内容,项目模板的管理、代码风格和格式化、错误处理和日志记录。这里离我们的目标功能还差比较远呢,所以我们继续升级

升级功能

目的我们是优化提示内容,

你需要安装一些包来帮助你升级 CLI 工具:

  • chalk 用于在命令行输出中添加颜色和样式,让输出的信息更易于阅读和区分。chalk
  • ora: 用于在命令行中显示旋转的加载指示器,提升用户等待时的体验。ora
  • commander 用于更容易地解析命令行参数和选项。
  • download-git-repo 用于下载并提取 git 仓库,适用于从 GitHub、GitLab、Bitbucket 等服务上 下载项目模板。
  • handlebars 是一个强大的模板引擎,可以用来根据用户的输入渲染文件和目录。
  • validate-npm-package-name 用于检查字符串是否是一个有效的 npm 包名。
#!/usr/bin/env node
const inquirer = require('inquirer');
const fs = require('fs-extra');
const path = require('path');
const chalk = require('chalk');
const ora = require('ora');
const validateNpmPackageName = require('validate-npm-package-name');

async function init() {
  console.log(chalk.green('Welcome to My Vue CLI'));

  const answers = await inquirer.prompt([
    {
      type: 'input',
      name: 'projectName',
      message: 'What is the name of your project?',
      validate: input => {
        const validationResult = validateNpmPackageName(input);
        if (validationResult.validForNewPackages) {
          return true;
        } else {
          return 'Invalid project name. Please choose another one.';
        }
      }
    }
  ]);

  const spinner = ora('Creating project...').start();

  try {
    const projectPath = path.join(process.cwd(), answers.projectName);
    await fs.ensureDir(projectPath);
    await fs.writeFile(path.join(projectPath, 'index.html'), '<h1>Hello Vue!</h1>');

    spinner.succeed('Project created successfully!');
    console.log(`\nNavigate to your project by running: ${chalk.cyan(`cd ${answers.projectName}`)}`);
    console.log(`Run your project with: ${chalk.cyan('npm run serve')}\n`);
  } catch (error) {
    spinner.fail('Failed to create project');
    console.error(chalk.red(error));
  }
}

init();

在这个脚本中,我们做了以下几点改进:

  1. 引入了 chalk 来为命令行输出添加颜色,提升了用户体验。
  2. 引入了 ora 来显示加载指示器,给用户一个正在处理的反馈。
  3. 对用户输入的项目名称进行了验证,确保其为有效的 npm 包名。
  4. 对项目创建过程中可能发生的错误进行了捕获和处理,确保了更好的错误反馈。

在运行这个脚本创建项目时,用户将会看到一个彩色的欢迎信息,输入项目名称后将看到一个加载指示器,直到项目创建完成。如果项目名称无效或者在创建过程中发生错误,用户将会收到相应的错误信息。这些改进提供了更友好和更专业的用户体验。

升级功能 ✖ 2

我们可以在 Vue CLI 官网上看到这个 选择需要用到的模版 ,依葫芦画瓢我们可以按照它的功能点,简单实现一下功能点

万字技术分享——从零开始 实现一个Vue-CLI

这个默认的设置非常适合快速创建一个新项目的原型,而手动设置则提供了更多的选项,它们是面向生产的项目更加需要的。

万字技术分享——从零开始 实现一个Vue-CLI

当你需要添加更多选项和根据这些选项生成不同的 Vue 开发模板时,你可以使用 inquirer 收集用户的选择,然后根据这些选择生成不同的项目结构和配置文件。

代码逻辑

下载对应的模版,参考这里的 template模版

#!/usr/bin/env node
const inquirer = require('inquirer');
const fs = require('fs-extra');
const path = require('path');
const chalk = require('chalk');
const ora = require('ora');
const validateNpmPackageName = require('validate-npm-package-name');

async function init() {
  console.log(chalk.green('Welcome to My Vue CLI'));

  const answers = await inquirer.prompt([
    // ... 保持现有的提示问题不变 ...
    { 
        type: 'list',
        name: 'packageManager',
        message: 'Which package manager would you like to use?',
        choices: ['npm', 'yarn'], 
    },
  ]);

  const spinner = ora('Creating project...').start();

  try {
    // 创建 package 对象
    const package = {
      name: answers.projectName,
      version: '1.0.0',
      description: `${answers.projectName} - a Vue.js project`,
      main: 'index.js',
      scripts: {
        serve: 'vue-cli-service serve',
        build: 'vue-cli-service build',
      },
      dependencies: {},
      devDependencies: {},
      keywords: [],
      author: '',
      license: 'ISC',
    };

    // 根据用户的选择动态添加依赖项
    package.dependencies.vue = answers.vueVersion === '3.x' ? '^3.0.0' : '^2.0.0';

    if (answers.useBabel) {
      package.devDependencies['@babel/core'] = '^7.0.0'; // 示例版本号
      package.devDependencies['@babel/preset-env'] = '^7.0.0'; // 示例版本号
    }

    if (answers.useEslint) {
      package.devDependencies.eslint = '^7.0.0'; // 示例版本号
    }

    if (answers.vueRouterVersion !== 'No Vue Router') {
      package.dependencies['vue-router'] = answers.vueRouterVersion === '3.x' ? '^3.0.0' : '^2.0.0';
    }

    if (answers.useVuex) {
      package.dependencies.vuex = answers.vueVersion === '3.x' ? '^4.0.0' : '^3.0.0';
    }

    // 定义项目路径并确保目录存在
    const projectPath = path.join(process.cwd(), answers.projectName);
    await fs.ensureDir(projectPath);

    // 写入 index.html 文件
    await fs.writeFile(path.join(projectPath, 'index.html'), '<h1>Hello Vue!</h1>');

    // 根据 package 对象生成 package.json 文件
    await fs.writeJson(path.join(projectPath, 'package.json'), package, { spaces: 2 });
    
    
    // 这里预先下载好
    
    // 调用函数来从本地模板生成项目 
    const templatePath = 'path/to/your/local/template'; // Replace with your local template path 
    const projectPath = path.join(process.cwd(), answers.projectName);
    
    await generateFromTemplate(templatePath, projectPath, { projectName: answers.projectName });
    
    // 判断选择的包管理器并执行相应命令安装依赖
    const installCmd = answers.packageManager === 'npm' ? 'npm install' : 'yarn install';
    const installProcess = spawn(installCmd, { stdio: 'inherit', shell: true, cwd: projectPath });

    installProcess.on('close', (code) => {
    if (code === 0) {
        spinner.succeed('Dependencies installed successfully!');
        console.log(`\nNavigate to your project by running: ${chalk.cyan(`cd ${answers.projectName}`)}`);
    console.log(`Run your project with: ${chalk.cyan(`${answers.packageManager} run serve`)}\n`);
      } else {
        spinner.fail('Failed to install dependencies');
        console.error(chalk.red(`Installation process exited with code ${code}`));
      }
});
    spinner.succeed('Project created successfully!');
    console.log(`\nNavigate to your project by running: ${chalk.cyan(`cd ${answers.projectName}`)}`);
    console.log(`Run your project with: ${chalk.cyan('npm run serve')}\n`);
  } catch (error) {
    spinner.fail('Failed to create project');
    console.error(chalk.red(error));
  }
}

init();

实现结果

万字技术分享——从零开始 实现一个Vue-CLI

万字技术分享——从零开始 实现一个Vue-CLI

声明 & 总结

本文旨意是学习交流,不是完完全全复刻实现 Vue-CLI,如果需要阅读源码和细节的可以到 Github 上进行阅读,这里我更加推荐这个兄弟的网站,上面分析 Vue-CLI 源码的学习内容也是十分的有帮助,向他学习,地址

后面我也再丰富我的内容让内容更加可读,方便大家学习。如果这篇文章帮到你的话,可以给我一个点赞收藏吗?这对我继续创作真的有很大的帮助,谢谢大家Thanks♪(・ω・)ノ!!!

文章参考

cli.vuejs.org/zh/guide/cr… github.com/vuejs/vue-c… github.com/vuejs/vue-c… kuangpf.com/vue-cli-ana…

转载自:https://juejin.cn/post/7298189093807603722
评论
请登录