前言
前不久,产品经理A
跑来对我说,你看,这项目(cloud-web
)能不能只有jingzhe
(目前项目里的一个独立的功能模块)?
产品经理B
又跑来对我说,我这个项目,既需要jingzhe
,也需要yuyi
(亦为一个独立的功能模块)
我看了下项目结构,如下图所示,略微思索了一下,回答他们,可以
views
下面的这些模块,皆为独立的路由模块,比如本地jingzhe
的访问路径为http:localhost:3000/jingzhe
changeLog
为日志模块,始终存在于各个不同的项目里
如何编写脚手架,根据用户选择,只生成用户所需的页面模块呢?
正文
开始
新建一个项目,名为sf-cli
,在该项目下,执行npm init
命令,自动生成package.json
文件
mkdir sf-cli && cd sf-cli
npm init
创建各个目录
在项目内新建bin
、lib
、templates
文件夹,如下图所示
在bin
文件夹里新建sf.js
,代码如下所示:
#!/usr/bin/env node
const program = require('commander');
program
.command('init')
.alias('i')
.description('欢迎使用 @sf/cli')
.action(() => {
const Init = require('../lib/init')
new Init();
});
program.parse(process.argv)
// 若参数不足2个,则打印该脚本所有命令
if (!process.argv.slice(2).length) {
program.outputHelp();
}
新建init.js
在lib
文件夹内新建init.js
,代码如下所示:
const clear = require('clear');
const figlet = require('figlet');
const chalk = require('chalk');
const ora = require('ora');
const inquirer = require('inquirer');
const GitDownloader = require('./initUtils/GitDownloader');
const Selector = require('./initUtils/Selector');
class InitInquirer {
interactionsHandler() {
// 提问模式
const questions = [
{
type: 'input',
name: 'name',
message: '请输入项目名称:',
validate: (input) => {
if (!input) {
return '请输入项目名称!';
}
return true;
},
default: 'cloud-web'
},
{
type: 'input',
name: 'description',
message: '请输入项目描述:',
default: 'Base on sf-cli'
}
];
return inquirer.prompt(questions);
}
}
module.exports = class Init {
constructor() {
clear();
console.log('*****************************');
console.log(chalk.green(figlet.textSync('SF Starter', { horizontalLayout: 'full' })));
console.log('*****************************');
console.log();
const spinner = ora('👉 检查构建环境...').start();
spinner.succeed(chalk.green('构建环境正常!'));
console.log();
this.init();
}
async init() {
const answer = await new InitInquirer().interactionsHandler();
// 依据配置下载所需的模板
const finalAnswer = await new Selector().interactionsSelect(answer);
// 构建配置保存
await new GitDownloader().syncDownload(answer, finalAnswer);
}
}
clear()
会清空控制台所有信息,如下图所示
init()
函数获取到用户的输入信息answer
,将其传给选择器Selector
,选择器Selector
内会让用户选择是否有jingzhe
、yuyi
等功能模块,再将finalAnswer
传递给GitDownloader
,执行仓库下载即可
新建init.js
相关的工具库
Selector.js
在lib
目录下新建initUtils
目录,再在该目录下,新建Selector.js
文件,代码如下所示:
const inquirer = require('inquirer');
const choiceList = require('./choiceList');
module.exports = class Selector {
async interactionsSelect() {
const questionsChoice = this.generateChoiceList(choiceList);
const resultChoice = await inquirer.prompt(questionsChoice);
return resultChoice;
}
generateChoiceList(choiceList){
return [
{
type: 'checkbox',
name: 'selectTypes',
message: '选择需要的页面类型',
choices: choiceList
}
];
}
}
同级目录下,新建choiceList.js
文件,代码如下所示:
module.exports = [
{ name: 'jingzhe', value: 'jingzhe' },
{ name: 'longhua', value: 'longhua' },
{ name: 'medical', value: 'medical' },
{ name: 'solution', value: 'solution' },
{ name: 'yuyi', value: 'yuyi' }
]
resultChoice
为多选数组,比如你选择了jingzhe
和yuyi
,则resultChoice
为[{ name: 'jingzhe', value: 'jingzhe' },{ name: 'yuyi', value: 'yuyi' }]
GitDownloader.js
在initUtils
目录下,新建GitDownloader.js
文件,代码如下所示:
const chalk = require('chalk');
const ora = require('ora');
const child_process = require('child_process');
const fs = require('fs');
const path = require('path');
const OptionsFilter = require('./OptionsFilter');
const templates = {
vue2: {
url: 'http://git.sf-express.com/scm/gis-dsb/gis-dsb-core-cloud.git',
description: 'SF Starter'
}
}
module.exports = class GitDownloader {
async syncDownload({type='vue2', name='', description}, finalOptions) {
console.log();
console.log(chalk.green('👉 开始构建,请稍侯...'));
console.log();
const spinner = ora('正在构建模板...').start();
const { url } = templates[type];
// 执行下载
await this.executeDownload(url, name);
const optionsFilter = new OptionsFilter();
// 选择包括模块:排除不用内容
await optionsFilter.excludeModules(name, finalOptions);
// 执行成功相关操作
this.executeBuildSuccess(spinner, {name, description});
}
executeDownload(url, name) {
return new Promise((resolve) => {
child_process.execSync(`git clone -b feature/dev/baseline ${url} ${name}`);
resolve(true);
});
}
executeBuildSuccess(spinner, options) {
console.log();
spinner.succeed(chalk.green('构建成功!'));
const packagePath = path.join(options.name, './code/cloud-web/package.json');
try {
const packageContent = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
packageContent.name = options.name;
packageContent.description = options.description;
// 写入配置
fs.writeFileSync(packagePath, JSON.stringify(packageContent, null, 2), {
encoding: 'utf8'
});
} catch (error) {
console.log('write file error==', error);
}
console.log();
console.log(chalk.green('👏 初始化项目完成!👏'));
console.log();
console.log(chalk.blue('命令提示:'));
console.log(chalk.blue(` # 进入项目`));
console.log(chalk.blue(` $ cd ./${options.name}`));
console.log(chalk.blue(` # 安装依赖`));
console.log(chalk.blue(` $ npm install`));
console.log(chalk.blue(` # 运行`));
console.log(chalk.blue(` $ npm run dev`));
console.log();
}
}
executeDownload
调用Node.js
内置的child_process.execSync
方法,拉取远程仓库http://git.sf-express.com/scm/gis-dsb/gis-dsb-core-cloud.git
,其分支名为feature/dev/baseline
里的代码
executeDownload
里的第二个参数name
,为init.js
里用户在命令行终端输入的项目名称name
,即会默认在当前命令执行目录内,生成name
文件夹,其里面的代码来源于上面的仓库
executeBuildSuccess
根据init.js
里用户的选择,重新生成package.json
,并替代原来的文件
OptionsFilter.js
文件
在initUtils
目录下,新建OptionsFilter.js
文件,代码如下所示:
const del = require('del');
const choiceList = require('./choiceList');
const path = require('path');
const fs = require('fs-extra');
const Handlebars = require('handlebars');
function camelize (str) {
return str.replace(/-(\w)/g, (_, c) => c ? c.toUpperCase() : '')
}
module.exports = class OptionsFilter {
async excludeModules(name, finalOptions) {
const excludeTypeList = [];
const includeTypeList = [];
const cwd = process.cwd();
const parentPath = path.join(`${cwd}/${name}`, "./code/cloud-web/src/pages/index");
choiceList.forEach(list => {
// 为空时代表要排除,返回值代表需要保留
if (!this.checkFileNeedtoKeep(list.value, finalOptions)) {
excludeTypeList.push(list);
} else {
includeTypeList.push(list)
}
})
this.generateRouteFile(includeTypeList, parentPath);
this.generateStoreFile(includeTypeList, parentPath);
// 执行删除
for (const needToExcludeItem of excludeTypeList) {
try {
if (needToExcludeItem.value) {
const delPath = path.join(parentPath, `./views/${needToExcludeItem.value}`);
await del(delPath);
}
} catch (error) {
console.log('excludeModules error..', error);
}
}
}
/**
* 检测是否需要保留内容
*
* @private
* @param {*} finalOptions
* @param {*} checkStr
* @returns
*
* @memberOf OptionsFilter
*/
checkFileNeedtoKeep(checkStr, finalOptions) {
let findStr = '';
for (let index = 0; index < finalOptions.selectTypes.length; index++) {
const elementSelectTypeItem = finalOptions.selectTypes[index];
if (checkStr === elementSelectTypeItem) {
findStr = elementSelectTypeItem;
break;
}
}
return findStr;
}
/**
* 生成路由文件
* @param {Array} includeTypeList 选中的views列表
* @param {String} parentPath 父级目录地址
*/
generateRouteFile(includeTypeList, parentPath) {
const solutionIndex = includeTypeList.findIndex(a => a.name === 'solution')
let defaultPage = ''
if (solutionIndex > -1) {
defaultPage = 'solution'
} else {
defaultPage = includeTypeList[0].name
}
const sourceFile = fs.readFileSync(path.join(__dirname, '../../templates/init/router.handlebars'), 'utf-8');
const source = Handlebars.compile(sourceFile)({
defaultPage,
list: includeTypeList
});
fs.outputFileSync(path.join(parentPath, `./config/router.config.js`), source, 'utf8');
}
generateStoreFile(includeTypeList, parentPath) {
const files = fs.readdirSync(path.join(parentPath, `./store/modules`), { withFileTypes: true })
const hasLonghua = includeTypeList.findIndex(a => a.name === 'longhua') > -1
const hasMedical = includeTypeList.findIndex(a => a.name === 'medical') > -1
const hasJingZhe = includeTypeList.findIndex(a => a.name === 'jingzhe') > -1
const sourceFile = fs.readFileSync(path.join(__dirname, '../../templates/init/store.handlebars'), 'utf-8');
const modules = files.map(a => {
const formatName = camelize(a.name.split('.')[0])
return {
name: formatName
}
})
const source = Handlebars.compile(sourceFile)({
modules,
hasLonghua,
hasMedical,
hasJingZhe
});
fs.outputFileSync(path.join(parentPath, `./store/index.js`), source, 'utf8');
}
}
generateRouteFile
根据templates
里的router.handlebars
模板以及用户选择的includeTypeList
,自动生成路由文件router.config.js
generateStoreFile
根据templates
里的store.handlebars
模板以及用户选择的includeTypeList
,自动生成store
目录下的index.js
执行del
命令,删除没有被选中的目录文件
templates
模板文件,我选择Handlebars
,当然,你们也可以选择其他模板引擎
在templates
目录下新建init
目录,在该目录下,新建router.handlebars
和store.handlebars
俩文件,其代码分别如下所示:
router.handlebars
// auto-generated by sf-cli
/**
* 当前以后台动态配置为准,此配置为备用项
* 走菜单,走权限控制
* @type {[null,null]}
*/
export const asyncRouterMap = [
{
path: '*',
redirect: '/404',
hidden: true,
},
]
/**
* 基础路由
* @type { *[] }
*/
export const constantRouterMap = [
{
path: '/',
redirect: '/{{defaultPage}}',
hidden: true,
},
{{#each list}}
{
path: '/{{name}}',
name: '{{name}}',
component: () => import(/* webpackChunkName: {{name}}*/ '@index/views/{{name}}/index'),
hidden: true,
},
{{/each}}
{
path: '/404',
component: () => import(/* webpackChunkName: "fail" */ '@/core/views/exception/404'),
}
]
store.handlebars
// auto-generated by sf-cli
import Vue from 'vue'
import Vuex from 'vuex'
import getters from './getters'
import createLogger from 'vuex/dist/logger'
{{#each modules}}
import {{name}} from './modules/{{name}}'
{{/each}}
{{#if hasLonghua}}
import LongHuaModules from '@index/views/longhua/store'
{{/if}}
{{#if hasMedical}}
import MedicalModules from '@index/views/medical/store'
{{/if}}
{{#if hasJingZhe}}
import JingZheModules from '@index/views/jingzhe/store'
{{/if}}
import { SFDModules } from '@sf/map-service/es/index'
import { SFViewGetters, SFViewModules } from '@/sf-view-vue/src/index.js'
import { blockdata } from '@sf/block-data'
Vue.use(Vuex)
const plugins = process.env.NODE_ENV !== 'production' ? [createLogger({})] : []
export default new Vuex.Store({
modules: {
{{#each modules}}
{{name}},
{{/each}}
{{#if hasLonghua}}
...LongHuaModules,
{{/if}}
{{#if hasMedical}}
...MedicalModules,
{{/if}}
{{#if hasJingZhe}}
...JingZheModules,
{{/if}}
...SFViewModules,
...SFDModules,
...blockdata
},
getters: {
...getters,
...SFViewGetters,
},
plugins,
})
package.json
package.json
里的代码如下所示:
{
"name": "@sf/cli",
"version": "3.0.4",
"description": "Command line interface for rapid Sf development",
"bin": {
"sf": "bin/sf.js"
},
"keywords": [
"sf",
"cli"
],
"author": "Lucy Zhu",
"license": "MIT",
"dependencies": {
"@sf/cli-ui": "^3.0.3",
"@vue/cli-shared-utils": "^4.5.7",
"babel-runtime": "^6.9.2",
"chalk": "^1.1.3",
"clear": "^0.1.0",
"commander": "^2.20.0",
"debug": "^4.1.0",
"deepmerge": "^4.2.2",
"del": "^6.0.0",
"figlet": "^1.5.0",
"fs-extra": "^7.0.1",
"handlebars": "^4.0.11",
"inquirer": "^7.1.0",
"lodash.clonedeep": "^4.5.0",
"minimist": "^1.2.5",
"ora": "^1.2.0",
"recast": "^0.18.8",
"resolve": "^1.17.0",
"shortid": "^2.2.15",
"slash": "^3.0.0"
},
"engines": {
"node": ">=8.9"
},
"publishConfig": {
"registry": "http://artifactory.sf-express.com/artifactory/api/npm/npm-sf-local/"
}
}
bin
表示我们发布该npm
包后,执行sf init
,相当于执行node bin/sf.js init
命令
publishConfig
为npm
包地址
发布npm包
执行npm publish
发包成功后,在命令行终端执行npm install @sf/cli -g
全局安装该包
在命令行终端输入sf
,有以下提示,即表明安装成功
在E
盘内(或电脑的任何磁盘内)打开git bash
,执行npm init
即可在E
盘下自动生成cloud-web
项目(如果你没有更改项目名称的话),项目内的router.config.js
及store -> index.js
均为根据handlebars
模板自动生成