开发脚手架的核心目标: 提升研发效率
项目共通操作
- 创建项目 + 通用代码
- git 操作
- 创建仓库
- 代码冲突
- 远程代码同步
- 创建版本
- 发布打 tag
- 构建 + 发布上线
- 依赖安装和构建
- 资源上传 CDN
- 域名绑定
- 测试/正式服务器
脚手架核心价值
- 自动化: 项目重复代码拷贝/git 操作/发布上线操作
- 标准化: 项目创建/git flow/发布流程/回滚流程
- 数据化: 研发过程系统化、数据化、使得研发过程可量化
和自动化构建工具的区别
jenkins、travis 等自动化构建工具已经比较成熟了, 为什么还需要自研脚手架?
不满足需求: jenkins、travis 通常在 git hooks 中触发, 需要服务端执行, 无法覆盖研发人员本地的功能, 如: 创建项目自动化, 本地 git 操作自动化
定制复杂: jenkins、travis 定制过程需要开发插件, 其过程较为复杂, 需要使用 java 语言, 对前端同学不够友好
从使用角度理解什么是脚手架
脚手架简介
脚手架本质是一个==操作系统的客户端==, 它通过命令行执行, 比如:
vue create vue-test-app
上述这条命令由 3 个部分组成:
- 主命令:
vue
- commamd:
create
- command 的 param:
vue-test-app
它表示创建一个 vue 项目, 项目的名称是 vue-test-app, 以上是一个较为简单的脚手架命令, 但实际场景往往更为复杂, 比如:
当前目录已经有文件了, 我们需要覆盖当前目录下的文件, 强制进行安装 vue 项目, 此时我们就可以输入:
vue create vue-test-app –force (–force 可以理解为–force true 的简写)
这里的--force
叫做option
, 用来辅助脚手架确认在特定场景下用户的选择(可以理解为配置), 还有一种场景:
通过vue create
创建项目时, 会自动执行npm install
帮助用户安装依赖, 如果我们希望使用淘宝源来安装, 可以输入命令:
vue create vue-test-app –force -r https://reqistry.npm.taobao.org
这里的-r
也叫做 option, 它与--force
不同的是它使用-
, 并且使用简写, 这里的-
也可以替换成--reqistry
vue create –help 查看支持的所有 options
脚手架执行原理
- 在终端中输入
vue create vue-test-app
- 终端解析出
vue
指令 (which vue /usr/local/bin/vue)
- 终端在环境变量中找到
vue
命令
- 终端根据
vue
命令链接到实际文件vue.js
- 终端利用
node
执行vue.js
vue.js
解析commamd/options
- 执行完毕, 退出执行
从应用的角度看如何开发一个脚手架
以 vue-cli 为例
- 开发
npm
项目, 该项目中应包含一个bin/vue.js
文件, 并将这个项目发布到npm
- 将
npm
项目安装到node
的lib/node_modules
- 在
node
的bin
目录下配置vue
软链接指向lib/node_modules/@vue/cli/bin/vue.js
疑问
- 为什么全局安装
@vue/cli
后会添加的命令为vue
?
在 package.json 文件中, bin { vue: /bin/vue.js } bin 文件 vue 指向 vue.js
npm install -g @vue-cli
1 2
| #!/usr/bin/env node 使用node执行 命令 创建一个软链接 指向vue.js 文件
|
- 为什么说脚手架的本质是操作系统的客户端? 它和我们在 PC 上安装的应用/软件有什么区别?
node.exe 是一个可执行文件 node ./test.js 的本质是 node -e ‘console.log(‘123’)’
本质上没有区别, node 没有提供 gui
1 2 3
| 添加软链接 cd /usr/local/bin ln -s 地址 启动名
|
描述脚手架命令执行的全过程
- 在环境变量$PATH 中查询命令 相当于执行 which vue
- 查询实际链接文件
- 通过#!/usr/bin/env node 执行文件
脚手架开发流程
- 创建 npm 项目
- 创建脚手架入口文件, 最上方添加
#!/usr/bin/env node
- 配置 package.json, 添加 bin 属性
- 编写脚手架代码
- 将脚手架发布到 npm
脚手架开发难点
- 分包: 将复杂的系统拆分成若干个模块
- 命令注册 如: vue create
- 参数解析 如–version -h vue command [options]
- 帮助文档
- 命令行交互
- 日志打印
- 命令行文字变色
- 网络通信: HTTP/WebSocket
- 文件处理
脚手架的本地调试
- 创建软链接指向本地目录文件
- 在当前文件执行
npm link
分包
- npm link 其他包文件
- 本地安装包
脚手架本地 link 标准流程
链接本地脚手架
1 2
| cd your-cli-dir npm link
|
链接本地库文件
1 2 3 4
| cd your-lib-dir npm link cd your-cli-dir npm link your-lib
|
取消链接本地库文件
1 2 3 4 5 6 7 8 9 10
| cd your-lib-dir npm unlink cd your-cli-dir
#link存在 npm unlink your-lib
#link不存在 rm -rf node_modules npm install your-lib
|
脚手架命令注册和参数解析
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| #!/usr/bin/env node const lib = require('tut-lib/lib')
// 注册一个命令 tut init const argv = require('process').argv const command = argv[2] const options = argv.slice(3) let [option, param] = options if (option) { option = option.replace(/^--/, '') }
lib[command] && lib[command]({ option, param })
// 实现参数解析 --version 和 init --name const versionReg = /^-{1,2}/ if (versionReg.test(command)) { const globOption = command.replace(versionReg, '') if (['version', 'V'].includes(globOption)) { fs.readFile('./package.json', 'utf-8', function (err, data) { if (err) return const { version } = JSON.parse(data) console.log('version: ', version) }) } }
|
脚手架框架
1 2 3 4 5 6 7 8 9 10 11
| Plop yeoman-generator -> vscode插件
Schematics 配合schematics-utilities可以做到语法级别的样板代码生成 引入了虚拟文件系统, 可以保证写入原子性 支持多个schametics之间的组合和管道
ink react开发cli应用
|
Lerna 学习
原生脚手架开发痛点分析
痛点一: 重复操作
- 多 Package 本地 link
- 多 Package 依赖安装
- 多 Package 单元测试
- 多 Package 代码提交
- 多 Package 代码发布
痛点二: 版本一致性
- 发布时版本一致性
- 发布后互相依赖版本升级 (lerna link)
Lerna 简介
Lerna 是一个优化基于 git + npm 的多 Package 项目的管理工具
优势
- 大幅减少重复操作
- 提升操作的标准化
Lerna 是架构优化的产物, 它揭示了一个架构真理: 项目复杂度提升后, 就需要对项目进行架构优化, 架构优化的主要目标往往都是以效能为核心. lerna 的主仓库必须是私有的
Lerna 开发脚手架流程
脚手架项目初始化
- 初始化 npm 项目
- 安装 lerna
- lerna init 初始化项目
创建 package
- lerna create 创建 package
- lerna add 为指定 package 安装依赖 lerna add package utils/
- lerna link 链接依赖
- lerna init
脚手架开发和测试
- lerna exec – (–scope + 包名)执行 shell 脚本 在每一个 package 中执行操作
- lerna run (–scoped + 包名) 执行 npm 命令 (可以用来执行单测)
- lerna clean 清空依赖
- lerna bootstrap 重装依赖
- lerna bootstrap –hoist 依赖安装到根目录
脚手架发布上线
package.json publishConfig{.”access”: “public” }
- lerna version bump version (提升版本号)
- lerna changed 查看上版本以来的所有变更
- lerna diff 查看 diff
- lerna publish 项目发布 发布后会给 git 仓库推一个 tag
脚手架核心流程开发
core 模块技术方案
命令执行流程
准备阶段
1 2
| prepare 检查版本号->检查node版本->检查root启动->检查用户主目录->检查入参->检查环境变量->检查是否为最新版本->提示更新
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 核心库 import-local commander inquirer 工具库 npmlog fs-extra semver // 版本匹配/比对 colors // 终端打印不同颜色的文本 path-exists user-home // 快速拿到用户主目录 minimist // 解析argument参数 dotenv // 拿到环境变量 url-join root-check // 降级权限
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110
| 'use strict'
module.exports = core
// require 支持加载的类型 .js .json .node(C++) // any => 通过.js进行解析 const semver = require('semver') const colors = require('colors/safe') const userHome = require('user-home') const pathExists = require('path-exists').sync
const pkg = require('../package.json') const log = require('@tut-cli-dev/log') const { getNpmSemverVersion } = require('@tut-cli-dev/get-npm-info') const path = require('path') const { LOWEST_NODE_VERSION, DEFAULT_CLI_HOME } = require('./const')
let args
async function core(argvs) { try { checkPackageVersion() checkNodeVersion() checkRoot() checkUserHome() checkInputArgs(argvs) checkEnv() await checkGlobalUpdate() } catch (e) { // NOTICE log.error(e.message) } } function checkPackageVersion() { log.notice('cli', pkg.version) } function checkNodeVersion() { const currentVersion = process.version if (!semver.gte(currentVersion, LOWEST_NODE_VERSION)) { throw new Error( colors.red(`tut-cli 需要安装${LOWEST_NODE_VERSION}以上的node版本`) ) } } function checkRoot() { const rootCheck = require('root-check') // core: invode process.setuid() rootCheck() // 管理员为0 使用rootCheck => 管理员不为0 // console.log(process.getuid()) } function checkUserHome() { if (!userHome || !pathExists(userHome)) { throw new Error(colors.red('用户主目录不存在!')) } } function checkInputArgs(argvs) { const minimist = require('minimist') args = minimist(argvs) checkArgs() // console.log('args: ', args) // args: { _: [ 'init', 'u' ], g: true } } function checkArgs() { if (args.debug) { process.env.LOG_LEVEL = 'verbose' } else { process.env.LOG_LEVEL = 'info' } // NOTICE log.level = process.env.LOG_LEVEL } function checkEnv() { // process.cwd() 返回 Node.js 进程当前工作的目录 const dotenv = require('dotenv') const dotenvPath = path.resolve(userHome, '.env') if (pathExists(dotenvPath)) { dotenv.config({ path: dotenvPath }) } createDefaultConfig() log.verbose('缓存路径', process.env.CLI_HOME_PATH) } function createDefaultConfig() { const cliConfig = { home: userHome } if (process.env.CLI_HOME) { cliConfig.cliHome = path.join(userHome, process.env.CLI_HOME) } else { cliConfig.cliHome = path.join(userHome, DEFAULT_CLI_HOME) } // BETTER 对环境变量的值处理后生成新的环境变量 process.env.CLI_HOME_PATH = cliConfig.cliHome // return cliConfig } async function checkGlobalUpdate() { // 1. 获取当前版本号和模块名 // 2. 调用npmApi, 获取所有版本号 https://registry.npmjs.org/@tut-cli-dev/core // 3. 提取所有版本号, 比对当前版本号, 如果当前版本号小于最新版本号, 提示更新 // 4. 提示用户更新到最新版本 const currentVersion = pkg.version const npmName = pkg.name const lastVersion = await getNpmSemverVersion(currentVersion, npmName) if (lastVersion && semver.gt(lastVersion, currentVersion)) { log.warn( colors.yellow( `当前版本号${currentVersion}不是最新版本号${lastVersion}, 请更新到最新版本 更新命令: npm install -g ${npmName}@${lastVersion}` ) ) } }
|
@get-npm-info
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
| 'use strict'
const axios = require('axios') const urlJoin = require('url-join') const semver = require('semver')
function getNpmInfo(npmName, registry) { if (!npmName) return null const registryUrl = registry || getDefaultRegistry(true) const npmInfoUrl = urlJoin(registryUrl, npmName) return axios .get(npmInfoUrl) .then((res) => { if (res.status === 200) { return res.data } else { return null } }) .catch((err) => { throw Promise.reject(err) }) }
function getDefaultRegistry(isOriginal = false) { return isOriginal ? 'https://registry.npmjs.org' : 'https://registry.npm.taobao.org' }
async function getNpmVersions(npmName, registry) { const data = await getNpmInfo(npmName, registry) if (!data) return [] return Object.keys(data.versions) }
function getSemverVersions(baseVersion, versions) { versions = versions .filter((version) => { return semver.satisfies(version, `^${baseVersion}`) }) .sort((a, b) => semver.gt(b, a)) return versions }
async function getNpmSemverVersion(baseVersion, npmName, registry) { const versions = await getNpmVersions(npmName, registry) const newVersions = getSemverVersions(baseVersion, versions) if (newVersions && newVersions.length > 0) { return newVersions[0] } }
module.exports = { getNpmInfo, getNpmVersions, getNpmSemverVersion }
|
命令注册
1 2 3 4
| registerCommand 注册init命令 -> 注册publish命令 -> 注册clean命令 -> 支持debug
commander
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105
| #!/usr/bin/env node
const utils = require('@tut-cli-dev/utils') // 全局安装的包如果本地有安装,优先用本地的 const importLocal = require('import-local') const colors = require('colors') const commander = require('commander') const pkg = require('../package.json')
// 获取commanderd单例 // const { program } = commander // 手动实例化一个commander const program = new commander.Command() program .name(Object.keys(pkg.bin)[0]) .usage('<command> [options]') .version(pkg.version) .option('-d, --debug', '是否启动调试模式', false) .option('-e, --env <envName>', '获取环境变量名称') // commmand api注册命令 // []: 可填项 <>必填项 const clone = program.command('clone <source> [destination]') // 这里不能连写 初始化clone(program.command)会返回一个新的command对象 clone .description('clone a repository') .option('-f, --force', '是否强制克隆') .action((source, destination, cmdObj) => { // cmdObj 当前command })
// addCommnad 注册子命令 const service = new commander.Command('service') service.description('some server options')
service .command('start [port]') .description('start service at some port') .action((port) => { console.log(port) })
service .command('stop') .description('stop service') .action(() => { console.log('stop service') })
program.addCommand(service)
// 高级定制 实现debug 模式 program.on('option:debug', function () { if (this.opts().debug) { process.env.LOG_LEVEL = 'verbose' } })
// 高级定制 对未知命令监听 program.on('command:*', function (unknowCmdList) { console.log(colors.red(`未知的命令: ${unknowCmdList.join('、')}`)) const availableCommands = program.commands.map((cmd) => cmd.name()) console.log(colors.green(`可用的命令: ${availableCommands.join('、')}`)) })
// 监听所有的命令输入, 处理未定义的命令 // program // .arguments('[command] [options]') // .description('test command', { // command: 'command to run', // options: 'options for command' // }) // .action((cmd, env) => { // // console.log(cmd, env) // })
// 通过独立的的可执行文件实现命令 (注意这里指令描述是作为`.command`的第二个参数) // 多脚手架串用 tut install init => tut-i init program .command('install [name]', 'install package', { executableFile: 'tut-i', // 修改可执行文件名称 isDefault: false, hidden: true }) .alias('i') // tut-install
// 高级定制: 自定义help内容 // program.helpInformation = function () { // return 'info' // } // program.on('--help', function () { // console.log('info') // })
program.parse(process.argv) // program.outputHelp() // console.log(program.opts()) // __filename 获取当前目录 // __dirname 获取当前目录的父目录
// if (importLocal(__filename)) { // } else { // // 去除node路径和bin路径 // require('../lib/index.js')(process.argv.slice(2)) // }
|
痛点分析
1 2 3 4 5 6 7 8
| core cli commamds init models utils get-npm-info log utils
问题 1. cli 安装速度慢: 所有package都集成在cli里, 因此当命令较多时, 会减慢cli的安装速度 2. 灵活性差: init 命令只能使用@tut-cli-dev/init, 对于集团公司而言, 每个部门的init命令可能都不相同, 可能需要实现init命令动态化
|
流程改进
1
| 脚手架启动阶段 => commander脚手架初始化 => 动态加载initCommand => new initCommand => Command constructor => 命令的准备阶段 => 命令的执行阶段 => init业务逻辑 => end
|


脚手架创建项目流程设计和开发
脚手架背后的思考
- 可拓展: 能够快速复用到不同的团队, 适用不同团队之间的差异
- 低成本: 在不改动脚手架源码的情况下, 能够新增模版, 且新增模版的成本很低
- 高性能: 控制存储空间, 安装时充分利用 Node 多进程提升安装性能
架构图



inquirer 命令行交互
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
| async getProjectInfo() { let projectInfo = {} // 3. 选择创建项目或组件 const { type } = await inquirer.prompt({ type: 'list', name: 'type', message: '请选择初始化类型', default: TYPE_PROJECT, choices: [ { name: '项目', value: TYPE_PROJECT }, { name: '组件', value: TYPE_COMPONENT } ] }) log.verbose('type', type) if (type === TYPE_PROJECT) { // 4. 获取项目的基本信息 const project = await inquirer.prompt([ { type: 'input', name: 'projectName', message: '请输入项目名称', validate: function (v) { // 1. 输入的首字符必须为英文字符 // 2. 尾字符必须为英文或数字, 不能为字符 // 3. 字符仅仅允许'-_'
// tip const done = this.async()
setTimeout(() => { if ( !/^[a-zA-Z]+(-[a-zA-Z][a-zA-Z0-9]*|_[a-zA-Z][a-zA-Z0-9]*|[a-zA-Z0-9])*$/.test(v) ) { done('请输入合法的项目名称') return } done(null, true) }, 0) } }, { type: 'input', name: 'projectVersion', message: '请输入项目版本号', validate: function (v) { const done = this.async()
setTimeout(() => { if (!semver.valid(v)) { done('请输入合法的版本号') return } done(null, true) }, 0) }, filter: function (v) { if (!!semver.valid(v)) { return semver.valid(v) } else { return v } } } ]) projectInfo = { type, ...project } } else if (type === TYPE_COMPONENT) { }
return projectInfo }
|
脚手架创建项目和组件功能开发
1 2 3 4 5 6 7
| 下载模版 安装依赖 拷贝模版代码至当前目录 依赖安装(white hosue ) const WHITE_COMMAND = ['npm', 'cnpm', 'yarn', 'pnpm'] 启动执行命令
|