likes
comments
collection
share

Vue源码解析系列(十八) -- Vue-cli的源码解读

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

前言

本篇旨在讲解vue-cli源码,通过

npm install -g @vue/cli
# OR
yarn global add @vue/cli

就可以在全局安装vue-cli,通过vue create [projectName]来创建一个快速开发的模板。有人说你怎么知道此中含有create的方法呢?在终端中打出vue,会去执行bin/vue.js,他就会有如此的提示。

Vue源码解析系列(十八) -- Vue-cli的源码解读

如果当前机器node版本低于指定版本,就会报错:

Vue源码解析系列(十八) -- Vue-cli的源码解读 那么我们打开vue-cli的源码,在vue-cli/packages/@vue/cli/bin/vue.js目录下,我们可以看到vue-cli的入口文件。

// vue-cli/packages/@vue/cli/bin/vue.js
const { chalk, semver } = require('@vue/cli-shared-utils')
const requiredVersion = require('../package.json').engines.node // "^12.0.0 || >= 14.0.0"
const leven = require('leven')

// 检查本地nodejs版本
function checkNodeVersion (wanted, id) {
  if (!semver.satisfies(process.version, wanted, { includePrerelease: true })) {
    console.log(chalk.red(
      'You are using Node ' + process.version + ', but this version of ' + id +
      ' requires Node ' + wanted + '.\nPlease upgrade your Node version.'
    ))
    process.exit(1)
  }
}

checkNodeVersion(requiredVersion, '@vue/cli')

const fs = require('fs')
const path = require('path')
const slash = require('slash')
const minimist = require('minimist')

// enter debug mode when creating test repo
if (
  slash(process.cwd()).indexOf('/packages/test') > 0 && (
    fs.existsSync(path.resolve(process.cwd(), '../@vue')) ||
    fs.existsSync(path.resolve(process.cwd(), '../../@vue'))
  )
) {
  process.env.VUE_CLI_DEBUG = true
}
...

在此文件中,当我们键入vue命令,就会开始执行checkNodeVersion函数,之后便是控制台的command选项。这里涉及到了一个commander包,我们简单认识一下他。

commander

  • 描述:这是一个使用子命令并带有帮助描述的更完整的程序。在多命令程序中,每个命令(或命令的独立可执行文件)都有一个操作处理程序。
  • 使用:
program.command('split')
  .description('Split a string into substrings and display as an array')
  .argument('<string>', 'string to split')
  .option('--first', 'display just the first substring')
  .option('-s, --separator <char>', 'separator character', ',')
  .action((str, options) => {
    const limit = options.first ? 1 : undefined;
    console.log(str.split(options.separator, limit));
  });

我们便知道可以借用commander包来实现在终端输出内容了,比如我们来看一下vue create [projectName]

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
    }
    // 加载create函数, 传入name与option
    require('../lib/create')(name, options)
  })
-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                      输出使用帮助信息

流程

Vue源码解析系列(十八) -- Vue-cli的源码解读

看此图我们可以了解到从敲下命令vue create [projectName]到模板的呈现的完整步骤。

  • 验证
  • 预设配置项
  • 依赖安装
  • 模板生成
  • 基础项目输出

验证

async function create (projectName, options) {
  // 使用指定的代理创建项目
  if (options.proxy) {
    process.env.HTTP_PROXY = options.proxy
  }
  // 创建变量
  const cwd = options.cwd || process.cwd() // 当前目录
  const inCurrent = projectName === '.' // 是否为当前目录
  const name = inCurrent ? path.relative('../', cwd) : projectName // 项目名称
  const targetDir = path.resolve(cwd, projectName || '.') // 生成项目的目录

  // 验证项目名称是否合法
  const result = validateProjectName(name)
  if (!result.validForNewPackages) {
    console.error(chalk.red(`Invalid project name: "${name}"`))
    result.errors && result.errors.forEach(err => {
      console.error(chalk.red.dim('Error: ' + err))
    })
    result.warnings && result.warnings.forEach(warn => {
      console.error(chalk.red.dim('Warning: ' + warn))
    })
    exit(1)
  }
  
  // 判断要生成的目录是否存在
  if (fs.existsSync(targetDir) && !options.merge) {
    // 强制移除目标目录  
    if (options.force) {
      await fs.remove(targetDir)
    } else {
      // 在当前目录生成项目
      await clearConsole()
      if (inCurrent) {
        const { ok } = await inquirer.prompt([
          {
            name: 'ok',
            type: 'confirm',
            message: `Generate project in current directory?`
          }
        ])
        if (!ok) {
          return
        }
      } else {
        // 选择当前目录覆盖的方式
        const { action } = await inquirer.prompt([
          {
            name: 'action',
            type: 'list',
            message: `Target directory ${chalk.cyan(targetDir)} already exists. Pick an action:`,
            choices: [
              { name: 'Overwrite', value: 'overwrite' },
              { name: 'Merge', value: 'merge' },
              { name: 'Cancel', value: false }
            ]
          }
        ])
        if (!action) {
          return
        } else if (action === 'overwrite') {
          console.log(`\nRemoving ${chalk.cyan(targetDir)}...`)
          await fs.remove(targetDir)
        }
      }
    }
  }
  
  // 创建creator
  // getPromptModules 获取预设模块
  const creator = new Creator(name, targetDir, getPromptModules())
  await creator.create(options)
}

在验证阶段做了项目名验证,这个验证主要是看看我们写的项目名是都符合规范而已,不去深究,再者做了覆盖当前目录的方式,vue作为全民公用框架,比不了私服脚手架,在我们定制cli的时候,不需要去考虑这些。

预设获取

Vue源码解析系列(十八) -- Vue-cli的源码解读

之后就是我们需要选择的preset预设,他能够让你可选性的安装配置,我们这里选择vue2

Vue源码解析系列(十八) -- Vue-cli的源码解读 我们先来看一下工程目录:

Vue源码解析系列(十八) -- Vue-cli的源码解读

package.json

{
  "name": "my", // 项目名称
  "version": "0.1.0", // 项目版本
  "private": true, // 私有化
  "scripts": { // 启动指令
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  "dependencies": { // 依赖
    "core-js": "^3.8.3",
    "vue": "^3.2.13"
  },
  "devDependencies": { // 依赖
    "@babel/core": "^7.12.16",
    "@babel/eslint-parser": "^7.12.16",
    "@vue/cli-plugin-babel": "~5.0.0",
    "@vue/cli-plugin-eslint": "~5.0.0",
    "@vue/cli-service": "~5.0.0",
    "eslint": "^7.32.0",
    "eslint-plugin-vue": "^8.0.3"
  },
  "eslintConfig": { // eslint配置
    "root": true,
    "env": {
      "node": true
    },
    "extends": [
      "plugin:vue/vue3-essential",
      "eslint:recommended"
    ],
    "parserOptions": {
      "parser": "@babel/eslint-parser"
    },
    "rules": {}
  },
  "browserslist": [ // 浏览器相关
    "> 1%",
    "last 2 versions",
    "not dead",
    "not ie 11"
  ]
}

这样我们就初始化了一个vue2的项目,我们可以看到在过程中vue-cli(V5.0.8)已经放弃了以前的多选配置项(比如eslintbabel),都作为默认的配置加载到了工程化文件当中。那么这一步是怎么做的呢,我们接下来看一下new Creator这个过程。

首先我们来分析一下Creator这个类。

Vue源码解析系列(十八) -- Vue-cli的源码解读

constructor (name, context, promptModules) {
    super()
    this.name = name // 项目名
    this.context = process.env.VUE_CLI_CONTEXT = context // 上下文
    const { presetPrompt, featurePrompt } = this.resolveIntroPrompts() // 获取了 presetPrompt list,在初始化项目的时候提供选择
    this.presetPrompt = presetPrompt // presetPrompt list
    this.featurePrompt = featurePrompt // babal, pwa, e2e etc.
    this.outroPrompts = this.resolveOutroPrompts() //  存放项目配置的文件(package.json || congfig.js) 以及是否将 presetPrompts 存放起来
    this.injectedPrompts = [] // 对应 feature 的 Prompts
    this.promptCompleteCbs = [] // injectedPrompts 的回调
    this.createCompleteCbs = []

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

    const promptAPI = new PromptModuleAPI(this)

    /**
     * 1. 将 babel, e2e, pwa 等 push 到 featurePrompt.choices 中,在选择项目需要配置哪些时显示出来 (checkbox);
     * 2. 将 babel, e2e, pwa 等 push 到 injectedPrompts 中,当设置了 feature 会对应通过 Prompts 来进一步选择哪种模式,比如当选择了 E2E Testing ,然后会再次让你
     *    选择哪种 E2E Testing,即, Cypress (Chrome only) ||  Nightwatch (Selenium-based);
     * 3. 将每中 feature 的 onPromptComplete push 到 promptCompleteCbs,在后面会根据选择的配置来安装对应的 plugin。
     */
    promptModules.forEach(m => m(promptAPI))
  }

PromptModuleAPI

Vue源码解析系列(十八) -- Vue-cli的源码解读

PromptModuleAPI 实例会调用它的实例方法,然后将 injectFeature, injectPrompt, injectOptionForPrompt, onPromptComplete保存到 Creator实例对应的变量中。

最后遍历 getPromptModules 获取的 promptModules,传入实例 promptAPI,初始化 Creator 实例中 featurePromptinjectedPromptspromptCompleteCbs 变量。

getPromptModules

getPromptModules出现在new Creator的参数中,getPromptModules就是按照目录去加载插件包的。

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

比如我们加上一个插件webpackPreset,则会在终端输出:

Vue源码解析系列(十八) -- Vue-cli的源码解读

那我们知道getPromptModules是让用户选择配置项的,那么每一个配置里面会是怎么样的呢?我们来看一下Router.js文件。

// Router.js
const { chalk } = require('@vue/cli-shared-utils')

// 在终端弹出配置可选Router属性
module.exports = cli => {
  cli.injectFeature({
    name: 'Router', // 名字
    value: 'router', // 值
    description: 'Structure the app with dynamic pages', // 描述
    link: 'https://router.vuejs.org/' // 文档链接
  })

  //在终端弹出配置可选项   
  cli.injectPrompt({
    name: 'historyMode', // 默认history模式
    when: answers => answers.features.includes('router'), // 当配置可选中出现router,调用此弹框
    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.`,
    link: 'https://router.vuejs.org/guide/essentials/history-mode.html'
  })
  
  // 选择回调,当有选择router的时候,加载官方插件@vue/cli-plugin-router
  cli.onPromptComplete((answers, options) => {
    if (answers.features.includes('router')) {
      options.plugins['@vue/cli-plugin-router'] = {
        historyMode: answers.historyMode
      }
    }
  })
}

至此getPromptModules中做的事情就完了,我们继续看一下new Creator做的事情。

creator.create

实例调用create方法,需要的是一个异步过程。

  async create(cliOptions = {}, preset = null) {
    // create函数 接受cliOptions和preset两个参数,因为在调用的时候create(options)只给了1个参数
    
    // 判断是tes环境还是debug环境
    const isTestOrDebug = process.env.VUE_CLI_TEST || process.env.VUE_CLI_DEBUG;
    
    // 获取Creator类的属性与方法
    const { run, name, context, afterInvokeCbs, afterAnyInvokeCbs } = this;

    // 一开始进来 preset为null,
    if (!preset) {
      if (cliOptions.preset) { // 判断 -p选项
        // vue create foo --preset bar
        preset = await this.resolvePreset(cliOptions.preset, cliOptions.clone);
      } else if (cliOptions.default) { // 判断 -d 选项
        // vue create foo --default
        preset = defaults.presets["Default (Vue 3)"];
      } else if (cliOptions.inlinePreset) { // 如果是行内参数,就要去解析他
        // vue create foo --inlinePreset {...}
        try {
          preset = JSON.parse(cliOptions.inlinePreset);
        } catch (e) {
          error(
            `CLI inline preset is not valid JSON: ${cliOptions.inlinePreset}`
          );
          exit(1);
        }
      } else {
        // vue create project, 通过promptAndResolvePreset去加载preset
        preset = await this.promptAndResolvePreset();
      }
    }

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

    if (cliOptions.bare) { // 创建项目时省略默认组件中的新手指导信息
      preset.plugins["@vue/cli-service"].bare = true;
    }

    // legacy support for router
    if (preset.router) { // 注入router插件
      preset.plugins["@vue/cli-plugin-router"] = {};

      if (preset.routerHistoryMode) { // 更改router模式
        preset.plugins["@vue/cli-plugin-router"].historyMode = true;
      }
    }

    // legacy support for vuex
    if (preset.vuex) { // 注入vuex插件
      preset.plugins["@vue/cli-plugin-vuex"] = {};
    }

先判断 vue create 命令是否带有 -p 选项,如果有的话会调用 resolvePreset 去解析 preset。

  async resolvePreset(name, clone) {
    let preset;
    const savedPresets = this.getPresets();
    
    // getPresets
    getPresets() {
      const savedOptions = loadOptions(); // 获取~/.vuerc中保存的预设
      return Object.assign({}, savedOptions.presets, defaults.presets);
    }
  

    if (name in savedPresets) {
      preset = savedPresets[name];
    } else if (name === "default") {
      preset = savedPresets["Default (Vue 3)"];
    } else if ( // 获取.json文件预设
      name.endsWith(".json") ||
      /^\./.test(name) ||
      path.isAbsolute(name)
    ) {
      preset = await loadLocalPreset(path.resolve(name));
    } else if (name.includes("/")) {
      log(`Fetching remote preset ${chalk.cyan(name)}...`);
      this.emit("creation", { event: "fetch-remote-preset" });
      try {
        preset = await loadRemotePreset(name, clone); // 通过远程下载preset
      } catch (e) {
        error(`Failed fetching remote preset ${chalk.cyan(name)}:`);
        throw e;
      }
    }

    if (!preset) {
      error(`preset "${name}" not found.`);
      const presets = Object.keys(savedPresets);
      if (presets.length) {
        log();
        log(`available presets:\n${presets.join(`\n`)}`);
      } else {
        log(`you don't seem to have any saved preset.`);
        log(`run vue-cli in manual mode to create a preset.`);
      }
      exit(1);
    }
    return preset;
  }
  • resolvePreset 函数会先获取~/.vuerc中保存的preset, 然后进行遍历,如果里面包含了-p中的 <presetName>,则返回~/.vuerc中的preset
  • 如果没有则判断是否是采用内联的JSON字符串预设选项,如果是就会解析.json文件,并返回preset.
  • 还有一种情况就是从远程获取prese(利用 download-git-repo 下载远程的preset.json)并返回。

promptAndResolvePreset

如果不带任何参数,则会调用promptAndResolvePreset去让我们手动去获取preset

 async promptAndResolvePreset(answers = null) {
    // prompt
    if (!answers) {
      await clearConsole(true); // 清空控制台
      answers = await inquirer.prompt(this.resolveFinalPrompts()); // inquirer实现选择交互
      
      /**
      * resolveFinalPrompts() {
        // 把getPromptsModule中的配置项与特性遍历执行
        this.injectedPrompts.forEach((prompt) => {
          const originalWhen = prompt.when || (() => true);
          prompt.when = (answers) => { // 当时answer被选择了ManuallySelect的时候
            return isManualMode(answers) && originalWhen(answers);
          };
        });
        
        // 加入特性选项弹窗
        const prompts = [
          this.presetPrompt,// 如果上一次的自定义预设保存到了~/.vuerc中,那么这一次会显示出来
          
          this.featurePrompt, // 项目的一些 feature,就是选择 babel,
                              // typescript,pwa,router,vuex,
                              // cssPreprocessors,linter,unit,e2e。
                              
          ...this.injectedPrompts, // 当你选择了feature之后就会注入模式,
                                   // 比如你选择了 unit,那么就会让你选择模式: 
                                   // `Mocha + Chai` 还是 `Jest`
                                   
          ...this.outroPrompts, // 是否写在package.json里面还是单独成config,
                                // 使用yarn还是npm等
        ];
        debug("vue-cli:prompts")(prompts);
        return prompts;
      }
      */
      
    }
    debug("vue-cli:answers")(answers);

    // 自定义中会让你选择 npm 或者pnpm 或者yarn来管理包
    if (answers.packageManager) {
      saveOptions({
        packageManager: answers.packageManager,
      });
    }

    let preset;
    // 如果没有选择ManuallySelect,则会根据resolvePreset来加载preset
    if (answers.preset && answers.preset !== "__manual__") {
      preset = await this.resolvePreset(answers.preset);
    } else {
      // manual
      preset = {
        useConfigFiles: answers.useConfigFiles === "files",
        plugins: {},
      };
      answers.features = answers.features || [];
      // run cb registered by prompt modules to finalize the preset
      this.promptCompleteCbs.forEach((cb) => cb(answers, preset));
    }

    // validate 验证预设
    validatePreset(preset);

    // save preset 保存预设
    if (
      answers.save &&
      answers.saveName &&
      savePreset(answers.saveName, preset)
    ) {
      log();
      log(
        `🎉  Preset ${chalk.yellow(answers.saveName)} saved in ${chalk.yellow(
          rcPath
        )}`
      );
    }

    debug("vue-cli:preset")(preset);
    return preset;
  }

依赖安装

包管理工具

    // 使用npm 、yarn 、pnpm来管理包
    const packageManager =
      cliOptions.packageManager ||
      loadOptions().packageManager ||
      (hasYarn() ? "yarn" : null) ||
      (hasPnpm3OrLater() ? "pnpm" : "npm");

    await clearConsole(); // 清空控制台

    // 获得包管理器实例
    const pm = new PackageManager({
      context,
      forcePackageManager: packageManager,
    });
    
    // 打印 在当前目录创建项目成功
    log(`✨  Creating project in ${chalk.yellow(context)}.`);
    this.emit("creation", { event: "creating" });

生成package.json文件


    // 获得cli插件最新版本
    const { latestMinor } = await getVersions();

    // 生成package.json文件
    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;
    });

写入package.json文件

    await writeFileTree(context, {
      "package.json": JSON.stringify(pkg, null, 2),
    });

    // generate a .npmrc file for pnpm, to persist the
    // `shamefully-flatten` flag
    if (packageManager === "pnpm") {
      const pnpmConfig = hasPnpmVersionOrLater("4.0.0")
        ? // pnpm v7 makes breaking change to 
        // set strict-peer-dependencies=true by default,
        // which may cause some problems
        // when installing
          "shamefully-hoist=true\nstrict-peer-dependencies=false\n"
        : "shamefully-flatten=true\n";

      await writeFileTree(context, {
        ".npmrc": pnpmConfig,
      });
    }

初始化git仓库


    // intilaize git repository before installing deps
    // so that vue-cli-service can setup git hooks.
    
    const shouldInitGit = this.shouldInitGit(cliOptions);
    if (shouldInitGit) {
      log(`🗃  Initializing git repository...`);
      this.emit("creation", { event: "git-init" });
      await run("git init");
    }

安装插件

    // 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();
    }

Invoking generators

    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,
    });
    await generator.generate({
      extractConfigFiles: preset.useConfigFiles,
    });

install additional deps

    // install additional deps (injected by generators)
    log(`📦  Installing additional dependencies...`);
    this.emit("creation", { event: "deps-install" });
    log();
    if (!isTestOrDebug || process.env.VUE_CLI_TEST_DO_INSTALL_PLUGIN) {
      await pm.install();
    }

Running completion hooks

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

生成README.md

    if (!generator.files["README.md"]) {
      // generate README.md
      log();
      log("📄  Generating README.md...");
      await writeFileTree(context, {
        "README.md": generateReadme(generator.pkg, packageManager),
      });
    }

上传README.md到仓库

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

generator.printExitLogs();

  • cd [projectName]
  • npm || pnpm || yarn serve
    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();
  }

npm run serve

在项目成功初始化之后终端会出现

cd projectName 进入目录

npm serve 启动服务

package.json里面我们也可以看到,vue-cli-service启动serve的:

Vue源码解析系列(十八) -- Vue-cli的源码解读

vue-cli-service

#!/usr/bin/env node

const { semver, error } = require('@vue/cli-shared-utils')
const requiredVersion = require('../package.json').engines.node

...

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
    // FIXME: --no-module, --no-unsafe-inline, no-clean, etc.
    'modern',
    'report',
    'report-json',
    'inline-vue',
    'watch',
    // serve
    'open',
    'copy',
    'https',
    // inspect
    'verbose'
  ]
})
const command = args._[0]

// 调用service,run方法,启动服务,用catch来捕捉异常。
service.run(command, args, rawArgv).catch(err => {
  error(err)
  process.exit(1)
})

其中run方法接受三个参数。

  • command函数,表示为一个指令,比如servebuild等。
 async run(name, args = {}, rawArgv = []) {
    // serve {} ['serve']
    ...
    const { fn } = command;
    return fn(args, rawArgv); // return serve(args, rawArgs)
  }

这里的fn函数就是command函数命令,传入的是serve。 那么重头戏来了,vue-cli-serve通过动态注册api.registerCommand指令,注册serve方法,所以fn的执行就是serve的执行。

工具方法与config

const {
  info,
  error,
  hasProjectYarn,
  hasProjectPnpm,
  IpcMessenger,
} = require("@vue/cli-shared-utils"); // 工具方法

const getBaseUrl = require("../util/getBaseUrl"); // 获取基础路径

const defaults = {
  host: "0.0.0.0",
  port: 8080,
  https: false,
}; // 默认配置 端口,主机名 协议

serve.js

module.exports = (api, options) => {
  const baseUrl = getBaseUrl(options);
  
  // registerCommand(
  //      name: string, 
  //      opts: RegisterCommandOpts, 
  //      fn: RegisterCommandFn
  // ): void
  
  api.registerCommand(
    "serve",
    {
      description: "start development server",
      usage: "vue-cli-service serve [options] [entry]",
      options: {
        "--open": `open browser on server start`,
        "--copy": `copy url to clipboard on server start`,
        "--stdin": `close when stdin ends`,
        "--mode": `specify env mode (default: development)`,
        "--host": `specify host (default: ${defaults.host})`,
        "--port": `specify port (default: ${defaults.port})`,
        "--https": `use https (default: ${defaults.https})`,
        "--public": `specify the public network URL for the HMR client`,
        "--skip-plugins": `comma-separated list of 
        plugin names to skip for this run`,
      },
    },
    
    async function serve(args) {
        ...code
    }
  );
};

那我们紧接着来看一下serve方法干了什么事情。

serve函数

// serve.js
async function serve(args) {
      info("Starting development server..."); // 给个提示框:服务启动

      const isInContainer = checkInContainer(); // 容器检查
      const isProduction = process.env.NODE_ENV === "production"; // 生产环境判断
      const { chalk } = require("@vue/cli-shared-utils");
      
      // webpack webpack-dev-server相关
      // 可见vue-cli的本地服务,也是借助于webpack-dev-server实现的
      const webpack = require("webpack");
      const WebpackDevServer = require("webpack-dev-server");
      
      const portfinder = require("portfinder");
      const prepareURLs = require("../util/prepareURLs");
      const prepareProxy = require("../util/prepareProxy");
      const launchEditorMiddleware = require("launch-editor-middleware");
      const validateWebpackConfig = require("../util/validateWebpackConfig");
      const isAbsoluteUrl = require("../util/isAbsoluteUrl");

用chainWebpack注入webpack配置

      // configs that only matters for dev server
      api.chainWebpack((webpackConfig) => {
        if (
          process.env.NODE_ENV !== "production" &&
          process.env.NODE_ENV !== "test"
        ) {
          if (!webpackConfig.get("devtool")) {
            webpackConfig.devtool("eval-cheap-module-source-map");
          }

          // https://github.com/webpack/webpack/issues/6642
          // https://github.com/vuejs/vue-cli/issues/3539
          webpackConfig.output.globalObject(
            `(typeof self !== 'undefined' ? self : this)`
          );

          if (
            !process.env.VUE_CLI_TEST &&
            (!options.devServer.client ||
              options.devServer.client.progress !== false)
          ) {
            // the default progress plugin 
            // won't show progress due to infrastructreLogging.level
            webpackConfig
              .plugin("progress")
              .use(require("progress-webpack-plugin"));
          }
        }
      });

resolveWebpackConfig获取webpack配置

resolveWebpackConfig 函数传入了一个 chainableConfig 参数,这个参数是以 webpack-chain 形式注入的 webpack 配置,看下如何获取 webpack-chain 形式的 webpack 配置:

      // 解析webpack配置
      const webpackConfig = api.resolveWebpackConfig();

resolveWebpackConfig

resolveWebpackConfig (chainableConfig = this.resolveChainableWebpackConfig()) {
  if (!this.initialized) {
    throw new Error('Service must call init() before calling resolveWebpackConfig().')
  }
  // get raw config
  // './config/base', './config/css', './config/dev', './config/prod', './config/app' 这 5 个内置插件的主要作用
  // 就是完成 webpack 本地编译构建时的各种相关的配置,注意下这几个插件的顺序,因为他们都是利用 api.chainWebpack 进行 webpack 配置的,
  // 而配置又是可以覆盖的,因此如果拥有相同的配置,后面加载的插件的配置会覆盖前面的,但优先级最高的还是项目配置中的 webpack 配置,因为
  // vue.config.js 或者 package.vue 中的 webpack 配置是最后解析的。
  let config = chainableConfig.toConfig() // 导出 webpack 配置对象

  const original = config
  // apply raw config fns
  // raw 式配置,传入 webpackChain 的配置
  this.webpackRawConfigFns.forEach(fn => {
    if (typeof fn === 'function') {
      // function with optional return value
      const res = fn(config)
      if (res) config = merge(config, res)
    } else if (fn) {
      // merge literal values
      config = merge(config, fn)
    }
  })
  ...

  return config
}

resolveChainableWebpackConfig

resolveChainableWebpackConfig () {
  const chainableConfig = new Config()
  // apply chains
  this.webpackChainFns.forEach(fn => fn(chainableConfig))
  return chainableConfig
}
  • 这就是获取 webpack-chain 的配置,代码很简洁,遍历执行 Service 实例中的 webpackChainFns 方法,合并所有插件以及项目配置中的 webpack-chain 配置,并最终生成项目的 webpack-chain配置。

  • 获取了 webpack-chain 形式的配置后,接下来就获取 raw 式的 webpack 配置(普通的 webpack 配置形式,或者说原生的 webpack 配置)。获取方法就是 遍历执行 Service 实例中的 configureWebpack 方法,并与 webpack-chain 形式的配置合并,生成项目最终的 webpack 配置。

获取 devServer 配置

获取 devServer 配置指的是获取 webpack-dev-server 配置,主要有两个地方可以配置, 第一种就是直接在 webpack 中配置,另外一种就是在 vue.config.js 或者 package.vue 中配置,后者配置方式拥有更高地优先级。在获取用户配置的 devServer 以后,还会对这些配置进行解析,比如用户没有配置,会使用默认的 devServer 配置,另外 CLI 参数或者 process.env 中 devServer 拥有更高的优先级,以 devServer.port 为例, vue.config.js 如下:

// vue.config.js
module.exports = {
  devServer: {
    port: 8081
  },
  configureWebpack: {
    devServer: {
      port: 8082
    }
  }
}

用户自定义devServer与default devServer

const projectDevServerOptions = Object.assign(
    webpackConfig.devServer || {},
    options.devServer
);

处理一些配置

      // resolve server options
      const modesUseHttps = ["https", "http2"];
      const serversUseHttps = ["https", "spdy"];
      const optionsUseHttps =
        modesUseHttps.some((modeName) => !!projectDevServerOptions[modeName]) ||
        (typeof projectDevServerOptions.server === "string" &&
          serversUseHttps.includes(projectDevServerOptions.server)) ||
        (typeof projectDevServerOptions.server === "object" &&
          projectDevServerOptions.server !== null &&
          serversUseHttps.includes(projectDevServerOptions.server.type));
      const useHttps = args.https || optionsUseHttps || defaults.https;
      const protocol = useHttps ? "https" : "http";
      const host =
        args.host ||
        process.env.HOST ||
        projectDevServerOptions.host ||
        defaults.host;
      portfinder.basePort =
        args.port ||
        process.env.PORT ||
        projectDevServerOptions.port ||
        defaults.port;
      const port = await portfinder.getPortPromise();
      const rawPublicUrl = args.public || projectDevServerOptions.public;
      const publicUrl = rawPublicUrl
        ? /^[a-zA-Z]+:\/\//.test(rawPublicUrl)
          ? rawPublicUrl
          : `${protocol}://${rawPublicUrl}`
        : null;
      const publicHost = publicUrl
        ? /^[a-zA-Z]+:\/\/([^/?#]+)/.exec(publicUrl)[1]
        : undefined;

      const urls = prepareURLs(
        protocol,
        host,
        port,
        isAbsoluteUrl(baseUrl) ? "/" : baseUrl
      );
      const localUrlForBrowser = publicUrl || urls.localUrlForBrowser;

      const proxySettings = prepareProxy(
        projectDevServerOptions.proxy,
        api.resolve("public")
      );

注入 webpack-dev-server和 hot-reload 中间件入口

webpack-dev-server 和 hot-reload(HRM)中间件入口。在开发中我们利用 webpack-dev-server 提供一个小型 Express 服务器 ,从而可以为 webpack 打包生成的资源文件提供 web 服务,并用 webpack 自带的 HRM 模块实现热更新。

      let webSocketURL;
      if (!isProduction) {
        if (publicHost) {
          // explicitly configured via devServer.public
          webSocketURL = {
            protocol: protocol === "https" ? "wss" : "ws",
            hostname: publicHost,
            port,
          };
        } else if (isInContainer) {
          // can't infer public network url if inside a container
          // infer it from the browser instead
          webSocketURL = "auto://0.0.0.0:0/ws";
        } else {
          // otherwise infer the url from the config
          webSocketURL = {
            protocol: protocol === "https" ? "wss" : "ws",
            hostname: urls.lanUrlForConfig || "localhost",
            port,
          };
        }

        if (process.env.APPVEYOR) {
          webpackConfig.plugins.push(
            new webpack.EntryPlugin(__dirname, "webpack/hot/poll?500", {
              name: undefined,
            })
          );
        }
      }

创建 webpack-dev-server 实例

      const compiler = webpack(webpackConfig);

      // 处理失败的结果
      compiler.hooks.failed.tap("vue-cli-service serve", (msg) => {
        error(msg);
        process.exit(1);
      });

      // 创建 webpack-dev-server 实例
      const server = new WebpackDevServer(
        Object.assign(
          {
            historyApiFallback: {
              disableDotRule: true,
              htmlAcceptHeaders: ["text/html", "application/xhtml+xml"],
              rewrites: genHistoryApiFallbackRewrites(baseUrl, options.pages),
            },
            hot: !isProduction, // 开发环境下的热更新
          },
          projectDevServerOptions, // devServer的配置
          {
            host, // 主机
            port, // 端口

            server: {
              type: protocol,
              ...(typeof projectDevServerOptions.server === "object"
                ? projectDevServerOptions.server
                : {}),
            },

            proxy: proxySettings, // 代理

            static: { // 静态资源处理
              directory: api.resolve("public"),
              publicPath: options.publicPath,
              watch: !isProduction,

              ...projectDevServerOptions.static,
            },

            client: { // 客户端
              webSocketURL,
              ...
            },
            ...
            open: args.open || projectDevServerOptions.open, // 在浏览器中打开
            setupExitSignals: true,
            ...
            },
          }
        ),
        compiler
      );
      ...
        
        // 服务启动
        server.start().catch((err) => reject(err));
      });
    }

完成上述这些操作后,整个项目就运行起来了。

npm run build

npm run build 跟上面一样,都是走的vue-cli-serve文件入口,执行fn函数,只不过这里的fn函数是执行build函数。

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

  //动态注册build指令
  api.registerCommand('build', {
    description: 'build for production',
    usage: 'vue-cli-service build [options] [entry|pattern]',
    options: {
      '--mode': `specify env mode (default: production)`,
      '--dest': `specify output directory (default: ${options.outputDir})`,
      '--no-module': `build app without generating <script type="module"> chunks for modern browsers`,
      '--target': `app | lib | wc | wc-async (default: ${defaults.target})`,
      '--inline-vue': 'include the Vue module in the final bundle of library or web component target',
      '--formats': `list of output formats for library builds (default: ${defaults.formats})`,
      '--name': `name for lib or web-component mode (default: "name" in package.json or entry filename)`,
      '--filename': `file name for output, only usable for 'lib' target (default: value of --name)`,
      '--no-clean': `do not remove the dist directory contents before building the project`,
      '--report': `generate report.html to help analyze bundle content`,
      '--report-json': 'generate report.json to help analyze bundle content',
      '--skip-plugins': `comma-separated list of plugin names to skip for this run`,
      '--watch': `watch for changes`,
      '--stdin': `close when stdin ends`
    }
  }, 
  
  // 绑定build函数
  async (args, rawArgs) => {
    for (const key in defaults) {
      if (args[key] == null) {
        args[key] = defaults[key]
      }
    }
    // 获取入口
    args.entry = args.entry || args._[0]
    if (args.target !== 'app') {
      args.entry = args.entry || 'src/App.vue'
    }
    process.env.VUE_CLI_BUILD_TARGET = args.target
    ...

    args.needsDifferentialLoading = needsDifferentialLoading
    if (!needsDifferentialLoading) {
      // 文件更改,重新build  
      await build(args, api, options)
      return
    }

    process.env.VUE_CLI_MODERN_MODE = true
    if (!process.env.VUE_CLI_MODERN_BUILD) {
      ...
      await build(legacyBuildArgs, api, options)
      ...
    } else {
      ...
      await build(moduleBuildArgs, api, options)
    }
  })
}

build

async function build (args, api, options) {
  
  // 引入包
  const fs = require('fs-extra')
  const path = require('path')
  const webpack = require('webpack')
  const { chalk } = require('@vue/cli-shared-utils')
  const formatStats = require('./formatStats')
  const validateWebpackConfig = require('../../util/validateWebpackConfig')
  const {
    log,
    done,
    info,
    logWithSpinner,
    stopSpinner
  } = require('@vue/cli-shared-utils')

  log()
  
  // mode配置项
  const mode = api.service.mode
  
  // 目标终端
  if (args.target === 'app') {
    const bundleTag = args.needsDifferentialLoading
      ? args.moduleBuild
        ? `module bundle `
        : `legacy bundle `
      : ``
    logWithSpinner(`Building ${bundleTag}for ${mode}...`)
  } else {
    const buildMode = buildModes[args.target]
    if (buildMode) {
      const additionalParams = buildMode === 'library' ? ` (${args.formats})` : ``
      logWithSpinner(`Building for ${mode} as ${buildMode}${additionalParams}...`)
    } else {
      throw new Error(`Unknown build target: ${args.target}`)
    }
  }

  if (args.dest) {
    // Override outputDir before resolving webpack config as config relies on it (#2327)
    options.outputDir = args.dest
  }

  const targetDir = api.resolve(options.outputDir)
  const isLegacyBuild = args.needsDifferentialLoading && !args.moduleBuild

获取webpack配置

  // resolve raw webpack config
  let webpackConfig
  if (args.target === 'lib') {
    webpackConfig = require('./resolveLibConfig')(api, args, options)
  } else if (
    args.target === 'wc' ||
    args.target === 'wc-async'
  ) {
    webpackConfig = require('./resolveWcConfig')(api, args, options)
  } else {
    webpackConfig = require('./resolveAppConfig')(api, args, options)
  }

  // check for common config errors
  validateWebpackConfig(webpackConfig, api, options, args.target)

  // watch监听
  if (args.watch) {
    modifyConfig(webpackConfig, config => {
      config.watch = true
    })
  }

  if (args.stdin) {
    process.stdin.on('end', () => {
      process.exit(0)
    })
    process.stdin.resume()
  }

  // 打包结果分析
  if (args.report || args['report-json']) {
    const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
    modifyConfig(webpackConfig, config => {
      const bundleName = args.target !== 'app'
        ? config.output.filename.replace(/\.js$/, '-')
        : isLegacyBuild ? 'legacy-' : ''
      config.plugins.push(new BundleAnalyzerPlugin({
        logLevel: 'warn',
        openAnalyzer: false,
        analyzerMode: args.report ? 'static' : 'disabled',
        reportFilename: `${bundleName}report.html`,
        statsFilename: `${bundleName}report.json`,
        generateStatsFile: !!args['report-json']
      }))
    })
  }
  
  // 清空上一次打包
  if (args.clean) {
    await fs.emptyDir(targetDir)
  }

  return new Promise((resolve, reject) => {
    webpack(webpackConfig, (err, stats) => {
      stopSpinner(false)
      if (err) {
        return reject(err)
      }
      
      // 失败捕获
      if (stats.hasErrors()) {
        return reject(new Error('Build failed with errors.'))
      }

        log(formatStats(stats, targetDirShort, api))
        if (args.target === 'app' && !isLegacyBuild) {
          if (!args.watch) {
            done(`Build complete. The ${chalk.cyan(targetDirShort)} directory 
            is ready to be deployed.`)
            info(`Check out deployment 
            instructions at ${chalk.cyan(`https://cli.vuejs.org/guide/
            deployment.html`)}\n`)
          } else {
            // 打包结束
            done(`Build complete. Watching for changes...`)
          }
        }
      }

      // test-only signal
      if (process.env.VUE_CLI_TEST) {
        console.log('Build complete.')
      }

      resolve()
    })
  })
}

总结

vue-cli是我们在快速开发的时候一大利器,官方提供了多种预设,不需要我们去配置各种依赖,是非常方便的。本文讲解了vue-cli如何去构建一个项目,他的vue-cli-serve如何去启动一个本地服务,和怎么打包构建。通过学习了本篇,我相信各位能够在vue-cli的基础上,去添加一些实际项目中所需要的配置包,更加快速开发。