阅读 3470

玩转自动化工具开发

在日常工作当中除了要实现业务功能外, 往往还会遇到一些需要进行自动化处理的场景。大多数情况下我们都会采用脚本的方式进行。那么作为前端工程师,如果你要用node.js去做一些自动化的工作,就需要掌握一些文本处理的技巧。 接下来这篇文章将介绍在开发一个自动化工具所会用到的一些技巧。

处理用户输入

针对所开发命令行工具类型的区别,我们通常有以下两种处理方式:

纯命令行工具

先完成一个欢迎界面:

const chalk = require('chalk');
const boxen = require('boxen');
const yargs = require('yargs');

const greeting = chalk.white.bold('欢迎使用xxx工具');
const boxenOptions = {
  padding: 1,
  margin: 1,
  borderStyle: 'round',
  borderColor: 'green',
  backgroundColor: '#555555',
};

const msgBox = boxen(greeting, boxenOptions);
console.log(msgBox);
复制代码

参数处理使用 yargs 这个工具,自动解析用户输入命令行:

const options = yargs
  .usage('Usage: --inject-janus|--inject-kani')
  .option('inject-janus', {
    describe: '注入janus',
    type: 'boolean',
    demandOption: false,
  })
  .option('inject-kani', {
    describe: '注入kani',
    type: 'boolean',
  }).argv;
复制代码

用来解析类似下面这种命令的菜单

./cli --inject-janus
复制代码

交互式命令行工具

Nodejs命令行工具对于用户输入的处理,我们可以采用inquirer这个库:

import inquirer from 'inquirer';

await inquirer.prompt([
  {
    name: 'repoName',
    type: 'input',
    message:
      '请输入项目名:',
  },
  {
    name: 'repoNamespace',
    type: 'input',
    message: '请输入 gitlab 命名空间,如 gfe',
    default: 'gfe',
  }]);
复制代码

内嵌脚本

Node命令行中往往需要借助系统原生的一些工具,针对linux、osx等,可以借助 shelljs 这个包来调用shell脚本,从而扩展我们自动化工具的能力。

const shell = require('shelljs');
 
shell.exec('git commit -am "Auto-commit"'
复制代码

文件读写

项目的配置信息基本上都会放在一个个独立的文件上,那么我们就需要借助处理文件相关的接口去进行处理。常用的文件处理接口有:

  • fs.access: 文件访问

  • fs.chmod: 修改文件读写权限

  • fs.copyFile: 复制文件

  • fs.link: 链接文件

  • fs.watch: 监听文件变化

  • fs.readFile: 读取文件(高频)

  • fs.mkdir: 创建文件夹

  • fs.writeFile: 写文件 (高频)

import {promises as fs} from 'fs';

async function readJson() {
    return fs.readFile('./snowflake.txt', 'utf8');
}

async function saveFile() {
    await fs.mkdir('./saved/snowfalkes', {recursive: true});
    await fs.writeFile('./saved/snowflakes/xx.txt', data);
}

console.log(await readSnowflake());
复制代码

JSON

JSON文件在前端项目中常常作为配置文件的形式存在,那么最常见的操作配置文件的方式就是处理JSON文本。 如代码所示:

const data = require('../test.json');

data['xxx'] = 'a';
复制代码

在做序列化的时候 JSON.stringify 接口的第二个参数(用来格式化),也十分常用:

// 保持两个空格的缩进
JSON.stringify(a, null, 2)
复制代码

路径

在读写文件过程当中路径的解析也是经常需要进行的。我们通常会借助 path.resolvepath.parse 这两个接口来进行相对路径和绝对路径的处理。前者用来做路径的转换,后者则主要用来获取路径上更详细的信息。

import * as path from 'path';

const relativeToThisFile = path.resolve(__dirname, './test.txt');
const parsed = path.parse(relativeToThisFile);

// interface ParsedPath {
//   root: string;
//   dir: string;
//   base: string;
//   ext: string;
//   name: string;
// }
复制代码
  1. __dirname: 当前文件所处路径

  2. process.cwd: 执行命令所在路径。

需要注意的是,在实际写工具的过程当中,需要区分好你所需要操作的文件路径以及当前命令行的相对路径信息。前者通常是用项目路径地址,后者通常是当前工作路径。

文本处理

有了文件读写等能力之后,在开发自动化工具的过程当中,我们还需要对文本进行替换修改。在实际开发过程中,文本处理通常采用两种方式:正则替换和抽象语法树转换。

正则替换

针对简单的文本,我们一般是采用正则的方式进行替换。好处是代码相对来说比较简洁,而且利用语言内生的接口就可以实现,无需借助额外的工具库。 JS里最常用的接口处理方式是 string.replace 或借助 shelljs 模块执行 shell 脚本。前者主要针对常规的正则处理,而后者则可以借助 shell 脚本强大的文本处理工具如 sedawk 等。 如下面这代码:

import { promises as fs } from 'fs';

const code = await fs.readFile('./test-code.js');

code = code.replace(/Hello/, 'World');
code = code.replace(/console.log\\((.*)\\)/, 'console.log($1.toUpperCase())');

await fs.writeFile('./test-new-code.js', code); 
复制代码

AST(抽象语法树)

使用正则方法针对常规的文件修改是足够用的,但是在使用过程中还会碰到一个问题,那就是字符串通常是非结构化的,所以使用正则的可读性不是十分良好。同时,针对复杂场景,如需要一些逻辑判断等,使用正则也很难很好的覆盖到。

那么我们就有了另一个方案,就是可以直接将源码解析成结构化的数据(AST),并直接在抽象语法树上进行增删改查,替换成我们最终想要的结果。最后再将转码后的AST写回文件当中去。 这一整个过程其实就有点像babel转译所做的工作一样。

学会操作AST,不仅有利于我们开发自动化工具,也能实现下面这些功能:

  1. JS 代码语法风格检查,(参考eslint)

  2. 在 IDE 中的错误提示、自动补全,重构

  3. 代码的压缩和混淆、代码的转换 (参考prettier, babel)

要学会使用AST做文本转换,首先需要先了解一下抽象语法树的常见结构。 它其实就是一个附带有语言编程信息的树形结构,里面包含的节点是词法解析后的产物,比如有字面量,标识和方法,调用声明等等。 下面是一些常用的语法节点信息(token):

  • Literal:字面量

  • Identifier: 标识符

  • CallExpression: 方法调用

  • VariableDeclaration: 变量声明

要查看一个代码解析后的抽象语法书,可以借助AST EXplorer.net astexplorer.net/ 这个工具。

esprima + esquery + escodegen

esprima + esquery + escodegen 的组合是操作AST常用的工具。 其中 esprima esprima.org/ 这个库主要用来解析js语法树,用法如下面代码所示:

import { parseScript } from 'esprima';

const code = `let total = sum(1 + 1);`
const ast = parseScript(code);
console.log(ast)
复制代码

通过 parseScript 接口就可以从源码文件中提取语法树结构。 得到下面的结构:

是一个嵌套的树形结构,可以通过深度遍历来获取所有节点信息。 那么在解析完源码得到语法树之后,我们就可以像操作dom结构一样去操作这些节点结构。这里借助 esquery 工具来找到所需要修改的节点:

import { parseScript } from 'esprima';
import { query } from 'esquery';

const code = 'let total = say("hello world")';
const ast = parseScript(code);
const nodes = query(ast, 'CallExpression:has(Identifier[name="say"]) > Literal');

console.log(nodes);
复制代码

最后就可以得到 say 方法调用的参数值:

[
  Literal {
    type: 'Literal',
    value: 'hello world',
    raw: '"hello world"'
  }
]
复制代码

接着,我们就可以尝试自己修改这些AST节点的信息, 比如这里我想将代码里的参数改成“hello bytedance", 最终生成代码。 代码如下:

import { parseScript } from 'esprima';
import { query } from 'esquery';
import { generate } from 'escodegen';

const code = 'let total = say("hello world")';
const ast = parseScript(code);
const [literal] = query(ast, 'CallExpression:has(Identifier[name="say"]) > Literal');
literal.value = 'hello bytedance';

// 借助escodegen生成最终代码, escodegen: 接受一个有效的ast,并生成js代码
const result = generate(ast);
console.log(result);
// 最终结果: let total = say("hello bytedance");
复制代码

当然,有时候是需要替换整个语法树,那么就可以使用 estemplate 这个库来快速生成对应的ast信息,并拼装到原有的ast上。 比如下面这段代码:

var ast = estemplate('var <%= varName %> = <%= value %> + 1;', {
  varName: {type: 'Identifier', name: 'myVar'},
  value: {type: 'Literal', value: 123}
});
console.log(escodegen.generate(ast));
// > var myVar = 123 + 1;
复制代码

可以用模板化语言的方式生成AST,从而在新增节点或替换节点的时候便于我们修改旧有的AST结构。

例子

下面我们来运用上面的知识点来实现几个有趣的小功能:

1. 实现一个自定义的eslint规则

import { parseScript } from 'esprima';
import { query } from 'esquery';

const code = `Object.freeze()`;
const ast = parseScript(code);
const queryStatement = 
  'CallExpression:has(MemberExpression[object.name="Object"][property.name="freeze"])';
const nodes = query(ast, queryStatement);

if (nodes.length !== 0) {
  throw new Error(`不要使用Object.freeze!`);
}
复制代码

可以把它们类比为: 在实际使用过程中,个人比较喜欢做一个jscodeshift这个工具,它是由Facebook官方提供的一个codemode的工具。底层封装了 recast github.com/benjamn/rec… 这个库。

在这个文件整个处理流程,原理同上面一样。也是包含解析语法树、修改语法树并最终生成代码等步骤。而且是通过 transform函数 对外暴露接口,它的优点是接口十分简洁,同时最终输出的代码还能保留原有代码的编程风格,所以非常适合代码重构、修改配置文件等场景。

它的整个工作原理如下图所示:

AST == DOM树 AST-EXPLORER == 浏览器 JSCODESHIFT == Jquery

find=>查找操作

节点查找是要AST操作的最核心的一步,我们通常可以借助ast-explorer这个平台来可视化节点信息。然后利用查询语句,定位到想要的节点路径。

如下面这段代码:

find(j.Property, {value: { type: 'literal',  raw: 'xxx' }   })
复制代码

replace=>替换操作

替换节点在实际开发过程中也是非常常用的一项功能,而新增的节点构造方式要遵守 ast-types github.com/benjamn/ast… 的类型定义:

node.replaceWith(j.literal('test')); // 替换成字符串节点

node.insertBefore(j.literal("test")); // 在该节点后插入新构造的ast

node.insertAfter(j.literal()); // 在该节点前插入新构造的ast
复制代码

这里记住API有个小诀窍就是:"找东西用大写,创建节点小写"。

create=>创建节点

j.template.statements`var a = 1 + 1`;


j.template.expression`{a: 1}`;
复制代码
export default function transformer(file, api) {

  // import jscodeshift
    const j = api.jscodeshift;
    // get source code

    const root = j(file.source);
    // find a node

    return root.find(j.VariableDeclarator, {id: {name: 'list'}})
    .find(j.ArrayExpression)
    .forEach(p => p.get('elements').push(j.template.expression`x`))
    .toSource();
};
复制代码
// 最后输出的代码字符串风格保持单引号形式
j(file.source).toSource({quote: 'single'});

// 双引号形式
j(file.source).toSource({quote: 'double'});
复制代码

print=>最后输出打印

打印部分的代码相对来说比较简单,直接利用 toSource 方法就可以完成。 有时候我们还需要控制一些代码输出格式(如引号等),就可以借助 quote 等属性来处理。

测试

写codemod的代码,测试是十分必要的。由于涉及到文件的修改,借助测试可以大大简化我们开发的工作。

在jscodeshift里,官方提供了一些测试工具函数,可以直接借助这些工具函数快速地编写我们的测试代码。 首先,需要先建立两个目录:

  1. testfixtures: 该目录主要用来存放待修改的测试文件, input.js 结尾的文件代表待转换的文件,而 output.js 结尾的则代表期望转换后的文件。

  2. tests: 该目录用来存放所有的测试用例代码

const { defineTest } = require('jscodeshift/dist/testUtils');
const transform = require('../index');
const jscodeshift = require('jscodeshift');
const fs = require('fs');
const path = require('path');

jest.autoMockOff();

defineTest(__dirname, 'bff');

describe('config', function () {
  it('should work correctly', function () {
    const source = fs.readFileSync(
      path.resolve(__dirname, '../__testfixtures__/config.output.ts'),
      'utf8'
    );
    const dest = fs.readFileSync(
      path.resolve(__dirname, '../__testfixtures__/config.output.ts'),
      'utf8'
    );
    const result = transform.config({ source, path }, { jscodeshift });
    expect(result).toEqual(dest);
  });
});
复制代码
// 第二个参数用来指定作用范围,如果不指定的话,则全局生效
jscodeshift.registerMethods({
    log: function() {
        return this.forEach(path => console.log(path.node.name));
    }
}, jscodeshift.Identifier);

jscodeshift.registerMethods({
    log: function() {
        return this.forEach(path => console.log(path.node.name));
    }
});

// 之后就可以直接在语法树使用自定义方法了
jscodeshift(ast).log();
复制代码

extend 扩充

jscodeshift除了官方提供的一些基本接口外,还提供了扩展接口方便我们用来自定义一些工具函数使用 registerMethods 这个方法就可以在jscodeshift命名空间上绑定我们自定义的工具函数。

例子

  1. 代码重构工具: github.com/reactjs/rea… 这是react官方提供的代码迁移工具,可以大大减少对于大项目代码重构时的人力成本。

参考文档

AST解析器:

文章分类
前端