Node.js + typescript 写一个命令批处理辅助工具

334次阅读  |  发布于3年以前

1.背景

工作中遇到这样一些场景:在 php 混合 html 的老项目中写 css,但是 css 写着不太好用,然后就想使用预编译语言来处理,或者写上 ts。然后问题来了: 每次写完以后都要手动执行一次命令行把文件编译成 css 文件,然后又要再输入一行命令把 css 压缩添加前缀;或者把 ts 编译成 js,然后 js 压缩混淆。

那么有没有办法不用手动输入命令行呢?如果只是为了不手动输入的话,那么可以在 vscode 上安装 compile hero 插件,或者在 webstorm 上开启 file watch 功能。可惜的是这些工具或功能只能对当前文件做处理,处理编译后的文件又要手动去执行命令,不能连续监听或监听一次执行多个命令,比如 webstorm 的 file watch 监听了 sass 文件变化, 那么它不能再监听 css 变化去压缩代码,否则会无限编译下去。

那么为什么不使用 webpack 或者 rollup 之类的打包工具呢?首先是这些打包工具太重了不够灵活,毕竟原项目没到重构的时候, 要想使用新一点的技术,那么只能写一点手动编译一点了。

好在这些预编译语言都提供 cli 工具可在控制台输入命令行编译,那么完全可以把它们的命令关联起来,做一个批量执行的工具。其实 shell 脚本也可以完成这些功能, 但是其一:shell 在 windows 上的话只能在 git bash 里运行,在 cmd 控制台上不能运行,需要专门打开一个 git bash,少了一点便利性;其二:在 windows 上不能监听文件变化。那么既然 nodejs 能够胜任,那么用前端熟悉的 js 做那是再好不过了。

2.目标

  1. 基础功能

2 . 进阶功能

3 . 额外功能

4 . 配置

ok,那么接下来进入正文吧(源码见底部 github 链接)。

3.基本功能

3.1 获取控制台输入的命令

首先是获取到控制台输入的命令,这里抽取出来做为一个工具函数。格式为以"="隔开的键值对,键名以"-"开头,值为空时设置该值为 true,变量之间用空格隔开。

// util.ts
/**
 * 获取命令行的参数
 * @param prefix 前缀
 */
export function getParams(prefix = "-"): { [k: string]: string | true } {
    return process.argv.slice(2).reduce((obj, it) => {
        const sp = it.split("=");
        const key = sp[0].replace(prefix, "");
        obj[key] = sp[1] || true;
        return obj;
    }, {} as ReturnType<typeof getParams>);
}

调用

console.log(getParams());

运行结果

3.2 运行单个命令

能获取到命令行参数那就好办了,接下来实现执行命令功能。

先实现一个简单的执行命令函数,这要用到 child_process 模块里的 exec 函数。

const util = require("util");
const childProcess = require('child_process');
const exec = util.promisify(childProcess.exec); // 这里把exec promisify

需要知道执行状态,所以把它封装一下,不能 try catch,出错就直接 reject 掉,避免后面的命令继续执行。

async function execute(cmd: string): Promise<string> {
    console.log('执行"' + cmd + '"命令...');
    const {stdout} = await exec(cmd);
    console.log('success!');
    console.log(stdout);
    return stdout;
}

设定命令参数为-command,且必须用”” ““包起来,多个则用“,”隔开

在工具中通过-command/-cmd=启用

调用

const args = getParams();
execute(args.command as string);

运行

3.3 运行多个命令

现在运行单个命令是没问题的,但是运行多个命令呢?

看结果可以发现:结果马上就报错了,把它改成顺序执行

async function mulExec(command: string[]) {
    for (const cmd of command) {
        await execute(cmd);
    }
}

运行

mulExec((args.command as string).split(","));

3.4 通过指定配置文件运行命令

在工具中通过-config/-c=设置配置的路径

这样通过命令行命令,执行相应的功能就完成了,但是可能会有情况下是要运行很多条命令的,每次都输入一长串命令就不那么好了,所以要添加一个通过配置文件执行的功能。

首先是定义配置文件格式。先来个最简单的

export interface ExecCmdConfig{
    command: string[]; // 直接执行命令列表
}

定义一下命令行配置文件变量名为-config

-config= 配置的路径

例如:cmd-que -config="test/cmd.config.js"

配置文件 test/cmd.config.js

module.exports = {
    command: [
        "stylus E:\\project\\cmd-que\\test\\test.styl",
        "stylus test/test1.styl",
    ]
};

加载配置文件

const Path = require("path");
const configPath = Path.resolve(process.cwd(), args.config);
try {
    const config = require(configPath);
    mulExec(config.command);
} catch (e) {
    console.error("加载配置文件出错", process.cwd(), configPath);
}

运行

搞定

4.进阶功能

到这里,一个简单的命令批量执行工具代码就已经基本完成了。但是需求总是会变的。

4.1 前后生命周期

为什么要添加生命周期?因为编译 pug 文件总是需要在编译完 js、css 之后,不可能总是需要手动给 pug 编译命令加上 debounce,所以加上结束的回调就很有必要了。

生命周期回调函数类型:

type execFn = (command: string) => Promise<string>;
export interface Config {
    beforeStart: (exec: execFn) => Promise<unknown> | unknown;
    beforeEnd: (exec: execFn) => Promise<unknown> | unknown;
}

代码

const Path = require("path");
const configPath = Path.resolve(process.cwd(), args.config);
try {
    const config = require(configPath);
    // beforeStart调用
    if (config.beforeStart) await config.beforeStart(execute);
    await mulExec(config.command);
    // beforeEnd调用
    config.beforeEnd && config.beforeEnd(execute);
} catch (e) {
    console.error("加载配置文件出错", process.cwd(), configPath);
}

配置文件 cmd.config.js

module.exports = {
    beforeStart() {
        console.time("time");
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                console.log("start");
                resolve();
            }, 1000);
        });
    },
    beforeEnd() {
        console.log("end");
        console.timeEnd("time");
    },
    command: [
        // "stylus D:\\project\\cmd-que\\test\\test.styl",
        "stylus E:\\project\\cmd-que\\test\\test.styl",
        "stylus test/test1.styl",
    ]
};

运行

4.2 遍历文件夹查找匹配运行

到现在,如果只是执行确定的命令,那么已经完全没问题了,但是有时候需要编译的文件会有很多,像 stylus、pug 这些可以直接编译整个文件夹的还好, 像 ts 的话就只能一个文件写一条命令,那也太麻烦了。

所以得增加一个需求:遍历文件夹查找目标文件, 然后执行命令的功能。

写一个遍历文件夹的函数:

// util.ts
const fs = require("fs");
const Path = require("path");

/**
 * 遍历文件夹
 * @param path
 * @param exclude
 * @param cb
 * @param showLog
 */
export async function forEachDir(
    path: string,
    exclude: RegExp[] = [],
    cb?: (path: string, basename: string, isDir: boolean) => true | void | Promise<true | unknown>,
    showLog = false,
) {
    showLog && console.log("遍历", path);
    try {
        const stats = fs.statSync(path);
        const isDir = stats.isDirectory();
        const basename = Path.basename(path);

        const isExclude = () => {
            const raw = String.raw`${path}`;
            return exclude.some((item) => item.test(raw));
        };
        if (isDir && isExclude()) return;


        const callback = cb || ((path, isDir) => undefined);
        const isStop = await callback(path, basename, isDir);

        if (!isDir || isStop === true) {
            return;
        }

        const dir = fs.readdirSync(path);
        for (const d of dir) {
            const p = Path.resolve(path, d);
            await forEachDir(p, exclude, cb, showLog);
        }
    } catch (e) {
        showLog && console.log("forEachDir error", path, e);
        // 不能抛出异常,否则遍历到System Volume Information文件夹报错会中断遍历
        // return Promise.reject(e);
    }
}

然后正则验证文件名,如果符合就执行命令

forEachDir("../test", [], (path, basename, isDir) => {
    if (isDir) return;
    const test = /\.styl$/;
    if (!test.test(basename)) return;
    return execute("stylus " + path);
});

运行

4.3 通过配置遍历文件夹

url 模板替换

看上面的执行情况可以看出,执行的每一条命令路径都是具体的,但是如果我们要遍历文件夹执行命令的话那么这样就不够用了。因为命令都是字符形式的无法根据情况改变,那么有两种方法解决这样的情况:

  1. 使用字符串模板替换掉对应的字符
  2. 使用js执行,根据传回的字符来替换掉对应的字符,再执行命令

现在实现一个模板替换的功能(模板来源于 webstorm 上的 file watcher 功能,有所增减)

export function executeTemplate(command: string, path = "") {
    const cwd = process.cwd();
    path = path || cwd;
    const basename = Path.basename(path);

    const map: { [k: string]: string } = {
        "\\$FilePath\\$": path, // 文件完整路径
        "\\$FileName\\$": basename, // 文件名
        "\\$FileNameWithoutExtension\\$": basename.split(".").slice(0, -1).join("."), // 不含文件后缀的路径
        "\\$FileNameWithoutAllExtensions\\$": basename.split(".")[0], // 不含任何文件后缀的路径
        "\\$FileDir\\$": Path.dirname(path), // 不含文件名的路径
        "\\$Cwd\\$": cwd, // 启动命令所在路径
        "\\$SourceFileDir\\$": __dirname, // 代码所在路径
    };
    const mapKeys = Object.keys(map);
    command = mapKeys.reduce((c, k) => c.replace(new RegExp(k, "g"), map[k]), String.raw`${command}`);
    return execute(command);
}

配置文件格式最终版如下:

type execFn = (command: string) => Promise<string>;

/**
 * @param eventName watch模式下触发的事件名
 * @param path 触发改动事件的路径
 * @param ext 触发改动事件的文件后缀
 * @param exec 执行命令函数
 */
type onFn = (eventName: string, path: string, ext: string, exec: execFn) => Promise<void>


type Rule = {
   test: RegExp,
   on: onFn,
   command: string[];
};

export type RuleOn = Omit<Rule, "command">;
type RuleCmd = Omit<Rule, "on">;
export type Rules = Array<RuleOn | RuleCmd>;

export interface Config {
   beforeStart: (exec: execFn) => Promise<unknown> | unknown;
   beforeEnd: (exec: execFn) => Promise<unknown> | unknown;
}

export interface ExecCmdConfig extends Config {
   command: string[]; // 直接执行命令列表 占位符会被替换
}


export interface WatchConfig extends Config {
   exclude?: RegExp[]; // 遍历时忽略的文件夹
   include?: string[] | string; // 要遍历/监听的文件夹路径 // 默认为当前文件夹
   rules: Rules
}

export function isRuleOn(rule: RuleOn | RuleCmd): rule is RuleOn {
   return (rule as RuleOn).on !== undefined;
}

实现

import {getParams, mulExec, forEachDir, executeTemplate} from "../src/utils";
import {isRuleOn, Rules} from "../src/configFileTypes";


(async function () {

    // 获取命令行参数
    const args = getParams();


    // 匹配正则
    async function test(eventName: string, path: string, basename: string, rules: Rules = []) {
        for (const rule of rules) {
            if (!rule.test.test(basename)) continue;
            if (isRuleOn(rule)) {
                await rule.on(
                    eventName,
                    path,
                    Path.extname(path).substr(1),
                    (cmd: string) => executeTemplate(cmd, path),
                );
            } else {
                await mulExec(rule.command, path);
            }
        }
    }

    // 遍历文件夹
    function foreach(
        path: string,
        exclude: RegExp[] = [],
        cb: (path: string, basename: string, isDir: boolean) => true | void | Promise<true | void>,
    ) {
        return forEachDir(path, exclude, (path: string, basename: string, isDir: boolean) => {
            return cb(path, basename, isDir);
        });
    }

    const Path = require("path");
    const configPath = Path.resolve(process.cwd(), args.config);
    try {
        const config = require(configPath);
        // beforeStart调用
        if (config.beforeStart) await config.beforeStart(executeTemplate);
        const include = config.include;
        // 设置默认路径为命令启动所在路径
        const includes = include ? (Array.isArray(include) ? include : [include]) : ["./"];
        const rules = config.rules;
        for (const path of includes) {
            await foreach(path, config.exclude, (path, basename) => {
                return test("", path, basename, rules);
            });
        }
        // beforeEnd调用
        config.beforeEnd && config.beforeEnd(executeTemplate);
    } catch (e) {
        console.error("加载配置文件出错", process.cwd(), configPath);
    }
})();

执行配置中的命令

配置文件如下:

// test-cmd.config.js
module.exports = {
    exclude: [
        /node_modules/,
        /\.git/,
        /\.idea/,
    ],
    rules: [
        {
            test: /\.styl$/,
            command: [
                "stylus <$FilePath$> $FileDir$\\$FileNameWithoutAllExtensions$.wxss",
                "node -v"
            ]
        }
    ]
};

运行结果

执行配置中的 js

module.exports = {
    beforeEnd(exec) {
        return exec("pug $Cwd$")
    },
    exclude: [
        /node_modules/,
        /\.git/,
        /\.idea/,
        /src/,
        /bin/,
    ],
    include: ["./test"],
    rules: [
        {
            test: /\.styl$/,
            on: async (eventName, path, ext, exec) => {
                if (eventName === "delete") return;
                const result = await exec("stylus $FilePath$");
                console.log("on", result);
            }
        },
        {
            test: /\.ts$/,
            on: (eventName, path, ext, exec) => {
                if (eventName === "delete") return;
                return exec("tsc $FilePath$");
            }
        },
    ]
};

运行结果

4.4 监听文件变动

在工具中通过-watch/-w 开启 需要与-config 搭配使用

监听文件变动 nodejs 提供了两个函数可供调用:

1 . fs.watch(filename[, options][, listener])