How UnoCSS works internally with Vite?
As Iām building my own CSS-in-JS solution MigaCSS, I became interested in how other solutions work internally. One of them is UnoCSS, and in this post Iām going to figure out how it works under the hood.
- 1. Rough intro to UnoCSS
- 2. How does UnoCSS work under the hood (with Vite)?
- 2.1 Vite plugin to extract the tokens from user code
- 2.2 Token extracting is done by splitting the code with RegExp.
- 2.3 Rule usage is derived from the extracted tokens then CSS code is generated
- 2.4 CSS is generated when
virtual:uno.css
is requested. - 2.5 Attributify mode has different extractor and CSS output format.
- 3. Summary
1. Rough intro to UnoCSS
UnoCSS claims to be Instant On-demand Atomic CSS Engine. Letās briefly talk about its position.
Below is some code we would write usually, in which style is attached to DOM by semantic class names.
<style>.title {font-weight: bold;font-size: 16px;}</style><p class="title">Hello JSer</p>-----It is a huge pain to choose a good class name!
<style>.title {font-weight: bold;font-size: 16px;}</style><p class="title">Hello JSer</p>-----It is a huge pain to choose a good class name!
This is not very scalable and Atomic CSS tries to improve by creating utility class which is separated from the semantics of HTML, like blow.
<style>.font-weight-bold {font-weight: bold;}.font-size-16px {font-size: 16px;}</style><p class="font-weight-bold font-size-16px">Hello JSer</p>--------------------------------Pain of naming is gone!
<style>.font-weight-bold {font-weight: bold;}.font-size-16px {font-size: 16px;}</style><p class="font-weight-bold font-size-16px">Hello JSer</p>--------------------------------Pain of naming is gone!
Well it looks pretty verbose, and Tailwind has well-defined classes to make it concise.
<script src="https://cdn.tailwindcss.com"></script><p class="font-bold text-base">Hello JSer</p>--------------------
<script src="https://cdn.tailwindcss.com"></script><p class="font-bold text-base">Hello JSer</p>--------------------
Tailwind is a dialect with lots of built-in CSS primitives and so it is opinionated. If we want to do Atomic CSS then probably we have to create the same set of rules, so donāt bother spending the unnecessary time to create your own, just follow the communityās de-facto standard - Tailwind.
Atomic CSS has the core idea of defining utility CSS, but it doesnāt necessary means we have to enable it through class names. Say we want to do Style Props, it is also sort of Atomic CSS if we have utility style under the hood.
Thatās what UnoCSS is for, it enables us to do Atomic CSS in any fashion we want, the Tailwind way could be just one of them.
ts
// uno.config.tsimport { defineConfig } from 'unocss'export default defineConfig({rules: [['font-bold', { fontWeight: 'bold' }],['text-base', { fontSize: '16px'}]],})
ts
// uno.config.tsimport { defineConfig } from 'unocss'export default defineConfig({rules: [['font-bold', { fontWeight: 'bold' }],['text-base', { fontSize: '16px'}]],})
With above configuration, with appropriate plugins, we are able to do styling in multiple way.
jsx
<p className="font-bold text-base">Hello JSer</p>// or<p font="bold" text="base">Hello JSer</p>// or some other fashions.
jsx
<p className="font-bold text-base">Hello JSer</p>// or<p font="bold" text="base">Hello JSer</p>// or some other fashions.
See the difference? Tailwind is more about defining the proper Atomic CSS rules, while UnoCSS is more about how to get Atomic CSS easily adopted.
2. How does UnoCSS work under the hood (with Vite)?
The setup for above UnoCSS code example is quite simple, you can find it on the homepage.
- add plugin
UnoCSS()
in Vite.
ts
// vite.config.tsimport UnoCSS from 'unocss/vite'import { defineConfig } from 'vite'export default defineConfig({plugins: [UnoCSS(),],})
ts
// vite.config.tsimport UnoCSS from 'unocss/vite'import { defineConfig } from 'vite'export default defineConfig({plugins: [UnoCSS(),],})
- configure the UnoCSS
ts
// uno.config.tsimport { defineConfig } from "unocss";import { presetAttributify } from "unocss";export default defineConfig({rules: [["font-bold", { "font-weight": "bold" }],["text-base", { "font-size": "16px" }],],presets: [presetAttributify()],});
ts
// uno.config.tsimport { defineConfig } from "unocss";import { presetAttributify } from "unocss";export default defineConfig({rules: [["font-bold", { "font-weight": "bold" }],["text-base", { "font-size": "16px" }],],presets: [presetAttributify()],});
- import
virtual:uno.css
ts
// main.tsimport 'virtual:uno.css'
ts
// main.tsimport 'virtual:uno.css'
Letās dive into the implementation details to understand each step.
2.1 Vite plugin to extract the tokens from user code
Looking again at the usage, the task is actually pretty straightforward.
We have a bunch of CSS rules, we want to see which of them are used in our code base and then output used ones into a standalone CSS.
Vite plugins (based on Rollup) allow us to hook into the internals, touch the source code and also the module graph it holds.
The entry plugin UnoCSS
is actually a bunch of plugins for different tasks.
export default function UnocssPlugin<Theme extends object>(configOrPath?: VitePluginConfig<Theme> | string,defaults: UserConfigDefaults = {},): Plugin[] {const ctx = createContext<VitePluginConfig>(configOrPath as any, {envMode: process.env.NODE_ENV === 'development' ? 'dev' : 'build',...defaults,})Context is global across different plugins inside, so that they could share data
Think about a plugin to collect usage of rules, and another plugin to generate CSS code,
there must be a place to hold the usage data - and this is what context is for,
const mode = inlineConfig.mode ?? 'global'const plugins = [ConfigHMRPlugin(ctx),...createTransformerPlugins(ctx),...createDevtoolsPlugin(ctx),]if (inlineConfig.inspector !== false)plugins.push(UnocssInspector(ctx))if (mode === 'per-module') {plugins.push(...PerModuleModePlugin(ctx))}else if (mode === 'vue-scoped') {plugins.push(VueScopedPlugin(ctx))}...else if (mode === 'global') {plugins.push(...GlobalModePlugin(ctx))}This plugin is where we'll look more into
return plugins.filter(Boolean) as Plugin[]}
export default function UnocssPlugin<Theme extends object>(configOrPath?: VitePluginConfig<Theme> | string,defaults: UserConfigDefaults = {},): Plugin[] {const ctx = createContext<VitePluginConfig>(configOrPath as any, {envMode: process.env.NODE_ENV === 'development' ? 'dev' : 'build',...defaults,})Context is global across different plugins inside, so that they could share data
Think about a plugin to collect usage of rules, and another plugin to generate CSS code,
there must be a place to hold the usage data - and this is what context is for,
const mode = inlineConfig.mode ?? 'global'const plugins = [ConfigHMRPlugin(ctx),...createTransformerPlugins(ctx),...createDevtoolsPlugin(ctx),]if (inlineConfig.inspector !== false)plugins.push(UnocssInspector(ctx))if (mode === 'per-module') {plugins.push(...PerModuleModePlugin(ctx))}else if (mode === 'vue-scoped') {plugins.push(VueScopedPlugin(ctx))}...else if (mode === 'global') {plugins.push(...GlobalModePlugin(ctx))}This plugin is where we'll look more into
return plugins.filter(Boolean) as Plugin[]}
Inside GlobalModePlugin
is where magic happens.
{name: 'unocss:global',apply: 'serve',enforce: 'pre',--------------This makes sure this plugin is run first, before the built-in plugin kicks in
...transform(code, id) {---------transform() is a hook for a plugin to transform the code and pass it to other plugins
notice that it returns null, so here it only uses the timing to see usage of rules
We can roughly think the
id
as file pathif (filter(code, id))tasks.push(extract(code, id))The code says itself.
It checks matched files, and spawn tasks to tokenize the code
return null},async load(id) {----load() hook allows a plugin to override the reading of source code
The import of
virtual:uno.css
is handled hereconst layer = resolveLayer(getPath(id))if (!layer)return null------------This makes sure only the
virtual:uno.css
is handledconst { hash, css } = await generateCSS(layer)---------------------------------------------So the file contents of
virtual:uno.css
is dynamically generated here!return {// add hash to the chunk of CSS that it will send back to client to check if there is new CSS generatedcode: `__uno_hash_${hash}{--:'';}${css}`,map: { mappings: '' },}},closeBundle() {clearWarnTimer()},}
{name: 'unocss:global',apply: 'serve',enforce: 'pre',--------------This makes sure this plugin is run first, before the built-in plugin kicks in
...transform(code, id) {---------transform() is a hook for a plugin to transform the code and pass it to other plugins
notice that it returns null, so here it only uses the timing to see usage of rules
We can roughly think the
id
as file pathif (filter(code, id))tasks.push(extract(code, id))The code says itself.
It checks matched files, and spawn tasks to tokenize the code
return null},async load(id) {----load() hook allows a plugin to override the reading of source code
The import of
virtual:uno.css
is handled hereconst layer = resolveLayer(getPath(id))if (!layer)return null------------This makes sure only the
virtual:uno.css
is handledconst { hash, css } = await generateCSS(layer)---------------------------------------------So the file contents of
virtual:uno.css
is dynamically generated here!return {// add hash to the chunk of CSS that it will send back to client to check if there is new CSS generatedcode: `__uno_hash_${hash}{--:'';}${css}`,map: { mappings: '' },}},closeBundle() {clearWarnTimer()},}
Actually the logic is pretty simple right? It requires some knowledge of Vite plugins but not that hard.
Letās summarize a bit. The plugin unocss:global
- checks the source code and extract the tokens in
transform()
hook - dynamically generate the CSS code for
virtual:uno.css
inload()
hook.
2.2 Token extracting is done by splitting the code with RegExp.
What would you do if asked to extract the tokens of some code?
Iād parse the code and traverse the AST. But this is heavy, UnoCSS claims to be fast because it doesnāt parse AST rather it split the code into tokens by RegExp.
async function extract(code: string, id?: string) {if (id)modules.set(id, code)const len = tokens.sizeawait uno.applyExtractors(code.replace(SKIP_COMMENT_RE, ''), id, tokens)---------------Configured extractors are run here
if (tokens.size > len)invalidate()----------invalidate() is update the contents of generate CSS
}
async function extract(code: string, id?: string) {if (id)modules.set(id, code)const len = tokens.sizeawait uno.applyExtractors(code.replace(SKIP_COMMENT_RE, ''), id, tokens)---------------Configured extractors are run here
if (tokens.size > len)invalidate()----------invalidate() is update the contents of generate CSS
}
async applyExtractors(code: string,id?: string,extracted: Set<string> | CountableSet<string> = new Set<string>(),): Promise<Set<string> | CountableSet<string>> {const context: ExtractorContext = {original: code,code,id,extracted,envMode: this.config.envMode,}for (const extractor of this.config.extractors) {----------------------Extractors are from config, the presets.
const result = await extractor.extract?.(context)if (!result)continueif (isCountableSet(result) && isCountableSet(extracted)) {for (const token of result)extracted.setCount(token, extracted.getCount(token) + result.getCount(token))}else {for (const token of result)extracted.add(token)The code is turned into a set of tokens
}}return extracted}
async applyExtractors(code: string,id?: string,extracted: Set<string> | CountableSet<string> = new Set<string>(),): Promise<Set<string> | CountableSet<string>> {const context: ExtractorContext = {original: code,code,id,extracted,envMode: this.config.envMode,}for (const extractor of this.config.extractors) {----------------------Extractors are from config, the presets.
const result = await extractor.extract?.(context)if (!result)continueif (isCountableSet(result) && isCountableSet(extracted)) {for (const token of result)extracted.setCount(token, extracted.getCount(token) + result.getCount(token))}else {for (const token of result)extracted.add(token)The code is turned into a set of tokens
}}return extracted}
And extractorArbitraryVariants()
is the default extractor which, by its name, tries
to extract from arbitrary files.
export const extractorArbitraryVariants: Extractor = {name: '@unocss/extractor-arbitrary-variants',order: 0,extract({ code }) {return splitCodeWithArbitraryVariants(code)},}
export const extractorArbitraryVariants: Extractor = {name: '@unocss/extractor-arbitrary-variants',order: 0,extract({ code }) {return splitCodeWithArbitraryVariants(code)},}
import type { Extractor } from '@unocss/core'import { defaultSplitRE, isValidSelector } from '@unocss/core'export const quotedArbitraryValuesRE = /(?:[\w&:[\]-]|\[\S+=\S+\])+\[\\?['"]?\S+?['"]\]\]?[\w:-]*/gexport const arbitraryPropertyRE = /\[(\\\W|[\w-])+:[^\s:]*?("\S+?"|'\S+?'|`\S+?`|[^\s:]+?)[^\s:]*?\)?\]/gconst arbitraryPropertyCandidateRE = /^\[(\\\W|[\w-])+:['"]?\S+?['"]?\]$/export function splitCodeWithArbitraryVariants(code: string): string[] {const result: string[] = []for (const match of code.matchAll(arbitraryPropertyRE)) {if (!code[match.index! - 1]?.match(/^[\s'"`]/))continueresult.push(match[0])}for (const match of code.matchAll(quotedArbitraryValuesRE))result.push(match[0])code.split(defaultSplitRE).forEach((match) => {if (isValidSelector(match) && !arbitraryPropertyCandidateRE.test(match))result.push(match)})Yep, it simply split the code into chunks.
return result}
import type { Extractor } from '@unocss/core'import { defaultSplitRE, isValidSelector } from '@unocss/core'export const quotedArbitraryValuesRE = /(?:[\w&:[\]-]|\[\S+=\S+\])+\[\\?['"]?\S+?['"]\]\]?[\w:-]*/gexport const arbitraryPropertyRE = /\[(\\\W|[\w-])+:[^\s:]*?("\S+?"|'\S+?'|`\S+?`|[^\s:]+?)[^\s:]*?\)?\]/gconst arbitraryPropertyCandidateRE = /^\[(\\\W|[\w-])+:['"]?\S+?['"]?\]$/export function splitCodeWithArbitraryVariants(code: string): string[] {const result: string[] = []for (const match of code.matchAll(arbitraryPropertyRE)) {if (!code[match.index! - 1]?.match(/^[\s'"`]/))continueresult.push(match[0])}for (const match of code.matchAll(quotedArbitraryValuesRE))result.push(match[0])code.split(defaultSplitRE).forEach((match) => {if (isValidSelector(match) && !arbitraryPropertyCandidateRE.test(match))result.push(match)})Yep, it simply split the code into chunks.
return result}
export const defaultSplitRE = /[\\:]?[\s'"`;{}]+/g------------------
export const defaultSplitRE = /[\\:]?[\s'"`;{}]+/g------------------
Above RegExp helps splits the code nicely no matter the CSS rule usage is in HTML attribute, JSX props literal or expression .etc.
For example. <p className="font-bold text-base"/>
is turned into ['<p', 'className=','font-bold', 'text-base', "/>"]
and we can easily see that bold CSS rules are used.
But this approach could cause false alert, for example <p> font-bold </p>
would be treated as using font-bold
which leads unnecessary CSS code being generated, illustrated as screenshot below.
This must be some trade-off that UnoCSS is already aware of.
2.3 Rule usage is derived from the extracted tokens then CSS code is generated.
async function generateCSS(layer: string) {await flushTasks()----------Notice that extract() is async
let result: GenerateResultlet tokensSize = tokens.sizedo {result = await uno.generate(tokens)-----------------------------------// to capture new tokens created during generationif (tokensSize === tokens.size)breaktokensSize = tokens.size} while (true)const css = layer === LAYER_MARK_ALL? result.getLayers(undefined, Array.from(entries).map(i => resolveLayer(i)).filter((i): i is string => !!i)): result.getLayer(layer)const hash = getHash(css || '', HASH_LENGTH)lastServedHash.set(layer, hash)lastServedTime = Date.now()return { hash, css }}
async function generateCSS(layer: string) {await flushTasks()----------Notice that extract() is async
let result: GenerateResultlet tokensSize = tokens.sizedo {result = await uno.generate(tokens)-----------------------------------// to capture new tokens created during generationif (tokensSize === tokens.size)breaktokensSize = tokens.size} while (true)const css = layer === LAYER_MARK_ALL? result.getLayers(undefined, Array.from(entries).map(i => resolveLayer(i)).filter((i): i is string => !!i)): result.getLayer(layer)const hash = getHash(css || '', HASH_LENGTH)lastServedHash.set(layer, hash)lastServedTime = Date.now()return { hash, css }}
In uno.generate()
each token is matched against configured rule set.
The matched rules are grouped by CSS layers.
async generate(input: string | Set<string> | CountableSet<string> | string[],options: GenerateOptions<boolean> = {},): Promise<GenerateResult<unknown>> {const {id,scope,preflights = true,safelist = true,minify = false,extendedInfo = false,} = options...const tokenPromises = Array.from(tokens).map(async (raw) => {if (matched.has(raw))returnconst payload = await this.parseToken(raw)--------------------------Each token is processed against the rule set
if (payload == null)returnif (matched instanceof Map) {matched.set(raw, {data: payload,count: isCountableSet(tokens) ? tokens.getCount(raw) : -1,})}else {matched.add(raw)}for (const item of payload) {const parent = item[3] || ''const layer = item[4]?.layerif (!sheet.has(parent))sheet.set(parent, [])sheet.get(parent)!.push(item)if (layer)layerSet.add(layer)}Matched rules are put in target layers
})...const getLayers = (includes = layers, excludes?: string[]) => {return includes.filter(i => !excludes?.includes(i)).map(i => getLayer(i) || '').filter(Boolean).join(nl)}This function here gets rules from target layer and put them together into CSS code!
return {get css() { return getLayers() },layers,matched,getLayers,getLayer,}}
async generate(input: string | Set<string> | CountableSet<string> | string[],options: GenerateOptions<boolean> = {},): Promise<GenerateResult<unknown>> {const {id,scope,preflights = true,safelist = true,minify = false,extendedInfo = false,} = options...const tokenPromises = Array.from(tokens).map(async (raw) => {if (matched.has(raw))returnconst payload = await this.parseToken(raw)--------------------------Each token is processed against the rule set
if (payload == null)returnif (matched instanceof Map) {matched.set(raw, {data: payload,count: isCountableSet(tokens) ? tokens.getCount(raw) : -1,})}else {matched.add(raw)}for (const item of payload) {const parent = item[3] || ''const layer = item[4]?.layerif (!sheet.has(parent))sheet.set(parent, [])sheet.get(parent)!.push(item)if (layer)layerSet.add(layer)}Matched rules are put in target layers
})...const getLayers = (includes = layers, excludes?: string[]) => {return includes.filter(i => !excludes?.includes(i)).map(i => getLayer(i) || '').filter(Boolean).join(nl)}This function here gets rules from target layer and put them together into CSS code!
return {get css() { return getLayers() },layers,matched,getLayers,getLayer,}}
In parseToken()
there is call of parseUtil()
where the actual matching happens.
async parseUtil(input: string | VariantMatchedResult<Theme>,context: RuleContext<Theme>,internal = false,shortcutPrefix?: string | string[] | undefined,): Promise<(ParsedUtil | RawUtil)[] | undefined> {const [raw, processed, variantHandlers] = isString(input)? await this.matchVariants(input): inputif (this.config.details)context.rules = context.rules ?? []// use map to for static rulesconst staticMatch = this.config.rulesStaticMap[processed]--------------------------This is from the rule set in uno config
if (staticMatch) {----------------Matching happens here!
if (staticMatch[1] && (internal || !staticMatch[2]?.internal)) {if (this.config.details)context.rules!.push(staticMatch[3])const index = staticMatch[0]const entry = normalizeCSSEntries(staticMatch[1])const meta = staticMatch[2]if (isString(entry))return [[index, entry, meta]]elsereturn [[index, raw, entry, meta, variantHandlers]]}}context.variantHandlers = variantHandlers....There are logic handling dynamic rule set
}
async parseUtil(input: string | VariantMatchedResult<Theme>,context: RuleContext<Theme>,internal = false,shortcutPrefix?: string | string[] | undefined,): Promise<(ParsedUtil | RawUtil)[] | undefined> {const [raw, processed, variantHandlers] = isString(input)? await this.matchVariants(input): inputif (this.config.details)context.rules = context.rules ?? []// use map to for static rulesconst staticMatch = this.config.rulesStaticMap[processed]--------------------------This is from the rule set in uno config
if (staticMatch) {----------------Matching happens here!
if (staticMatch[1] && (internal || !staticMatch[2]?.internal)) {if (this.config.details)context.rules!.push(staticMatch[3])const index = staticMatch[0]const entry = normalizeCSSEntries(staticMatch[1])const meta = staticMatch[2]if (isString(entry))return [[index, entry, meta]]elsereturn [[index, raw, entry, meta, variantHandlers]]}}context.variantHandlers = variantHandlers....There are logic handling dynamic rule set
}
2.4 CSS is generated when virtual:uno.css
is requested.
export const VIRTUAL_ENTRY_ALIAS = [/^(?:virtual:)?uno(?::(.+))?\.css(\?.*)?$/,]export const LAYER_MARK_ALL = '__ALL__'export const RESOLVED_ID_WITH_QUERY_RE = /[\/\\]__uno(?:(_.*?))?\.css(\?.*)?$/export const RESOLVED_ID_RE = /[\/\\]__uno(?:(_.*?))?\.css$/export function resolveId(id: string) {if (id.match(RESOLVED_ID_WITH_QUERY_RE))return idfor (const alias of VIRTUAL_ENTRY_ALIAS) {const match = id.match(alias)if (match) {return match[1]? `/__uno_${match[1]}.css`: '/__uno.css'}}so
virtual:uno.css
is resolved to/__uno.css
}{name: 'unocss:global',resolveId(id) {const entry = resolveId(id)if (entry) {resolved = trueclearWarnTimer()entries.add(entry)return entry}},async load(id) {const layer = resolveLayer(getPath(id))if (!layer)return nullThis makes sure that CSS is generated for target paths like /__uno.css
const { hash, css } = await generateCSS(layer)return {// add hash to the chunk of CSS that it will send back to client to check if there is new CSS generatedcode: `__uno_hash_${hash}{--:'';}${css}`,map: { mappings: '' },}},}export const RESOLVED_ID_RE = /[\/\\]__uno(?:(_.*?))?\.css$/export function resolveLayer(id: string) {const match = id.match(RESOLVED_ID_RE)if (match)return match[1] || LAYER_MARK_ALL}
export const VIRTUAL_ENTRY_ALIAS = [/^(?:virtual:)?uno(?::(.+))?\.css(\?.*)?$/,]export const LAYER_MARK_ALL = '__ALL__'export const RESOLVED_ID_WITH_QUERY_RE = /[\/\\]__uno(?:(_.*?))?\.css(\?.*)?$/export const RESOLVED_ID_RE = /[\/\\]__uno(?:(_.*?))?\.css$/export function resolveId(id: string) {if (id.match(RESOLVED_ID_WITH_QUERY_RE))return idfor (const alias of VIRTUAL_ENTRY_ALIAS) {const match = id.match(alias)if (match) {return match[1]? `/__uno_${match[1]}.css`: '/__uno.css'}}so
virtual:uno.css
is resolved to/__uno.css
}{name: 'unocss:global',resolveId(id) {const entry = resolveId(id)if (entry) {resolved = trueclearWarnTimer()entries.add(entry)return entry}},async load(id) {const layer = resolveLayer(getPath(id))if (!layer)return nullThis makes sure that CSS is generated for target paths like /__uno.css
const { hash, css } = await generateCSS(layer)return {// add hash to the chunk of CSS that it will send back to client to check if there is new CSS generatedcode: `__uno_hash_${hash}{--:'';}${css}`,map: { mappings: '' },}},}export const RESOLVED_ID_RE = /[\/\\]__uno(?:(_.*?))?\.css$/export function resolveLayer(id: string) {const match = id.match(RESOLVED_ID_RE)if (match)return match[1] || LAYER_MARK_ALL}
So above code explains whyimport 'virtual:uno.css'
is required in the setup.
2.5 Attributify mode has different extractor and CSS output format.
Attributify Mode is the reason why I wanted to dive into UnoCSS. It is similar to what I want to achieve in MigaCSS.
A few things to notice from above screenshot.
- generated CSS code has attribute selector, rather than
.font-bold{font-weight:bold;}
, it is[font~="bold"]{font-weight:bold}
. - in HTMl, the attributes are not stripped, such as
font="bold"
andtext="base"
.
This doesnāt look very elegant comparing to the clean class names, and attribute selector of course is not the best at performance. I guess UnoCSS choose this is to avoid modifying the HTMl structure because it requires AST parsing to do it right which compromise the performance in all.
To understand how it works under the hood, following what weāve learned so far, we need to treat font="bold"
as a usage of font-bold
.
Obviously this needs some special extractor, which is @unocss/preset-attributify/extractor
in preset-attributify.
const splitterRE = /[\s'"`;]+/gconst elementRE = /<[^>\s]*\s((?:'.*?'|".*?"|`.*?`|\{.*?\}|[^>]*?)*)/gconst valuedAttributeRE = /([?]|(?!\d|-{2}|-\d)[a-zA-Z0-9\u00A0-\uFFFF-_:!%-.~<]+)=?(?:["]([^"]*)["]|[']([^']*)[']|[{]([^}]*)[}])?/gms------------------------------------------------------------------------------------------------------------This matches the key-value pair like
font="bold"
export const defaultIgnoreAttributes = ['placeholder', 'fill', 'opacity']export function extractorAttributify(options?: AttributifyOptions): Extractor {const ignoreAttributes = options?.ignoreAttributes ?? defaultIgnoreAttributesconst nonValuedAttribute = options?.nonValuedAttribute ?? trueconst trueToNonValued = options?.trueToNonValued ?? falsereturn {name: '@unocss/preset-attributify/extractor',extract({ code }) {const result = Array.from(code.matchAll(elementRE)).flatMap(match => Array.from((match[1] || '').matchAll(valuedAttributeRE))).flatMap(([, name, ...contents]) => {const content = contents.filter(Boolean).join('')For
font="bold"
, content here is "bold" and name is "font"...if (['class', 'className'].includes(name)) {return content.split(splitterRE).filter(isValidSelector)}else {if (options?.prefixedOnly && options.prefix && !name.startsWith(options.prefix))return []return content.split(splitterRE).filter(v => Boolean(v) && v !== ':').map(v => `[${name}~="${v}"]`)------------------This generates
[font~="bold"]
}})return new Set(result)},}}
const splitterRE = /[\s'"`;]+/gconst elementRE = /<[^>\s]*\s((?:'.*?'|".*?"|`.*?`|\{.*?\}|[^>]*?)*)/gconst valuedAttributeRE = /([?]|(?!\d|-{2}|-\d)[a-zA-Z0-9\u00A0-\uFFFF-_:!%-.~<]+)=?(?:["]([^"]*)["]|[']([^']*)[']|[{]([^}]*)[}])?/gms------------------------------------------------------------------------------------------------------------This matches the key-value pair like
font="bold"
export const defaultIgnoreAttributes = ['placeholder', 'fill', 'opacity']export function extractorAttributify(options?: AttributifyOptions): Extractor {const ignoreAttributes = options?.ignoreAttributes ?? defaultIgnoreAttributesconst nonValuedAttribute = options?.nonValuedAttribute ?? trueconst trueToNonValued = options?.trueToNonValued ?? falsereturn {name: '@unocss/preset-attributify/extractor',extract({ code }) {const result = Array.from(code.matchAll(elementRE)).flatMap(match => Array.from((match[1] || '').matchAll(valuedAttributeRE))).flatMap(([, name, ...contents]) => {const content = contents.filter(Boolean).join('')For
font="bold"
, content here is "bold" and name is "font"...if (['class', 'className'].includes(name)) {return content.split(splitterRE).filter(isValidSelector)}else {if (options?.prefixedOnly && options.prefix && !name.startsWith(options.prefix))return []return content.split(splitterRE).filter(v => Boolean(v) && v !== ':').map(v => `[${name}~="${v}"]`)------------------This generates
[font~="bold"]
}})return new Set(result)},}}
So in this extractor, weāll get the attribute selector [font~="bold"]
, then there must be
some normalization in the rule matching, and it is actually inside parseToken()
we mentioned before.
async parseToken(raw: string,alias?: string,): Promise<StringifiedUtil<Theme>[] | undefined | null> {const applied = await this.matchVariants(raw, current)------------------This transform raw '[font~="bold"]' to ['[font~="bold"]', 'font-bold'
if (!applied || this.isBlocked(applied[1])) {this.blocked.add(raw)this._cache.set(cacheKey, null)return}}
async parseToken(raw: string,alias?: string,): Promise<StringifiedUtil<Theme>[] | undefined | null> {const applied = await this.matchVariants(raw, current)------------------This transform raw '[font~="bold"]' to ['[font~="bold"]', 'font-bold'
if (!applied || this.isBlocked(applied[1])) {this.blocked.add(raw)this._cache.set(cacheKey, null)return}}
And in parseUtil()
, the 2nd item in the array is actually used.
3. Summary
Now we see how UnoCSS works under the hood, here is a brief summary.
- UnoCSS contains a bunch of Vite(Rollup) plugins that do different jobs
- It reads raw source code at pluginās
transform()
hook to collect tokens.- They are not technically tokens, but chunks of code split by RegExp
- AST parsing is avoided for performance
- Because of no AST, extracted tokens are not guaranteed to be used as styling, which has chances of false alert.
- On requesting
virtual:uno.css
, it generates the CSS code from the tokens- It picks up the rules from config that also exist in tokens
- Then it generate CSS code by layers
- Different extractors allows different Atomic CSS approach to work in UnoCSS, e.g. attributify mode.
IMHO, I donāt like some of design choices from UnoCSS, Iād put performance as lower priority comparing to correctness and output format. On the other hand UnoCSS shows us perfectly that in programming trade-offs are everywhere.