手把手实现新版@vue/cli脚手架及其插件系统

830 阅读9分钟

1.准备工作

1.1 monorepo

  • monoRepo: 是将所有的模块统一的放在一个主干分支之中管理。
  • multiRepo: 将项目分化成为多个模块,并针对每一个模块单独的开辟一个Repo来进行管理。

1.jpg

1.2 Lerna

  • Lerna是一个管理多个 npm 模块的工具,优化维护多包的工作流,解决多个包互相依赖,且发布需要手动维护多个包的问题

1.2.1 安装

npm i lerna -g

1.2.2 初始化

lerna init
命令功能
lerna bootstrap安装依赖
lerna clean删除各个包下的node_modules
lerna init创建新的lerna库
lerna list查看本地包列表
lerna changed显示自上次release tag以来有修改的包, 选项通 list
lerna diff显示自上次release tag以来有修改的包的差异, 执行 git diff
lerna exec在每个包目录下执行任意命令
lerna run执行每个包package.json中的脚本命令
lerna add添加一个包的版本为各个包的依赖
lerna import引入package
lerna link链接互相引用的库
lerna create新建package
lerna publish发布

1.2.3 文件

1.2.3.1 package.json
{
  "name": "root",
  "private": true,
  "devDependencies": {
    "lerna": "^4.0.0"
  }
}
1.2.3.2 lerna.json
{
  "packages": [
    "packages/*"
  ],
  "version": "0.0.0"
}
1.2.3.3 .gitignore
node_modules
.DS_Store
design
*.log
packages/test
dist
temp
.vuerc
.version
.versions
.changelog

1.2.4 yarn workspace

  • yarn workspace 允许我们使用 monorepo 的形式来管理项目
  • 在安装 node_modules 的时候它不会安装到每个子项目的 node_modules 里面,而是直接安装到根目录下面,这样每个子项目都可以读取到根目录的 node_modules
  • 整个项目只有根目录下面会有一份 yarn.lock 文件。子项目也会被 linknode_modules 里面,这样就允许我们就可以直接用 import 导入对应的项目
  • yarn.lock 文件是自动生成的,也完全 Yarn 来处理 yarn.lock 锁定你安装的每个依赖项的版本,这可以确保你不会意外获得不良依赖
1.2.4.1 package.json

package.json

{
  "name": "root",
  "private": true,
+  "workspaces": [
+    "packages/*"
+  ],
  "devDependencies": {
    "lerna": "^4.0.0"
  }
}
1.2.4.2 lerna.json

lerna.json

{
  "packages": [
    "packages/*"
  ],
  "version": "1.0.0",
+ "useWorkspaces": true,
+ "npmClient": "yarn"
}
1.2.4.3 添加依赖

设置加速镜像

yarn config set registry http://registry.npm.taobao.org
npm config set registry https://registry.npm.taobao.org
作用命令
查看工作空间信息yarn workspaces info
给根空间添加依赖yarn add chalk cross-spawn fs-extra --ignore-workspace-root-check
给某个项目添加依赖yarn workspace create-react-app3 add commander
删除所有的 node_moduleslerna clean 等于 yarn workspaces run clean
安装和linkyarn install 等于 lerna bootstrap --npm-client yarn --use-workspaces
重新获取所有的 node_modulesyarn install --force
查看缓存目录yarn cache dir
清除本地缓存yarn cache clean

1.2.5 创建子项目

lerna create james-cli
lerna create james-cli-shared-utils
1.2.5.1 james-cli
1.2.5.1.1 package.json

packages\james-cli\bin\package.json

{
  "name": "james-cli",
  "version": "0.0.0",
  "description": "james-cli",
  "keywords": [
    "james-cli"
  ],
  "author": "james <1204788939@qq.com>",
  "homepage": "https://github.com/GolderBrother/lerna-demo#readme",
  "license": "MIT",
  "main": "bin/vue.js",
  "directories": {
    "lib": "lib",
    "test": "__tests__"
  },
  "files": [
    "lib"
  ],
  "publishConfig": {
    "registry": "https://registry.npm.taobao.org/"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/GolderBrother/lerna-demo.git"
  },
  "scripts": {
    "test": "echo \"Error: run tests from root\" && exit 1"
  },
  "bugs": {
    "url": "https://github.com/GolderBrother/lerna-demo/issues"
  }
}

1.2.5.1.2 vue.js

packages\james-cli\bin\vue.js

#!/usr/bin/env node
console.log('vue cli');
1.2.5.2 james-cli-shared-utils
1.2.5.2.1 package.json

packages\james-cli-shared-utils\package.json

{
  "name": "james-cli-shared-utils",
  "version": "0.0.0",
  "description": " james-cli-shared-utils",
  "keywords": [
    "james-cli-shared-utils"
  ],
  "author": "james <1204788939@qq.com>",
  "homepage": "https://github.com/GolderBrother/james-cli-shared-utils#readme",
  "license": "MIT",
  "main": "index.js",
  "directories": {
    "lib": "lib",
    "test": "__tests__"
  },
  "files": [
    "lib"
  ],
  "publishConfig": {
    "registry": "https://registry.npm.taobao.org/"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/GolderBrother/james-cli-shared-utils.git"
  },
  "scripts": {
    "test": "echo \"Error: run tests from root\" && exit 1"
  },
  "bugs": {
    "url": "https://github.com/GolderBrother/james-cli-shared-utils/issues"
  }
}

1.2.5.2.2 index.js

packages\james-cli-shared-utils\index.js

console.log('james-cli-shared-utils');

1.2.6 创建软链接

yarn
cd packages/james-cli
npm link
npm root -g
james-cli

1.2.7 create 命令

{
  "name": "root",
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  "devDependencies": {
    "lerna": "^4.0.0"
  },
  "scripts": {
+   "create": "node ./packages/james-cli/bin/vue.js create hello1"
  }
}

1.2.8 调试命令

使用 vscode 创建一个 debugger 调试器

.vscode/launch.json

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "vue-cli",
            "cwd":"${workspaceFolder}",
            "runtimeExecutable": "npm",
            "runtimeArgs": [
                "run",
                "create"
            ],
            "port":9229,
            "autoAttachChildProcesses": true,
            "stopOnEntry": true,
            "skipFiles": [
                "<node_internals>/**"
            ]
        }
    ]
}

1.3 安装依赖

到两个 package 分别安装下依赖,会自动安装到根目录下的 node_modules

npm config set registry=https://registry.npm.taobao.org
yarn config set registry https://registry.npm.taobao.org

cd packages/james-cli-shared-utils
yarn workspace james-cli-shared-utils add  chalk execa

cd packages/james-cli
yarn workspace james-cli add  james-cli-shared-utils commander inquirer execa chalk ejs globby  lodash.clonedeep fs-extra ora isbinaryfile

1.4 lerna vs yarn

  • 两者很多功能是等价的
  • yarn用来处理依赖,lerna用于初始化和发布

1.5 commander.js

  • commander 是一款强大的命令行框架,提供了用户命令行输入和参数解析功能

安装

npm install commander -D
#!/usr/bin/env node
const program = require('commander');
program
    .version(`james-cli 0.0.0}`)
    .usage('<command> [options]')

program
    .command('create <app-name>')
    .description('create a new project powered by vue-cli-service')
    .action((name) => {
        console.log(name);
    })

program.parse(process.argv)
james-cli                             
Usage: james-cli <command> [options]

Options:
  -V, --version      output the version number
  -h, --help         display help for command

Commands:
  create <app-name>  create a new project powered by vue-cli-service
  help [command]     display help for command

node 1.2.commander.js create hello  

1.6 Inquirer.js

  • Inquirer是一个交互式命令行工具
const inquirer = require('inquirer')
const isManualMode = answers => answers.preset === '__manual__';
const defaultPreset = {
    useConfigFiles: false,
    cssPreprocessor: undefined,
    plugins: {
        '@vue/cli-plugin-babel': {},
        '@vue/cli-plugin-eslint': {
            config: 'base',
            lintOn: ['save']
        }
    }
}
const presets = {
    'default': Object.assign({ vueVersion: '2' }, defaultPreset),
    '__default_vue_3__': Object.assign({ vueVersion: '3' }, defaultPreset)
}
const presetChoices = Object.entries(presets).map(([name, preset]) => {
    let displayName = name
    if (name === 'default') {
        displayName = 'Default'
    } else if (name === '__default_vue_3__') {
        displayName = 'Default (Vue 3)'
    }
    return {
        name: `${displayName}`,
        value: name
    }
})
const presetPrompt = {
    name: 'preset',
    type: 'list',
    message: `Please pick a preset:`,
    choices: [
        ...presetChoices,
        {
            name: 'Manually select features',
            value: '__manual__'
        }
    ]
}
let features = [
    'vueVersion',
    'babel',
    'typescript',
    'pwa',
    'router',
    'vuex',
    'cssPreprocessors',
    'linter',
    'unit',
    'e2e'
];
const featurePrompt = {
    name: 'features',
    when: isManualMode,
    type: 'checkbox',
    message: 'Check the features needed for your project:',
    choices: features,
    pageSize: 10
}
const prompts = [
    presetPrompt,
    featurePrompt
]

;(async function(){
 let result = await inquirer.prompt(prompts);
 console.log(result);
})();

1.7 execa

  • execa 是可以调用 shell 和本地外部程序
  • 它会启动子进程执行,是对child_process.exec的封装
const execa = require('execa');

(async () => {
    const {stdout} = await execa('echo', ['hello']);
    console.log(stdout);
})();

1.8 chalk

  • chalk可以修改控制台字符串的样式,包括字体样式、颜色以及背景颜色等
const chalk = require('chalk');
console.log(chalk.blue('Hello world!'));

1.9 ejs

  • ejs是高效的嵌入式 JavaScript 模板引擎
  • slashWindows 系统的反斜杠路径转换为斜杠路径,如foo\\barfoo/bar
  • globby是用于模式匹配目录文件的

1.9.1 main.js

template/main.js

<%_ if (rootOptions.vueVersion === '3') { _%>
  import { createApp } from 'vue'
  import App from './App.vue'
  createApp(App).mount('#app')
<%_ } else { _%>
  import Vue from 'vue'
  import App from './App.vue'
  Vue.config.productionTip = false
  new Vue({
    render: h => h(App),
  }).$mount('#app')
<%_ } _%>

1.9.2 components

doc\template\components

<template>
  <h1>HelloWorld</h1>
</template>

<script>
export default {
  name: 'HelloWorld'
}
</script>

1.9.3 ejs.js

doc/1.7.ejs.js

const path = require('path');
const fs = require('fs');
const ejs = require('ejs');
const globby = require('globby')
const slash = require('slash')
let source = path.join(__dirname, 'template');
;(async function () {
    const _files = await globby(['**/*'], { cwd: source })
    let files = {};
    for (const rawPath of _files) {
        const sourcePath = slash(path.resolve(source, rawPath))
        const template = fs.readFileSync(sourcePath, 'utf8')
        const content = ejs.render(template, {
            rootOptions: { vueVersion: '2' }
        })
        files[sourcePath] = content;
    }
    console.log(files);
})();

1.10 isbinaryfile

  • isbinaryfile可以检测一个文件是否是二进制文件
const path = require('path');
const { isBinaryFileSync } = require('isbinaryfile');
let logo = path.join(__dirname,'template/assets/logo.png');
let isBinary = isBinaryFileSync(logo);
console.log(isBinary);
let main = path.join(__dirname,'template/main.js');
isBinary = isBinaryFileSync(main);
console.log(isBinary);

1.11 ora

  • ora主要用来实现node.js命令行环境的 loading 效果,和显示各种状态的图标等
const ora = require('ora')
const spinner = ora()

exports.logWithSpinner = (msg) => {
    spinner.text = msg
    spinner.start();
}

exports.stopSpinner = () => {
    spinner.stop();
}

exports.logWithSpinner('npm install');
setTimeout(()=>{
    exports.stopSpinner();
},3000);

2.核心概念

  • @vue/cli是一个基于 Vue.js 进行快速开发的完整系统

2.1 插件

  • 插件
  • Vue CLI 使用了一套基于插件的架构。如果你查阅一个新创建项目的 package.json,就会发现依赖都是以 @vue/cli-plugin- 开头的。插件可以修改 webpack 的内部配置,也可以向 vue-cli-service 注入命令。在项目创建的过程中,绝大部分列出的特性都是通过插件来实现的
  • 每个 CLI 插件都会包含一个 (用来创建文件的) 生成器和一个 (用来调整 webpack 核心配置和注入命令的) 运行时插件
  • 官方插件格式@vue/cli-plugin-eslint,社区插件vue-cli-plugin-apollo,指定的 scope 使用第三方插件@foo/vue-cli-plugin-bar

2.2 预设

  • 一个 Vue CLI preset 是一个包含创建新项目所需预定义选项和插件的 JSON 对象,让用户无需在命令提示中选择它们
  • vue create 过程中保存的 preset 会被放在你的 home 目录下的一个配置文件中 (~/.vuerc)。你可以通过直接编辑这个文件来调整、添加、删除保存好的 preset
  • Preset 的数据会被插件生成器用来生成相应的项目文件
exports.defaultPreset = {
  useConfigFiles: false,
  cssPreprocessor: undefined,
  plugins: {
    '@vue/cli-plugin-babel': {},
    '@vue/cli-plugin-eslint': {
      config: 'base',
      lintOn: ['save']
    }
  }
}

2.3 特性

  • 在手工模式下,我们可以自由选择以下特性
    • vueVersion
    • babel
    • typescript
    • pwa
    • router
    • vuex
    • cssPreprocessors
    • linter
    • unit
    • e2e
  • 选择不同的特性会添加不同的插件,不同的插件就会生成不同的文件和修改项目的配置

2.4 create

我们来看下 create 整个的流程

2.png

3.参数解析

3.1 vue.js

packages/james-cli/bin/vue.js

#!/usr/bin/env node
const program = require('commander');
program
    .version(`@vue/james-cli ${require('../package').version}`)
    .usage('<command> [options]')

program
    .command('create <app-name>')
    .description('create a new project powered by vue-cli-service')
    .action((name) => {
        require('../lib/create')(name)
    })

program.parse(process.argv)

3.2 create.js

packages\james-cli\lib\create.js

const path = require('path');
async function create(projectName, options) {
    const cwd = process.cwd();
    const name = projectName;
    const targetDir = path.resolve(cwd, projectName);
    console.log(name);
    console.log(targetDir);
}

module.exports = (...args) => {
    return create(...args).catch(err => console.log(err));
}

4.获取预设

4.1 create.js

packages/james-cli/lib/create.js

const path = require('path');
+const Creator = require('./Creator');
+const { getPromptModules } = require('./util/createTools')
async function create(projectName) {
  const cwd = process.cwd();
  const name = projectName;
  const targetDir = path.resolve(cwd, projectName);
+ const promptModules = getPromptModules();
+ const creator = new Creator(name, targetDir,promptModules);
+ await creator.create();
}

module.exports = (...args) => {
  return create(...args).catch(err => console.log(err));
}

4.2 options.js

packages/james-cli/lib/options.js

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

exports.defaults = {
  presets: {
    'default': Object.assign({ vueVersion: '2' }, exports.defaultPreset),
    '__default_vue_3__': Object.assign({ vueVersion: '3' }, exports.defaultPreset)
  }
}

4.3 PromptModuleAPI.js

packages/james-cli/lib/PromptModuleAPI.js

class PromptModuleAPI {
  constructor(creator) {
    this.creator = creator;
  }
  injectFeature(feature) {
    this.creator.featurePrompt.choices.push(feature);
  }

  injectPrompt(prompt) {
    this.creator.injectedPrompts.push(prompt);
  }

  onPromptComplete(cb) {
    this.creator.promptCompleteCbs.push(cb);
  }
}
module.exports = PromptModuleAPI;

4.4 createTools.js

packages/james-cli/lib/util/createTools.js

const getPromptModules = () => {
  const files = ['vueVersion'];
  return files.map((file) => require(`../promptModules/${file}`));
};
module.exports = {
  getPromptModules,
};

4.5 vueVersion.js

packages/james-cli/lib/promptModules/vueVersion.js

module.exports = (cli) => {
  //cli.injectFeature 是注入 featurePrompt,即初始化项目时选择 babel,typescript,pwa 等等
  cli.injectFeature({
    name: 'Choose Vue version',
    value: 'vueVersion',
    description: 'Choose a version of Vue.js that you want to start the project with',
    checked: true,
  });
  //cli.injectPrompt 是根据选择的 featurePrompt 然后注入对应的 prompt,当选择了 unit,接下来会有以下的 prompt,选择 Mocha + Chai 还是 Jest
  cli.injectPrompt({
    name: 'vueVersion',
    when: (answers) => answers.features.includes('vueVersion'),
    message: 'Choose a version of Vue.js that you want to start the project with',
    type: 'list',
    choices: [
      {
        name: '2.x',
        value: '2',
      },
      {
        name: '3.x',
        value: '3',
      },
    ],
    default: '2',
  });
  //cli.onPromptComplete 就是一个回调,会根据选择来添加对应的插件, 当选择了 mocha ,那么就会添加 @vue/cli-plugin-unit-mocha 插件
  cli.onPromptComplete((answers, options) => {
    if (answers.vueVersion) {
      options.vueVersion = answers.vueVersion;
    }
  });
};

4.6 Creator.js

packages/james-cli/lib/Creator.js

const { defaults } = require('./options');
const PromptModuleAPI = require('./PromptModuleAPI');
const inquirer = require('inquirer');
const isManualMode = (answers) => answers.preset === '__manual__';
class Creator {
  constructor(name, context, promptModules) {
    this.name = name;
    this.context = process.env.VUE_CLI_CONTEXT = context;
    const { presetPrompt, featurePrompt } = this.resolveIntroPrompts();
    this.presetPrompt = presetPrompt;
    this.featurePrompt = featurePrompt;
    this.injectedPrompts = [];
    this.promptCompleteCbs = [];
    const promptAPI = new PromptModuleAPI(this);
    promptModules.forEach((m) => m(promptAPI));
  }
  async create() {
    let preset = await this.promptAndResolvePreset();
    console.log('preset', preset);
  }
  resolveFinalPrompts() {
    this.injectedPrompts.forEach((prompt) => {
      const originalWhen = prompt.when || (() => true);
      prompt.when = (answers) => {
        return isManualMode(answers) && originalWhen(answers);
      };
    });
    const prompts = [this.presetPrompt, this.featurePrompt, ...this.injectedPrompts];
    return prompts;
  }
  async promptAndResolvePreset(answers = null) {
    if (!answers) {
      answers = await inquirer.prompt(this.resolveFinalPrompts());
    }
    let preset;
    if (answers.preset && answers.preset !== '__manual__') {
      preset = await this.resolvePreset(answers.preset);
    } else {
      preset = {
        plugins: {},
      };
      answers.features = answers.features || [];
      this.promptCompleteCbs.forEach((cb) => cb(answers, preset));
    }
    return preset;
  }
  async resolvePreset(name) {
    const savedPresets = this.getPresets();
    return savedPresets[name];
  }
  getPresets() {
    return Object.assign({}, defaults.presets);
  }
  resolveIntroPrompts() {
    const presets = this.getPresets();
    const presetChoices = Object.entries(presets).map(([name]) => {
      let displayName = name;
      if (name === 'default') {
        displayName = 'Default';
      } else if (name === '__default_vue_3__') {
        displayName = 'Default (Vue 3)';
      }
      return {
        name: `${displayName}`,
        value: name,
      };
    });
    const presetPrompt = {
      name: 'preset',
      type: 'list',
      message: `Please pick a preset:`,
      choices: [
        ...presetChoices,
        {
          name: 'Manually select features',
          value: '__manual__',
        },
      ],
    };
    const featurePrompt = {
      name: 'features',
      when: isManualMode,
      type: 'checkbox',
      message: 'Check the features needed for your project:',
      choices: [],
      pageSize: 10,
    };
    return {
      presetPrompt,
      featurePrompt,
    };
  }
}
module.exports = Creator;

5.写入package.json

5.1 cli-shared-utils\index.js

packages/james-cli-shared-utils/index.js

exports.chalk = require('chalk')

5.2 Creator.js

packages/james-cli/lib/Creator.js

const { defaults } = require('./options');
const PromptModuleAPI = require('./PromptModuleAPI');
const inquirer = require('inquirer')
+const cloneDeep = require('lodash.clonedeep')
+const writeFileTree = require('./util/writeFileTree')
+const { chalk } = require('james-cli-shared-utils')
const isManualMode = answers => answers.preset === '__manual__'
class Creator {
    constructor(name, context, promptModules) {
        this.name = name;
        this.context = process.env.VUE_CLI_CONTEXT = context;
        const { presetPrompt, featurePrompt } = this.resolveIntroPrompts();
        this.presetPrompt = presetPrompt;
        this.featurePrompt = featurePrompt;
        this.injectedPrompts = []
        this.promptCompleteCbs = []
        const promptAPI = new PromptModuleAPI(this)
        promptModules.forEach(m => m(promptAPI))
    }
    async create() {
+       const {name,context} = this;
        let preset = await this.promptAndResolvePreset()
        console.log('preset', preset);
+       preset = cloneDeep(preset);
+       preset.plugins['@vue/cli-service'] = Object.assign({projectName: name}, preset);
+       console.log(`✨  Creating project in ${chalk.yellow(context)}.`)
+       const pkg = {
+           name,
+           version: '0.1.0',
+           private: true,
+           devDependencies: {}
+       }
+       const deps = Object.keys(preset.plugins)
+       deps.forEach(dep => {
+           pkg.devDependencies[dep] = 'latest';
+       })
+       await writeFileTree(context, {
+           'package.json': JSON.stringify(pkg, null, 2)
+       })
    }
    resolveFinalPrompts() {
        this.injectedPrompts.forEach(prompt => {
            const originalWhen = prompt.when || (() => true)
            prompt.when = answers => {
                return isManualMode(answers) && originalWhen(answers)
            }
        })
        const prompts = [
            this.presetPrompt,
            this.featurePrompt,
            ...this.injectedPrompts,
        ]
        return prompts
    }
    async promptAndResolvePreset(answers = null) {
        if (!answers) {
            answers = await inquirer.prompt(this.resolveFinalPrompts())
        }
        let preset;
        if (answers.preset && answers.preset !== '__manual__') {
            preset = await this.resolvePreset(answers.preset)
        } else {
            preset = {
                plugins: {}
            }
            answers.features = answers.features || []
            this.promptCompleteCbs.forEach(cb => cb(answers, preset))
        }
        return preset
    }
    async resolvePreset (name) {
        const savedPresets = this.getPresets()
        return savedPresets[name];
    }
    getPresets() {
        return Object.assign({}, defaults.presets)
    }
    resolveIntroPrompts() {
        const presets = this.getPresets()
        const presetChoices = Object.entries(presets).map(([name]) => {
            let displayName = name
            if (name === 'default') {
                displayName = 'Default'
            } else if (name === '__default_vue_3__') {
                displayName = 'Default (Vue 3)'
            }
            return {
                name: `${displayName}`,
                value: name
            }
        })
        const presetPrompt = {
            name: 'preset',
            type: 'list',
            message: `Please pick a preset:`,
            choices: [
                ...presetChoices,
                {
                    name: 'Manually select features',
                    value: '__manual__'
                }
            ]
        }
        const featurePrompt = {
            name: 'features',
            when: isManualMode,
            type: 'checkbox',
            message: 'Check the features needed for your project:',
            choices: [],
            pageSize: 10
        }
        return {
            presetPrompt,
            featurePrompt
        }
    }
}


module.exports = Creator;

5.3 writeFileTree.js

packages\james-cli\lib\util\writeFileTree.js

const fs = require('fs-extra');
const path = require('path');
async function writeFileTree(dir, files) {
  Object.entries(files).forEach(([filename, value]) => {
    const filePath = path.join(dir, filename);
    // 确保目录的存在。如果目录结构不存在,就创建一个
    fs.ensureDirSync(path.dirname(filePath));
    fs.writeFileSync(filePath, value);
  });
}
module.exports = writeFileTree;

6.安装依赖

6.1 Creator.js

packages/james-cli/lib/Creator.js

const { defaults } = require('./options');
const PromptModuleAPI = require('./PromptModuleAPI');
const inquirer = require('inquirer')
const cloneDeep = require('lodash.clonedeep')
const writeFileTree = require('./util/writeFileTree')
+const { chalk, execa } = require('james-cli-shared-utils')
const isManualMode = answers => answers.preset === '__manual__'
class Creator {
    constructor(name, context, promptModules) {
        this.name = name;
        this.context = process.env.VUE_CLI_CONTEXT = context;
        const { presetPrompt, featurePrompt } = this.resolveIntroPrompts();
        this.presetPrompt = presetPrompt;
        this.featurePrompt = featurePrompt;
        this.injectedPrompts = []
        this.promptCompleteCbs = []
+       this.run = this.run.bind(this)//运行函数
        const promptAPI = new PromptModuleAPI(this)
        promptModules.forEach(m => m(promptAPI))
    }
+   run(command, args) {
+       return execa(command, args, { cwd: this.context })
+   }
    async create() {
+       const {name,context,run} = this;
        let preset = await this.promptAndResolvePreset()
        console.log('preset', preset);
        preset = cloneDeep(preset);
        preset.plugins['@vue/cli-service'] = Object.assign({projectName: name}, preset);
        console.log(`✨  Creating project in ${chalk.yellow(context)}.`)
        const pkg = {
            name,
            version: '0.1.0',
            private: true,
            devDependencies: {}
        }
        const deps = Object.keys(preset.plugins)
        deps.forEach(dep => {
            pkg.devDependencies[dep] = 'latest';
        })
        await writeFileTree(context, {
            'package.json': JSON.stringify(pkg, null, 2)
        })
+       console.log(`🗃  Initializing git repository...`)
+       await run('git init');
+       console.log(`⚙\u{fe0f} Installing CLI plugins. This might take a while...`)
+       await run('npm install');
    }
    resolveFinalPrompts() {
        this.injectedPrompts.forEach(prompt => {
            const originalWhen = prompt.when || (() => true)
            prompt.when = answers => {
                return isManualMode(answers) && originalWhen(answers)
            }
        })
        const prompts = [
            this.presetPrompt,
            this.featurePrompt,
            ...this.injectedPrompts,
        ]
        return prompts
    }
    async promptAndResolvePreset(answers = null) {
        if (!answers) {
            answers = await inquirer.prompt(this.resolveFinalPrompts())
        }
        let preset;
        if (answers.preset && answers.preset !== '__manual__') {
            preset = await this.resolvePreset(answers.preset)
        } else {
            preset = {
                plugins: {}
            }
            answers.features = answers.features || []
            this.promptCompleteCbs.forEach(cb => cb(answers, preset))
        }
        return preset
    }
    async resolvePreset (name) {
        const savedPresets = this.getPresets()
        return savedPresets[name];
    }
    getPresets() {
        return Object.assign({}, defaults.presets)
    }
    resolveIntroPrompts() {
        const presets = this.getPresets()
        const presetChoices = Object.entries(presets).map(([name]) => {
            let displayName = name
            if (name === 'default') {
                displayName = 'Default'
            } else if (name === '__default_vue_3__') {
                displayName = 'Default (Vue 3)'
            }
            return {
                name: `${displayName}`,
                value: name
            }
        })
        const presetPrompt = {
            name: 'preset',
            type: 'list',
            message: `Please pick a preset:`,
            choices: [
                ...presetChoices,
                {
                    name: 'Manually select features',
                    value: '__manual__'
                }
            ]
        }
        const featurePrompt = {
            name: 'features',
            when: isManualMode,
            type: 'checkbox',
            message: 'Check the features needed for your project:',
            choices: [],
            pageSize: 10
        }
        return {
            presetPrompt,
            featurePrompt
        }
    }
}

module.exports = Creator;

7.实现插件机制

packages/james-cli/lib/Creator.js

3.png

7.1 Creator.js

packages/james-cli/lib/Creator.js

const { defaults } = require('./options');
const PromptModuleAPI = require('./PromptModuleAPI');
const inquirer = require('inquirer')
const cloneDeep = require('lodash.clonedeep')
const writeFileTree = require('./util/writeFileTree')
+const { chalk, execa,loadModule } = require('james-cli-shared-utils')
+const Generator = require('./Generator')
const isManualMode = answers => answers.preset === '__manual__'
class Creator {
    constructor(name, context, promptModules) {
        this.name = name;
        this.context = process.env.VUE_CLI_CONTEXT = context;
        const { presetPrompt, featurePrompt } = this.resolveIntroPrompts();
        this.presetPrompt = presetPrompt;
        this.featurePrompt = featurePrompt;
        this.injectedPrompts = []
        this.promptCompleteCbs = []
        this.run = this.run.bind(this)//运行函数
        const promptAPI = new PromptModuleAPI(this)
        promptModules.forEach(m => m(promptAPI))
    }
    run(command, args) {
        return execa(command, args, { cwd: this.context })
    }
    async create() {
        const {name,context,run} = this;
        let preset = await this.promptAndResolvePreset()
        console.log('preset', preset);
        preset = cloneDeep(preset);
        preset.plugins['@vue/cli-service'] = Object.assign({projectName: name}, preset);
        console.log(`✨  Creating project in ${chalk.yellow(context)}.`)
        const pkg = {
            name,
            version: '0.1.0',
            private: true,
            devDependencies: {}
        }
        const deps = Object.keys(preset.plugins)
        deps.forEach(dep => {
            pkg.devDependencies[dep] = 'latest';
        })
        await writeFileTree(context, {
            'package.json': JSON.stringify(pkg, null, 2)
        })
        console.log(`🗃  Initializing git repository...`)
        await run('git init');
        console.log(`⚙\u{fe0f} Installing CLI plugins. This might take a while...`)
        await run('npm install');
+       console.log(`🚀  Invoking generators...`)
+       const plugins = await this.resolvePlugins(preset.plugins)
+       const generator = new Generator(context, {pkg,plugins})
+       await generator.generate();
    }
+   async resolvePlugins(rawPlugins) {
+       const plugins = []
+       for (const id of Object.keys(rawPlugins)) {
+           try{
+               const apply = loadModule(`${id}/generator`, this.context) || (() => {})
+               let options = rawPlugins[id] || {}
+               plugins.push({ id, apply, options })
+           }catch(error){
+               console.log(error);
+           } 
+       }
+       return plugins
+   }
	  // 遍历插件的generator,插件通过GeneratorAPI向package.json中加入依赖或字段,并通过render准备添加文件
    resolveFinalPrompts() {
        this.injectedPrompts.forEach(prompt => {
            const originalWhen = prompt.when || (() => true)
            prompt.when = answers => {
                return isManualMode(answers) && originalWhen(answers)
            }
        })
        const prompts = [
            this.presetPrompt,
            this.featurePrompt,
            ...this.injectedPrompts,
        ]
        return prompts
    }
    async promptAndResolvePreset(answers = null) {
        if (!answers) {
            answers = await inquirer.prompt(this.resolveFinalPrompts())
        }
        let preset;
        if (answers.preset && answers.preset !== '__manual__') {
            preset = await this.resolvePreset(answers.preset)
        } else {
            preset = {
                plugins: {}
            }
            answers.features = answers.features || []
            this.promptCompleteCbs.forEach(cb => cb(answers, preset))
        }
        return preset
    }
    async resolvePreset (name) {
        const savedPresets = this.getPresets()
        return savedPresets[name];
    }
    getPresets() {
        return Object.assign({}, defaults.presets)
    }
    resolveIntroPrompts() {
        const presets = this.getPresets()
        const presetChoices = Object.entries(presets).map(([name]) => {
            let displayName = name
            if (name === 'default') {
                displayName = 'Default'
            } else if (name === '__default_vue_3__') {
                displayName = 'Default (Vue 3)'
            }
            return {
                name: `${displayName}`,
                value: name
            }
        })
        const presetPrompt = {
            name: 'preset',
            type: 'list',
            message: `Please pick a preset:`,
            choices: [
                ...presetChoices,
                {
                    name: 'Manually select features',
                    value: '__manual__'
                }
            ]
        }
        const featurePrompt = {
            name: 'features',
            when: isManualMode,
            type: 'checkbox',
            message: 'Check the features needed for your project:',
            choices: [],
            pageSize: 10
        }
        return {
            presetPrompt,
            featurePrompt
        }
    }
}
module.exports = Creator;

7.2 cli-shared-utils\index.js

packages\james-cli-shared-utils\index.js

+['pluginResolution','module'].forEach(module => {
+    Object.assign(exports, require(`./lib/${module}`))
+})
exports.chalk = require('chalk')
exports.execa = require('execa')

7.3 module.js

packages/james-cli-shared-utils/lib/module.js

const Module = require('module');
const path = require('path');
function loadModule(request, context) {
  // 加载 CommonJS 模块
  return Module.createRequire(path.resolve(context, 'package.json'))(request);
}
module.exports = {
  loadModule,
};

7.4 pluginResolution.js

packages/james-cli-shared-utils/lib/pluginResolution.js

const pluginRE = /^@vue\/cli-plugin-/;
// 解析插件名称 @vue/cli-plugin-babel => babel
const toShortPluginId = (id = '') => id.replace(pluginRE, '');
const isPlugin = (id = '') => pluginRE.test(id);
const matchesPluginId = (input, full) => input === full;
module.exports = {
  toShortPluginId,
  isPlugin,
  matchesPluginId,
};

7.5 mergeDeps.js

packages/james-cli/lib/util/mergeDeps.js

function mergeDeps(sourceDeps, depsToInject = {}) {
  const result = Object.assign({}, sourceDeps);
  Object.entries(depsToInject).forEach((depName, dep) => {
    result[depName] = dep;
  });
  return result;
}

module.exports = mergeDeps;

7.6 normalizeFilePaths.js

packages/james-cli/lib/util/normalizeFilePaths.js

const slash = require('slash');
// 将Windows反斜杠路径转换为斜杠路径,如foo\\bar➔ foo/bar
function normalizeFilePaths(files = {}) {
  Object.entries(files).forEach(([filePath, file]) => {
    const normalized = slash(filePath);
    // 说明反斜杠路径转换为斜杠路径了
    if (filePath !== normalized) {
      files[normalized] = file;
      delete files[filePath];
    }
  });
  return files;
}

module.exports = normalizeFilePaths;

7.7 GeneratorAPI.js

packages/james-cli/lib/GeneratorAPI.js

const { toShortPluginId } = require('james-cli-shared-utils');
const mergeDeps = require('./util/mergeDeps');
const { isBinaryFileSync } = require('isbinaryfile');
const isString = (val) => typeof val === 'string';
const isObject = (val) => val && typeof val === 'object';
const path = require('path');
const fs = require('fs');
const ejs = require('ejs');
class GeneratorAPI {
  constructor(id, generator, options, rootOptions) {
    this.id = id;
    this.generator = generator;
    this.options = options;
    this.rootOptions = rootOptions;
    this.pluginsData = generator.plugins
      .filter(({ id }) => id !== `@vue/cli-service`)
      .map(({ id }) => ({ name: toShortPluginId(id) }));
  }
  hasPlugin(id) {
    return this.generator.hasPlugin(id);
  }
  extendPackage(fields) {
    const pkg = this.generator.pkg;
    const toMerge = fields;
    for (const key in toMerge) {
      const value = toMerge[key];
			const existing = pkg[key];
      if (isObject(value) && ['dependencies', 'devDependencies'].includes(key)) {
        pkg[key] = mergeDeps(existing || {}, value);
      } else {
        pkg[key] = value;
      }
    }
  }
  injectFileMiddleware(middleware) {
    this.generator.fileMiddlewares.push(middleware);
  }
  resolveData(additionalData) {
    return Object.assign(
      {
        options: this.options,
        rootOptions: this.rootOptions,
        plugins: this.pluginsData,
      },
      additionalData,
    );
  }
  render(source, additionalData) {
    const baseDir = extractCallDir();
    if (isString(source)) {
      source = path.resolve(baseDir, source);
      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) => {
              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);
          files[targetPath] = content;
        }
      });
    }
  }
}
function extractCallDir() {
  const obj = {};
  Error.captureStackTrace(obj);
  const callSite = obj.stack.split('\n')[3];
  const namedStackRegExp = /\s\((.*):\d+:\d+\)$/;
  let matchResult = callSite.match(namedStackRegExp);
  const fileName = matchResult[1];
  return path.dirname(fileName);
}
function renderFile(name, data) {
  if (isBinaryFileSync(name)) {
    return fs.readFileSync(name);
  }
  const template = fs.readFileSync(name, 'utf8');
  return ejs.render(template, data);
}
module.exports = GeneratorAPI;

7.8 Generator.js

packages/james-cli/lib/Generator.js

const { isPlugin, matchesPluginId } = require('james-cli-shared-utils');
const ejs = require('ejs');
const GeneratorAPI = require('./GeneratorAPI');
const writeFileTree = require('./util/writeFileTree');
class Generator {
  constructor(context, { pkg = {}, plugins = [] } = {}) {
    this.context = context;
    this.plugins = plugins;
    this.pkg = pkg;
    this.files = {};
    this.fileMiddleWares = [];
    const allPluginIds = [
      ...Object.keys(this.pkg.dependencies || {}),
      ...Object.keys(this.pkg.devDependencies || {}),
    ].filter(isPlugin);
    this.allPluginIds = allPluginIds;
    const cliService = plugins.find((p) => p.id === '@vue/cli-service');
    this.rootOptions = cliService.options;
  }
  async generate() {
    await this.initPlugins();
    // 将一些配置信息从 package.json 中提取到单独的文件中,比如 postcss.config.js babel.config.js
    this.extractConfigFiles();
    // 遍历 fileMiddleware,向 files 里写入文件,并插入 import 和 rootOptions
    await this.resolveFiles();
    // console.log(this.files);
    this.sortPkg();
    this.files['package.json'] = JSON.stringify(this.pkg, null, 2) + '\n';
    //把内存中的文件写入硬盘
    await writeFileTree(this.context, this.files);
  }
  sortPkg() {
    console.log('ensure package.json keys has readable order');
  }
  extractConfigFiles() {
    console.log('extractConfigFiles');
  }
  async initPlugins() {
    const { rootOptions, plugins = [] } = this;
    for (const plugin of plugins) {
      const { id, apply, options } = plugin;
      const api = new GeneratorAPI(id, apply, options, rootOptions);
      await apply(api, options, rootOptions);
    }
  }
  // 解析文件
  async resolveFiles() {
    const files = this.files;
    for (const fileMiddleWare of this.fileMiddleWares) {
      await fileMiddleWare(files, ejs.render);
    }
    normalizeFilePaths(files);
  }
  hasPlugin(id) {
    const pluginIds = [...this.plugins.map((plugin) => plugin.id), ...this.allPluginIds];
    return pluginIds.some((_id) => matchesPluginId(id, _id));
  }
  printExitLogs() {
    console.log('printExitLogs');
  }
}
module.exports = Generator;

8.完成create命令

8.1 Creator.js

packages/james-cli/lib/Creator.js

const { defaults } = require('./options');
const PromptModuleAPI = require('./PromptModuleAPI');
const inquirer = require('inquirer')
const cloneDeep = require('lodash.clonedeep')
const writeFileTree = require('./util/writeFileTree')
const { chalk, execa,loadModule } = require('james-cli-shared-utils')
const Generator = require('./Generator')
const isManualMode = answers => answers.preset === '__manual__'
class Creator {
    constructor(name, context, promptModules) {
        this.name = name;
        this.context = process.env.VUE_CLI_CONTEXT = context;
        const { presetPrompt, featurePrompt } = this.resolveIntroPrompts();
        this.presetPrompt = presetPrompt;
        this.featurePrompt = featurePrompt;
        this.injectedPrompts = []
        this.promptCompleteCbs = []
        this.run = this.run.bind(this)//运行函数
        const promptAPI = new PromptModuleAPI(this)
        promptModules.forEach(m => m(promptAPI))
    }
    run(command, args) {
        return execa(command, args, { cwd: this.context })
    }
    async create() {
        const {name,context,run} = this;
        let preset = await this.promptAndResolvePreset()
        console.log('preset', preset);
        preset = cloneDeep(preset);
        preset.plugins['@vue/cli-service'] = Object.assign({projectName: name}, preset);
        console.log(`✨  Creating project in ${chalk.yellow(context)}.`)
        const pkg = {
            name,
            version: '0.1.0',
            private: true,
            devDependencies: {}
        }
        const deps = Object.keys(preset.plugins)
        deps.forEach(dep => {
            pkg.devDependencies[dep] = 'latest';
        })
        await writeFileTree(context, {
            'package.json': JSON.stringify(pkg, null, 2)
        })
        console.log(`🗃  Initializing git repository...`)
        await run('git init');
        console.log(`⚙\u{fe0f} Installing CLI plugins. This might take a while...`)
        await run('npm install');
        console.log(`🚀  Invoking generators...`)
        const plugins = await this.resolvePlugins(preset.plugins)
        const generator = new Generator(context, {pkg,plugins})
        await generator.generate();
+       console.log(`📦  Installing additional dependencies...`)
+       await run('npm install');
+       console.log('📄  Generating README.md...');
+       await writeFileTree(context, {
+           'README.md': `cd ${name}\n npm run serve`
+       });
+       await run('git', ['add', '-A']);
+       await run('git', ['commit', '-m', 'created', '--no-verify']);
+       console.log(`🎉  ${chalk.green('Successfully created project')} ${chalk.yellow(name)}`);
+       console.log(
+           `👉  Get started with the following commands:\n\n` +
+           (chalk.cyan(`cd ${name}\n`)) +
+           (chalk.cyan(`npm run serve`))
+       );
+       generator.printExitLogs();
    }
    //遍历插件的generator,插件通过GeneratorAPI向package.json中加入依赖或字段,并通过render准备添加文件
    async resolvePlugins(rawPlugins) {
        const plugins = []
        for (const id of Object.keys(rawPlugins)) {
            try{
                const apply = loadModule(`${id}/generator`, this.context) || (() => {})
                let options = rawPlugins[id] || {}
                plugins.push({ id, apply, options })
            }catch(error){
                console.log(error);
            } 
        }
        return plugins
    }
    resolveFinalPrompts() {
        this.injectedPrompts.forEach(prompt => {
            const originalWhen = prompt.when || (() => true)
            prompt.when = answers => {
                return isManualMode(answers) && originalWhen(answers)
            }
        })
        const prompts = [
            this.presetPrompt,
            this.featurePrompt,
            ...this.injectedPrompts,
        ]
        return prompts
    }
    async promptAndResolvePreset(answers = null) {
        if (!answers) {
            answers = await inquirer.prompt(this.resolveFinalPrompts())
        }
        let preset;
        if (answers.preset && answers.preset !== '__manual__') {
            preset = await this.resolvePreset(answers.preset)
        } else {
            preset = {
                plugins: {}
            }
            answers.features = answers.features || []
            this.promptCompleteCbs.forEach(cb => cb(answers, preset))
        }
        return preset
    }
    async resolvePreset (name) {
        const savedPresets = this.getPresets()
        return savedPresets[name];
    }
    getPresets() {
        return Object.assign({}, defaults.presets)
    }
    resolveIntroPrompts() {
        const presets = this.getPresets()
        const presetChoices = Object.entries(presets).map(([name]) => {
            let displayName = name
            if (name === 'default') {
                displayName = 'Default'
            } else if (name === '__default_vue_3__') {
                displayName = 'Default (Vue 3)'
            }
            return {
                name: `${displayName}`,
                value: name
            }
        })
        const presetPrompt = {
            name: 'preset',
            type: 'list',
            message: `Please pick a preset:`,
            choices: [
                ...presetChoices,
                {
                    name: 'Manually select features',
                    value: '__manual__'
                }
            ]
        }
        const featurePrompt = {
            name: 'features',
            when: isManualMode,
            type: 'checkbox',
            message: 'Check the features needed for your project:',
            choices: [],
            pageSize: 10
        }
        return {
            presetPrompt,
            featurePrompt
        }
    }
}


module.exports = Creator;

8.2 Generator.js

packages/james-cli/lib/Generator.js

const { isPlugin,matchesPluginId } = require('james-cli-shared-utils')
const GeneratorAPI = require('./GeneratorAPI')
const normalizeFilePaths = require('./util/normalizeFilePaths')
const writeFileTree = require('./util/writeFileTree')
const ejs = require('ejs')
class Generator {
    constructor(context, { pkg = {}, plugins = [] } = {}) {
    this.context = context;
    this.plugins = plugins;
    this.pkg = pkg;
    this.files = {};
    this.fileMiddleWares = [];
    const allPluginIds = [
      ...Object.keys(this.pkg.dependencies || {}),
      ...Object.keys(this.pkg.devDependencies || {}),
    ].filter(isPlugin);
    this.allPluginIds = allPluginIds;
    const cliService = plugins.find((p) => p.id === '@vue/cli-service');
    this.rootOptions = cliService.options;
  }
  async generate() {
    await this.initPlugins();
    // 将一些配置信息从 package.json 中提取到单独的文件中,比如 postcss.config.js babel.config.js
    this.extractConfigFiles();
    // 遍历 fileMiddleware,向 files 里写入文件,并插入 import 和 rootOptions
    await this.resolveFiles();
    // console.log(this.files);
    this.sortPkg();
    this.files['package.json'] = JSON.stringify(this.pkg, null, 2) + '\n';
    //把内存中的文件写入硬盘
    await writeFileTree(this.context, this.files);
  }
  sortPkg() {
    console.log('ensure package.json keys has readable order');
  }
  extractConfigFiles() {
    console.log('extractConfigFiles');
  }
  async initPlugins() {
    const { rootOptions, plugins = [] } = this;
    for (const plugin of plugins) {
      const { id, apply, options } = plugin;
      const api = new GeneratorAPI(id, apply, options, rootOptions);
      await apply(api, options, rootOptions);
    }
  }
  // 解析文件
  async resolveFiles() {
    const files = this.files;
    for (const fileMiddleWare of this.fileMiddleWares) {
      await fileMiddleWare(files, ejs.render);
    }
    normalizeFilePaths(files);
  }
  hasPlugin(id) {
    const pluginIds = [...this.plugins.map((plugin) => plugin.id), ...this.allPluginIds];
    return pluginIds.some((_id) => matchesPluginId(id, _id));
  }
+ printExitLogs(){
+   console.log('printExitLogs');
+ }
}

module.exports = Generator;