工具脚本项目构建-NodeJS
目录
引子
项目早期架构没考虑静态文本的单独引用,随着项目越滚越大,积存了不少静态文本,这些静态文本不通过后端返回,写死在项目中导致如下问题:
- 静态文本占用包内存,如:旅游推荐类文章四篇纯文本(不计入css)占用36KB;
- 维护成本增大,改个文案流程从定位->修改->打包->部署,流程繁琐耗时长。
解决以上两个问题的前端方法就是提取出静态文本后通过ajax按需请求和加载。
静态文本提取不难,但是前端开发中如何更好的维护这些静态文本,如何让需求方也能维护,这是值得思考的,对于如何维护我们提出的解决方案是all in script
,就是让脚本帮助我们管理这些静态文本。
如何编写脚本
大到Webpack小到linux常用命令,脚本在我们开发中无处不在。
脚本俗名可执行文件
,通过Shell
、Python
、NodeJS
等语言语法编写后,给予一定权限就可以在命令行中执行了,通过脚本我们可以做很多事情,如运维人员通过Shell脚本批量执行服务器操作、办公人员通过Python脚本过滤和整理Excel数据等。
1. 第一步当然是选择一款趁手的语言
编程语言几百种,简单功能大部分编程语言都能完成,挑适合自己的才是最重要的,比如要完成一个包含UI界面的游戏,Matlab
、Qt C++
和 网页(HTML + JS + CSS)
都可以做到,如果交给前端开发人员当然会选择网页应用的方式(其它两个不一定会),同样的道理,编写命令行脚本也应该选择自己擅长的语言。
2. 构建执行环境
当选择完编程语言后我们就可以准备开发了,如果是shell脚本或者VIMScript,有台linux机器就可以跑了,如果是Python或者NodeJS脚本那我们就需要构建对应的执行环境(安装Python和NodeJS)。
3. 需求分析
分析引子里的需求,初步确认我们要做的脚本主要功能是管理静态内容,对静态内容提供如下功能:
- 通过命名空间区分作用范围;
- 根据模板内容生成静态文本配置文件;
- 合并配置文件为一个json文件供前端i18n工具使用;
- 生成静态文章类文件;
- 给文件加密及压缩;
- 提供搜索功能,用于搜索关键字,定位到配置文件及调用文件;
- 自动化生成脚本执行环境,如安装依赖;
- 其它,如:编写README文档、配套自动化脚本等。
4. 编写脚本
:::tip 我们可以按引子里的需求构建脚本以解决对应的问题。 脚本语言: NodeJS 执行环境: ZSH、NodeJS v10.13 编码工具: VSCode :::
我们看一下这个脚本工具完成后最终的目录文件:
.
├── README.md # 帮助文档
├── argvs.js # 定义OPTIONS
├── config.json # 脚本配置文件
├── example.txt # 静态文本配置文件模板
├── genzip.sh # 配合Jenkins的打包脚本(生成zip文件)
├── init.js # 初始化脚本
├── main.js # 主程序脚本
├── path.js # 路径信息
├── script-i18n # 脚本程序入口文件
└── utils.js # 存放工具函数
一般命令行原生命令脚本就一个文件,但如果将功能细分可以达到更好维护的效果
4-1. 代码调试方法
前端开发网页应用可以直接用浏览器调试,但是开发NodeJS应用可能就有点陌生了,这里我们将调试方式分为两种:
- 通过命令行调试 🔗: 命令语法
node debug /path/to/file.js
或者node inspect /path/to/file.js
, 这种方式调试对使用过GDB、PDB的同学非常友好,几乎没有学习成本, 命令行调试是开发脚本推荐的调试方式; - 通过浏览器调试: 使用命令
node --inspect-brk /path/to/file.js
后在chrome中输入chrome://inspect/#devices
可以在Remote Target下找到按钮链接,点进去可以打开一个调试面板。NodeJS之前的几个版本是没有这个功能,但可以安装工具node-nightly
使用它提供的Node实现,用过的是不是有过关闭node-nightly
自动检测更新的经历…
关于命令使用可直接输入man node debugger
查看, 或者查阅在线文档学习如何命令行调试(点击查看 🔗)。
根据需求分析里分析的功能点我们现在可以开始开发了。
4-2. 确认命令及参数
思考我们这个脚本完成需求需要有哪些命令,及需要哪些参数,最终确认执行命令命名和确认需要传递的参数, 比如我们这个项目的帮助文档是这样的:
$ node script-i18n help
Usage: node script-i18n <命令> [选项]
命令:
script-i18n add 增加i18n元文件
script-i18n search 搜索命名空间
script-i18n dist 生成目标文件
script-i18n info 当前项目信息输出
script-i18n init 工具初始化
选项:
-n, --namespace 命名空间名 [字符串]
-k, --keyword 关键词,如命名空间名或关键字名 [字符串]
-h, --help 显示帮助信息 [布尔]
-v, --version 显示版本号 [布尔]
示例:
script-i18n add -n namespace
script-i18n search -k keyword
script-i18n dist
script-i18n info
script-i18n init
4-3. 定义配置、目录、参数文件
我们可以先touch相应的文件,在编写主体代码后慢慢加入
配置文件(config.json)
{
// 版本
"version": "v1.0.0",
// 程序入口
"script": "script-i18n",
// 最终生成文件路径
"distPath": "dist/mice2/pc/static/i18n/",
// 工作目录
"workPath": "src",
// 最终生成文件名称
"targetFileName": "app.json",
// 静态文本配置文件名
"fileName": "i18n.static",
// 项目配置文件
"config": "config.json",
// 静态文本配置文件模板
"example": "example.txt",
// 日志等级,使用log4js打印日志
"logLevel": "info",
// 静态文本是否需要加密
"textEncrypt": false,
// 静态文章是否需要加密
"artiEncrypt": true,
// 适配的关键字
"keywords": ["namespace", "create", "attribute"],
// 脚本命令及说明
"command": {
"add": {
"tip": "生成配置文件"
},
"search": {
"tip": "查询配置"
},
"dist": {
"tip": "生存最终文件"
},
"info": {
"tip": "基本信息"
}
},
// 最终的npm命令, 通过调用传入-v可查看功能说明
"scripts": {
"i18n:info": "script-i18n info",
"i18n:add": "script-i18n add -n",
"i18n:search": "script-i18n search -k",
"i18n:dist": "script-i18n dist",
"i18n:zip": "sh scripts/i18n/genzip.sh"
},
// 脚本依赖的模块包
"requirement": [
// 解析参数
"yargs",
// 输出上色
"chalk",
// 读取用户输入
"readline-sync",
// 日志工具
"log4js",
"dayjs",
"btoa",
"pako"
]
}
目录文件(path.js)
这个文件和CRA(create-react-app)脚手架生成项目的config/path.js
文件相似,用于提供程序调用需要用到的各种目录
const path = require('path');
const fs = require('fs');
const config = require('./config.json');
const plist = fs.realpathSync(process.cwd()).split('/');
while (!fs.existsSync(path.resolve(plist.join('/'), 'package.json'))) {
plist.pop();
if (plist.length === 0) process.exit(0);
}
const appDirectory = plist.join('/');
const resolveApp = (...relativePath) => path.resolve(appDirectory, ...relativePath);
module.exports = {
// 项目根目录
basePath: resolveApp(''),
// 生成目标文件目录
distPath: resolveApp(config.distPath),
// 工作目录
workPath: resolveApp(config.workPath),
// 执行命令的路径
initPath: process.env.INIT_CWD,
// 脚本所在目录
homePath: resolveApp('scripts/i18n'),
// 模块包安装目录
modulePath: resolveApp('node_modules'),
// 文章源文件目录
articlePath: resolveApp('src/article'),
// 目标静态文章生成目录
distArticlePath: resolveApp(config.distPath, 'article'),
// 项目可执行命令存放目录,存放模块包提供的真实命令文件的引用, 我们入口文件最终会放在这个目录下面
binPath: resolveApp('node_modules/.bin/'),
};
Notes:
- path 🔗模块用于处理和文件路径有关的各类问题,如文件分隔符,linux下是
/
, window下是\
, 此时就可以使用path.sep
获取分隔符; - fs 🔗全称
file system
,用于处理文件IO类操作,读取写入文件都会用到它。
参数文件(argv.js)
在这里我们使用yargs来定义与配置命令参数
const config = require('./config.json');
module.exports = require('yargs/yargs')(process.argv.slice(2))
.help()
.alias('h', 'help')
.alias('v', 'version')
.version(config.version)
.usage('Usage: node $0 <commond> [options]')
.command('add', '增加i18n元文件')
.command('search', '搜索命名空间')
.command('dist', '生成目标文件')
.command('info', '当前项目信息输出')
.command('init', '工具初始化')
.example('$0 add -n namespace')
.example('$0 search -k keyword')
.example('$0 dist')
.example('$0 info')
.example('$0 init')
.option('n', {
alias: 'namespace',
describe: '命名空间名',
type: 'string',
})
.option('k', {
alias: 'keyword',
describe: '关键词,如命名空间名或关键字名',
type: 'string',
})
.epilog('')
.argv;
Notes:
- yargs 🔗的使用:
- 引入yargs并传入参数初始化:
require('yargs/yargs')(process.argv.slice(2))
, 使用链式调用配置命令(command)及参数(option); - 配置参数
.option('参数简写', 配置对象)
,第二个参数参入一个对象, alias为参数全称,describe为参数描述,type为参数数据类型; - 配置命令
.command('命令名称', '命令描述')
; .version('版本号')
和.help()
用于定义或开启返回版本号和返回帮助信息的功能,是提供的option定义的一种简写;- 插件预定义
version
和help
用于显示版本号和帮助信息,此时我们可以使用.alias('参数简写', '参数全写')
定义全写与简写的映射; - 定义用例
.example('$0 命令名称')
,如果熟悉shell编程的应该可以猜到$0
的作用,这里的$0
用于获取执行命令的第一个入参; - 解析完成后返回
argv
字段,就是我们要的命令参数结果。
- 引入yargs并传入参数初始化:
点击查看最终生成的帮助文档, 通过帮助文档配合阅读我们可以一目了然得知道上面的链式结构代码定义哪些命令和参数。
4-4. 定义静态文本配置模版文件(example.text)
考虑到JSON、YAML等市面上流行的配置文件都不是很适用,这里我们定义了一个超级简单的配置文件格式,用极少的代码定义规则和解析语义,是真的很简单,规则如下:
//
用于注释#
后的单词表示关键字, 需符合变量命名规范- 关键字之间的为对应的值
- 值在我们这里的场景分为两类解析,一类是纯文本,纯文本的解析规则根据关键词定义, 如namespace的值作为命名空间名需符合变量命名规范,create的值是语义化的时间字串,attribute的值是
key=value
的格式.
// namespace为变量作用域
// attr格式为key=value
# namespace
${target.namespace}
# create
${target.create}
# attribute
此刻我们脚本需要用到的各类关键文件都已经有了,接下来我们该利用好这些关键内容编写我们脚本的主体代码了
4-5. 脚本流程代码编写
入口文件(i18n-script)
// 指定当该文件作为可执行程序运行时的应用,即指定关联该文件的脚本解释器
#!/usr/bin/env node
// 引入log4js初始化logger对象用于日志输出
const logger = require('log4js').getLogger('i18n');
logger.level = config.logLevel;
const command = argvs._[0];
if (!command || !config.command[command]) return logger.error('命令错误', command);
// 分别引用前面定义好的配置、目录、参数文件
const paths = require('./path');
const argvs = require('./argvs');
const config = require('./config');
// 引入并实例化主程序,并执行入口函数
const Mainer = require('./main');
const mainer = new Mainer(command, { argvs, config, paths, logger });
mainer.exec();
Notes:
- log4js 🔗的使用:
- 实例化一个指定作用域的logger对象:
const logger = require('log4js').getLogger('name');
; - 设置日志输出级别:
logger.level = 'level';
; - 通过属性方法configure传入配置参数提供更高级的插件化用法,可阅读插件文档学习使用。
- 实例化一个指定作用域的logger对象:
主程序(main.js)
主程序代码用于处理业务逻辑, 与业务功能强关联, 大致结构如下:
module.exports = class Mainer {
constructor(command, { argvs, config, paths, logger }) {
// 命令名称
this.command = command;
// 入参
this.argvs = argvs;
// 项目配置信息
this.config = config;
// 项目路径
this.paths = paths;
// 日志输出工具
this.logger = logger;
// 其它需要在构造函数内初始化的属性
...
}
addHandle() {
// add命令执行函数, 添加方法执行函数
}
searchHandle() {
// search命令执行函数
}
infoHandle() {
// info命令执行函数, 输出相关信息
}
distHandle() {
// dist命令执行函数, 生成指定目标文件
}
exec() {
// 程序入口函数, 通过命令分发的方式执行特定的处理函数
this[`${this.command}Handle`]();
}
};
4-6. 配套工具
到这里脚本主要功能已经写完了,为了让脚本达到更好的体验,我们可以增加两个配套工具,分别用于检测并构建执行环境的初始化工具和jenkins用的打包工具。
脚本初始化工具(init.js)
这个脚本做了如下工作:
- 用ln命令将入口脚本软链到NodeJS识别的可执行文件目录下;
- 检查模块包目录是否已经安装脚本必须依赖,如果未发现则安装依赖;
- 写入脚本定义的npm命令到
package.json
文件的script属性下; - 初次生成目标文件;
- 打印帮助文档。
const { execSync, spawnSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const config = require('./config');
const paths = require('./path');
const { binPath, homePath } = require('./path');
const binScript = path.resolve(binPath, config.script);
const packagePath = path.resolve(paths.basePath, 'package.json');
function genRequirementScript() {
// 安装依赖
const requirements = config.requirement.filter(name => !fs.existsSync(path.resolve(paths.modulePath, name)));
if (requirements.length === 0) return false;
return 'npm install -D ' + requirements.join(' ');
}
function writeScript() {
// 向pageage.json中写入命令
const package = JSON.parse(fs.readFileSync(packagePath));
Object.assign(package.scripts, config.scripts);
fs.writeFileSync(packagePath, JSON.stringify(package, false, 4));
}
const scripts = [
genRequirementScript(),
`rm -f ${binScript}`,
`ln -s ${path.resolve(homePath, config.script)} ${binPath}`,
].filter(s => s);
const script = scripts.join(' && ');
console.log('\n执行初始化命令:', script, '\n');
execSync(script, { stdio: 'inherit' });
// 将脚本相关命令写入package.json文件
writeScript();
const scriptPath = path.resolve(paths.homePath, config.script);
// 生成最终静态文件
spawnSync('node', [scriptPath, 'dist'], { stdio: 'inherit' });
// 打印帮助信息
spawnSync('node', [scriptPath, 'help'], { stdio: 'inherit' });
console.log('\n脚本初始化成功!\n');
Notes:
- child_process 🔗的使用:
- 这个包用于创建子进程,并执行shell命令,在这里我们用到了spawnSync和execSync方法;
- spawn和exec对应两个同步函数spawnSync和execSync,spawn和exec的区别在于调用方式,exec是在spawn的基础上封装的,spawnSync的调用方式
spawnSync('command', [...params], options)
, execSync的调用方式execSync('command && command', options)
, exec方式执行命令更强,但相应的可读性也更差; - options中定义stdio值为inherit表示输入输出流继承于父进程, 如果不设置则子进程的输出不在父进程上显示, 注意并不是没有输出。
jenkins打包脚本
先执行生成目标文件命令,再将所有静态资源目录打包成一个zip包, 第一个参数对应pack命令的mode
, 第二个参数对于pack的--env
#!/usr/bin/env sh
# 示例: npm run i18n:zip micesource teststable
# 生成: h5-caiku-micesource_teststable.zip
WORKPATH=`pwd`
NAME=$1
ENV=$2
zipFile=${WORKPATH}/dist/h5-caiku-${NAME}_${ENV}.zip
zipPath=${WORKPATH}/dist/mice2/pc
echo "zipFile ----->"${zipFile}
echo "zipPath ----->"${zipPath}
cd ${WORKPATH}
npm run i18n:dist
rm -f ./dist/*.zip
cd ${zipPath}
zip -rDq ${zipFile} *
echo "静态文件生成成功 "${zipFile}
4-7. 编写项目README文件
最后至关重要的一环, 生成脚本的README文件, 麻雀虽小五脏俱全, 一个完整的工具脚本项目就出来了。。。
5. 验证脚本功能
现在我们可以使用我们编写的脚本大展身手了,在我们这个项目中,我们是把命令放在node_modules/.bin/
下然后便可以在项目的任何路径下通过npm run
的方式调用了,使用npm方式调用是不可以传递Option的,因此传递的Option会被npm接收,npm判断是否是它自己的Option,如果不是就抛弃,这只对-
开头的处理,传入的其它参数仍可以传到我们的脚本中执行,如果必须通过传入多Option控制脚本功能,我们可以将脚本命令软链到全局执行命令中,可以通过如下步骤实现:
- 目标可执行文件的文件头部添加解释器引导(让系统能找到可以解析此脚本的工具), 格式如:;
- 给定名称系统自己定位:
#!/usr/bin/env node
- 知道具体地址(
which node
):#!/usr/local/bin/node
- 给定名称系统自己定位:
- 给目标文件添加可执行权限,直接给文件755权限,或者单独添加
chmod +x /path/to/file
(单独添加的话其它用户无权限调用); - 执行
echo $PATH
找个我们有写文件权限的路径,比如/usr/local/bin
; - 使用ln命令将文件软链到
/usr/local/bin
; - 将以上步骤写成自动化命令,用于初始化执行。
最后如果需要可以给脚本加上功能测试,如使用chai+mocha
或者用测试框架jest
总结
通过这篇博文的学习,我们已经了解了如何使用NodeJS制作一个工具脚本的相关套路,这个套路适用于使用NodeJS的大部分场景,我们大概总结一下套路要点:
- 知道自己要做什么,找出项目中需要用到的配置及路径存起来方便调用;
- 使用原生和第三方模块拼出功能逻辑代码:
Path(路径)
、File System(文件增删改读)
、Child Processes(执行shell命令)
、Readline(交互)
、chalk(美化输出)
、yargs(解析入参)
、log4js(日志)
等; - 知道如何Debugger代码, 这是编程最重要的能力,也是最容易被忽视的。
有这么一个需求场景:页面需要字体等宽,本地等宽字体无法满足(需要适配中文),我们需要到google字体库里找,终于找到了,网站推荐的方式是使用LINK标签引入,但考虑国内环境等因素我们还是选择把字体文件下载下来。 那么问题来了:字体文件普遍都很大,字体网站会给字体进行切片处理,一个大点的字体文件会被切成200个小文件,我们从总的CSS文件中一个一个扣地址再下载,可能会花费很多时间,还会搞混弄乱,此时脚本的好处就出来了,我们执行一下脚本,字体文件全部下载下来,不适合再执行一下弄一个新的字体。这个需求很应景,大家可以试一下! 这是一个什么字体都有的神奇网站 🔗
链接
:::tip 原创博客,转载请注明出处。更多原创博客请移步 🔗 :::