Skip to content

Commit c46a84a

Browse files
committed
perf: reuse v3 hmr generation context
1 parent a135bb2 commit c46a84a

4 files changed

Lines changed: 177 additions & 51 deletions

File tree

.changeset/v3-hmr-context-cache.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"weapp-tailwindcss": patch
3+
---
4+
5+
优化 Tailwind CSS v3 开发热更新性能,增量生成时复用 Tailwind v3 runtime context,并缓存稳定 CSS 源的 legacy compat 转换结果,避免新增 class 时重复重建 v3 上下文和重复转换兼容 CSS。

packages/weapp-tailwindcss/src/bundlers/shared/generator-css/legacy-compat.ts

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,40 @@ const LEGACY_CONTAINER_COMPAT_CSS = [
4040
'}',
4141
].join('\n')
4242

43+
const LEGACY_COMPAT_CACHE_LIMIT = 128
44+
const legacyCompatSourceCache = new Map<string, string>()
45+
const legacyCompatTransformCache = new Map<string, string>()
46+
47+
function setLimitedCacheValue(cache: Map<string, string>, key: string, value: string) {
48+
if (cache.size >= LEGACY_COMPAT_CACHE_LIMIT) {
49+
const firstKey = cache.keys().next().value
50+
if (firstKey !== undefined) {
51+
cache.delete(firstKey)
52+
}
53+
}
54+
cache.set(key, value)
55+
}
56+
57+
function createStableJson(value: unknown): string {
58+
if (value === undefined) {
59+
return 'undefined'
60+
}
61+
if (value === null || typeof value !== 'object') {
62+
return JSON.stringify(value)
63+
}
64+
if (Array.isArray(value)) {
65+
return `[${value.map(item => createStableJson(item)).join(',')}]`
66+
}
67+
return `{${Object.keys(value).sort().map((key) => {
68+
const record = value as Record<string, unknown>
69+
return `${JSON.stringify(key)}:${createStableJson(record[key])}`
70+
}).join(',')}}`
71+
}
72+
73+
function createLegacyCompatTransformCacheKey(source: string, options: IStyleHandlerOptions) {
74+
return `${createStableJson(options)}\0${source}`
75+
}
76+
4377
export function removeTailwindApplyRules(rawSource: string) {
4478
try {
4579
const root = postcss.parse(rawSource)
@@ -67,8 +101,14 @@ export function removeTailwindApplyRules(rawSource: string) {
67101
}
68102

69103
function resolveLegacyCompatCssSource(rawSource: string) {
104+
const cached = legacyCompatSourceCache.get(rawSource)
105+
if (cached !== undefined) {
106+
return cached
107+
}
70108
const source = removeTailwindSourceDirectives(stripTailwindBanners(rawSource))
71-
return removeUnsupportedMiniProgramAtRules(removeTailwindApplyRules(source))
109+
const resolved = removeUnsupportedMiniProgramAtRules(removeTailwindApplyRules(source))
110+
setLimitedCacheValue(legacyCompatSourceCache, rawSource, resolved)
111+
return resolved
72112
}
73113

74114
function hasContainerConfigToken(rawSource: string) {
@@ -145,10 +185,17 @@ export async function appendLegacyCompatCss(
145185
return createCssAppend(css, compatSource)
146186
}
147187

148-
const { css: compatCss } = await styleHandler(compatSource, {
188+
const styleOptions = {
149189
...cssHandlerOptions,
150190
...generatorStyleOptions,
151-
})
191+
}
192+
const compatCssCacheKey = createLegacyCompatTransformCacheKey(compatSource, styleOptions)
193+
let compatCss = legacyCompatTransformCache.get(compatCssCacheKey)
194+
if (compatCss === undefined) {
195+
const handled = await styleHandler(compatSource, styleOptions)
196+
compatCss = handled.css
197+
setLimitedCacheValue(legacyCompatTransformCache, compatCssCacheKey, compatCss)
198+
}
152199
const cleanedCompatCss = collectDedupedPostTransformCompatCss(
153200
removeDuplicatedViteMarkers(removeUnsupportedMiniProgramAtRules(compatCss), css),
154201
css,
@@ -183,10 +230,17 @@ export async function appendLegacyContainerCompatCss(
183230
return css
184231
}
185232

186-
const { css: compatCss } = await styleHandler(LEGACY_CONTAINER_COMPAT_CSS, {
233+
const styleOptions = {
187234
...cssHandlerOptions,
188235
...generatorStyleOptions,
189-
})
236+
}
237+
const compatCssCacheKey = createLegacyCompatTransformCacheKey(LEGACY_CONTAINER_COMPAT_CSS, styleOptions)
238+
let compatCss = legacyCompatTransformCache.get(compatCssCacheKey)
239+
if (compatCss === undefined) {
240+
const handled = await styleHandler(LEGACY_CONTAINER_COMPAT_CSS, styleOptions)
241+
compatCss = handled.css
242+
setLimitedCacheValue(legacyCompatTransformCache, compatCssCacheKey, compatCss)
243+
}
190244
const cleanedCompatCss = collectDedupedPostTransformCompatCss(
191245
removeUnsupportedMiniProgramAtRules(compatCss),
192246
css,

packages/weapp-tailwindcss/src/tailwindcss/v3-engine/generator.ts

Lines changed: 44 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ interface TailwindV3Internals {
4444
}
4545

4646
interface TailwindV3IncrementalGenerateCacheEntry {
47+
context: TailwindV3Context
4748
seenCandidates: Set<string>
4849
classSet: Set<string>
4950
css: string
@@ -234,42 +235,6 @@ function createRuntimeReadyCacheKey(source: TailwindV3ResolvedSource, rootPath:
234235
].join('\0')
235236
}
236237

237-
function isTailwindImport(params: string, layer: string) {
238-
const trimmed = params.trim()
239-
return new RegExp(`^(?:url\\()?['"]tailwindcss/${layer}(?:\\.css)?['"]\\)?(?:\\s|$)`).test(trimmed)
240-
}
241-
242-
function createUtilitiesOnlyCss(css: string) {
243-
try {
244-
const root = postcss.parse(css)
245-
root.walkAtRules((rule) => {
246-
if (rule.name === 'tailwind') {
247-
const layer = rule.params.trim()
248-
if (layer === 'base' || layer === 'components') {
249-
rule.remove()
250-
}
251-
return
252-
}
253-
if (rule.name === 'import' && (isTailwindImport(rule.params, 'base') || isTailwindImport(rule.params, 'components'))) {
254-
rule.remove()
255-
return
256-
}
257-
if (rule.name === 'layer') {
258-
const layer = rule.params.trim()
259-
if (layer === 'base' || layer === 'components') {
260-
rule.remove()
261-
}
262-
}
263-
})
264-
return root.toString()
265-
}
266-
catch {
267-
return css
268-
.replace(/@tailwind\s+(?:base|components)\s*;/g, '')
269-
.replace(/@import\s+(?:url\()?['"]tailwindcss\/(?:base|components)(?:\.css)?['"][^;]*;/g, '')
270-
}
271-
}
272-
273238
function isDirectUtilitiesOnlyCss(css: string) {
274239
return css.replace(/\s+/g, '') === '@tailwindutilities;'
275240
}
@@ -304,8 +269,8 @@ function sortCandidates(candidates: Iterable<string>) {
304269
})
305270
}
306271

307-
function appendDirectUtilityRules(root: postcss.Root, context: TailwindV3Context) {
308-
const sortedRules = context.offsets.sort([...context.ruleCache])
272+
function appendUtilityRules(root: postcss.Root, context: TailwindV3Context, rules: Array<[unknown, postcss.Node]>) {
273+
const sortedRules = context.offsets.sort(rules)
309274
for (const [sort, rule] of sortedRules) {
310275
const tailwindRaw = rule.raws.tailwind as { parentLayer?: string } | undefined
311276
if (sort.layer === 'utilities' || (sort.layer === 'variants' && tailwindRaw?.parentLayer === 'utilities')) {
@@ -314,6 +279,10 @@ function appendDirectUtilityRules(root: postcss.Root, context: TailwindV3Context
314279
}
315280
}
316281

282+
function appendDirectUtilityRules(root: postcss.Root, context: TailwindV3Context) {
283+
appendUtilityRules(root, context, [...context.ruleCache])
284+
}
285+
317286
function createRuntimeReadyPromise(source: TailwindV3ResolvedSource) {
318287
const patcher = createTailwindcssPatcher({
319288
basedir: source.cwd,
@@ -397,6 +366,7 @@ export function createTailwindV3Engine(source: TailwindV3ResolvedSource): Tailwi
397366
return {
398367
css,
399368
rawCss,
369+
context,
400370
classSet,
401371
rawCandidates: collectCandidates(options.candidates),
402372
dependencies: [...dependencies],
@@ -407,6 +377,35 @@ export function createTailwindV3Engine(source: TailwindV3ResolvedSource): Tailwi
407377
}
408378
}
409379

380+
async function generateIncrementalMissingUtilities(
381+
context: TailwindV3Context,
382+
candidates: string[],
383+
target: TailwindV3GenerateTarget,
384+
styleOptions: Partial<IStyleHandlerOptions> | undefined,
385+
) {
386+
tailwindInternals ??= loadTailwindV3Internals(source)
387+
const internals = tailwindInternals
388+
const root = postcss.root()
389+
const result: TailwindV3ProcessResult = {
390+
css: '',
391+
messages: [],
392+
}
393+
const rules = internals.generateRules(new Set(sortCandidates(candidates)), context)
394+
appendUtilityRules(root, context, rules)
395+
internals.resolveDefaultsAtRules(context)(root, result)
396+
internals.collapseAdjacentRules(context)(root, result)
397+
internals.collapseDuplicateDeclarations(context)(root, result)
398+
399+
const rawCss = root.toString()
400+
const css = await transformTailwindV3CssByTarget(rawCss, target, styleOptions)
401+
return {
402+
css,
403+
rawCss,
404+
classSet: collectClassSet(context),
405+
dependencies: collectDependencyMessages(result),
406+
}
407+
}
408+
410409
async function generateWithIncrementalCache(options: TailwindV3GenerateOptions = {}) {
411410
if ((options.sources?.length ?? 0) > 0) {
412411
return generateOnce(source, options)
@@ -432,14 +431,12 @@ export function createTailwindV3Engine(source: TailwindV3ResolvedSource): Tailwi
432431
}
433432
}
434433

435-
const utilitySource = {
436-
...source,
437-
css: createUtilitiesOnlyCss(source.css),
438-
}
439-
const generated = await generateOnce(utilitySource, {
440-
...options,
441-
candidates: missingCandidates,
442-
})
434+
const generated = await generateIncrementalMissingUtilities(
435+
cached.context,
436+
missingCandidates,
437+
target,
438+
options.styleOptions,
439+
)
443440
for (const candidate of missingCandidates) {
444441
cached.seenCandidates.add(candidate)
445442
}
@@ -464,6 +461,7 @@ export function createTailwindV3Engine(source: TailwindV3ResolvedSource): Tailwi
464461

465462
const generated = await generateOnce(source, options)
466463
incrementalGenerateCache.set(cacheKey, {
464+
context: generated.context,
467465
seenCandidates: new Set(requestedCandidates),
468466
classSet: new Set(generated.classSet),
469467
css: generated.css,

packages/weapp-tailwindcss/test/bundlers/generator-css.unit.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,75 @@ describe('bundlers/shared generator css', () => {
224224
}))
225225
})
226226

227+
it('reuses transformed legacy compat css for stable source during hmr', async () => {
228+
const firstRuntimeSet = new Set(['bg-blue-500'])
229+
const secondRuntimeSet = new Set(['bg-red-500'])
230+
const rawSource = '@import "tailwindcss";\n.card{color:red}'
231+
const generateMock = vi.fn(async (options: any) => {
232+
const candidate = [...options.candidates][0]
233+
return {
234+
css: `.${candidate}{background-color:red}`,
235+
rawCss: `.${candidate}{background-color:red}`,
236+
target: 'weapp',
237+
classSet: options.candidates,
238+
dependencies: [],
239+
sources: [],
240+
root: null,
241+
}
242+
})
243+
244+
vi.doMock('@/generator', () => ({
245+
...createDefaultGeneratorMock(),
246+
createWeappTailwindcssGenerator: vi.fn(() => ({
247+
generate: generateMock,
248+
})),
249+
}))
250+
251+
const { generateCssByGenerator } = await import('@/bundlers/shared/generator-css')
252+
const styleHandler = vi.fn(async (code: string) => ({ css: `legacy:${code}` }))
253+
const baseOptions = {
254+
opts: {
255+
styleHandler,
256+
} as any,
257+
runtimeState: {
258+
twPatcher: {
259+
majorVersion: 4,
260+
} as any,
261+
readyPromise: Promise.resolve(),
262+
},
263+
rawSource,
264+
file: 'app.wxss',
265+
cssHandlerOptions: {
266+
isMainChunk: true,
267+
postcssOptions: {
268+
options: {
269+
from: 'app.wxss',
270+
},
271+
},
272+
majorVersion: 4,
273+
} as any,
274+
cssUserHandlerOptions: {
275+
isMainChunk: false,
276+
majorVersion: 4,
277+
} as any,
278+
styleHandler,
279+
debug: vi.fn(),
280+
}
281+
282+
const first = await generateCssByGenerator({
283+
...baseOptions,
284+
runtime: firstRuntimeSet,
285+
})
286+
const second = await generateCssByGenerator({
287+
...baseOptions,
288+
runtime: secondRuntimeSet,
289+
})
290+
291+
expect(first?.css).toContain('legacy:.card{color:red}')
292+
expect(second?.css).toContain('legacy:.card{color:red}')
293+
expect(styleHandler).toHaveBeenCalledTimes(1)
294+
})
295+
227296
it('treats weapp-tailwindcss imports as Tailwind v4 source entries by default', async () => {
228297
const runtimeSet = new Set(['w-[100px]'])
229298
const rawSource = '@import "weapp-tailwindcss";\n.card{color:red}'

0 commit comments

Comments
 (0)