tailwindcss in weapp 的原理
2024-02-20 版本
前言
转眼又是一年,感觉是时候再来修订一下 tailwindcss in weapp 的原理
这篇文章了, 放心,这次我写作核心就是要让大多数人看懂!
什么是 weapp-tailwindcss
如果下一个定义的话,我把 weapp-tailwindcss
定义成一个 转义器,在我看来它就做了一件事情。那就是把 tailwindcss
中,小程序不兼容的写法,转化成小程序兼容的写法,并尽量保持生成的 CSS
效果一致罢了。
更加细一点的解释,就是允许开发者像 tailwindcss
h5
环境里那样去写小程序,插件在背后 帮助你把 wxml
,js
,wxss
等等这类产物的小程序兼容转化给做了。
仅仅如此罢了,做了一点小小的努力和贡献。
为什么需要 weapp-tailwindcss ?
因为小程序 wxml
,wxss
等等文件/产物,本身是不支持 Tailwindcss
里面的一些特殊转义字符的,比如像 []
,!
,.
等等。
经过测试后,wxml
中 class
属性,实际上只支持英文字母,数字,以及 -
和 _
这 2
个特殊字符,不支持的字符会被默认转化成空格,所以我们要做的就是把 Tailwindcss
选择器的写法,转化成小程序允许的方式。这也是核心包 @weapp-core/escape
实现的功能。
插件大体的发展历程和方向,可以看下方以前的 2023-03-19
版本,在此不再叙述,接下来我们只来谈谈核心原理。
核心原理
插件的核心就是转义,那么具体是怎么做的呢?
实际上就是插件去解析编译后的 wxml
,js
,wxss
这些产物,并对它们进行改写。
工具方面, weapp-tailwindcss
使用 htmlparser2
去解析改造 wxml
,使用 babel
去解析改造 js
和 wxs
,使用 postcss
去解析改造所有的 wxss
。
wxml
为什么是 htmlparser2
其实,weapp-tailwindcss
一开始使用的是 @vivaxy/wxml
,这是一个 wxml
的 ast
工具,
但是它已经很久没有更新了,在很多场景,比如遇到内联的 wxs
的时候,会直接挂掉,而本身我也没有技术能力,去修复和完善这个 wxml
自动机,所以后续放弃了它。后来自己实现了一套基于正则的模板匹配引擎,但是再使用一段时间后,发现 正则
同样是有问题的,比如这样一个 case
:
<view class="{{2>1?'xxx':'yyy'}}"></view>
由于 2>1
的存在,它会与 <view class="{{2>
进行提前匹配并返回,这和我们的期望不符合,而且正则复杂了之后,其实匹配效率也是很低的 (具体可以在 regex101 进行测试和调试),所以还是要使用 ast
工具才能做到兼顾效率的同时,做精确匹配转化。
所有,我还是要去寻找对应的 xml/html ast
工具来解析 wxml
。在寻找的过程中,找到了符合条件的一些包,其中 parse5
对 html5
是严格的匹配,不怎么适用于 wxml
,而 htmlparser2
是不 严格标签匹配,所以经过测试之后,最后使用 htmlparser2
来处理 wxml
。
如何使用 htmlparser2
实际上很简单,我们可以在 webpack
/ vite
/ gulp
或者自己的构建脚本中,找到对应的生命周期 (hooks
),对 wxml
产物进行处理。
首先在获取到 wxml
产物的内容之后,把它构造成一个 MagicString
对象,因为 magic-string
这个库,操作字符串十分方便。
对产物内容字符串进行解析后,获取到所有 class
属性的内容,进行转义即可。代码示例如下:
import * as htmlparser2 from "htmlparser2";
const parser = new htmlparser2.Parser({
onopentag(name, attributes) {
// code
},
onclosetag(tagname) {
// code
},
// ....
});
parser.write(wxmlCode);
parser.end();
字符串以及变量绑定的处理
不过在获取到 class
/ hover-class
这些标签里面的内容之后,需要进行进一步解析和转化。
为什么?因为原生小程序可以使用 {{expression}}
表达式来动态绑定 js
的值,假设有一段 wxml
是这样写的:
<view class="w-[13px] {{flag?'h-[23px]':'h-[6px]'}} bg-[#123456] {{customClass}}"></view>
这时候你就必须在匹配 class
属性值的情况下,对字符串进行转义,再对 {{}}
里包裹的 js
表达式,进行转义,使得结果变成:
<view class="w-_13px_ {{flag?'h-_23px_':'h-_6px_'}} bg-_#123456_ {{customClass}}"></view>
那么怎么做呢?也很简单,我们通过 htmlparser2
匹配 class
属性,可以获取到 w-[13px] {{flag?'h-[23px]':'h-[6px]'}} bg-[#123456] {{customClass}}
这个字符串。
然后对这个字符串进行 {{}}
表达式匹配,在 {{}}
表达式匹配之外的字符串,直接进行转义。在 {{}}
表达式内的代码,使用 babel
进行解析,然后获取到所有的 js
字符串字面量,进行转义即可。
同时在进行匹配和转义的时候,实际上我们是可以获取到对应字符串 start
和 end
的下标的,这就为我们使用 MagicString
进行替换,提供了良好的基础。
js / wxs
我们同样要对所有的 js
里面的字符串,进行扫描,发现它是 tailwindcss
的类名,就需要进行转义。
我们还是以上方的代码片段为例:
<view class="w-[13px] {{flag?'h-[23px]':'h-[6px]'}} bg-[#123456] {{customClass}}"></view>
在 wxml
的处理过程中,看似我们已经解决了大部分 class
内容的转义的,但是开发者也可能在 customClass
绑定的 js
字符串中,直接去编写 tailwindcss
的类名。比如:
Page({
data: {
customClass: "bg-[url('https://xxx.com/xx.webp')] text-[#123456] text-[50px] bg-[#fff]",
},
})
又或者直接在 wxs
里面去写类名,那么这时候怎么办呢?获取所有字符串字面量和模板字符串,进行转义 即可吗?
显然这是不行的,一个应用程序里面,大部分的字符串字面量都是和 tailwindcss
无关的,假如全部转化只会把应用程序弄奔溃。
那么,我们是否有什么方式,去获取到 tailwindcss
的上下文,再从里面把它所有的类名提取出来,再和我们应用程序里面的字符串进行匹配,从而做到精确转义呢?可是 tailwindcss
只是一个 postcss
插件啊,怎么从 webpack
/ vite
插件里,把一个 postcss
插件里的内容取出来呢?
很开心的是我办到了,这就是 tailwindcss-mangle 做的事情。
通过这种方式,我们可以在 postcss
/ postcss-loader
执行之后,把 tailwindcss
执行时的 1 或者多个上下文给取出来,交给插件进行使用,从而达到所有 js
字符串字面量筛选的效果,简单实用 babel
实现的代码如下:
let ast: ParseResult<File> = parse(rawSource, {
sourceType: 'unambiguous'
})
const ms = new MagicString(rawSource)
const ropt: TraverseOptions<Node> = {
StringLiteral: {
enter(p) {
// set 为 tailwindcss 上下文里所有生效的 classnames
if(set.has(p.node.value)){
// do escape
const value = escape(p.node.value)
ms.update(start, end, value)
}
},
},
}
traverse(ast, ropt)
const code = ms.toString()
通过这种方式来精确的把 js
/ wxs
里面字符串字面量,给转义成小程序允许的方式。
这里我们使用 babel
作为 js ast
的工具,因为目前为止它也是最流行的这方面的包,但是出于效率上的考虑,未来将会使用 swc
并编写 swc
插件的方式,来取代 babel
。
wxss
最后就是样式的处理了,在转义完 wxml
,js
和 wxs
后,我们需要把 wxss
里 tailwindcss
生成的样式,转化成与之前的 wxml
,js
,wxs
中匹配的方式。这样才能做到类名的 11 对应,从而达到我们最终的目的。
那么怎么做呢?这里我们选用的工具,自然是 postcss
,它是目前用 js
编写的最流行的一个 css ast
工具,它要比 css-tree
生态好很多,也有很多现成的稳定插件可以使用,帮助我们完成针对 css
各种各样的处理。
所以最后我们所要完成的工作,就是把 tailwindcss
的产物,转化成与 wxml
,js
和 wxs
匹配,且小程序能够适配的样子了!
怎么做?也很简单,直接使用 postcss
扫描 wxss
里面所有的 Rule
节点,然后获取到里面的选择器,进行转义即可!
postcss 对象简单介绍
什么是 Rule
节点?在 postcss
插件中,大致有这 5
类对象(其实还有一个 Document
):
Root
: CSS tree 的根结点,通常可以代表整个 CSS 文件.AtRule
: 以@
开始的 CSS 语句,比如@charset "UTF-8"
或者@media (screen) {}
.Rule
: 普通的选择器节点,内部由 CSS 声明填充,比如.btn { /*decls*/ }
.Declaration
: key-value 键值对,代表 CSS 声明,比如color: black
;Comment
: CSS 注释.
通过这些对象,postcss
完成了对 CSS
的抽象,从而让我们可以通过操作这些对象来对 CSS
进行增删改查。
Tailwindcss 简单原理和运行表现
在转化所有的 Rule
节点之前,我们先分析一下 Tailwindcss
的原理,它也是一个 postcss
插件,它通过提取 content
配置里面的 glob
表达式 or vfile
对象,从里面提取到符合它规则的字符串,然后生成大量的 AtRule
/ Rule
对象保存起来。
然后再扫描到我们注册 Tailwindcss
的地方:
@tailwind base;
@tailwind components;
@tailwind utilities;
把这些对象进行展开,替换掉原先的 @tailwind xxx;
,从而达到了写什么, CSS
就自动生成什么的效果。
其中你可以把 base
/ components
/ utilities
理解成一个个 layer
标志位,不同的 AtRule
/ Rule
对象集合,会在它对应的 layer
出展开。比如 base
负责所有的 css vars
和 preflight css
注入,utilities
负责所有原子工具类的生成。
同时它们再通过 @layer
来控制它们的优先级,从而做到它们之间的 CSS
不会产生优先级相互覆盖冲突的问题。
@layer
实际上是原生CSS
的一个实验性功能,用来声明一个 级联层,从而让开发者更容易的控制自己 CSS 代码的优先级罢了。这里我们能使用@layer
是因为这个功能被tailwindcss
用它自己的方式实现了,使得我们可以在CSS
里预先使用@layer
罢了,最终产物里面是没有@layer
的。相关文档详见 MDN @layer
开始转化
首先我们先简单声明一个 postcss
插件:
import type { PluginCreator } from 'postcss'
import { ruleTransformSync } from '../selectorParser'
const creator: PluginCreator<Options> = () => {
return {
postcssPlugin,
Rule(rule) {
ruleTransformSync(rule, options)
},
}
}
creator.postcss = true
这里我们把针对 Rule
的转化,封装进 ruleTransformSync
这个方法里:
这里我们需要用到 postcss-selector-parser
来对 Rule
中的选择器,做更加细致的转化。
因为 postcss
中的 Rule
对象的选择器千变万化,可以非常的简单,也可以非常的复杂,你单独把它当作字符串来处理,很容易出现问题。
所以我们需要 postcss-selector-parser
来解析 Rule#selector
然后进行修改再转化回字符串。
例如:
import selectorParser from 'postcss-selector-parser'
import type { Rule } from 'postcss'
import type { IStyleHandlerOptions } from '@/types'
export const ruleTransformSync = (rule: Rule, options: IStyleHandlerOptions) => {
const transformer = selectorParser((selectors) => {
selectors.walk((selector) => {
// do something with the selector
})
})
return transformer.transformSync(rule, {
lossless: false,
updateSelector: true
})
}
我们通过对 selectors
的 walk
,再方法里面去筛选和修改节点,从而达到我们替换和转义 Rule#selector
的效果。
这样就可以去转义所有的 Tailwindcss
生成的所有节点了!