構建一個用於創建組件庫的項目腳手架工具(類 Vue-cli3)

緣起

最近公司內部想搭建一個私有的 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 的源碼 找到了這款工具,用於命令行的表單。能夠更加直觀的配置選項。

image

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 就能在你使用這個模塊的時候,給你更加健全的提示。

image

這裏額外提醒下,經過我的研究,element-ui 這樣的組件庫,能有 props 的提示是因爲人家 vetur 組件專門給開的後門,寫 types 只能擁有 JS 層面的提示,寫 Vue-template 的時候依舊沒有,期待後續能夠支持。

參考

vue-cli

Vue cli3 庫模式搭建組件庫併發布到 npm的流程

element-ui

我的個人博客

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章