从零开始写一个babel插件,实现styleName转换

322 阅读5分钟

从 0 开发一个 babel 插件,实现 styleName 转换

背景

在前端开发中,CSS Modules 通常用于实现样式隔离,避免不同组件之间的样式冲突。然而,使用 CSS Modules 的语法可能会比较繁琐,因此可以选择使用 Babel 插件来简化书写过程。

为什么说书写比较繁琐

可以看一下 umi 中 cssmodule 的示例

import styles from './index.css';
​
function Test() {
  return <div className={styles.title}>Hello World</div>;
}

类似的还有 cra 搭建项目的示例

import React, { Component } from 'react';
import styles from './Button.module.css'; // Import css modules stylesheet as styles
import './another-stylesheet.css'; // Import regular stylesheetconst Button = () => {
  // reference as a js object
  return <button className={styles.error}>Error Button</button>;
};

那如果在使用 cssmodule 时候,需要和三方库样式结合呢,譬如在使用 cssmodule 同时使用 tailwindcss?

一种解决方案是使用模板字符串,还是使用先前的 demo

import React, { Component } from 'react';
import styles from './Button.module.css'; // Import css modules stylesheet as stylesconst Button = () => {
  // reference as a js object
  return (
    <button className={`${styles.error} t-color-white t-xxx...`}>
      Error Button
    </button>
  );
};

另一种稍微好一点的方法,使用 classnames

import React, { Component } from 'react';
import styles from './Button.module.css'; // Import css modules stylesheet as styles
import cx from 'classnames';
​
const Button = ({ className }) => {
  // reference as a js object
  return (
    <button className={cx(styles.error, className, 't-color-white t-xxx...')}>
      Error Button
    </button>
  );
};

使用 classname 可以简化代码的书写,但还是比较繁琐,最好的解决方案当然是全部使用 tailwindcss 等原子化的 css 框架,避免 cssmodule 使用; 这样也能最大限度的降低打包后 css 的体积,但实际开发中,全部使用原子化的 css 还是比较困难,不可避免要使用当前这种写法。

babel-plugin-react-css-modules 的使用

在 react 中,可以选择 babel-plugin-react-css-modules 来简化代码的书写,但比较遗憾的是这个插件已经很长时间没有更新,在使用新版本 css-loader 的项目中,并不能很好的使用,但也提供了一个解决思路。

在 使用这个插件后,cssmodule 的样式类名使用 styleName,而使用全局 css 的类名还是使用 className。

听起来似乎没有什么区别,只是把这两部分拆出来,但如果 styleName 不用使用 styles.xxx 这种形式呢?

直接使用一个字符串是不是更简单

例如

import React from 'react';
import './table.css';
​
const Table = () => {
  return (
    <div styleName="table">
      <div styleName="row">
        <div styleName="cell">A0</div>
        <div styleName="cell">B0</div>
      </div>
    </div>
  );
};

这段代码在运行后结果和使用 styles.xxx 是等价的

<div class="table__table___32osj">
  <div class="table__row___2w27N">
    <div class="table__cell___1oVw5">A0</div>
    <div class="table__cell___1oVw5">B0</div>
  </div>
</div>

在这个基础上,可以和 tailwindcss 结合起来,二者互不影响,譬如

import React from 'react';
import './table.css';
​
const Table = () => {
  return (
    <div styleName="table" className="t-bg-white t-xxx ...">
      <div styleName="row">
        <div styleName="cell">A0</div>
        <div styleName="cell">B0</div>
      </div>
    </div>
  );
};

到目前为止,只是对 babel-plugin-react-css-modules 的粗浅使用,但可以看到对样式处理已经简便了不少

babel-plugin-react-css-modules 存在的问题

  • 需要先修改 styles,声明对应 class 后,再去组件内添加对应 styleName,否则就会不更新,反复改,非常麻烦
  • css-loader 升级后,更新了 class 的计算方式,导致原先的插件生成的 class 与 css-loader 生成的 class 不相同(可以调整配置来解决)

umi3、umi4 中使用的问题

umi 引入 cssmodule 需要使用 import styles from 'xxx.css'

但在 umi3、umi4 使用 tsx 开发组件时,如果对导入的变量没有使用,那 umi 的配置会干掉这些引入,譬如下面这段代码

import React from 'react';
import styles from './table.css';
​
const Table = () => {
  return (
    <div styleName="table" className="t-bg-white t-xxx ...">
      <div styleName="row">
        <div styleName="cell">A0</div>
        <div styleName="cell">B0</div>
      </div>
    </div>
  );
};

可以看到这里并没有直接引用 styles,经过 loader 处理后,styles 会被清除掉,后续 styleName 拿不到引入的 css,就会报错

相关的配置可以看下面这段代码

// 源码地址 https://github.com/umijs/umi/blob/master/packages/babel-preset-umi/src/index.ts#L62
require.resolve('@umijs/bundler-utils/compiled/babel/preset-typescript'),
  {
    allowNamespaces: true,
    allowDeclareFields: true,
    // Why false?
    // 如果为 true,babel 只删除 import type 语句,会保留其他通过 import 引入的 type
    // 这些 type 引用走到 webpack 之后,就会报错
    onlyRemoveTypeImports: false,
    optimizeConstEnums: true,
    ...opts.presetTypeScript,
  };
// ...

onlyRemoveTypeImports false 会清除包含类型导入在内的所有 未使用 的 import,也就是说,如果代码里只有 import styles,没有 styles 的引用,那这个 import 语句就会被 babel/preset-typescript 给清除掉

onlyRemoveTypeImports

参考地址 www.babeljs.cn/docs/babel-…

boolean 类型,默认值为 false。

添加于: v7.9.0

当设置为 true 时,转换时只是删除 type-only imports (在 TypeScript 3.8 版本中引入)。仅在使用 TypeScript >= 3.8 版本时才应使用此参数。

umi 避免导入变量由于未使用而被清除

解决方法也简单,给代码加个引用就行,譬如使用 babel 或 umi 插件,在有 import styles 的 tsx、jsx 文件中,添加上这一行代码,这样 babel/preset-typescript 就不会移除对应的 import 语句。

import React from 'react';
import styles from './table.css';

const Table = () => {
  return (
    <div styleName="table" className="t-bg-white t-xxx ...">
      <div styleName="row">
        <div styleName="cell">A0</div>
        <div styleName="cell">B0</div>
      </div>
    </div>
  );
};

// 插件插入这段代码,styles有引用,那就不会被清理掉
(() => {})(styles);

如果觉得引入这个不太舒服,也可以等编译完成了,让 loader 访问所有 tsx 文件,给这段代码清理掉就可以,因为模板是固定的,所以清理也很方便

解决方案

第一种是调整 css-loader 的配置,更改他生成 class 的函数 getLocalIdent,具体可以参考

github.com/gajus/babel…

同时,这里在这个 issues 里,也找到一个基于 babel-plugin-react-css-modules 实现的库,看描述是解决了 css-loader 升级后,class 计算方式不同的问题

www.npmjs.com/package/@dr…

实现一个 babel plugin 来解决这个问题

相较前两种解决方案,这种当然颇具难度,但借此机会也可以了解下 babel 插件的开发流程;

同时因为只用到了插件的部分功能,所以实现一个简化版本也能满足基本的业务需求。

需求

  • 支持 className styleName
  • 引入方式优化,可以 import styles from 'xxx' 也可以 import './index.module.css'
  • 不存在 css、组件反复改,class 加不上的问题
  • 兼容多版本 css-loader,升级 css-loader 不会导致插件不可用

分析

说了那么多,归根结底其实就是实现 className 和 styleName 合并

import styles from 'xxx.module.scss';
const App = () => {
  return <div className="xxx" styleName="abc" />;
};

经过 plugin 转换,变成

import styles from 'xxx.module.scss';
const App = () => {
  return <div className={'xxx ' + styles['abc']} />;
};

为什么使用 styles['abc']的形式呢?因为大多时候定义 class 都采用中划线形式,如果直接 styles.xxx,转换后可能就报错了

import styles from 'xxx.module.scss';
const App = () => {
  // styles.abc-xxx 肯定是行不通的
  return <div className={'xxx ' + styles['abc-xxx']} />;
};

原先插件需要先写 css 类目,再去组件使用的问题

这里没有做缓存,不管是组件还是 css 文件被修改, loader 都会被重新调用,所以不存在先写 class、再改组件的先后步骤,什么样的顺序都可以。

怎么做到 css-loader 更新,插件依然可用?

只处理 class 合并,不管唯一的 class 生成即可,把这些工作交给 css-loader 去做,这处理的工作减少了,但实际效果是等价的。

styles 被 babel/preset-typescript 移除

这里只是在编辑器里提示未引用,在插件运行后,编译后的 jsx、tsx 中都会有对 styles 的引用,所以 babel/preset-typescript 并不会清理掉这些样式文件。

编辑器未引用标红的问题

可以用下面这几种方式来让编辑器不报红:

  • 使用后缀替代 styles 引入,这里后缀支持自由配置.module .cssmodule 都可以

    在插件中,会清除对应的 import 语句,替换成 import styles_uuid 这种形式

// 原先的引入方式
import styles from 'xxx.css';

// 新的引入方式
import './index.module.css';

// 插件处理后
import styles_uuid from './index.module.css';
  • 引入时给变量名加个 _
// 原先的引入方式
import styles from 'xxx.css';
// 新的引入方式
import _styles from 'xxx.css';

有其他方式,可以评论区一起探讨

实现思路

这里主要分为两部分,一部分是 import 语句的处理,另一部分是 styleName 的转化和 className 合并

import 语句处理

在这里,引入样式文件有多种形式,只对下面这两种做处理

  • import styles from 'xxx.css'
  • import './index.module.scss'

在访问到 import 节点 , babel 中对应 ast 的节点类型为 ImportDeclaration,通过访问这个节点,可以拿到引入文件的路径,也可以获取到对应的变量名,譬如

import styles from './index.css';

这里能获取到他的变量名是 styles,也可以获取到他的路径是./index.css

另一种结构

import './index.module.css';

这里通过解析 ast,也可以获取到对应的路径,但是获取不到变量名;

通过路径的解析,能知道他是一个 css 文件,且命名规则符合对 cssmodule 的定义,那就需要按照 cssmodule 来处理。

上述这两种方式如何区分呢,可以观察下对应的 ast,推荐来这个网站查看astexplorer.net/

这两种引入对应的 ast 如下

{
  "type": "Program",
  "start": 0,
  "end": 64,
  "body": [
    // import styles from './index.css';
    {
      "type": "ImportDeclaration",
      "start": 0,
      "end": 33,
      "specifiers": [
        {
          "type": "ImportDefaultSpecifier",
          "start": 7,
          "end": 13,
          "local": {
            "type": "Identifier",
            "start": 7,
            "end": 13,
            "name": "styles"
          }
        }
      ],
      "source": {
        "type": "Literal",
        "start": 19,
        "end": 32,
        "value": "./index.css",
        "raw": "'./index.css'"
      }
    },
    // import './index.module.css';
    {
      "type": "ImportDeclaration",
      "start": 35,
      "end": 63,
      "specifiers": [],
      "source": {
        "type": "Literal",
        "start": 42,
        "end": 62,
        "value": "./index.module.css",
        "raw": "'./index.module.css'"
      }
    }
  ],
  "sourceType": "module"
}

只需要考虑 type 为 ImportDeclaration 的 ast 即可,通过比较,很容易发现:

在 import styles from './xxx.css'这种形式下,对应的 ast specifiers 属性是一个对象数组,而另一种方式为空数组

在 specifiers 上,通过 type 等于 ImportDefaultSpecifier 就能获取到默认导入的变量名,把它存储下来就行

  "specifiers": [
    {
      "type": "ImportDefaultSpecifier",
      "start": 7,
      "end": 13,
      "local": {
        "type": "Identifier",
        "start": 7,
        "end": 13,
        "name": "styles"
      }
    }
  ],

这也是 umi 中,判断 cssmodule 的实现方法,对应代码

// https://github.com/umijs/umi/blob/master/packages/babel-preset-umi/src/plugins/autoCSSModules.ts
const CSS_EXT_NAMES = ['.css', '.less', '.sass', '.scss', '.stylus', '.styl'];
// ...
ImportDeclaration(path: Babel.NodePath<t.ImportDeclaration>) {
  const {
    specifiers,
    source,
    source: { value },
  } = path.node;
  if (specifiers.length && CSS_EXT_NAMES.includes(extname(value))) {
    source.value = `${value}?modules`;
  }
},
// ...

这里没有考虑同时引入多个 css 的情况,大家有兴趣的话,可以在这个基础上继续完善

styleName 转换

在 jsx 中,添加属性值有很多种方法,譬如字符串形式、表达式形式,相对应,可能出现的情况就有以下几种

  • className 是字符串 className=''
  • className 是表达式 className={}
  • styleName 是表达式
  • styleName 是字符串

styleName 的转换步骤如下

  • 按空格分割
  • 过滤空字符串
  • 变成 styles[item] item 为当前截取字符串数组的某一项,styles 为先前 import 进来的变量名
  • 过滤,把 styles 中不包含的 class 清理掉
  • 拼接,调用 join(' ') 也就是下面这段代码
styleName.split(' ').filter(Boolean).map(d=>(styles[d])).filter(d=>d!===undefined).join(' ')

也就是说,不管 styleName 是字符串还是表达式,最终执行的逻辑都是上面这段,得到的还是一个表达式,表达式跟字符串拼接后的结果,肯定还是个表达式,所以这里就需要先清除掉 ast 上面 className、styleName 的节点,后续计算完成了,再给 ast 上面加一个 className 节点,对应值就是合并后的表达式。

用到的 babel 插件

@babel/types

Babel Types 模块是一个用于 AST 节点的 Lodash 式工具库(译注:Lodash 是一个 JavaScript 函数工具库,提供了基于函数式编程风格的众多工具函数), 它包含了构造、验证以及变换 AST 节点的方法。 该工具库包含考虑周到的工具方法,对编写处理 AST 逻辑非常有用。

在插件中,可以使用他来判断节点类型、合并表达式、将字符串值转换为表达式。

@babel/template

babel-template 是另一个虽然很小但却非常有用的模块。 它能让你编写字符串形式且带有占位符的代码来代替手动编码, 尤其是生成的大规模 AST 的时候。 在计算机科学中,这种能力被称为准引用(quasiquotes)。

在这里可以看到对他们的介绍,详细使用可以参照对应的官方文档 github.com/jamiebuilds…

具体实现

基本框架

一个 babel 插件的基本结构如下

const babelTypes = require('@babel/types');module.exports = () => {
  return {
    name: '',
    visitor: {},
  };
};

可以看到他导出了一个函数,函数返回值有 name 和 visitor,name 为当前插件名称,visitor 中,可以添加节点的 type,譬如

module.exports = () => {
  return {
    name: '',
    visitor: {
      Identifier() {
        console.log('Called!');
      },
    },
  };
};

Identifier 是标识符的 type,譬如当前这个函数

function test(){
let a = 123
let b = 233
return a*b
}

使用定义的这个 babel 插件,编译后运行,因为有三个标识符,所以会 log3 次 Called!

参数

当你有一个 Identifier() 成员方法的访问者时,你实际上是在访问路径而非节点。 通过这种方式,你操作的就是节点的响应式表示(译注:即路径)而非节点本身。

const MyVisitor = {
  Identifier(path) {
    console.log("Visiting: " + path.node.name);
  }
};
a + b + c;
path.traverse(MyVisitor);
Visiting: a
Visiting: b
Visiting: c

详细的可以看这里github.com/jamiebuilds…

顺序

需要注意,处理 import 语句、解析 jsx 有着严格的先后顺序,如果先处理 jsx,再处理 import 语句,那在 jsx 解析时,无法获取到定义的全局变量 styles,整个流程就无法继续,所以这里需要使用下面这种方式,来保证他们按顺序解析

module.exports = () => {
  return {
    name: '',
    visitor: {
      Program: {
        enter(path, {opts}) {
          path.traverse({ImportDeclaration(path) {}});
          path.traverse({JSXElement(path) {}});
        }
      }
    }
  };
};

处理 import 语句

import 对应的 ast type 为 ImportDeclaration,在 visitor 里加一个即可。

 visitor: {
  Program: {
    enter(path, {opts}) {
      path.traverse({ImportDeclaration(path) {
          
      }});
    }
  }
}

这里还是可以按照 实现思路 部分的总结来编写对应代码

​
const CSS_EXT_NAMES = ['.css', '.less', '.sass', '.scss', '.stylus', '.styl'];
​
const uuid = () => Math.random().toString(36).slice(2);
const getMandomImportName = () => `styles${uuid()}`;
ImportDeclaration(path) {
  const {defaultImport = true, suffix = ['cssmodule', 'module']} = opts;
  const {
    specifiers,
    source,
    source: {value}
  } = path.node;
  const extName = extname(value);
​
  const isCssModule = CSS_EXT_NAMES.includes(extName);
  if (specifiers.length && isCssModule) {
    parseLog.info(
      `${specifiers[0].local.name}----${path.hub.file.opts.filename}`
    );
    // 这里写的不够严格,应该filter下,判断是默认导出才对
    path.scope.setData('stylesBinding', specifiers[0].local.name);
    return;
  }
​
  // 场景2
  const flag = isCssModule && suffix.some(s => includes(value, s));
  if (flag) {
    // 添加一个import
    const name = getMandomImportName();
    const importDeclaration = babelTypes.importDeclaration(
      [
        babelTypes.importDefaultSpecifier(
          babelTypes.identifier(name)
        )
      ],
      source
    );
    path.scope.setData('stylesBinding', name);
    path.replaceWith(importDeclaration);
  }
}

path.scope.setData

通过调用这个方法,可以把当前解析到的 css 变量名称保存下来,方便后续使用

解析 jsx

JSXElement(path) {
  const stylesBinding = path.scope.getData('stylesBinding');
​
  // 拓展属性,譬如{...props} 这里可能也有className、styleName
  // const spreadAttributes = path.node.openingElement.attributes.filter(
  //   (attr) => {
  //     return babelTypes.isJSXSpreadAttribute(attr);
  //   }
  // );
  const attributes = path.node.openingElement.attributes.filter(
    attribute =>
      typeof attribute.name !== 'undefined' &&
      attribute.name.name === 'styleName'
  );
​
  if (attributes.length === 0 || !stylesBinding) return;
​
  for (const attribute of attributes) {
    const destinationName = 'className';
    // string styleName='xxx'
    if (babelTypes.isStringLiteral(attribute.value)) {
      if (!attribute.value.value) continue;
      resolveStringLiteral(
        path,
        stylesBinding,
        attribute,
        destinationName
      );
      return;
    }
    // styleName是表达式
    // styleName = {'xxx'}
    // styleName= {cx('xxx,{xxx:'xxx'})}
    if (babelTypes.isJSXExpressionContainer(attribute.value)) {
      resolveExpressionContainer(
        path,
        stylesBinding,
        attribute,
        destinationName
      );
      return;
    }
  }
}

这里使用 babelTypes 判断 styleName 的属性值是字符串还是表达式形式,不同形式后续做的处理其实也大同小异

styleName 是字符串

剔除对应节点

在这一步,先把 ast 上的 className、styleName 节点都干掉,然后去生成 styleName 的表达式,这里先不关注对应的实现,只看流程


module.exports.resolveStringLiteral = (
  path,
  stylesBinding,
  attribute,
  destinationName,
) => {
  const resolvedStyleName = attribute.value.value;
  // 查找className属性
  const destinationAttribute = path.node.openingElement.attributes.find(
    (attribute) => {
      return (
        typeof attribute.name !== 'undefined' &&
        attribute.name.name === destinationName
      );
    },
  );
​
  // 删除className
  const classNameIndex = path.node.openingElement.attributes.indexOf(destinationAttribute);
  
  if (classNameIndex !== -1) {
      path.node.openingElement.attributes.splice(classNameIndex,1,);
  }

  // 删除styleName
  const styleNameIndex = path.node.openingElement.attributes.indexOf(attribute);
  
  if (styleNameIndex !== -1){
    path.node.openingElement.attributes.splice(styleNameIndex, 1);
  }

  // 生成styleName的表达式
  const styleNameExpression = getStyleNameByString(
    resolvedStyleName,
    stylesBinding,
  );
  // ...省略部分代码
};
合并表达式

styleName 的表达式现在已经生成,需要做的是把 className 和当前生成的表达式进行合并,这里 className 有三种情况

  • 不存在 className 属性
  • className 属性为字符串
  • className 属性为表达式
第一种好处理,直接给 ast 上新加一个节点就行

if (!destinationAttribute) {
    const classNameAttribute = jsxAttribute(
        jsxIdentifier('className'),
        jsxExpressionContainer(styleNameExpression),
    );
    path.node.openingElement.attributes.push(classNameAttribute);
}

第二种,calssName 是字符串

字符串不能和表达式直接相加,所以需要先转换成表达式,这里使用@babel/types 的 stringLiteral 方法进行转换,转换后直接合并即可。

合并表达式,这里使用@babel/types 的 binaryExpression 方法。


if (isStringLiteral(destinationAttribute.value)) {
        const expression = binaryExpression(
        '+',
        stringLiteral(destinationAttribute.value.value + ' '),
        styleNameExpression,
    );
    const classNameAttribute = jsxAttribute(
        jsxIdentifier('className'),
        jsxExpressionContainer(expression),
    );
    path.node.openingElement.attributes.push(classNameAttribute);
}

第三种,className 也是表达式

这样就省去了字符串转表达式的过程,直接合并即可

if (isJSXExpressionContainer(destinationAttribute.value)) {
  const expression = mergeExpression(
      CLASSNAME_EXPRESSION: destinationAttribute.value.expression,
      STYLENAME_EXPRESSION: styleNameExpression,
  );
​
  const classNameAttribute = jsxAttribute(
    jsxIdentifier('className'),
    jsxExpressionContainer(expression),
  );
  path.node.openingElement.attributes.push(classNameAttribute);
  return;
}

styleName 的转化

这里分为两种形式,字符串形式和表达式形式

  • 字符串

    <div styleName='a b c'/>
    
  • 表达式

    <div styleName={'a b c'}/>
    <div styleName={a>b?'a':'b'}/>
    <div styleName={a && 'a'}/>
    <div styleName={cx('a b c',{active:isActive})}
    

这两种本质上是一样的,因为需要的都是表达式执行完成后的结果,在这个结果基础上进行操作,而字符串转表达式,调用下 stringLiteral 方法即可。

使用 ast 来转换 styleName

//   1.先根据空格分割
  const styleNameSplitExpression = callExpression(
    memberExpression(expressionContainerValue.expression, identifier("split")),
    [stringLiteral(" ")]
  );
  // 2.过滤空字符串
  const filteredStyleNameExpression = callExpression(
    memberExpression(
      styleNameSplitExpression, // 这是上面创建的split调用表达式
      identifier("filter")
    ),
    [
      arrowFunctionExpression(
        [identifier("item")], // 参数
        binaryExpression("!==", identifier("item"), stringLiteral(" ")) // item !== ' '
      ),
    ]
  );
  // 3.使用map操作符,将数组中的每一项转换为stylesBinding.item + ' '
  const styleNameExpression = callExpression(
    memberExpression(
      filteredStyleNameExpression, // 这是要进行map操作的数组表达式的AST节点
      identifier("map")
    ),
    [
      arrowFunctionExpression(
        [identifier("item")], // map回调函数的参数
        binaryExpression(
          "+", // 使用加法操作符来拼接字符串
          memberExpression(identifier(stylesBinding), identifier("item"), true), // 使用计算属性名
          stringLiteral(" ") // 拼接一个空格字符串
        )
      ),
    ]
  );
​
  // 构建分割表达式
  const styleNameSplitExpression = styleNameSplitExpressionTemplate({
    EXPRESSION_CONTAINER_VALUE: expressionContainerValue.expression,
  });
​
  // 构建过滤表达式
  const filteredStyleNameExpression = filteredStyleNameExpressionTemplate({
    STYLE_NAME_SPLIT_EXPRESSION: styleNameSplitExpression,
  });
​
  // 构建map表达式
  const styleNameExpression = buildStyleNameExpression({
    STYLE_NAME_EXPRESSION: filteredStyleNameExpression,
    STYLES_BINDING: stylesBinding,
  });

这里 styleNameExpression 就是转换后的表达式了,这一长串代码看起来有没有头昏脑胀?只是实现一个小功能就需要写这么长一串。。。

别急,可以使用 template 来优化下。

使用 template 来简化操作

const template = require('@babel/template').default;
const { flow } = require('lodash');
​
const buildStyleNameExpression = template.expression(`
  STYLE_NAME_EXPRESSION
    .map(item => ({ className: item, val: STYLES_BINDING[item] }))
    .filter(({ className, val }) => {
      if (val) return true;
      console.error('找不到class', className, '请检查class是否定义');
      return false;
    })
    .map(d => d.val)
    .join(' ')
`);
​
const filteredStyleNameExpressionTemplate = template.expression(`
  STYLE_NAME_SPLIT_EXPRESSION.filter(item => item !== ' ' && !!item)
`);
​
const styleNameSplitExpressionTemplate = template.expression(`
  EXPRESSION_CONTAINER_VALUE.split(' ')
`);
​
/**解析表达式形式的styleName */
const getStyleNameByExpression = (styleName, stylesBinding) => {
  return flow(
    () =>
      styleNameSplitExpressionTemplate({
        EXPRESSION_CONTAINER_VALUE: styleName,
      }),
    (val) =>
      filteredStyleNameExpressionTemplate({ STYLE_NAME_SPLIT_EXPRESSION: val }),
    (val) =>
      buildStyleNameExpression({
        STYLE_NAME_EXPRESSION: val,
        STYLES_BINDING: stylesBinding,
      }),
  )();
};

使用 template 可以很大程度上简化代码编写,之前的一堆代码,现在都可以用这个函数替代,并且功能更多,还可以实现 class 的检查,判断 css 里是否有定义对应 class。

用 lodash 的 flow 方法,写起来比较清晰,可以看到一层一层的调用关系。

flow 是从左向右依次调用函数,上一个函数的返回值就是下一个函数的参数;相对应还有 flowRight,从右向左依次调用,这个和 compose 其实是一样的。

在 styleName 为字符串形式时,传进来的 styleName 先用 stringLiteral 处理下即可

/** 解析string形式的styleName */
const getStyleNameByString = (styleName, stylesBinding) => {
  return getStyleNameByExpression(stringLiteral(styleName), stylesBinding);
};

mergeExpression 实现

const mergeExpression = template.expression(`
 CLASSNAME_EXPRESSION ? CLASSNAME_EXPRESSION + " " + STYLENAME_EXPRESSION : STYLENAME_EXPRESSION
`);

调用时候

const expression = mergeExpression({
  CLASSNAME_EXPRESSION: classNameExpression,
  STYLENAME_EXPRESSION: styleNameExpression,
});

打包和测试

这里使用 webpack 进行打包,将当前插件打包成单独一个 js 文件,方便使用

const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

module.exports = {
  mode: 'production',
  entry: './src/transform.js',
  output: {
    path: path.resolve(__dirname, 'dist'), // 输出目录
    filename: 'css.js', // 输出文件名
    libraryTarget: 'commonjs2', // 适用于 Node.js 环境
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
        },
      },
    ],
  },
  target: 'node', // 目标环境是 Node.js
  devtool: 'source-map', // 生成 source map 以便调试
  plugins: [
    new CleanWebpackPlugin(), // 每次打包前清除输出目录
  ],
};

有用到的依赖,这里有些是本地测试时候的,本地测试可以使用 nodemon

    "@babel/core": "^7.24.9",
    "@babel/generator": "^7.24.10",
    "@babel/parser": "^7.24.8",
    "@babel/preset-react": "^7.24.7",
    "@babel/template": "^7.25.0",
    "@babel/traverse": "^7.24.8",
    "@babel/types": "^7.24.9",
    "babel-loader": "^9.1.3",
    "clean-webpack-plugin": "^4.0.0",
    "cross-env": "^7.0.3",
    "lodash": "^4.17.21",
    "webpack": "^5.93.0",
    "webpack-cli": "^5.1.4",

测试文件

const fs = require('fs');
const babel = require('@babel/core');

// babel/types 工具库 该模块包含手动构建TS的方法,并检查AST节点的类型。(根据不同节点类型进行转化实现)
const babelTypes = require('@babel/types');
const transform = require('./transform');

const template = fs.readFileSync(require.resolve('../template.js'), 'utf8');

const targetCode = babel.transform(template, {
  presets: ['@babel/preset-react'],
  plugins: [[transform]],
});
// 再转换回jsx

console.log(targetCode.code);

使用时,执行 nodemon src/index.js

主要代码上面都有,完整代码就不贴了,大家有需要可以 cv 下来,按照思路敲一遍。

注意事项

项目使用打包完成后的 loader 测试时,每次重启项目前,先删除 node_modules/.cache 缓存文件夹,或禁用 webpack 缓存。

总结

到这里插件的基本功能就已经实现了,完成了从 styleName 到 className 的转换,可以看到还是比较简单,希望对大家有所帮助。

当然,相较于 babel-plugin-react-css-modules 功能只实现了一部分,有很多都没有实现,但按这个思路往下写应该也是可行的。

未实现的功能:

  • 多个 cssmodule 引入
  • jsx 拓展属性
  • 部分特殊值处理