构建一个用于创建组件库的项目脚手架工具 [类 Vue-cli-3]

Posted by Yinode on Tuesday, July 9, 2019

TOC

缘起

最近公司内部想搭建一个私有的 npm 仓库,用于将平时用到次数相当频繁的工具或者组件独立出来,方便单独管理,随着项目的规模变大,数量变多,单纯的复制粘粘无疑在优雅以及实用性上都无法满足我们的需求,所以进一步模块化是必然的。

但是一个组件库的建立其实是一个非常麻烦的过程,基础 webpack 的配置不用多说,接着你还要配合增加一些 es-lint 之类的工具来规范化团队成员的代码。在开发过程中,你自然需要一个目录来承载使用示例,方便 dev 这个组件,随后呢,你还得建立一个打包规范,发布到私有 npm 仓库中。

如此一来,必然大大降低我们的积极性,所以不如创建一个用于建立模块包的脚手架工具,方便我们项目的初始化。

tips:最终成品在底部

私有 NPM

这里简单提及一下 私有 npm 的搭建。

npm i verdaccio -g pm2 start verdaccio

推荐配合 nrm 使用 快速切换仓库地址

verdaccio github

还整个意大利名,属实洋气。

工具

在进入正题之前,我先介绍一些要点和工具,有了这写关键点,写起来其实就相当简单了。

npm bin

大家有没有想过一些全局安装的工具,他是如何做到在命令行里面自由调用的呢?

事实上这个东西是 npm 提供的链接功能

// package.json
{
  "name": "lucky-for-you",
  "bin": {
    "lucky": "bin/lucky"
  }
}

当这样的一个模块被发布之后,一旦有人使用 -g 参数全局安装

sudo npm i luck-for-you -g

/usr/local/bin/lucky -> /usr/local/lib/node_modules/luckytiger-package-cli/bin/lucky # npm 帮你进行链接

npm 事实上会帮你进行一次链接,链接到你操作系统的 Path 之中,从而但你敲出 Lucky 这个命令的时候,能从 path 中成功找到对应的程序

另外一点就是用于链接执行的文件 一般在开头都要加上如下内容,让 bash 能够正确识别该文件应该如何执行

#!/usr/bin/env node
// 意味使用 node 运行该文件
// next script

Commander.js

tj 大神的作品,可以方便的书写命令行工具。能够自动生成帮助命令

const program = require('commander');

program.version('0.0.1').usage('<command> [options]');

program
  .command('create <app-name>')
  .description('创建一个全新的 npm 组件模块')
  .action((name, cmd) => {
    const options = cleanArgs(cmd);
    require('../lib/create')(name, options);
  });

// 用户未输入完整命令 输出帮助
if (!process.argv.slice(2).length) {
  program.outputHelp();
}

program.parse(process.argv);

Commander.js github

inquirer

事实上当我第一次使用 vue-cli3.0 的时候,里面的命令行表单真是非常惊艳,翻了 vue-cli3 的源码 找到了这款工具,用于命令行的表单。能够更加直观的配置选项。

inquirer
  .prompt([
    {
      type: 'list',
      name: 'template',
      message: 'template: 请选择项目起始模板',
      choices: [
        {
          key: '1',
          name: 'JavaScript Library - 适用于普通 JS 库',
          value: 'js-lib',
        },
        {
          key: '2',
          name: 'Vue-components - 适用于 Vue 组件库',
          value: 'vue-component',
        },
      ],
    },
    {
      type: 'input',
      name: 'author',
      message: 'author: 请输入你的名字',
      validate: function(value) {
        return !!value;
      },
    },
    {
      type: 'input',
      name: 'desc',
      message: 'desc: 请输入项目描述',
      validate: function(value) {
        return !!value;
      },
    },
    {
      type: 'confirm',
      name: 'confirm',
      message: 'confirm: 完成配置了?',
      default: false,
    },
  ])
  .then(answers => {
    console.log(answers.template);
    console.log(answers.author);
    console.log(answers.desc);
  });

还有很多的表单类型,我这里几个最简单的 list + input + confirm 就足够了。

inquire github

开始构建

现在开始分享我的构建流程。由于代码量比较大,挨个文件帖出来没有什么必要,所以我这里只做简单介绍,具体的可以查看我的 github项目。

我把我的 cli 工具大致分为两部分 template模板 + 创建器 z 创建器的主要功能是吸收用户的可选项,基于模板进行复制+渲染。Vue-cli3.0对于这部分操作会更加复杂,他把模板里面具体的功能都抽象成了一个 Plugin,可以按需组建模板,对于面向普遍大众当然是更好的。

但是我这个项目因为是公司内部用,所以不太需要太过泛化的设计,一个模板直接解决一个问题,简化模型就可以了。比如一个模板用于创建 Vue 的组件库,一个模板用于创建 React 的组件库,还有一个模板用于创建JavaScript 的工具函数类库。

如此一来我们的 template模板 创建器在一定程度上可以做到解耦,也就是说日后需要更多类型的模板,不需要修改创建器部分的代码。

目录结构

├── README.md
├── bin
│   └── lucky #主程序
├── lib
│   ├── copy.js #复制
│   └── create.js #主创建器
├── package-lock.json
├── package.json
├── templates
│   ├── config.js #模板配置 解耦
│   ├── js-lib #预设模板1
│   └── vue-component #预设模板2
├── utils # 工具目录
│   └── dir.js

package.json

{
  "name": "luckytiger-package-cli",
  "version": "1.1.14",
  "description": "package-cli",
  "bin": {
    "lucky": "bin/lucky"
  },
  "scripts": {
    "lucky": "node bin/lucky",
    "bootstarp": "cnpm i && cd ./templates/js-lib/ &&  cnpm i   && cd ../vue-component/ && cnpm i  ",
    "dev:js-lib": "cd templates/js-lib  && npm run dev",
    "dev:vue-component": "cd templates/vue-component && npm run dev",
    "dev:create": "rm -rf test-app && node bin/lucky create test-app",
    "clear": "sudo rm -rf node_modules && sudo rm -rf templates/js-lib/node_modules && sudo rm -rf templates/vue-component/node_modules"
  },
  "author": "zhangzhengyi",
  "license": "ISC",
  "dependencies": {
    "chalk": "^2.4.2",
    "commander": "^2.20.0",
    "ejs": "^2.6.2",
    "inquirer": "^6.4.1",
    "validate-npm-package-name": "^3.0.0"
  }
}

配置了一些脚本 方便快速 DEV 模板的效果。

这样运行 > npm run dev:js-lib

就能查看和开发 js-lib 这个模板

主程序

bin/lucky

#!/usr/bin/env node

const program = require('commander')

program.version('0.0.1').usage('<command> [options]')

program
  .command('create <app-name>')
  .description('创建一个全新的 npm 组件模块')
  .action((name, cmd) => {
    const options = cleanArgs(cmd)
    require('../lib/create')(name, options)
  })

if (!process.argv.slice(2).length) {
  program.outputHelp()
}

program.parse(process.argv)

// commander passes the Command object itself as options,
// extract only actual options into a fresh object.
function cleanArgs(cmd) {
  const args = {}
  cmd.options.forEach(o => {
    const key = camelize(o.long.replace(/^--/, ''))
    // if an option is not present and Command has a method with the same name
    // it should not be copied
    if (typeof cmd[key] !== 'function' && typeof cmd[key] !== 'undefined') {
      args[key] = cmd[key]
    }
  })
  return args
}

这个文件主要是做一下基本的命令设置 利用了 commander这个库

如果用户调用了创建命令,就会转发给 lib/create.js 处理

主创建器

lib/cerate.js

const path = require('path')
const inquirer = require('inquirer')
const validateProjectName = require('validate-npm-package-name')
const chalk = require('chalk')
const copy = require('./copy')
const fs = require('fs')
const dir = require('../utils/dir')
const templates = require('../templates/config')

async function create(projectName, options) {
  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(`无效的项目名: "${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))
      })
    return
  }

  if (!dir.isDir(targetDir)) {
    fs.mkdirSync(targetDir)
  } else {
    console.error(chalk.red(`该目录下已经存在该文件夹 请删除或者修改项目名`))
    return
  }

  const answers = await inquirer.prompt([
    {
      type: 'list',
      name: 'template',
      message: 'template: 请选择项目模板',
      choices: templates.map((v, i) => ({
        key: i,
        name: v.name,
        value: v.dir
      }))
    },
    {
      type: 'input',
      name: 'author',
      message: 'author: 请输入你的名字',
      validate: function(value) {
        return !!value
      }
    },
    {
      type: 'input',
      name: 'desc',
      message: 'desc: 请输入项目描述',
      validate: function(value) {
        return !!value
      }
    },
    {
      type: 'confirm',
      name: 'confirm',
      message: 'confirm: 完成配置了?',
      default: false
    }
  ])

  // 启动复制流程
  const sourceDir = path.resolve(__dirname, '..', 'templates', answers.template)
  console.log(chalk.blue(`🚀    开始创建...`))

  try {
    await copy({
      from: sourceDir,
      to: targetDir,
      renderData: {
        desc: answers.desc,
        author: answers.author,
        name: projectName
      },
      ignore: ['node_modules', 'package.json']
    })
  } catch (e) {
    console.error(chalk.red(e))
    return
  }

  console.log(chalk.green('🎉    创建完毕!'))
  console.log()
  console.log(chalk.cyan(` $ cd ${projectName}`))
  console.log(chalk.cyan(` $ npm i && npm run dev`))
}

module.exports = create

这里主要做了几件事

  1. 保证项目名合法。
  2. 确认项目在当前目录不存在
  3. 收集用户的填写信息
  4. 启动复制流程

这里面 chalk 这个库能够输出带颜色的命令行,美观一点。

我把模板的一些配置信息都放到了 templates/config.js 中,目的是为了解耦

//templates/config.js
module.exports = [
  {
    name: 'JavaScript Library - 适用于普通 JS 库',
    dir: 'js-lib'
  },
  {
    name: 'Vue-components - 适用于 Vue 组件库',
    dir: 'vue-component'
  }
]

接下来让我们看看复制流程

复制

lib/copy

const fs = require('fs')
const path = require('path')
const dir = require('../utils/dir')
const ejs = require('ejs')

async function copy({ from, to, renderData, ignore = [] }) {
  let files = fs.readdirSync(from)
  // 区分 文件 和 目录
  let rFiles = []
  let dirs = []
  for (const fileName of files) {
    if (dir.isDir(path.resolve(from, fileName))) {
      dirs.push(fileName)
    } else {
      rFiles.push(fileName)
    }
  }

  // 复制并编译文件
  rFiles.forEach(fileName => {
    // 需要忽略
    if (ignore.some(v => v === fileName)) {
      return
    }
    let content = fs.readFileSync(path.resolve(from, fileName), 'utf-8')
    // 该文件需要调用 ejs 模板引擎进行编译
    if (/ejs$/.test(fileName)) {
      content = ejs.render(content, renderData)
      fileName = fileName.replace('.ejs', '')
    }
    fs.writeFileSync(path.resolve(to, fileName), content)
  })

  // 递归复制 目录
  dirs.forEach(dirName => {
    // 需要忽略
    if (ignore.some(v => v === dirName)) {
      return
    }
    const fromDir = path.resolve(from, dirName)
    const toDir = path.resolve(to, dirName)
    if (!dir.isDir(toDir)) {
      fs.mkdirSync(toDir)
    }
    copy({ from: fromDir, to: toDir, renderData, ignore })
  })
}

module.exports = copy

copy 是一个递归复制文件和目录的结构,深度优先。

其中他拥有四个参数源文件夹,目标文件夹,渲染数据,忽略列表。

我们的模板其实是需要一些按需渲染内容的能力的,比如生成的 package.json 应该拥有用户创建时填写的项目名,创建者,描述等等信息。所以我这里采用了 EJS 模板引擎进行渲染,所有以.ejs 结尾的文件,都将经过引擎+渲染数据的渲染,接着再输出,比如package.json.ejs

另外做了一些忽略的设计,原因是某些文件在开发模板的过程中需要,实际生成的时候需要进行过滤。

全部采用同步 API,因为我们的文件都是比较小的,并且不是服务器上用,阻塞一下也没有问题。

模板的构建

我的这里设计了两个预设模板,分别是 Vue-component 组件库模板 另外一个是 JS 库的模板(示例同样基于 Vue)。如果你们有类似的 需求可以去看看。这两个模板都是先用 vue-cli3.0生成之后进行改装。

改装的目的就是为了更加契合组件库这一需求,跟普通的项目不太一样,组件库需要在 DEV 模式下对组件进行测试和开发,然后必须拥有单独打包这个组件的能力,接着进行发布。

具体可以直接看代码

构建的过程中有些坑需要注意

模板内部应该拥有两个 package.json 文件

package.json 用于模板的 DEV 模式

package.json.ejs 用于创建时的最终导出

并且不要在 package.json 里面使用 files 字段做文件 publish 白名单,这会导致你的 cli 工具无法正常发布整个模板(这个应该是模板内部的 package.json 与整个 cli 工具的 package.json 产生了覆盖关系)。

模板内部的.gitignore文件加个.ejs

同样是 cli publish 的时候无法正常 上传模板里面的.gitignore 文件,所以加个 ejs 可以让他伪装成普通文件。

所以我觉得 npm包 的嵌套是不是太容易产生干扰了一点。

types 推荐

这里推荐大家写组件库的时候,可以手写一下 TS 的类型声明 types,在 VSCode 下能获得非常好的代码提示效果。

首先你需要在组件库的 package.json 里面添加一个属性

{
  "typings": "types/index.d.ts",
}

我这里写一个简单的函数

// 最终导出
export default {
  say (name) {
    return `your name: ${name}`
  }
}
// index.d.ts
function say(name: String): String

export default {
  say
}

这样 VSCode 就能在你使用这个模块的时候,给你更加健全的提示。

这里额外提醒下,经过我的研究,element-ui 这样的组件库,能有 props 的提示是因为人家 vetur 组件专门给开的后门,写 types 只能拥有 JS 层面的提示,写 Vue-template 的时候依旧没有,期待后续能够支持。

参考

vue-cli

Vue cli3 库模式搭建组件库并发布到 npm的流程

element-ui

我的个人博客