从 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 stylesheet
const 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 styles
const 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,具体可以参考
同时,这里在这个 issues 里,也找到一个基于 babel-plugin-react-css-modules 实现的库,看描述是解决了 css-loader 升级后,class 计算方式不同的问题
实现一个 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 拓展属性
- 部分特殊值处理