阅读 1586

Vue CLI 插件开发实战——10 分钟实现组件自动生成

前言

近期工作的过程中跟 Vue CLI 的插件打交道比较多,想了想自己在学校写项目的时候最烦的就是项目创建之后手动创建组件/页面和配置路由,于是突发奇想决定写一个脚手架的插件,自动实现创建组件/页面和配置路由的功能

本文会一步一步教你如何编写一个自己的 Vue CLI 插件,并发布至 npm,为所有因为这个问题而烦恼的同学解放双手。

关注 「Hello FE」 获取更多实战教程,正好最近在抽奖,查看历史文章即可获取抽奖方法~

本教程的插件完整代码放在了我的 GitHub 上,欢迎大家 Starvue-cli-plugin-generators

同时,我也将这个插件发布到了 npm,大家可以直接使用 npm 安装并体验添加组件的能力。

PS:添加页面和配置路由的能力还在开发中。

体验方式:

  1. 通过 npm 安装
npm install vue-cli-plugin-generators -D
vue invoke vue-cli-plugin-generators
复制代码
  1. 通过 yarn 安装
yarn add vue-cli-plugin-generators -D
vue invoke vue-cli-plugin-generators
复制代码
  1. 通过 Vue CLI 安装(推荐)
vue add vue-cli-plugin-generators
复制代码

注意:一定要注意是复数形式的 generators,不是单数形式的 generatorgenerator 被前辈的占领了。

废话不多说,我们直接开始吧!

前置知识

要做好一个 Vue CLI 插件,除了要了解 Vue CLI 插件的开发规范之外,我们还需要了解几个 npm 包:

  • chalk 让你的控制台输出好看一点,为文字或背景上色
  • glob 让你可以使用 Shell 脚本的方式匹配文件
  • inquirer 让你可以使用交互式的命令行来获取需要的信息

主要出现的 npm 包就只有这三个,其他的都是基于 Node.js 的各种模块,比如 fspath,了解过 Node.js 的同学应该不陌生。

项目初始化

创建一个空的文件夹,名字最好就是你的插件的名字。

这里我的名字是 vue-cli-plugin-generators,你可以取一个自己喜欢的名字,不过最好是见名知义的那种,比如 vue-cli-plugin-component-generator 或者 vue-cli-plugin-page-generator,一看就知道是组件生成器和页面生成器。

至于为什么一定要带上 vue-cli-plugin 的前缀这个问题,可以看一下官方文档:命名和可发现性

然后初始化我们的项目:

npm init
复制代码

输入一些基本的信息,这些信息会被写入 package.json 文件中。

创建一个基本的目录结构:

.
├── LICENSE
├── README.md
├── generator
│   ├── index.js
│   └── template
│       └── component
│           ├── jsx
│           │   └── Template.jsx
│           ├── sfc
│           │   └── Template.vue
│           ├── style
│           │   ├── index.css
│           │   ├── index.less
│           │   ├── index.sass
│           │   ├── index.scss
│           │   └── index.styl
│           └── tsx
│               └── Template.tsx
├── index.js
├── package.json
├── src
│   ├── add-component.js
│   ├── add-page.js
│   └── utils
│       ├── log.js
│       └── suffix.js
└── yarn.lock
复制代码

目录结构创建好了之后就可以开始编码了。

目录解析

一些不重要的文件就不讲解了,主要讲解一下作为一个优秀的 Vue CLI 插件,需要哪些部分:

.
├── README.md
├── generator.js  # Generator(可选)
├── index.js      # Service 插件
├── package.json
├── prompts.js    # Prompt 文件(可选)
└── ui.js         # Vue UI 集成(可选)
复制代码

主要分为 4 个部分:Generator/Service/Prompt/UI

其中,Service 是必须的,其他的部分都是可选项。

先来讲一下各个部分的作用:

Generator

Generator 可以为你的项目创建文件、编辑文件、添加依赖

Generator 应该放在根目录下,被命名为 generator.js 或者放在 generator 目录下,被命名为 index.js,它会在调用 vue add 或者 vue invoke 时被执行。

来看下我们这个项目的 generator/index.js

/**
 * @file Generator
 */
'use strict';

// 前置知识中提到的美化控制台输出的包
const chalk = require('chalk');

// 封装的打印函数
const log = require('../src/utils/log');

module.exports = (api) => {
  // 执行脚本
  const extendScript = {
    scripts: {
      'add-component': 'vue-cli-service add-component',
      'add-page': 'vue-cli-service add-page'
    }
  };
  // 拓展 package.json 为其中的 scripts 中添加 add-component 和 add-page 两条指令
  api.extendPackage(extendScript);

  // 插件安装成功后 输出一些提示 可以忽略
  console.log('');
  log.success(`Success: Add plugin success.`);
  console.log('');
  console.log('You can use it with:');
  console.log('');
  console.log(`   ${chalk.cyan('yarn add-component')}`);
  console.log('   or');
  console.log(`   ${chalk.cyan('yarn add-page')}`);
  console.log('');
  console.log('to create a component or page.');
  console.log('');
  console.log(`${chalk.green.bold('Enjoy it!')}`);
  console.log('');
};
复制代码

所以,当我们执行 vue add vue-cli-plugin-generators 的时候,generator/index.js 会被执行,你就可以看到你的控制台输出了这样的指引信息:

vue add

同时你还会发现,执行了 vue add vue-cli-plugin-generators 的项目中,package.json 发生了变化:

package.json

添加了两条指令,让我们可以通过 yarn add-componentyarn add-page 去添加组件/页面。

虽然添加了这两条指令,但是现在这两条指令还没有被注册到 vue-cli-service 中,这时候我们就需要开始编写 Service 了。

Service

Service 可以为你的项目修改 Webpack 配置、创建 vue-cli-service 命令、修改 vue-cli-service 命令

Service 应该放在根目录下,被命名为 index.js,它会在调用 vue-cli-service 时被执行。

来看一下我们这个项目的 index.js

/**
 * @file Service 插件
 */
'use strict';

const addComponent = require('./src/add-component');
const addPage = require('./src/add-page');

module.exports = (api, options) => {
  // 向 vue-cli-service 中注册 add-component 指令
  api.registerCommand('add-component', async () => {
    await addComponent(api);
  });

  // 向 vue-cli-service 中注册 add-page 指令
  api.registerCommand('add-page', async () => {
    await addPage(api);
  });
};
复制代码

为了代码的可读性,我们把 add-componentadd-page 指令的回调函数单独抽了出来,分别放在了 src/add-component.jssrc/add-page.js 中:

前方代码量较大,建议先阅读注释理解思路。

/**
 * @file Add Component 逻辑
 */
'use strict';

const fs = require('fs');
const path = require('path');
const glob = require('glob');
const chalk = require('chalk');
const inquirer = require('inquirer');

const log = require('./utils/log');
const suffix = require('./utils/suffix');

module.exports = async (api) => {
  // 交互式命令行参数 获取组件信息
  // componentName {string} 组件名称 默认 HelloWorld
  const { componentName } = await inquirer.prompt([
    {
      name: 'componentName',
      type: 'input',
      message: `Please input your component name. ${chalk.yellow(
        '( PascalCase )'
      )}`,
      description: `You should input a ${chalk.yellow(
        'PascalCase'
      )}, it will be used to name new component.`,
      default: 'HelloWorld'
    }
  ]);

  // 组件名称校验
  if (!componentName.trim() || /[^A-Za-z0-9]/g.test(componentName)) {
    log.error(
      `Error: Please input a correct name. ${chalk.bold('( PascalCase )')}`
    );
    return;
  }

  // 项目中组件文件路径 Vue CLI 创建的项目中默认路径为 src/components
  const baseDir = `${api.getCwd()}/src/components`;
  // 遍历组件文件 返回组件路径列表
  const existComponent = glob.sync(`${baseDir}/*`);

  // 替换组件路径列表中的基础路径 返回组件名称列表
  const existComponentName = existComponent.map((name) =>
    name.replace(`${baseDir}/`, '')
  );

  // 判断组件是否已存在
  const isExist = existComponentName.some((name) => {
    // 正则表达式匹配从控制台输入的组件名称是否已经存在
    const reg = new RegExp(
      `^(${componentName}.[vue|jsx|tsx])$|^(${componentName})$`,
      'g'
    );
    return reg.test(name);
  });

  // 存在则报错并退出
  if (isExist) {
    log.error(`Error: Component ${chalk.bold(componentName)} already exists.`);
    return;
  }

  // 交互式命令行 获取组件信息
  // componentType {'sfc'|'tsx'|'jsx'} 组件类型 默认 sfc
  // componentStyleType {'.css'|'.scss'|'.sass'|'.less'|'.stylus'} 组件样式类型 默认 .scss
  // shouldMkdir {boolean} 是否需要为组件创建文件夹 默认 true
  const {
    componentType,
    componentStyleType,
    shouldMkdir
  } = await inquirer.prompt([
    {
      name: 'componentType',
      type: 'list',
      message: `Please select your component type. ${chalk.yellow(
        '( .vue / .tsx / .jsx )'
      )}`,
      choices: [
        { name: 'SFC (.vue)', value: 'sfc' },
        { name: 'TSX (.tsx)', value: 'tsx' },
        { name: 'JSX (.jsx)', value: 'jsx' }
      ],
      default: 'sfc'
    },
    {
      name: 'componentStyleType',
      type: 'list',
      message: `Please select your component style type. ${chalk.yellow(
        '( .css / .sass / .scss / .less / .styl )'
      )}`,
      choices: [
        { name: 'CSS (.css)', value: '.css' },
        { name: 'SCSS (.scss)', value: '.scss' },
        { name: 'Sass (.sass)', value: '.sass' },
        { name: 'Less (.less)', value: '.less' },
        { name: 'Stylus (.styl)', value: '.styl' }
      ],
      default: '.scss'
    },
    {
      name: 'shouldMkdir',
      type: 'confirm',
      message: `Should make a directory for new component? ${chalk.yellow(
        '( Suggest to create. )'
      )}`,
      default: true
    }
  ]);

  // 根据不同的组件类型 生成对应的 template 路径
  let src = path.resolve(
    __dirname,
    `../generator/template/component/${componentType}/Template${suffix(
      componentType
    )}`
  );
  // 组件目标路径 默认未生成组件文件夹
  let dist = `${baseDir}/${componentName}${suffix(componentType)}`;
  // 根据不同的组件样式类型 生成对应的 template 路径
  let styleSrc = path.resolve(
    __dirname,
    `../generator/template/component/style/index${componentStyleType}`
  );
  // 组件样式目标路径 默认未生成组件文件夹
  let styleDist = `${baseDir}/${componentName}${componentStyleType}`;

  // 需要为组件创建文件夹
  if (shouldMkdir) {
    try {
      // 创建组件文件夹
      fs.mkdirSync(`${baseDir}/${componentName}`);
      // 修改组件目标路径
      dist = `${baseDir}/${componentName}/${componentName}${suffix(
        componentType
      )}`;
      // 修改组件样式目标路径
      styleDist = `${baseDir}/${componentName}/index${componentStyleType}`;
    } catch (e) {
      log.error(e);
      return;
    }
  }

  // 生成 SFC/TSX/JSX 及 CSS/SCSS/Sass/Less/Stylus
  try {
    // 读取组件 template
    // 替换组件名称为控制台输入的组件名称
    const template = fs
      .readFileSync(src)
      .toString()
      .replace(/helloworld/gi, componentName);
    // 读取组件样式 template
    // 替换组件类名为控制台输入的组件名称
    const style = fs
      .readFileSync(styleSrc)
      .toString()
      .replace(/helloworld/gi, componentName);
    if (componentType === 'sfc') {
      // 创建的组件类型为 SFC 则将组件样式 template 注入 <style></style> 标签中并添加样式类型
      fs.writeFileSync(
        dist,
        template
          // 替换组件样式为 template 并添加样式类型
          .replace(
            /<style>\s<\/style>/gi,
            () =>
              `<style${
                // 当组件样式类型为 CSS 时不需要添加组件样式类型
                componentStyleType !== '.css'
                  ? ` lang="${
                      // 当组件样式类型为 Stylus 时需要做一下特殊处理
                      componentStyleType === '.styl'
                        ? 'stylus'
                        : componentStyleType.replace('.', '')
                    }"`
                  : ''
              }>\n${style}</style>`
          )
      );
    } else {
      // 创建的组件类型为 TSX/JSX 则将组件样式 template 注入单独的样式文件
      fs.writeFileSync(
        dist,
        template.replace(
          // 当不需要创建组件文件夹时 样式文件应该以 [组件名称].[组件样式类型] 的方式引入
          /import '\.\/index\.css';/gi,
          `import './${
            shouldMkdir ? 'index' : `${componentName}`
          }${componentStyleType}';`
        )
      );
      fs.writeFileSync(styleDist, style);
    }
    // 组件创建完成 打印组件名称和组件文件路径
    log.success(
      `Success: Component ${chalk.bold(
        componentName
      )} was created in ${chalk.bold(dist)}`
    );
  } catch (e) {
    log.error(e);
    return;
  }
};
复制代码

上面的代码是 add-component 指令的执行逻辑,比较长,可以稍微有点耐心阅读一下。

由于 add-page 指令的执行逻辑还在开发过程中,这里就不贴出来了,大家可以自己思考一下,欢迎有好想法的同学为这个仓库提 PR:vue-cli-plugin-generators

现在我们可以来执行一下 yarn add-component 来体验一下功能了:

yarn add-component

这里我们分别创建了 SFC/TSX/JSX 三种类型的组件,目录结构如下:

.
├── HelloJSX
│   ├── HelloJSX.jsx
│   └── index.scss
├── HelloSFC
│   └── HelloSFC.vue
├── HelloTSX
│   ├── HelloTSX.tsx
│   └── index.scss
└── HelloWorld.vue
复制代码

其中 HelloWorld.vueVue CLI 创建时自动生成的。

对应的文件中组件名称和组件样式类名也被替换了。

到这里我们就算完成了一个能够自动生成组件的 Vue CLI 插件了。

但是,还不够!

Prompt

Prompt 会在创建新的项目或者在项目中添加新的插件时输出交互式命令行,获取 Generator 需要的信息,这些信息会在用户输入完成后以 options 的形式传递给 Generator,供 Generator 中的 ejs 模板渲染。

Prompt 应该放在根目录下,被命名为 prompt.js,它会在调用 vue add 或者 vue invoke 时被执行,执行顺序位于 Generator 前。

在我们的插件中,我们并不需要在调用 vue add 或者 vue invoke 时就创建组件/页面,因此不需要在这个时候获取组件的相关信息。

UI

UI 会在使用 vue ui 指令打开图形化操作界面后给到用户一个图形化的插件配置功能。

这个部分的内容比较复杂,讲解起来比较费劲,大家可以到官网上阅读:UI 集成

在我们的插件中,我们并不需要使用 vue ui 启动图形化操作界面,因此不需要编写 UI 相关的代码。

深入学习

我们可以到 Vue CLI 插件开发指南中查看更详细的指南,建议阅读英文文档,没有什么教程比官方文档更加合适了

总结

一个优秀的 Vue CLI 插件应该有四个部分:

.
├── README.md
├── generator.js  # Generator(可选)
├── index.js      # Service 插件
├── package.json
├── prompts.js    # Prompt 文件(可选)
└── ui.js         # Vue UI 集成(可选)
复制代码
  • Generator 可以为你的项目创建文件、编辑文件、添加依赖

  • Service 可以为你的项目修改 Webpack 配置、创建 vue-cli-service 命令、修改 vue-cli-service 命令

  • Prompt 会在创建新的项目或者在项目中添加新的插件时输出交互式命令行,获取 Generator 需要的信息,这些信息会在用户输入完成后以 options 的形式传递给 Generator,供 Generator 中的 ejs 模板渲染。

  • UI 会在使用 vue ui 指令打开图形化操作界面后给到用户一个图形化的插件配置功能。

四个部分各司其职才能更好地实现一个完美的插件!

本教程的插件完整代码放在了我的 GitHub 上,欢迎大家 Starvue-cli-plugin-generators

也欢迎大家通过 npm/yarn 安装到自己的项目中体验~

关注 「Hello FE」 获取更多实战教程

参考资料