需求
最近有个项目,技术选型是vite4+react18,项目写起来没啥问题,但是在写react组件的样式的时候,简直是太痛苦了。 有两个基本的痛点:
- 在vue3中组件样式还好,有scoped标签,可以明确区分组件内样式还是全局样式,但是在react中全都是className一把梭。
- 组件样式写起来太麻烦了,比如下面几种: 尤其是这种,简直反人类,这是要累死谁啊!!!
<div className={`${styles.foo} ${styles.bar} mgl10 mgt20`} />
<div className={[styles.foo, styles.bar].join(" ")} />
<div className={styles.foo + ' ' + styles.bar} />
<div className={styles.foo + ' ' + styles.bar} />
我理想中的书写组件样式的方式是类似下面这样:
<div className="mgl10 mgt20" styleName="test test1" />
className是全局样式, styleName是组件内样式,而且不需要我写什么{styles["test1"]}。
难点
这里的难点是什么呢?
我认为这里的难点:
-
如何组件样式隔离? 为什么需要进行组件样式隔离呢? 这很明显因为styleName是组件内的样式,如果你编译后覆盖了别的组件的样式,就会造成样式错乱。
-
如何自动化的识别styleName,通过编译最后编译成class名字呢?
-
如何合并className和styleName?毕竟最后不管是className还是styleName最后都是编译成了class,所以两者必须得合并。
解决
有了需求和难点,就得想办法去解决。
- 如何解决组件样式隔离呢?
这个问题很好解决,比如vite提供了css module,通过在css的类名上面加hash值进行区别,react也有babel-react-css-modules可以解决。这个解决起来最简单。
- 如何自动化的识别styleName,通过编译最后编译成class名字呢。 这个在react+webpack的项目中是有成熟的解决技术方案的,比如通过配置babel-react-csss-modules就可以支持。 但是在vite中是否支持,还是一个问号?
通过搜索发现了一个插件vite-plugin-react-css-modules,但是试用以后发现虽然可以解决把styleName转换,但是最后css样式并没有挂载到dom元素上。
最终决定放弃使用这个插件,原因如下:
- 最近的代码提交是2年前提交的,里边的vite使用的版本是2.0,距离现在太久远了。
- star数只有13,star数目太少。
不过除了这个vite-plugin-react-css-modules这个插件外,好像没有搜索到其它相关的插件。
不过黄天不负有心人,最后还是搜索到一个相关的插件vite-plugin-css-auto-import, 相关问题截图如下:
相关地址: www.reddit.com/r/reactjs/c…
根据官方文档试用了下,但是发现几个问题:
- 自动注入css,也就是说你不需要在组件内,不需要引入样式,但是因为前面已经写的很多样式,就得需要删除。还有一个原因是这样自动注入样式,如果别人接受项目,接手的心智成本会急剧增大。
- 只能自动注入className,但是无法识别styleName,所以需要做到可以识别styleName,。
所以针对上面的问题,需要去修改对应的源码来进行修改。
- 自动注入css,这个问题在源码里边很好解决
在src/jsxTransformer.ts文件中注释自动注入的代码就可以
// 防止自动注入css
// source.prepend(`import "${styleModuleId}";\n`);
这样就解决了自动注入css的问题
- 如何识别styleName
思路是这样的:
因为vite-plugin-css-auto-import这个插件本身是可以识别className的,所以可以增加让vite-plugin-css-auto-import首先识别出styleName,然后把styleName的值合并到className里边。
举一例例子 比如有这样的代码
<div className="mgl10 mgt20" styleName="test test1"><div>
经过转换后,变成如下这种代码
<div className="mgl10 mgt20 test test1"><div>
剩下的事情就还是利用vite-plugin-css-auto-import已经有的功能就可以了。
2.1 解决识别styleName,并且合并到className里边
vite-plugin-css-auto-import是利用的ast实现的,那么识别是styleName和合并到className也很简单。
思路如下:
可以在遍历ast的时候,判断dom元素上的属性,如果元素是styleNameNode,那么就可以获取 styleNameNode的值,然后合并到classNameNode的值里边,最后再把styleNameNode删除就可以了。
代码如下:
JSXOpeningElement(path: any) {
let classNameNode: any = null;
let styleNameNode: any = null;
// 这里判断是否是styleNameNode节点还是classNameNode节点
path.node.attributes.forEach((attr: any) => {
if (isJSXAttribute(attr) && isJSXIdentifier(attr.name)) {
if (attr.name.name === 'className') {
classNameNode = attr;
}
if (attr.name.name === 'styleName') {
styleNameNode = attr;
// path.node.attributes.splice(index, 1);
}
}
});
// 如果发现有styleNamenode节点就获取值合并到classNameNode节点里边,并且删除styleNameNode节点
if (classNameNode && styleNameNode) {
// Merge the values
if (
isStringLiteral(classNameNode.value) &&
isStringLiteral(styleNameNode.value)
) {
const { start, end } = styleNameNode;
const newStart = start >= 1 ? start - 1 : start;
source.remove(newStart, end);
source.trimStart(start);
const newVal =
classNameNode.value.value + ' ' + styleNameNode.value.value;
source.replace(classNameNode.value.value, newVal);
classNameNode.value.value = newVal;
}
// Remove styleName attribute
// console.log(path.node.attributes.indexOf(styleNameNode), 9999);
// console.log(path.node.attributes, 'begin');
path.node.attributes.splice(
path.node.attributes.indexOf(styleNameNode),
1
);
// console.log(path.node.attributes, 'end');
}
},
代码整体实现比较简单
遇到的问题1:
这个是在执行测试用例的时候,发现删除styleName的时候,会遗留一个空格,这里可以在remove的时候,把位置前移一位就可以解决。
代码如下:
const newStart = start >= 1 ? start - 1 : start;
source.remove(newStart, end);
source.trimStart(start);
到此为止,基本解决了所有问题,执行测试用例,结果如下:
测试用例执行没有什么问题,接下来就是直接在项目中使用,vite-plugin-css-auto-import在playground/react下面可以直接测试。
但是在playground/react源码里边有个问题,它引入vite-plugin-css-auto-import是在package.json里边是使用file的方式引入的,这样调试非常不方便,所以我给改成了pnpm link的方式。
效果如下:
在浏览器里边查下一下效果
通过效果可以发现,不仅识别出来了styleName,并且也自动合并到了className里边了。
遇到的问题1:
这里应该是@babel/traverse的问题,虽然上面使用测试用例没啥问题,但是实际在浏览器里边使用还是会报错,可以使用下面代码解决。
解决如下:
// https://github.com/babel/babel/issues/13855
// @ts-ignore
let newTraverse: any = null;
// const traverse = _traverse.default as typeof _traverse;
if (typeof traverse !== 'function') {
newTraverse = (traverse as any).default;
} else {
newTraverse = traverse;
}
总结: 对vite-plugin-css-auto-import源码的修改几个方面:
- npm 换成了pnpm
- 源码修改
- 测试用例增加了styleName的测试用例
- playground/react换成了pnpm link的方式引入
- 去除了自动引入css的方式
拓展
通过上面可以发现,为了解决上面的需求,我们其实主要是利用了ast的技术
- ast的使用编写vite插件
所以拓展也就两个方面
-
ast方面,其实还可以应用在任何地方,因为它是把你的代码编译成ast树,所以理论上只要是代码可以实现的,我们都可以通过遍历ast树,通过对ast树进行增删改查,重构ast树,重新生成代码。比如最常见的应用自动注入vue或者react,去除console代码,或者是根据git提交的个人信息,保留部分代码等等;
-
vite插件同样也可以做很多事情,比如性能优化打包插件、根据自己需求开发各种对应的插件等;
优化
其实vite-plugin-css-auto-import这里还有很多可以优化的地方,比如把babel换成swc,这个本来实现了其中一部分,但是发现如果换成swc再使用ast进行遍历比较麻烦又换回来了。
比如换成swc后出现了一个问题如下:
Error: failed to handle: base_dir(./) must be absolute. Please ensure that jsc.baseUrl is specified correctly. This cannot be deduced by SWC itself because SWC is a transpiler and it does not try to resolve project details. In other works, SWC does not know which directory should be used as a base directory. It can be deduced if .swcrc is used, but if not, there are many candidates. e.g. the directory containing package.json, or the current working directory. Because of that, the caller (typically the developer of the JavaScript package) should specify it. If you see this error, please report an issue to the package author.
解决办法是锁定@swc/core的版本 "@swc/core": "1.3.75",
其它
这里使用的比较新的技术是vite4, vite自从换成swc以后,确实打包速度提升了很多,而且比起webpack5的繁琐配置,确实简化了很多,但是swc还是存在生态不完整的问题,后续可以持续关注。
另外使用react18进行的测试,react升级比起vue的升级,就平滑了很多,vue3和vue2虽然选项式Api的写法和vue2的没多大变化,但是组合式Api完成改变了以前的vue2的写法,导致很多人根本有点茫然,举一个最最明显的例子就是,我曾经问过很多人vue3的生命周期里边是否真的还有created的生命周期,几乎所有人都认为没有。