距离上次更文有10个月的时间了,其实平时有总结很多技术点,但在掘金上只想发表关于前端工程化系列方面的文章,而又由于这段时间一直没有可落地的工程化项目(就是懒了🤦!),所以也不好在没有自己切身试验的情况下撰写博文。
OK,写这篇文章的契机呢,是因为我即将要做一个超级超级超级大项目,前期希望把前端基建的一些东西给搭建好,所以想着做一个脚手架工具,将基建的东西集成到模板中去,达到一个规范和提效的目的。其实这篇文章的重点并不是为了教大伙如何编写一个脚手架(掘金上关于这方面的教程太多),而是为了向你们安利我写的脚手架工具——pandly-cli
😏。
正如标题所说,pandly-cli
最大的特色就是集成了Element UI
、View Design
、Ant design
三大主流UI
库供用户选择,并且还支持全局和按需的引入方式。当然,pandly-cli
中不止这一个功能,还集成了很多提效的功能,文章后面会详细介绍。按照惯例,我还是先简单阐述下我写这个脚手架的心路历程。
脚手架
整体思路还是借鉴了vue-cli2
的搭建模式(为什么不借鉴vue-cli3
的?太复杂了!),然后自己做了点修改。整体目录结构如下:
|-pandly-cli
| |-bin # 命令执行文件
| | |-pandly # 主命令
| | |-pandly-create # 创建命令
| |-lib # 工具模块
| | |-ask.js # 交互询问
| | |-check-version.js # 检查脚手架版本
| | |-complete.js # 命令执行完成后的操作
| | |-generate.js # 模板渲染
| |-package.json
复制代码
相比较vue-cli2
做了很大的简化,其中最大的不同是,vue-cli2
是先通过脚手架工具将模板下载下来,然后再根据交互的输入渲染模板。我修改为,先收集交互的输入,再将模板下载下来去渲染。
使用 commander 解析命令
首先,我们使用npm init
初始化一个npm
工程,在这个工程的package.json
的bin
字段中定义命令名和对应的可执行文件:
{
"name": "pandly-cli",
"version": "1.0.0",
"description": "An awesome CLI for scaffolding Vue.js projects",
"preferGlobal": true,
"bin": {
"pandly": "bin/pandly",
"pandly-create": "bin/pandly-create"
},
...
复制代码
在bin/pandly
文件中处理用户输入的命令:
#!/usr/bin/env node
const program = require('commander')
program
.version(require('../package').version)
.usage('<command> [options]')
.command('create', '用模板创建一个新的项目')
.parse(process.argv)
复制代码
这里有两点需要注意:
- 文件的头部一定要加上一行
#!/usr/bin/env node
代码使其成为一个可执行文件; - 在
commander
中如果定义了子命令而没有显示调用action()
时,commander
将尝试在入口脚本的目录中搜索文件名为[command]-[subcommand]
的可执行文件执行。比如.command('create')
命令则会找到同文件夹下的pandly-create
,所以会调用文件bin/pandly-create
并执行。
pandly-cli
只有一个命令pandly create xxx
。
使用 inquirer 实现命令行交互
由于询问的条目比较多,所以把命令行交互的逻辑单独放在了lib/ask.js
文件中:
const { prompt } = require('inquirer')
const questions = [
...,
{
name: 'UI',
type: 'list',
message: 'Pick a UI library to install',
choices: [{
name: 'Element UI',
value: 'element-ui',
short: 'Element'
}, {
name: 'View Design',
value: 'view-design',
short: 'View'
}, {
name: 'Ant Design',
value: 'ant-design-vue',
short: 'Ant'
}]
},
...
]
module.exports = function ask () {
return prompt(questions).then(answers => {
return answers
})
}
复制代码
lib/ask.js
最后抛出一个promise
对象,返回收集到的用户的输入。
使用 download-git-repo 下载模板
收集到用户的输入以后,开始下载模板,在bin/pandly-create
中:
#!/usr/bin/env node
const program = require('commander')
const chalk = require('chalk')
const path = require('path')
const home = require('user-home')
const exists = require('fs').existsSync
const inquirer = require('inquirer')
const ora = require('ora')
const rm = require('rimraf').sync
const download = require('download-git-repo')
const checkVersion = require('../lib/check-version')
const generate = require('../lib/generate')
const ask = require('../lib/ask')
program
.usage('[project-name]')
.parse(process.argv)
// 创建项目的目录名
const rawName = program.args[0]
// true则表示没写或者'.',即在当前目录下构建
const inPlace = !rawName || rawName === '.'
// 如果是在当前目录下构建,则创建项目名为当前目录名;如果不是,创建项目名则为 rawName
const projectName = inPlace ? path.relative('../', process.cwd()) : rawName
// 创建项目目录的绝对路径
const projectPath = path.resolve(projectName || '.')
// 远程模板下载到本地的路径
const downloadPath = path.join(home, '.vue-pro-template')
const spinner = ora()
process.on('exit', () => {
console.log()
})
// 在当前目录下创建或者创建的目录名已经存在,则进行询问,否则直接执行 run 函数
if (inPlace || exists(projectPath)) {
inquirer.prompt([{
type: 'confirm',
message: inPlace ? 'Generate project in current directory?' : 'Target directory exists. Do you want to replace it?',
name: 'ok'
}]).then(answers => {
if (answers.ok) {
console.log(chalk.yellow('Deleting old project ...'))
if (exists(projectPath)) rm(projectPath)
run()
}
}).catch(err => console.log(chalk.red(err.message.trim())))
} else {
run()
}
function run() {
// 先收集用户的输入,再下载模板
ask().then(answers => {
if (exists(downloadPath)) rm(downloadPath)
checkVersion(() => {
// 模板的 github 地址为 https://github.com/pandly/vue-pro-template
const officalTemplate = 'pandly/vue-pro-template'
downloadAndGenerate(officalTemplate, answers)
})
})
}
function downloadAndGenerate (officialTemplate, answers) {
spinner.start('Downloading template ...')
download(officialTemplate, downloadPath, { clone: false }, err => {
if (err) {
spinner.fail('Failed to download repo ' + officialTemplate + ': ' + err.message.trim())
} else {
spinner.succeed('Successful download template!')
generate(projectName, downloadPath, projectPath, answers)
}
})
}
复制代码
由于模板会不定期的更新,为了保证能使用到最新的模板,所以每次使用脚手架创建项目时,都会先把本地的老模板删除,然后再重新从github
上下载最新的模板。
使用 metalsmith + handlebars 处理模板
pandly-cli
提供的模板不仅仅是一个纯粹的文件,而是可以通过用户输入的参数进行编译,得到不同的目标文件。所以先通过metalsmith
来获取模板中的每个文件,然后使用handlebars
对每个文件进行编译。在lib/generate.js
中:
const Metalsmith = require('metalsmith')
const Handlebars = require('handlebars')
const exists = require('fs').existsSync
const path = require('path')
const rm = require('rimraf').sync
const ora = require('ora')
const complete = require('./complete')
const spinner = ora()
// register handlebars helper
Handlebars.registerHelper('if_eq', function (a, b, opts) {
return a === b
? opts.fn(this)
: opts.inverse(this)
})
Handlebars.registerHelper('unless_eq', function (a, b, opts) {
return a === b
? opts.inverse(this)
: opts.fn(this)
})
module.exports = function generate (name, src, dest, answers) {
spinner.start('Generating template ...')
if (exists(dest)) rm(dest)
Metalsmith(path.join(src, 'template'))
.metadata(answers)
.clean(false)
.source('.')
.destination(dest)
.use((files, metalsmith, done) => {
const metadata = metalsmith.metadata()
const keys = Object.keys(files)
keys.forEach(fileName => {
const str = files[fileName].contents.toString()
if (!/{{([^{}]+)}}/g.test(str)) {
return
}
files[fileName].contents = Buffer.from(Handlebars.compile(str)(metadata))
})
done()
})
.build((err, files) => {
if (err) {
spinner.fail(`Faild to generate template: ${err.message.trim()}`)
} else {
new Promise((resolve, reject) => {
setTimeout(() => {
spinner.succeed('Successful generated template!')
resolve()
}, 3000)
}).then(() => {
const data = {...answers, ...{
destDirName: name,
inPlace: dest === process.cwd()
}}
complete(data)
})
}
})
}
复制代码
模板中都是以{{}}
占位符的形式来进行参数的替换和条件编译,比如在package.json
中:
{
"name": "{{ name }}",
"version": "1.0.0",
"description": "{{ description }}",
"author": "{{ author }}",
...
"dependencies": {
...
{{#if_eq UI "element-ui"}}
"element-ui": "^2.13.1"
{{/if_eq}}
{{#if_eq UI "view-design"}}
"view-design": "^4.2.0"
{{/if_eq}}
{{#if_eq UI "ant-design-vue"}}
"ant-design-vue": "^1.5.3"
{{/if_eq}}
},
...
}
复制代码
使用 child_process 创建进程执行命令
模板编译成功以后,如果用户选择了初始化git
本地仓库或者使用npm
来安装项目,则需要另开启一个进程来执行这些命令。在lib/complete.js
中:
const spawn = require('child_process').spawn
const chalk = require('chalk')
const path = require('path')
module.exports = function complete(data) {
const cwd = path.join(process.cwd(), data.inPlace ? '' : data.destDirName)
if (data.git) {
initGit(cwd).then(() => {
if (data.autoInstall) {
installDependencies(cwd, data.autoInstall).then(() => {
printMessage(data)
}).catch(e => {
console.log(chalk.red('Error:'), e)
})
} else {
printMessage(data)
}
}).catch(e => {
console.log(chalk.red('Error:'), e)
})
} else if (data.autoInstall) {
installDependencies(cwd, data.autoInstall).then(() => {
printMessage(data)
}).catch(e => {
console.log(chalk.red('Error:'), e)
})
} else {
printMessage(data)
}
}
function initGit (cwd, executable = 'git') {
return runCommand(executable, ['init'], {
cwd,
})
}
function installDependencies(cwd, executable = 'npm') {
console.log(`\n# ${chalk.green('Installing project dependencies ...')}`)
console.log('# ========================\n')
return runCommand(executable, ['install'], {
cwd,
})
}
function runCommand(cmd, args, options) {
return new Promise((resolve, reject) => {
const spwan = spawn(
cmd,
args,
Object.assign(
{
cwd: process.cwd(),
stdio: 'inherit',
shell: true,
},
options
)
)
spwan.on('exit', () => {
resolve()
})
})
}
function printMessage(data) {
const message = `
# ${chalk.green('Project initialization finished!')}
# ========================
To get started:
${chalk.yellow(
`${data.inPlace ? '' : `cd ${data.destDirName}\n `}${installMsg(data)}npm run serve`
)}
`
console.log(message)
}
function installMsg(data) {
return !data.autoInstall ? 'npm install\n ' : ''
}
复制代码
整个脚手架搭建的过程大致就以上步骤,不难,只要掌握核心的几个库就能轻松的搭建起自己的脚手架。好了,接下来我要开始介绍我这个模板了。
模板
模板是基于vue-cli3
的二次集成,包含了以下内容:
1. vue 全家桶
vue
+ vue-router
+ vuex
2. 初始化 git 本地仓库
3. 可选择三大 UI 库(ElementUI、ViewDesign、AntDesign)安装,并支持全局引入和按需引入
4. 直接生成与 UI 库对应的头部和导航栏布局的后台管理模板,只需要专注页面内容的编写
5. router 模块解耦方案,兼并导航栏的渲染
|-src
| |-router
| | |-modules
| | | |-index.js
| | | |-navigation1.router.js
| | |-index.js
复制代码
modules/navigation1.js
:路由模块按功能划分,比如navigation1.router.js
中存放关于navigation1
模块的路由;modules/index.js
:使用require.context
实现了modules
中路由模块的自动合并,无需手动合并;index.js
:vue-router
的相关配置。
最后导出的路由表将渲染出导航栏,无需再另外编写导航栏数据。
6. 优雅的 axios 请求方案
详情请参考:前端工程化(3):在项目中优雅的设计基于Axios的请求方案
前端缓存机制没有添加在模板中,需要的同学可以自行添加
7. vue + js 的代码风格校验
eslint
模式默认是standard
,同时可以自行选择vue
代码风格的校验程度:
8. 只依赖webpack-dev-server
的本地mock-server
方案;
老方案:express
+ nodemon
新方案:webpack-dev-server
+ mocker-api
新方案的mock-server
完全基于webpack-dev-server
来实现,无需在项目中安装express
另起服务。所以在启动前端服务的同时,mock-server
就会自动启动。启动的同时,借助mocker-api
,模拟api
代理,并支持热mocker
文件替换。
9. 基于 http-proxy-middleware 的多环境联调方案
详情请参考:前端工程化(4):http-proxy-middleware在多环境下的代理应用
10. 基于 angular 团队的约定式 git 提交规范
详情请参考:前端工程化(2):快速搭建基于angular团队代码提交规范的工作流
11. 打包分析
执行npm run build --report
来进行打包分析
待完善
- 单元测试
- 支持
ES7
新语法 - 构建速度优化
- 首屏渲染优化
最后
本文并不是一篇技术干货文,只是提供了一个基于脚手架的项目解决方案,希望能对同学们有所帮助!
使用方式:
-
npm install pandly-cli -g
-
pandly create xxx
github
地址:github.com/pandly/pand…
来源:oschina
链接:https://my.oschina.net/u/4418383/blog/4280657