我们是怎么使用AST提升工作效率的?

3,804 阅读10分钟

我们是怎么使用AST提升工作效率的?

AST 是什么

抽象语法树 (Abstract Syntax Tree),简称 AST,它是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

都遇到了些什么需求

突然的国际化: 产品在迭代两年多后,我们开始开拓海外市场,于是所有项目都要支持多语言切换。问题是我们在前期开发中,根本没有预留多语言的拓展能力,也就是说我们需要在一个版本的开发周期内,将代码中的中文文案全部提取出来,翻译,再以f(key)f(key)的形式替换。

丢弃babel的后果: 为了提升编译速度,我们使用swc替换babel来编译ts&tsx代码,导致基于插件:babel-plugin-react-css-modulesstyleName 形式的css modules 写法不再被支持,需要切换回 className 写法。

本地化改造: 项目本地化部署时,大概率是局域网环境,因此需要将项目中引用到的CDN上的资源(图片,字体等)全部下载到本地,再将所有的引用切换为本地相对路径。

为什么会用到AST

上述提到的需求都是些耗时耗力的体力活,作为一个合格的程序员,肯定是拒绝手动的去做这些重复工作的;于是,如何写一个高效率的,能尽可能的覆盖更多case的工具就成了我们首要问题。不出意外的联想到了AST,而AST也是处理这类需求最好的且没有之一的工具(如果有,请狠狠地打我脸)。

在完成需求的同时,我们基于AST,实现了以下工具:

  • 自动提取&替换国际化文案的工具;
  • 自动下载项目中引用到的远程资源&替换资源引用链接的工具;
  • 自动将styleName转换为className工具;

案例实操 -- 将styleName切换为className

代码

源码请看这里:style2class

背景

在日常开发中,启用css modules后,给开发带来了很多便利,例如通过css scoped为每个类名都加上hash,很好的避免了样式污染。但是,原生的css modules也有一些缺点:

  1. You have to use camelCase CSS class names.
  2. You have to use styles object whenever constructing a className.
  3. Mixing CSS Modules and global CSS classes is cumbersome.
  4. Reference to an undefined CSS Module resolves to undefined without a warning.

基于以上原因,我们项目的css modules都是基于babel-plugin-react-css-module的babel插件,通过styleName来定义css类名。

当我们将bebel替换为swc后,因为babel-plugin-react-css-module插件不再被支持,导致所有的css样式不再生效;因此需要将插件支持的styleNames形式的写法全部切换回原生的className写法。

分析

对于单个tsx或jsx文件,单独使用className或者styleName方案时,主要差别有以下几点:

  • 样式文件导入方式不同:styleName并没有将样式导入为指定名称的样式对象,className则导入为指定名称的样式对象。
  • 样式引用不同:styleName直接引用类名,className需要从样式对象中获取。

因此,在将styleName切换回className时,可将过程拆分为两个步骤:

  • 转换样式导入,即:
      // 转换前
      import 'index.less'// 转换后
      import styles from 'index.less'
    
  • 转换样式取值,即:
      // 转换前
      <div styleName="container"/>
      // 转换后
      <div className={styles.container}/>
    

标准的使用AST修改代码的流程如下图:

1.png

工具

以下是过程中用到的重要的工具:

jscodeshift: 基于babel的AST工具包,它提供API让遍历或者修改AST更丝滑;

astexplorer: AST可视化平台;

@babel/types: AST节点类型文档;

实现

工具函数

在开始处理实际需求前,先实现了以下工具函数。

getEntryPath: 遍历文件系统,返回路径名。该方法用于扫描项目中所有的ts|tsx文件;


    const fg = require('fast-glob');  //fast-glob: https://github.com/mrmlnc/fast-glob
    const getEntryPath = (entry) => {
        const entries = fg.sync(entry, {
            dot: true,
            cwd: process.cwd(),
        });
        return entries;
    };

getAstFromSource: 将源代码转换为AST;用于将单个ts|tsx文件源码转换成AST。


    const jscodeshift = require('jscodeshift');
    const j = jscodeshift.withParser('tsx');
    const fs = require('fs-extra');

    const getAstFromSource = (entry) => {
        let source = fs.readFileSync(entry, { encoding: 'utf8' });
        const ast = j(source);
        return ast;
    };

getSourceFromAst: 将AST转换为源代码;


    const getSourceFromAst = (ast) => {
        return ast.toSource({ lineTerminator: '\n', quote: 'single' });
    };

createMemberExpression:创建对象取值表达式,如:style.content

    const createMemberExpression = (key, value, computed) = > {
        const isValid = !value.includes('-'); // 属性名包含“-”是非法的,需要以这种方式取值style["xxxx"]
        const isRmComputed = !computed && isValid;

        const memberExpression = j.memberExpression(
            j.identifier(key),
            isRmComputed ? j.identifier(value) : j.stringLiteral(value),
            !isRmComputed
        );
        return memberExpression;
    }
步骤一: 转换Import语句

在转换前,我们得知道Import语句对应的AST结构是怎么样的,此时就可以通过 astexplorer 来查看,更详细的内容,可以在babel的types文档中查看ImportDeclaration

从文档中可以看到,import语句对应的AST节点类型定义如下:

    type ImportDeclaration = {
        type:"ImportDeclaration"
        specifiers: Array<ImportSpecifier | ImportDefaultSpecifier | ImportNamespaceSpecifier> (required)
        source: StringLiteral (required)
        assertions: Array<ImportAttribute> (default: null, excluded from builder function)
        attributes: Array<ImportAttribute> (default: null, excluded from builder function)
        importKind: "type" | "typeof" | "value" (default: null, excluded from builder function)
        module: boolean (default: null, excluded from builder function)
        phase: "source" | "defer" (default: null, excluded from builder function)
    }

现在只需要特别关注specifiers和source两个属性。source表示从哪个包或者文件路径导入;specifiers表示被导入后内容的标识符数组,每个数组项可以是ImportSpecifier ,ImportDefaultSpecifier,ImportNamespaceSpecifier(分别代表什么,文档写的很清楚);

import "index.module.less" 的AST结构如下图:

企业微信20240417-100009@2x.png

import style from "index.module.less" 的AST结构如下图:

企业微信20240417-100044@2x.png

与源代码导入语句的AST相比,目标代码的AST节点的specifiers属性不为空,有一个ImportDefaultSpecifier类型的节点,其代表:导入样式后指定样式对象的名称,如:import styles from 'index.module.less' 语句中的 styles

因此转换逻辑大致为:

  1. 找到所有的Import语句;
  2. 通过AST节点的source属性过滤出所有的样式导入语句;ImportDefaultSpecifier
  3. 找到specifiers为空数组的样式导入语句;
  4. 计算出一个安全样式对象名称后,构造ImportDefaultSpecifier节点;
  5. 将ImportDefaultSpecifier节点push到该导入语句的specifiers数组中;

核心代码如下:

   originData.find(j.ImportDeclaration).forEach((path) => { // originData表示整个tsx文件的AST;找到所有Import语句
        const importValue = get(path, 'value.source.value');
        const isLess = importValue.endsWith('.less');
        if (isLess) {                                       // 判断是否是样式导入(我们全部less)
            const isImportDefaultSpecifier = 
                get(path, 'value.specifiers[0].type') === 'ImportDefaultSpecifier';
            if (isImportDefaultSpecifier) {
                const names = getImportVariableNames(path.value);
                specifier = names[0];                       // 如果已经是默认导入,则记录specifierName
            } else {
                const globalVars = getGlobalVariableNames(originData);
                specifier = getGlobalStyleId(globalVars);   // 如果不是默认导入,生成一个合法的标识符并记录

                j(path).replaceWith((p) => {
                    p.value.specifiers = [                  // specifiers 插入一个 ImportDefaultSpecifier 类型节点
                        {
                            type: 'ImportDefaultSpecifier',  
                            local: {
                                type: 'Identifier',
                                name: specifier,
                            },
                        },
                    ];
                    return p.value;
                });
            }
        }
    });
步骤二:将JSX的styleName属性转换为className属性

styleName是jsx节点的一个属性,AST结构如下图:

企业微信20240417-102829@2x.png

因此第一步就是遍历AST找到所有拥有styleName属性的JSX节点,并获取styleName属性的属性值的AST节点。

    originData.find(j.JSXOpeningElement).forEach((path) => {
        j(path)
        .find(j.JSXAttribute)
        .forEach((p) => {
            if (p.value.name.name === 'styleName') {
                // ......
            }
        });
    });

找到styleName属性并且拿到属性值了,要怎么将其转换成className形式呢?

我们知道styleName类型为字符串,可在实际开发过程中,它不可能只是形如styleName="container"这样的简单字符串形式,当业务复杂度起来后,这个字符串可以是由任意的表达式生成的。

经过分析,styleName的属性值可能有以下几种情况进行赋值:

  1. 简单字符串,如:styleName="container";
  2. 带空格的简单字符串,如:styleName="container small";
  3. 逻辑表达式,如:styleName={true && "container"};
  4. 三元表达式,如:styleName={true ? "show" : "hide"};
  5. 变量,如:styleName={styles};
  6. 函数调用,如:styleName={fn()};
  7. 模版字符串,如:styleName={'container ${ true ? "show" : "hide" }'}
  8. 上述7种类型嵌套形式;

但仔细想想,不管什么类型的表达式,只需要保证表达式的结构不改变的同时,将表达式中表示类名的字符串”XXX“转换为style.XXX

基于此,将以上多种形式的表达式,分为三类:

  1. 基础形式(简单字符串,带空格的简单字符串);
  2. 简单表达式形式(没有嵌套其他表达式,直接返字符串);如:styleName={true ? "show" : "hide"} 就认为是简单表达式,styleName={true ? false && "show" : "hide"} 则不是,因为在条件表达式的基础上还嵌套了逻辑表达式;
  3. 嵌套表达式形式(1~7形式任意嵌套);
基础形式

简单字符串的AST节点类型如下:

    type Literal = {
        value:string (required)
    }

简单字符串形式: 在转换这种形式时,目标是将styleName="container" 转换为 className={styles.container}

需要注意的是,在JSX中,当属性值为字符串时,可以直接给属性赋值;当属性值为非字符串时,都需要使用 {} 包裹。

带空格的简单字符串形式 当styleName属性值出现空格时,说明是多个类名,例如:styleName="container small" 就不能转换成className={styles["container small"]}

多个类名时,一般使用模版字符串来处理,此时应该转换成:className={'${styles.container} ${styles.small}'}

以上两种形式是后续转换的基础,因为无论结构多复杂,目标都是将表示css类名的字符常量转换为样式对象取值

代码实现如下:

    /**
     * @param {string} content
     * @param {boolean} [needContainer=false] 是否需要用 {} 包裹
     */
    function literalHandler(content, needContainer = false) {
        if (!content) { // 空字符时
            const node = j.stringLiteral(content);
            return needContainer ? j.jsxExpressionContainer(node) : node; // 如果转换后的值要直接复制给JSX属性,则需要使用jsxExpressionContainer类型的节点({})来包裹转换后的值
        }
        const splits = content.split(' ').filter(Boolean); 
        const { specifier, computed } = global.style2class;
        if (splits.length === 1) {                         // 只有一个类名 - 直接转换成样式对象取值
            return needContainer
                ? j.jsxExpressionContainer(createMemberExpression(specifier, splits[0], computed))
                : createMemberExpression(specifier, splits[0], computed);
        } else {                                           // 多个类名时 - 需要将每个类名使用样式对象取值后,再用模版字符串来拼接
            const templateElements = [
                createTemplateElement(''),
                ...Array(splits.length - 1).fill(createTemplateElement(' ')),
                createTemplateElement(''),
            ];
            const compressions = splits.map((split) => {
                return createMemberExpression(specifier, split, computed);
            });
            const templateLiteral = j.templateLiteral(templateElements, compressions);
            return needContainer ? j.jsxExpressionContainer(templateLiteral) : templateLiteral;
        }
    }
简单表达式

这部分的代码实现放到后文。

逻辑表达式形式: 条件表达式的AST结构如下:

    type LogicExpression = {
        operator: "||" | "&&" | "??" (required)
        left: Expression (required)
        right: Expression (required)
    }

通常,使用条件表达式来计算styleName的值时,条件表达式的右表达式(AST节点的right属性)作为取值表达式;

三元表达式形式 三元表达式的AST结构为:

    type ConditionalExpression = {
        test: Expression (required)
        consequent: Expression (required)
        alternate: Expression (required)
    }

通常,使用条件表达式来计算styleName的值时,consequent节点和alternate节点均可作为取值表达式;

变量形式 变量类型的值,我们直接忽略,处理起来略显复杂。因为它并不是个静态值,需要结合上下文(上下文甚至是夸文件的)来分析变量最终取值形式;

函数调用形式 函数调用本来也不是一个静态值,但是有一种特殊的且大量使用的情况是可以处理的。

社区提供了classnames包,用于有条件地将ClassNames加在一起。当使用classnames计算styleName的值,classnames函数的行为是可预测的,因此这种情况是可以被处理的。

先看下函数调用表达式的AST结构:

    type CallExpression = {
        callee: Expression | Super | V8IntrinsicIdentifier (required)
        arguments: Array<Expression | SpreadElement | JSXNamespacedName | ArgumentPlaceholder> (required)
    }

针对这种特殊情况的处理方式为:

  1. 判断表达式是否为函数调用表达式,是的话则获取函数名,即callee节点的字面量值;
  2. 全局所有的Import语句,判断该函数名是否是从classnames导入的函数(更严谨的话应该分析作用域链);
  3. 如果是,则处理;不是,则记录为异常;

classNames的官方文档可以看见,该函数的入参类型和返回值如下:

    classNames('foo', 'bar'); // => 'foo bar'
    classNames('foo', { bar: true }); // => 'foo bar'
    classNames({ 'foo-bar': true }); // => 'foo-bar'
    classNames({ 'foo-bar': false }); // => ''
    classNames({ foo: true }, { bar: true }); // => 'foo bar'
    classNames({ foo: true, bar: true }); // => 'foo bar'

    // lots of arguments of various types
    classNames('foo', { bar: true, duck: false }, 'baz', { quux: true }); // => 'foo bar baz quux'

    // other falsy values are just ignored
    classNames(null, false, 'bar', undefined, 0, 1, { baz: null }, ''); // => 'bar 1'

    //Arrays will be recursively flattened as per the rules above:
    const arr = ['b', { c: true, d: false }];
    classNames('a', arr); // => 'a b c'

因此classNames函数入参的转换规则为:

  1. 当入参为字符串时,按照 简单字符串 转换规则处理即可;
  2. 当入参为非字符串的基本类型时(number,boolean,null,undefined),falsy的值被过滤掉,truly的值转换成字符串后,再按照 简单字符串 转换规则处理即可;
  3. 当入参为Object类型时,Object的value保持不变,key转换为样式对象取值,例:{container: true} => { [style.container]: true} ;
  4. 当入参为数组时,每个数组项按前三个步骤分别处理即可;

模版字符串形式 模版字符串对应的AST节点是这么定义的:

    type TemplateLiteral = {
        quasis: Array<TemplateElement> (required)
        expressions: Array<Expression> (required)
    }

    type TemplateElement = {
        value: { raw: string, cooked?: string } (required)
        tail: boolean (default: false)
    }

'abc ${window}def'为例,其中:

expressions表示表达式集合:对应的是 ${window}; quasis表示字符集合:一个TemplateElement节点对应一段字符,如abc 或者def;

两个集合在映射成模板字符串的规则为:以TemplateElement节点开始,TemplateElementExpression节点交替排列且以TemplateElement结束,末尾的TemplateElement节点的tail为true,表示一个模版字符串表达式结束。

嵌套表达式

使用递归来转换嵌套表示,递归的终止条件为:当前表达式的取值节点(如:逻辑表达式的right节点,条件表达式的alternate和consequent节点)类型是Literal或则StringLiteral类型;

前文的简单表达式部分只是讲述了各个表达式的AST结构,以及在理想状态下如何去转换。

现在开始,讲述如何结合递归去解决各个表达式的转换逻辑。

expressionHandler 经分析,我们需要处理的表达式类型有:LogicalExpression,ConditionalExpression,ObjectExpression,ArrayExpression,CallExpression,TemplateLiteral;

声明了名为expressionHandler方法作为递归入口,入参为Expression节点,返回值类型为{node:Expression;transfered:boolean},其中node表示Expression节点,transfered表示入参传入的节点是否被转换,代码如下:

各个类型表示的handler的返回值均与expressionHandler函数的返回值类型一致,在递归过程中,只要有handler返回的transfered为false,就表示改节点无法被转换,会被记录为异常。

    function expressionHandler(node) {
        if (node.type === 'LogicalExpression') {
            return logicalHandler(node);
        }

        if (node.type === 'ConditionalExpression') {
            return conditionalHandler(node);
        }

        if (node.type === 'ObjectExpression') {
            return objectExpressionHandler(node);
        }
        if (node.type === 'ArrayExpression') {
            return arrayExpressionHandler(node);
        }
        if (node.type === 'CallExpression') {
            return callExpressionHandler(node);
        }

        if (node.type === 'TemplateLiteral') {
            return templateLiteralHandler(node);
        }
        // 除了上述表达式类型,被认为无法处理,记录为异常;
        return {
            transfered: false,
            node: node,
        };
    }

logicalHandler

通过logicalHandler方式来处理逻辑表达式,实现如下:

    /**
     * @param {LogicalExpression} node
     */
    function logicalHandler(node) {
        const logicalRight = node.right;
        const logicalRightType = logicalRight.type;
        const logicalRightValue = logicalRight.value;
        const isLiteral = isLiteralType(logicalRightType);
        let newLogicalRight = logicalRight;
        if (isLiteral) {  // 如果逻辑表达式的right节点是字符常量,则由 literalHandler 来转换
            const expression = literalHandler(logicalRightValue);
            newLogicalRight = expression;
            return {
                transfered: true,
                node: j.logicalExpression(node.operator, node.left, newLogicalRight),
            };
        } else {      // 如果逻辑表达式的right节点是表达式,则由expressionHandler方法递归处理
            const { node: newNode, transfered } = expressionHandler(logicalRight);

            return {
                transfered,
                node: transfered ? j.logicalExpression(node.operator, node.left, newNode) : node,
            };
        }
    }

conditionalHandler

通过conditionalHandler方式来转换三元逻辑表达式,实现如下:

    /**
     * @param {ConditionalExpression} compressionNode
     */
    function conditionalHandler(compressionNode) {
        const consequentNode = compressionNode.consequent;
        const alternateNode = compressionNode.alternate;

        //转换 consequent 部分
        const { node: newConsequentNode, transfered: consequentTransfered } = conditionalSideHandler(
            consequentNode
        );
        //转换 alternate 部分
        const { node: newAlternateNode, transfered: alternateTransfered } = conditionalSideHandler(
            alternateNode
        );

        return {
            transfered: consequentTransfered && alternateTransfered,
            node: j.conditionalExpression(compressionNode.test, newConsequentNode, newAlternateNode),
        };
    }

    function conditionalSideHandler(node) {
        let newNode = node;

        if (isLiteralType(node.type)) { // 如果是字符常量,由 literalHandler 来转换
            newNode = literalHandler(node.value);
            return { transfered: true, node: newNode };
        }
        return expressionHandler(node); // 如果是表达式,则由 expressionHandler 方法递归处理
    }

objectExpressionHandler

ObjectExpression类型的表达式一般出现在classNames函数调用时,也一块加入到递归分支中,通过objectExpressionHandler来转换。

    /**
     * @param {ObjectExpression} node
     */
    function objectExpressionHandler(node) {
        const properties = node.properties.map((property) => {
            const propertyKeyNode = property.key;
            const propertyKeyNodeType = propertyKeyNode.type;
            // propertyKeyNodeType === 'Identifier' ==> { name:"haoqidewukong"}
            // isLiteralType(propertyKeyNodeType)  ==> {["name"]:"haoqidewukong"}
            if (propertyKeyNodeType === 'Identifier' || isLiteralType(propertyKeyNodeType)) {
                const keyString = isLiteralType(propertyKeyNodeType)
                    ? property.key.value
                    : property.key.name;
                const { specifier, computed } = global.style2class;
                const key = createMemberExpression(specifier, keyString, computed);
                const objectProperty = j.objectProperty(key, property.value, 'init', true);
                objectProperty.computed = true;
                return { node: objectProperty, transfered: true };
            } else {
                return {
                    transfered: false,
                    node: property,
                };
            }
        });

        const isModified = properties.every((v) => v.transfered);

        return {
            transfered: isModified,
            node: j.objectExpression(properties.map((v) => v.node)),
        };
    }

arrayExpressionHandler

同样的,使用arrayExpressionHandler来转换ArrayExpression类型的表达式,实现如下:

    /**
     * @param {ArrayExpression} node
     */
    function arrayExpressionHandler(node) {
        const elements = node.elements.map((element) => {
            if (isLiteralType(element.type)) {
                return {
                    transfered: true,
                    node: literalHandler(element.value),
                };
            }
            return expressionHandler(element); // 递归处理表达式类型
        });

        const isModified = elements.every((v) => v.transfered);
        return {
            transfered: isModified,
            node: j.arrayExpression(elements.map((v) => v.node)),
        };
    }

callExpressionHandler

实现callExpressionHandler来转换CallExpression类型的表达式:

    /**
     * @param CallExpression node
     */
    function callExpressionHandler(node) {
        let transfered = false;
        const isPass = classnamesIdentifierChecker(node); // 检查改函数是否是从 classNames 包导入的
        if (!isPass) {
            return {
                transfered,
                node,
            };
        }
        const arguments = node.arguments;

        const newArguments = arguments.map((argument) => {
            if (isLiteralType(argument.type)) {
                return {
                    transfered: true,
                    node: literalHandler(argument.value),
                };
            }

            return expressionHandler(argument); // 递归处理表达式类型
        });

        const isModified = newArguments.every((v) => v.transfered);

        return {
            transfered: isModified,
            node: j.callExpression(
                node.callee,
                newArguments.map((v) => v.node)
            ),
        };
    }

templateLiteralHandler

观察下面两个例子,就能发现TemplateLiteral类型不能独立的去处理expressions和quasis两部分。

str1会转换成 "container show"的字符串,继而再转换成 '${style.container} ${style.show}' ;这种情况是可以分别 expressions和quasis;

str2 可能希望生成 'card-2' 这样的字符串,因此最终期望被转换成 'style['card-${index}']'的形式;如果此时分别去处理expressions和quasis的话,最后会被转换成'${style['card-']} ${style[index]}',明显与实际期望的效果有偏差。

    const str1 = `container ${true? "show" : "hide"}`;
    const str2 = `card-${index}`;

那么要如何处理呢?

关键点在quasis每个节点的value属性代表的字符常量首尾是否存在空格

例如: str1的quasis的第一个TemplateElement的value属性对应的字符常量为"container ",他是quasis的第一个节点且有尾空格,所以可以认为他是一个完整的css 类名; 而str2的quasis的第一个TemplateElement的value属性对应的字符常量为"card-",没有尾空格,说明"card-"实在与某个表达式一起拼接成新css 类名。

因此,可以实现一个工具函数来判断一个TemplateElement是否是完成的css 类名或者某个动态css 类名一部分组成,实现如下:

    /**
     * @param {string} value 
     * @param {boolean} isStart 是否是第一个TemplateElement节点
     * @param {boolean} isEnd 是否是最后一个TemplateElement节点
     * @returns 
     */
    function checkQuasisValueValid(value, isStart, isEnd) {
        if (isEnd && isStart) return true;
        if (isStart && value.endsWith(' ')) return true;
        if (isEnd && value.startsWith(' ')) return true;
        return value.startsWith(' ') && value.endsWith(' ');
    }

检测到某个TemplateElement节点非法时,并不是不能处理,只是成本较高,所以记录为异常,后续手动处理即可。

quasisHandler

通过quasisHandler方法来处理模版字符串的quasis部分,实现代码如下:

    /**
     * @param {TemplateElement} node
     */
    function quasisHandler(node, index, length) {
        const value = node.value.raw;
        if (!value) {
            return {
                transfered: true,
                node,
            };
        }
        const isStart = index === 0;
        const isEnd = index === length - 1;
        if (checkQuasisValueValid(value, isStart, isEnd)) { //  判断该节点是否合法
            const trimmedValue = value.trim();
            if (!trimmedValue) { 
                // 若过滤掉收尾空格后是空字符的话,说明是分隔空格,需要返回一个值为一个空格的TemplateElement节点
                return {
                    transfered: true,
                    node: j.templateElement({ raw: ' ', cooked: ' ' }, false),
                };
            }
            return {
                transfered: true,
                node: literalHandler(trimmedValue),
            };
        } else { // 不合法
            return {
                transfered: false,
                node,
            };
        }
    }

expressions部分很明细是个表达式数组,使用expressionHandler方法递归处理。

templateLiteralHandler

expressionsquasis两部分都处理完后,就可以生成新的TemplateLiteral节点。实现如下:

    /**
    * @param {TemplateLiteral} node
    */
    function templateLiteralHandler(node) {
        const expressions = node.expressions;
        const quasis = node.quasis;
        const elementLength = quasis.length + expressions.length;
        // 转换quasi部分
        const quasisNodes = quasis.map((quasi, index) => {
            return quasisHandler(quasi, index * 2, elementLength);
        });
        // 转换expressions部分
        const expressionNodes = expressions.map((expression) => {
            return expressionHandler(expression);
        });

        // 所有被处理的节点是否都合法
        const isModified =
            quasisNodes.every((v) => v.transfered) && expressionNodes.every((v) => v.transfered);

        if (isModified) {
            const mergedExpressionNodes = [];
            for (let i = 0; i < quasisNodes.length; i++) {
                const expressionNode = expressionNodes[i];
                const quasisNode = quasisNodes[i];
                mergedExpressionNodes.push(quasisNode.node);
                if (i !== quasisNodes.length - 1) {
                    mergedExpressionNodes.push(expressionNode.node);
                }
            }

            const filteredMergedExpressionNodes = mergedExpressionNodes.filter(
                (v) => v.type !== 'TemplateElement'
            );

            const insertQuasisNode = [];

            for (let idx = 0; idx < filteredMergedExpressionNodes.length; idx++) {
                if (idx !== 0) {
                    insertQuasisNode.push(j.templateElement({ raw: ' ', cooked: ' ' }, false));
                } else {
                    insertQuasisNode.push(j.templateElement({ raw: '', cooked: '' }, false));
                }
            }
            insertQuasisNode.push(j.templateElement({ raw: '', cooked: '' }, true));

            const newTemplateLiteral = j.templateLiteral(
                insertQuasisNode,
                filteredMergedExpressionNodes
            );

            return {
                transfered: true,
                node: newTemplateLiteral,
            };
        } else {
            return {
                transfered: false,
                node,
            };
        }
    }

以上就是各个形式的styleName属性值转换的核心代码;

效果

一下为部分case的转换效果;

    // ------------- 转换前 ---------------------------
    import '../styles/index.module.less';
    import classnames from 'classnames';

    const Home = () => {
        return (
            <div styleName="container">
                <div styleName="content small sl-2"></div>
                <div styleName={true ? 'show' : 'hide'}></div>
                <div styleName={true && 'show'}></div>
                <div styleName={`content ${true ? 'small' : ''}`}></div>
                <div styleName={classnames('content', { show: true })}></div>
            </div>
        );
    };

    // ------------- 转换后 ---------------------------
    import s from '../styles/index.module.less';
    import classnames from 'classnames';

    const Home = () => {
        return (
            <div className={s.container}>
                <div className={`${s.content} ${s.small} ${s['sl-2']}`}></div>
                <div className={true ? s.show : s.hide}></div>
                <div className={true && s.show}></div>
                <div className={`${s.content} ${true ? s.small : ''}`}></div>
                <div className={classnames(s.content, {[s.show]: true})}></div>
            </div>
        );
    };

对于不能转换的case,控制台也有相关提示:

文件"test/index.tsx"有脏东西,需要手动处理的代码为:
    test/index.tsx:13
案例总结

前文废话说了这么多,其实最终实现被没有覆盖全部case,但是总实际使用来看,是能覆盖95%以上的case的;

至于为什么没有尽可能的去覆盖全部,有以下两点考虑:

  1. 实现难度比较大。例如当为变量形式的取值时,需要结合上下文(上下文环境甚至是跨文件的)去分析变量的终止取值;
  2. 实现成本比较高。例如在考虑className和styleName合并时,处理成本直接翻倍;

因此,在处理这些case时,都是将其记录为异常(文件名+代码行号)输出,提醒使用者手动处理即可。

最后的总结

前面的案例只用到了AST能力的冰山一角,更深奥的用法需要更多编译原理的知识,笔者功力尚浅,还不足以掌握。

文中若是有不对的地方,欢迎指正~