如何为已有 tailwind 项目增加前缀?

根本的解决方法是将 Modal 组件封进 Web Component,但 HeadlessUI 目前并没有一个好的解决方案。为了快速修复问题,决定暂时从「避免全局样式污染」的方向解决。
Tailwindcss 可能引发污染的有两部分:
baselayer 中的 Preflight,用 modern-normalize 来消除跨浏览器的不一致性- 不带前缀的
utilityclass
对于第一部分,关闭 Preflight 避免生成 normalize 相关的 CSS
// tailwind.config.js
module.exports = {
corePlugins: {
preflight: false,
},
};
对于第二部分,要为 tailwind classes 加上前缀。Tailwind 配置中可以设置 prefix。但在已有大量代码的项目中,还要批量替换掉模板中已有的 classes 代码。
问题解决思路

Tailwind 的工作原理是扫描所有 HTML、JavaScript 组件和任何其他模板文件中的类名,然后为这些样式生成相应的 CSS。 而 tailwind 扫描源代码以查找类的方式非常简单——只是使用正则表达式来提取每个可能是类名的字符串。
在整个工作流程中 tailwind 处理的对象始终是 CSS,并没有修改模板内容的机会。
tailwind 的工作机制决定了替换模板无法在编译打包中完成,那只能写个脚本一次性替换了。脚本的工作无非是匹配模版中的所有 class,再替换为 ${prefix}${class}。如何匹配到位?class 可能写在元素的 class 属性上,可能写在 javascript 的某个字符串里,情况比较复杂。如果用 tailwind 的提取器来匹配岂不是能一步到位?设想很完美,探索看看 tailwind 源码能不能实现吧。
Tailwind 提取流程
tailwind 的核心逻辑在 expandTailwindAtRules,主要分为三个步骤:
- 提取候选 classes:通过正则匹配提取出所有可能是类名的字符串,得到字符串集合 candidates
- 生成 CSS Rules:对 candidates 进一步筛选得到原子 classes 并生成对应的 Rules
- 构建 StyleSheet:将 Rules 划分到所属 layer,修改 PostCSS AST
// sharedState.NOT_ON_DEMAND = new String('*')
export default function expandTailwindAtRules(context) {
let seen = new Set();
let candidates = new Set([
...(context.candidates ?? []),
sharedState.NOT_ON_DEMAND,
]);
// 提取候选 classes :transformer(content) → extractor → 得到候选classes(candidates)
for (let { file, content, extension } of context.changedContent) {
let transformer = getTransformer(context.tailwindConfig, extension);
let extractor = getExtractor(context, extension);
content = file ? fs.readFileSync(file, "utf8") : content;
getClassCandidates(transformer(content), extractor, candidates, seen);
}
// 按字符排序
let sortedCandidates = new Set(
[...candidates].sort((a, z) => {
if (a === z) return 0;
if (a < z) return -1;
return 1;
})
);
// 进一步筛选 candidates 并生成 CSS Rules
generateRules(sortedCandidates, context);
// 构建 layer to rule Set 的数据结构
if (
context.stylesheetCache === null ||
context.classCache.size !== classCacheCount
) {
context.stylesheetCache = buildStylesheet([...context.ruleCache], context);
}
// ... AST操作
}
我们的目标是匹配到文件中的 classes 并做替换,所以专注于步骤一和步骤二。
提取候选 classes
这一阶段的目标是从模板中匹配出可能是 class 的所有字符串,即候选 classes
// 提取候选 classes :transformer(content) → extractor → 得到候选classes(candidates)
for (let { file, content, extension } of context.changedContent) {
let transformer = getTransformer(context.tailwindConfig, extension);
let extractor = getExtractor(context, extension);
content = file ? fs.readFileSync(file, "utf8") : content;
getClassCandidates(transformer(content), extractor, candidates, seen);
}
提取候选 classes 的流程是先通过 transformer 转换模板内容,再使用 extractor 扫描内容提取。
transformer 用于应对提取前要先转换内容的情况,例如 markdown 文件需要先编译成 HTML 再交给 tailwind 提取 classes, transformer 可在 tailwind config 中配置,默认情况直接返回文件原内容。
extractor 也可以在 tailwind config 中自定义配置,如果没有自定义 extract,将使用 defaultExtractor。
// tailwind.config.js 示例
const remark = require("remark");
module.exports = {
content: {
files: ["./src/**/*.{html,md}"],
transform: {
md: (content) => {
return remark().process(content);
},
},
extract: {
wtf: (content) => {
return content.match(/[^<>"'`\s]*/);
},
},
},
// ...
};
转换内容和确定提取器后,开始提取。
getClassCandidates 的工作是提供运行框架,对每份模板文件进行按行提取,辅以一些缓存机制来提高效率。
// 确定要使用的 extractor,如果配置中没有对当前文件类型自定义 extractor,使用默认的 extractor
Function getExtractor (context, fileExtension) {
let extractors = context.tailwindConfig.content.extract
return (
extractors[fileExtension] ||
extractors.DEFAULT ||
builtInExtractors[fileExtension] ||
Object.assign(builtInExtractors.DEFAULT(context), { DEFAULT_EXTRACTOR: true })
)
}
// 扫描模板提取 classes ,用 cache 提升后续编译的速度
// 注意:tailwind 采用的是按行扫描
let extractorCache = new WeakMap()
function getClassCandidates(content, extractor, candidates, seen) {
if (!extractorCache.has(extractor)) {
extractorCache.set(extractor, new LRU({ maxSize: 25000 }))
}
for (let line of content.split('\n')) {
line = line.trim()
if (seen.has(line)) {
continue
}
seen.add(line)
if (extractorCache.get(extractor).has(line)) {
for (let match of extractorCache.get(extractor).get(line)) {
candidates.add(match)
}
} else {
let extractorMatches = extractor(line).filter((s) => s !== '!*')
let lineMatchesSet = new Set(extractorMatches)
for (let match of lineMatchesSet) {
candidates.add(match)
}
extractorCache.get(extractor).set(line, lineMatchesSet)
}
}
}
提取的核心在 defaultExtractor,它的逻辑包含三部分:
buildRegExps:构建一系列正则匹配规则(tailwind config 中可能配置了separator和prefix,需要灵活构建)- 正则规则逐个和模板内容匹配, 将所有匹配结果集合到一个数组中;
clipAtBalancedParens:针对 arbitrary values (任意值,例如text-[14px])做平衡字符串剪裁,确保括号、引号等符号匹配
const builtInExtractors = {
DEFAULT: defaultExtractor,
};
export function defaultExtractor(context) {
// 获取一系列匹配规则
let patterns = Array.from(buildRegExps(context));
// 返回提取函数
/**
* @param {string} content
*/
return (content) => {
// 逐个模式匹配,将所有匹配结果集合到 results
/** @type {(string|string)[]} */
let results = [];
for (let pattern of patterns) {
Results = [...Results, ...(content.Match(pattern) ?? [])];
}
// 针对自定义值(arbitrary-values)的处理,对字符串进行剪裁,确保括号、引号等符号匹配。
return results.filter((v) => v !== undefined).map(clipAtBalancedParens);
};
}
tailwind 维护了一个 regex 模块,封装常用的 regex 方法,使用这些方法分解复杂的正则表达式,以便阅读和维护。
any:或逻辑,使用|分隔,optional: 0 或 1 项,加上?zeroOrMore:0 或多项,加上*escape: 特殊值转换pattern: 把多个pattern连接成一个pattern,允许将长表达式拆分书写
和单行正则表达式相比,buildRegExps 的代码在 regex 辅助下清晰整洁的多。
function* buildRegExps(context) {
// 指定修饰器分隔符,默认是 `:`
let separator = context.tailwindConfig.separator
// variantGrouping 实验功能:支持 focus:(outline-none,ring-2,ring-offset-2,ring-indigo-500)
let variantGroupingEnabled = flagEnabled(context.tailwindConfig, 'variantGrouping')
// prefix: 前缀的正则表达 'gij-' → '(?:-?gij-)?'
Let prefix =
context.tailwindConfig.prefix !== ''
? regex.optional(regex.pattern([/-?/, regex.escape(context.tailwindConfig.prefix)]))
: ''
let utility = regex.any([
// 任意属性:Tailwind 未内置的CSS属性
// 例如: [mask-type:luminance],[--scroll-offset:56px]
/\[[^\s:'"`]+:[^\s\[\]]+\]/,
// 带嵌套方括号的任意属性
// 例如:[--w-sm:theme('width[1/12]')]
/\[[^\s:'"`]+:[^\s]+?\[[^\s]+\][^\s]+?\]/,
// Utilities
regex.pattern([
// Utility Name / Group Name (class名或group名)
// 例如:text, bg, group
// 副作用:宽泛的字符匹配,会匹配到所有分词
/-?(?:\w+)/,
// Normal/Arbitrary values (属性值)
regex.optional(
regex.any([
// 任意值
regex.pattern([
// Arbitrary values
// 例如:-[22px], -[#bada55], -[--my-color]
/-(?:\w+-)*\[[^\s:]+\]/,
// Not immediately followed by an `{[(`
// 不跟随 {([]
/(?![{([]])/,
// optionally followed by an opacity modifier
// 跟随透明度修饰符
/(?:\/[^\s'"`\\><$]*)?/,
]),
// 带()的任意值
regex.pattern([
// Arbitrary values
// 例如:-[length:var(--my-var)]
/-(?:\w+-)*\[[^\s]+\]/,
// Not immediately followed by an `{[(`
// 不跟随 {([]
/(?![{([]])/,
// optionally followed by an opacity modifier
// 跟随透明度修饰符
/(?:\/[^\s'"`\\$]*)?/,
]),
// Normal values w/o quotes — may include an opacity modifier
// 普通值或者透明度修饰符
// 例如: -base, -gray-700/0.5, /0.5, /item
/[-\/][^\s'"`\\$={><]*/,
])
),
]),
])
// Variant 变体前缀
let variantPatterns = [
// Without quotes 不带引号的变体前缀
Regex.Any ([
// 为“@”变体提供特殊支持
// @[200px]:underline
regex.pattern([/@\[[^\s"'`]+\](\/[^\s"'`]+)?/, separator]),
// Arbitrary variants 任意变体
// supports-[display:grid]:, data-[size=large]:, [&:nth-child(3)]:
regex.pattern([/([^\s"'`\[\\]+-)?\[[^\s"'`]+\]/, separator]),
// Normal 普通变体
// hover:, group-hover/edit:,
regex.pattern([/[^\s"'`\[\\]+/, separator]),
]),
// With quotes allowed
regex.any([
regex.pattern([/([^\s"'`\[\\]+-)?\[[^\s`]+\]/, separator]),
regex.pattern([/[^\s`\[\\]+/, separator]),
]),
]
// 遍历变体的匹配模式组装出整体匹配模式,用迭代器逐个匹配
// 简化一下 ${variantPattern}?!?${prefix}${utility}
for (const variantPattern of variantPatterns) {
yield regex.pattern([
// Variants
' ((?=((',
variantPattern,
')+))\\2)?',
// Important (optional)
/!?/,
prefix,
variantGroupingEnabled
? regex.any([
// Or any of those things but grouped separated by commas
regex.pattern([/\(/, utility, regex.zeroOrMore([/,/, utility]), /\)/]),
// Arbitrary properties, constrained utilities, arbitrary values, etc…
utility,
])
: utility,
])
}
// 匹配由非 <>"'`\s.(){}[\]#=%$ 字符组成的字符串
// 5. Inner matches
yield /[^<>"'`\s.(){}[\]#=%$]*[^<>"'`\s.(){}[\]#=%:$]/g
}
我们通过正则表达式发现 defaultExtractor 的匹配规则比想象的宽泛,不仅仅能匹配到长得像 tailwind 原子类的字符串,还能匹配到各种分词。
跑两个例子会更加直观。
new Set(
defaultExtractor(`
<div class="text-center font-bold px-4 pointer-events-none"></div>
`)
);
/** Output:
{
'div',
'class',
'text-center',
'font-bold',
'px-4',
'pointer-events-none',
'/div'
}
**/
new Set(
defaultExtractor(`
const className = "px-4";
document.querySelector("h1").classList.add(className);
`)
);
/** Output:
{
'const',
'className',
'px-4',
'document',
'querySelector',
'h1',
'classList',
'add',
';'
}
**/
这一阶段经过正则匹配,提取到了候选 classes,最终 candidates 记录了一个宽泛的分词集合。
筛选 candidates
接着我们来研究 generateRules,看看这一阶段是如何在分词集合中找出 class 的。先看一下整体流程:
- 对每个 candidate 字符串调用
resolveMatches函数,获取 candidate 在 tailwind 系统中匹配到的 Rules,借助匹配结果可以区分原子 class 和普通字符串 resolveMatches中先将 candidate 的variants、important、class三部分拆分开,class字符串可能是有效的原子 class,命名为classCandidate,交给resolveMatchedPlugins匹配 Rules

function generateRules(candidates, context) {
for (let candidate of candidates){
let matches = Array.from(resolveMatches(candidate, context))
If (matches. Length === 0) {
context.notClassCache.add(candidate)
continue
}
context.classCache.set(candidate, matches)
}
}
function* resolveMatches(candidate, context, original = candidate) {
let separator = context.tailwindConfig.separator
let [classCandidate, ...variants] = splitWithSeparator(candidate, separator).reverse()
let important = false
if (classCandidate.startsWith('!')) {
important = true
classCandidate = classCandidate.slice(1)
}
for (let matchedPlugins of resolveMatchedPlugins(classCandidate, context)) {
// ... 处理 Rules:生成动态样式的 Rules,解析每个 Plugin 的 Rule
}
}
描述 resolveMatchedPlugins 的逻辑之前,得先说一下 context.candidateRuleMap
在 Tailwind 中,预置的原子 class 也是以 plugin 的方式注入到 tailwind 系统中的,这部分在 corePlugins.js 中维护。通过 plugin 注入的原子 class 被解析为前缀标识(例如 container,mx,bg)和 Rules (或动态样式的 Rule 生成函数) ,它们的对应关系记录在 context. CandidateRuleMap 中。
借助 context.candidateRuleMap 便能确定一个字符串是否能命中前缀标识。
resolveMatchedPlugins 中包含四类匹配
- 无修饰符的 class,例如:
block,border,hidden - 自定义 class,例如:
[mask-type:alpha] - 负向无修饰符 class,例如:
-mx、-p - 带修饰符的 class,例如
text-base、-px-2
function* resolveMatchedPlugins(classCandidate, context) {
// 无修饰符的 class,例如:block, border, hidden
if (context.candidateRuleMap.has(classCandidate)) {
yield [
context.candidateRuleMap.get(classCandidate),
"DEFAULT"
];
}
// 自定义 class,例如:[mask-type:alpha]
yield* function*(arbitraryPropertyRule) {
if (arbitraryPropertyRule !== null) {
yield [
arbitraryPropertyRule,
"DEFAULT"
];
}
}(extractArbitraryProperty(classCandidate, context));
let candidatePrefix = classCandidate;
let negative = false;
const twConfigPrefix = context.tailwindConfig.prefix;
const twConfigPrefixLen = twConfigPrefix.length;
const hasMatchingPrefix = candidatePrefix.startsWith(twConfigPrefix) || candidatePrefix.startsWith(`-${twConfigPrefix}`);
if (candidatePrefix[twConfigPrefixLen] === "-" && hasMatchingPrefix) {
negative = true;
candidatePrefix = twConfigPrefix + candidatePrefix.slice(twConfigPrefixLen + 1);
}
// 负向无修饰符 class,例如:-mx
if (negative && context.candidateRuleMap.has(candidatePrefix)) {
yield [
context.candidateRuleMap.get(candidatePrefix),
"-DEFAULT"
];
}
// 带修饰符 class
for (let [prefix, modifier] of candidatePermutations(candidatePrefix)){
If (context.CandidateRuleMap.Has (prefix)) {
yield [
context.candidateRuleMap.get(prefix),
negative ? `-${modifier}` : modifier
];
}
}
}
经过 resolveMatchedPlugins 筛掉了原子 class 之外的字符串。在接下来处理 Rules 的过程中,会检查 class 和 modifier 搭配的有效性。
所以 resolveMatches 中的每一步都无法删减,是否是原子 class 得由 Array.from(resolveMatches(candidate, context)).length === 0 来确定。
实现解决方案
经过上面的探查我们可以梳理出 tailwind “提取” classes 的实际流程:
createContext创建context:在其中完成 plugins 注册,准备好context.candidateRuleMapgetClassCandidates提取候选 classes:按行扫描模板内容获得分词集合;默认的提取器是defaultExtractor,它让内容和buildRegExps生成的一系列规则做正则匹配,并做平衡括号处理resolveMatches筛选出原子 class:拆解出 class 部分,借助context.candidateRuleMap筛掉无法命中前缀标识的字符串
在这些资料的基础上设计一个 prefixAdder,步骤如下:
createContext创建contextgetAllTemplateFiles: 获取所有模版文件processTailwindPrefix: 逐个处理模板文件,处理的方式是扫描文件内的原子 class,将其替换为带 prefix 的版本,生成新的内容写入文件generateNewContent: 逐行扫描文件内容,使用defaultExtractor和resolveMatches找出原子 class,使用正则替换为带 prefix 的版本,所有行重新连接addPrefix: 生成带 prefix 的 class,先用splitWithSeparator拆分,再加入prefix重组
const fs = require ("fs");
const resolveConfig = require("tailwindcss/lib/public/resolve-config").default;
const resolveConfigPath =
require("tailwindcss/lib/util/resolveConfigPath").default;
const {
createContext,
getFileModifiedMap,
} = require("tailwindcss/lib/lib/setupContextUtils");
const {
parseCandidateFiles,
resolvedChangedContent,
} = require("tailwindcss/lib/lib/content");
const {
defaultExtractor: createDefaultExtractor,
} = require("tailwindcss/lib/lib/defaultExtractor");
const { resolveMatches } = require("tailwindcss/lib/lib/generateRules");
const regex = require("tailwindcss/lib/lib/regex");
const {
splitAtTopLevelOnly,
} = require("tailwindcss/lib/util/splitAtTopLevelOnly");
const PREFIX = "gij-";
// 创建 context
function getContext(tailwindConfig) {
return createContext(tailwindConfig);
}
// 获取所有模板文件
function getAllTemplateFiles(context, tailwindConfig) {
let fileModifiedMap = getFileModifiedMap(context);
let candidateFiles = parseCandidateFiles(context, tailwindConfig);
let [allFiles] = resolvedChangedContent(
context,
candidateFiles,
fileModifiedMap
);
return allFiles;
}
// 主逻辑
function processTailwindPrefix() {
const tailwindConfig = resolveConfig(require(resolveConfigPath()));
const context = getContext(tailwindConfig);
const allFiles = getAllTemplateFiles(context, tailwindConfig);
for (let { file, content } of allFiles) {
content = file ? fs.readFileSync(file, "utf8") : content;
// 匹配+替换生成新内容
const newContent = generateNewContent(content, context);
if (newContent !== content) {
fs.writeFile(file, newContent, "utf-8", (err) => {
if (err) {
console.error(`Update ${file} Failed`);
} else {
Console.Log (`Update ${file} Success`);
}
});
}
}
}
function generateNewContent(content, context) {
const defaultExtractor = createDefaultExtractor(context);
// 逐行匹配并替换
const newContent = content
.split("\n")
.map((line) => {
if (line.trim().length === 0) {
return line;
}
// 提取行中原子 class
const candidates = new Set(
defaultExtractor(line).filter((s) => s !== "!*")
);
const classes = Array.from(candidates).filter(
(candidate) => Array.from(resolveMatches(candidate, context)).length > 0
);
if (classes.length) {
// 将 class 替换为增加了 prefix 的 class
const classRegex = regex.pattern([
/(?<=^|\s|['"`])/,
regex.any(classes.map(regex.escape)),
/(?=$|\s|['"`])/,
]);
return line.replace(classRegex, (match) => addPrefix(match, context));
} else {
Return line;
}
})
.join("\n");
return newContent;
}
// 增加 Prefix
// 拆解出 variants, important, class
// 再组装为 `${variants}${important}${prefix}${class}`
function addPrefix(tailwindClass, context) {
const separator = context.tailwindConfig.separator;
let [classCandidate, ...variants] = splitAtTopLevelOnly(
tailwindClass,
separator
).reverse();
let important = false;
if (classCandidate.startsWith("!")) {
important = true;
classCandidate = classCandidate.slice(1);
}
let negative = false;
if (classCandidate.startsWith("-")) {
negative = true;
classCandidate = classCandidate.slice(1);
}
// 任意属性不添加前缀
if (classCandidate.startsWith("[")) {
return tailwindClass;
}
variants.reverse().push("");
return (
variants.join(separator) +
(important ? "!" : "") +
(negative ? "-" : "") +
PREFIX +
classCandidate
);
}
processTailwindPrefix();
我们来跑跑看,效果不错。

但是,为什么 js 变量也被替换了。

新的问题
因为 container 恰好是 tailwind 的原子 class,而 tailwind 并不会去分析某个字符串在模板中的角色,只扫描命中原子类的字符串。
非精确扫描对 tailwind 来说没有大问题,顶多只是额外多生成了一些 CSS。但对于要精确定位的场景来说,就有点问题了。
理解模板是解析器做的事,我们要试试看加入解析器来优化这个问题,那就到下篇分解吧。
在这一篇通过分析 Tailwind 的提取逻辑,我们掌握了如何识别 tailwind class,如何给 class 加上前缀,并且使用已掌握的内容写了一个基本能用但有瑕疵的 prefixAdder,收工。
转载自:https://juejin.cn/post/7240428977063673893