写一个Babel插件监听AntD Form

488 阅读8分钟

为什么要做这件事情?

开发中遇到的痛点:

我们在使用AntD Form表单提交数据的时候,很难清楚的知道表单中的数据长什么样,比如<GoodsList />这个组件经过用户操作后变成了这样:

[{name: '商品A', stock: 100, number: 1}]

于是,我们就需要去form.getFieldsValue手动获取一下表单数据,或者在<Form>上添加onValuesChange函数来监听数据的变化,当控制台打印的东西过多,看不过来的时候,我们通常又会将打印的内容删除

那么,能不能有一种方法能够自动监听Form表单的数据,不需要我们手动在控制台打印呢?

思路

  1. Babel在编译的时候,是一个文件一个文件的处理的。在处理文件的时候,我们首先判断是否引入了Antd库的Form组件,如果没有引入,则不处理,跳过。

  2. 如果引入了AntdForm组件,则通过package.json获取主版本号(因为Antd3.x、和4.x、5.x有一些差异)

  3. onValuesChange方法进行劫持,将Form中变化的数据挂载到window上(然后通过浏览器插件的形式将数据可视化出来)

    const onValuesChange = (changedValue, allValues) => {
      // 插入以下代码
      if (!window.WATCH_FORM_DATA_EXTENSIONS) {
        window.WATCH_FORM_DATA_EXTENSIONS = {};
    	}
    	window.WATCH_FORM_DATA_EXTENSIONS['formKey_changedValues'] = changedKey; 
    	window.WATCH_FORM_DATA_EXTENSIONS['formKey_allValues'] = allValues;
    }
    
  4. 4.x为例主要分为以下几种情况:

    • Form中原本就有onValuesChange方法(这里需要考虑普通函数、箭头函数、函数变量三种写法)
    // Babel 转化前:
    const onValuesChange = (changedValue, allValues) => {
      console.log('changedValue', changedValue);
      console.log('allValues', allValues);
    }
    
    <Form onValuesChange={onValuesChange}>
    </Form>
    
    // Babel 转化后:
    const onValuesChange = (changedValue, allValues) => {
      console.log('changedValue', changedValue);
      console.log('allValues', allValues);
      
      // 插入以下代码
      if (!window.WATCH_FORM_DATA_EXTENSIONS) {
        window.WATCH_FORM_DATA_EXTENSIONS = {};
    	}
    	window.WATCH_FORM_DATA_EXTENSIONS['formKey_changedValues'] = changedKey; 
    	window.WATCH_FORM_DATA_EXTENSIONS['formKey_allValues'] = allValues;
    }
    
    <Form onValuesChange={onValuesChange}>
    </Form>
    
    • Form中原本就没有onValuesChange方法
    // Babel 转化前:
    <Form>
    </Form>
    
    // Babel插件转化后:
    
    // 插入以下代码
    const onValuesChange = (changedValue, allValues) => {
     
      if (!window.WATCH_FORM_DATA_EXTENSIONS) {
        window.WATCH_FORM_DATA_EXTENSIONS = {};
    	}
    	window.WATCH_FORM_DATA_EXTENSIONS['formKey_changedValues'] = changedKey; 
    	window.WATCH_FORM_DATA_EXTENSIONS['formKey_allValues'] = allValues;
    }
    
    
    <Form onValuesChange={onValuesChange}>
    </Form>
    

插件的一些基本概念

  1. Babel的编译流程分为解析(生成AST)、转化(对AST进行遍历,生成新的AST)、生成(根据AST生成新代码)三个阶段,Babel插件主要是在转化阶段对AST进行一些操作,从而产生新的代码

  2. Babel中的AST:AST可以理解为一颗抽象语法树,Babel插件就是在遍历这棵树的过程中,通过Babel提供的一些API对个AST进行修改。AST中常用的节点类型有:

    • Literal: 字面量。比如 let name = 'zaoren'中,'zaoren'就是一个字符串字面量 StringLiteral

    • Identifier: Identifier是标识符的意思,变量名、属性名、参数名等各种生命和引用的名字,都是Identifer

    • Statement: 是语句,它是可以独立执行的单位,比如break、continue、debugger、return、if语句、while语句、for语句、还有声明语句、表达式语句等。

    • Expression:exporession是表达式,特点是执行完以后有返回值,这是和语句(statement)的区别。

      ... 还有很多通用的,可以翻一下文档,我这里着重讲一下我接下来要用到的:

    • CallExpression: 调用表达式,属于Expression中的一种

    • JSXOpeningElement:JSXOpeningElement 用于表示 JSX 元素的开头部分,包括元素的名称、属性以及是否为自闭合元素。

    更多请参考: github.com/babel/babel…

    @babel/types 的 typescript 类型定义

  3. Babel中的API:

    • @babel/parser:babel parser 叫 babylon,是基于 acorn 实现的。可以将js文件解析成AST

    • @babel/traverse:parse出的AST由@babel/traverse来遍历和修改,babel traverse包提供了traverse方法:

      ......

    • @babel/core:这是Babel最核心的包。这个包的功能就是完成整个编译的流程,从源代码到目标代码,生成sourcemap。实现plugin和preset的调用(我们开发的插件,就需要通过这个包来调用)

  4. Babel中的访问者模式

    Visitor模式的思想是:当被操作的对象结构比较稳定,而操作对象的逻辑经常变化的时候,通过分离逻辑和对象结构,使得他们能够独立拓展。

    前面我们说了,我们的Babel插件主要就是在Babel的转化阶段做一些事情。对应到babel traverse的实现,就是AST和visitor分离,在traverse AST的时候调用注册的visitor来对其进行处理。

  5. 一个比较简单的Babel插件模板:

    const watchAntdFormPlugin = declare((api) => {
      api.assertVersion(7);
    
      return {
        visitor: {
          Program: {
            enter: (path, state) => {
              // 在处理当前文件的时候,Babel插件如何对AST做转化?
          },
          CallExpression(path, state) {
            // 碰到调用表达式的时候,Babel插件如何对AST做转化?
          },
          JSXOpeningElement(path, state) {
            // 碰到JSX的时候,Babel插件如何对AST做转化?
          }
      };
    });
    
    module.exports = watchAntdFormPlugin;
    

开始开发插件

1.写一个简单的Babel调用流程:
  • 1.读取编译前的源文件
  • 2.Babel的Parse流程,生成ast
  • 3.Babel的Transform 和 生成流程,生成新的ast
  • 4.生成新的文件
const fs = require('fs');
const path = require('path');
const { transformFromAstSync } = require('@babel/core');
const parser = require('@babel/parser');
const minimist = require('minimist');
const watchAntdForm = require('./plugin/watch-antd-form');

function compileFile(filePath) {
  // 1. 读取源文件
  const sourceCode = fs.readFileSync(filePath, 'utf-8');
	
  // 2. Babel的Parse流程
  const ast = parser.parse(sourceCode, {
    sourceType: 'unambiguous',
    plugins: [
      'decorators',
      'jsx',
      'typescript',
    ],
  });
	
  // 3. Babel的Transform 和 生成流程
  const { code } = transformFromAstSync(ast, sourceCode, {
    // 在这里添加自定义插件
    plugins: [
      [
        watchAntdForm, {
          antdMajorVersion: envArgs.antdMajorVersion
        }
      ]
    ],
  });

  const distDir = './dist';
  if (!fs.existsSync(distDir)) {
    fs.mkdirSync(distDir);
  }

  const relativeDir = path.dirname(path.relative('./example', filePath));
  const distDirPath = path.join(distDir, relativeDir);

  if (!fs.existsSync(distDirPath)) {
    fs.mkdirSync(distDirPath, { recursive: true });
  }

  const fileName = path.basename(filePath);
  const distFilePath = path.join(distDirPath, fileName);
  // 4. 写到文件系统
  fs.writeFileSync(distFilePath, code);
}

compileFile('../index.tsx')
2.在开始处理文件的时候,判断文件中是否有使用Antd的Form组件

这个时候,有个技巧,可以将源文件放在AST网站上先解析出来,看看AST长什么样

于是,我们就可以通过访问者模式,专门对ImportDeclaration进行处理,当引入了'antd'并且引用了Form,则在state中用变量importedFormComponent标识一下,当前文件是需要进行Form监听的。

/* eslint-disable consistent-return */
/* eslint-disable no-param-reassign */
const parser = require('@babel/parser');
const { declare } = require('@babel/helper-plugin-utils');
const types = require('@babel/types');
const {
  getMajorVersion,
  generateRandomVariableName,
  getInsertExtensionsCode,
  getInsertExtensionsCodeWithoutArguments,
  processOnValuesChangeFunction,
} = require('../util/index');

const watchAntdFormPlugin = declare((api) => {
  api.assertVersion(7);

  return {
    visitor: {
      Program: {
        enter: (path, state) => {
          // 在处理当前文件的时候,做两件事:
          //    1.读取Antd的Major版本(后续对于不同版本的Antd组件需要做不同的处理)
          //    2.提前判断一下是否有Import Form组件,如果没有,也不需要处理当前文件
          const { antdMajorVersion } = state.opts;

          state.antdMajorVersion = antdMajorVersion || getMajorVersion('antd');
          if (!state.antdMajorVersion) {
            path.stop(); // 不存在antd版本,直接stop,不做额外的AST操作
          }
          path.traverse({
            ImportDeclaration(curPath) {
              const requirePath = curPath.get('source').node.value;
              if (requirePath === 'antd') {
                // 如果已经引入了form,记录一下
                if (
                  curPath
                    .get('specifiers')
                    .some((item) => item.toString() === 'Form')
                ) {
                  state.importedFormComponent = true;
                  // TODO 这个想一下怎么优化一下 skip 好像也不行,跳过了当前节点就不会继续遍历了
                  // path.skip(); // 找到了就跳过当前节点
                }
              }
            },
          });
        },
      },
      
    },
  };
});

module.exports = watchAntdFormPlugin;
3. 以Antd4.0为例,我们需要对<Form />这个jsx语法进行处理

同样的,首先我们先通过AST网站查看一下解析出来的AST

然后,确定我们需要对JSXOpeningElement这个类型的AST进行处理:大致思路:

  • 首先我们得确定Antd的版本,比如3.x的版本,我们就不需要处理了(3.x的版本不在这里添加onValuesChange监听,在From.create中)
  • 接着判断,JSX的name属性必须是Form,如果是<Input>组件,我们就没必要处理了
  • 如果是Form组件,先判断是否有onValuesChange的属性,如果没有,添加自定义的onValuesChange方法
  • 如果有onValuesChange方法,还要根据是箭头函数写法、普通函数写法、函数变量写法中的哪一种,往里面插入自定义的代码。
JSXOpeningElement(path, state) {
        if (![4, 5].includes(state.antdMajorVersion)) {
          return;
        }
        // 对Form的jsx attributes进行操作
        if (path.get('name').isJSXIdentifier({ name: 'Form' })) {
          const attributes = path.get('attributes');
          // 看看是否有 onValuesChange 属性,有的话,改造,没有的话直接添加
          const onValuesChangeAttribute = attributes.find(
            (attribute) => attribute.get('name').isJSXIdentifier({ name: 'onValuesChange' }),
          );

          if (onValuesChangeAttribute) {
            // 改造 onValuesChange 属性
            const onValuesChangeExpression = onValuesChangeAttribute.get('value.expression');
            if (onValuesChangeExpression.isArrowFunctionExpression()) {
              const { params, body } = onValuesChangeExpression.node;
              const uniquePrefix = generateRandomVariableName();
              // 构建 changedValues 参数节点
              const changedValuesParam = types.identifier(`${uniquePrefix}changedValues`);
              // 构建 allValues 参数节点
              const allValuesParam = types.identifier(`${uniquePrefix}allValues`);
              // 注意 antd4.0开始只有 changedValues 和 allValues两个参数
              if (params.length === 0) {
                // 将参数节点插入到参数列表的结尾
                params.push(changedValuesParam, allValuesParam);
              } else if (params.length === 1) {
                // 将参数节点插入到参数列表的结尾
                params.push(allValuesParam);
              }
              // 这个时候需要有一个标识名来标识Form
              const ast = parser.parse(
                getInsertExtensionsCodeWithoutArguments(
                  state.file.opts.filename,
                  params.length >= 1 ? params[0].name : `${uniquePrefix}changedValues`,
                  params.length >= 2 ? params[1].name : `${uniquePrefix}allValues`,
                ),
              );
              if (body.type === 'BlockStatement') {
                // 有函数体就在开始插入监控代码
                body.body.unshift(ast.program.body[0]);
              }
            } else if (onValuesChangeExpression.isIdentifier()) {
              // 处理函数变量写法 onValuesChange: onValuesChangeFunc
              const onValuesChangeName = onValuesChangeExpression.node.name;
              const uniquePrefix = generateRandomVariableName();
              const changedValuesParam = types.identifier('changedValues');
              const allValuesParam = types.identifier('allValues');
              const ast = parser.parse(
                getInsertExtensionsCodeWithoutArguments(state.file.opts.filename, `${uniquePrefix}changedValues`, `${uniquePrefix}allValues`),
              );
              const newExpression = types.arrowFunctionExpression(
                [changedValuesParam, allValuesParam],
                types.blockStatement([
                  ...ast.program.body,
                  types.expressionStatement(
                    types.callExpression(
                      types.identifier(onValuesChangeName),
                      [changedValuesParam, allValuesParam],
                    ),
                  ),
                ]),
              );
              onValuesChangeAttribute.get('value').replaceWith(types.jsxExpressionContainer(newExpression));
            }
          } else {
            // 没有声明 onValuesChange,直接添加属性
            const ast = parser.parse(
              getInsertExtensionsCodeWithoutArguments(state.file.opts.filename, 'onChangedValues', 'allValues'),
            );
            const newOnValuesChangeAttribute = types.jsxAttribute(
              types.jsxIdentifier('onValuesChange'),
              types.jsxExpressionContainer(
                types.arrowFunctionExpression(
                  [
                    types.identifier('changedValues'),
                    types.identifier('allValues'),
                  ],
                  types.blockStatement([ast.program.body[0]]),
                ),
              ),
            );

            // attributes.push(onValuesChangeAttribute);
            // 将新的属性添加到 JSX Opening Element 的 attributes 属性中
            path.pushContainer('attributes', newOnValuesChangeAttribute);
          }
        }
      },

编写测试用例测试插件

写完了之后,为了尽可能覆盖多中场景,我在/example目录下创建了各种场景的源文件,用于测试。

使用jest编写测试用例:

核心代码:用Babel重新编译 一遍,判断一下编译结果里面是否注入了特定的代码

// 检查代码中是否包含特定文本
const expectedTexts = [
  'onValuesChange',
  'window.WATCH_FORM_DATA_EXTENSIONS = {}',
  // 'window.WATCH_FORM_DATA_EXTENSIONS[*] = *',
];

const expectedPattern = new RegExp(
  'window\\.WATCH_FORM_DATA_EXTENSIONS\\[.*\\] = .*',
  's'
);

源代码参考

babel-plugin-watch-form

开发完之后的反思

插件开发完之后还存在几个问题:

1.Form表单的初始化数据拿不到,需要再处理一下

2.onValuesChange只能监听到用户行为触发的变化,对于setFiledsValues、setFeildValue、resetFieldsValue等API修改的表单数据是监听不到的,还需要处理一下,我麻了...

所以,在经过一番思考后,重新出发,决定换一个思路。重写AntD的Form组件,在组件内部对onValuesChange进行重写。