Skip to content

Commit 0ec3e8b

Browse files
committed
fix: cache tailwind v3 incremental css generation
1 parent aceef73 commit 0ec3e8b

7 files changed

Lines changed: 256 additions & 11 deletions

File tree

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 生成器在 Vite 热更新中的增量 CSS 生成路径。现在 v3 生成器在热更新场景会复用同一 source/style/target 下已生成的 CSS,只为新增候选类生成 utilities 片段,减少重复执行完整 Tailwind v3 PostCSS 生成的次数。

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ export async function generateCssByGenerator(
271271
const generator = createWeappTailwindcssGenerator(source)
272272
return generator.generate({
273273
candidates: runtime,
274+
incrementalCache: majorVersion === 3,
274275
scanSources: majorVersion === 4,
275276
styleOptions: generatorStyleOptions,
276277
tailwindcssV3Compatibility: generatorOptions.tailwindcssV3Compatibility,

packages/weapp-tailwindcss/src/bundlers/vite/generate-bundle.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,29 @@ interface GenerateBundleThis {
6464
}) => string
6565
}
6666

67+
function addSiblingCssFile(files: Set<string>, file: string) {
68+
if (file.endsWith('.wxml')) {
69+
files.add(file.replace(/\.wxml$/, '.wxss'))
70+
}
71+
else if (file.endsWith('.js')) {
72+
files.add(file.replace(/\.js$/, '.wxss'))
73+
}
74+
}
75+
76+
function collectRuntimeLinkedCssFiles(snapshot: BundleSnapshot) {
77+
const files = new Set<string>()
78+
for (const file of snapshot.runtimeAffectingChangedByType.html) {
79+
addSiblingCssFile(files, file)
80+
}
81+
for (const file of snapshot.runtimeAffectingChangedByType.js) {
82+
addSiblingCssFile(files, file)
83+
}
84+
return files
85+
}
86+
6787
export function createGenerateBundleHook(context: GenerateBundleContext) {
6888
const state = createBundleBuildState()
89+
const lastCssResultByFile = new Map<string, string>()
6990
const cssHandlerOptions = createCssHandlerOptionsCache({
7091
appType: context.opts.appType,
7192
mainCssChunkMatcher: context.opts.mainCssChunkMatcher,
@@ -184,6 +205,7 @@ export function createGenerateBundleHook(context: GenerateBundleContext) {
184205
}
185206
}
186207
const generatorCandidateSignature = createCandidateSignature(generatorRuntime)
208+
const runtimeLinkedCssFiles = collectRuntimeLinkedCssFiles(snapshot)
187209
recordGeneratorCandidates?.(generatorRuntime)
188210
const defaultTemplateHandlerOptions = {
189211
runtimeSet: transformRuntime,
@@ -284,16 +306,34 @@ export function createGenerateBundleHook(context: GenerateBundleContext) {
284306
const cssRuntimeAffectingSignature = snapshot.runtimeAffectingSignatureByFile.get(file) ?? rawSource
285307
const cssShareScope = createCssTransformShareScopeKey(opts, file, rawSource)
286308
const cssHandlerOptions = getCssHandlerOptions(file)
287-
const cssRuntimeSignature = createCssRuntimeSignature(runtimeSignature, generatorCandidateSignature)
309+
const shouldTrackGeneratorRuntime = !useIncrementalMode
310+
|| cssHandlerOptions.isMainChunk
311+
|| processFiles.css.has(file)
312+
|| runtimeLinkedCssFiles.has(file)
313+
const scopedGeneratorCandidateSignature = shouldTrackGeneratorRuntime
314+
? generatorCandidateSignature
315+
: 'generator:stable'
316+
const cssRuntimeSignature = createCssRuntimeSignature(runtimeSignature, scopedGeneratorCandidateSignature)
288317
const cssSharedCacheKey = `${cssShareScope}:${cssRuntimeSignature}:${runtimeState.twPatcher.majorVersion ?? 'unknown'}:${cssHandlerOptions.isMainChunk ? '1' : '0'}:${cssRuntimeAffectingSignature}`
318+
if (!shouldTrackGeneratorRuntime) {
319+
const lastCss = lastCssResultByFile.get(file)
320+
if (lastCss != null) {
321+
originalSource.source = lastCss
322+
markCssAssetProcessed?.(originalSource, file)
323+
metrics.css.cacheHits++
324+
debug('css replay last result: %s', file)
325+
continue
326+
}
327+
}
289328
tasks.push(
290329
processCachedTask<string>({
291330
cache,
292331
cacheKey: file,
293332
hashKey: `${file}:css:${cssRuntimeSignature}:${runtimeState.twPatcher.majorVersion ?? 'unknown'}`,
294-
hash: `${getSnapshotHash(snapshot.runtimeAffectingHashByFile, file, cssRuntimeAffectingSignature)}:${generatorCandidateSignature}`,
333+
hash: `${getSnapshotHash(snapshot.runtimeAffectingHashByFile, file, cssRuntimeAffectingSignature)}:${scopedGeneratorCandidateSignature}`,
295334
applyResult(source) {
296335
originalSource.source = source
336+
lastCssResultByFile.set(file, source)
297337
markCssAssetProcessed?.(originalSource, file)
298338
if (cssHandlerOptions.isMainChunk) {
299339
rememberMainCssSource?.(file, rawSource, cssRuntimeSignature)

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

Lines changed: 180 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import type { IStyleHandlerOptions } from '@weapp-tailwindcss/postcss/types'
12
import type { Config } from 'tailwindcss'
23
import type {
34
TailwindV3CandidateSource,
45
TailwindV3Engine,
56
TailwindV3GenerateOptions,
7+
TailwindV3GenerateTarget,
68
TailwindV3ResolvedSource,
79
} from './types'
10+
import fs from 'node:fs'
811
import { createRequire } from 'node:module'
912
import postcss from 'postcss'
1013
import { createTailwindcssPatcher } from '@/tailwindcss/patcher'
@@ -21,6 +24,16 @@ interface TailwindcssPlugin {
2124
}
2225

2326
const runtimeReadyPromiseCache = new Map<string, Promise<void>>()
27+
const incrementalGenerateCache = new Map<string, TailwindV3IncrementalGenerateCacheEntry>()
28+
29+
interface TailwindV3IncrementalGenerateCacheEntry {
30+
seenCandidates: Set<string>
31+
classSet: Set<string>
32+
css: string
33+
rawCss: string
34+
dependencies: string[]
35+
target: TailwindV3GenerateTarget
36+
}
2437

2538
interface LegacyContentObject {
2639
files?: unknown
@@ -51,6 +64,10 @@ function createRawContentEntries(candidates: Iterable<string>, sources: Tailwind
5164
return entries
5265
}
5366

67+
function collectCandidates(candidates: Iterable<string> | undefined) {
68+
return new Set(candidates ?? [])
69+
}
70+
5471
function mergeContent(content: unknown, rawEntries: Array<{ raw: string, extension: string }>) {
5572
if (isLegacyContentObject(content)) {
5673
return {
@@ -111,6 +128,51 @@ function loadTailwindcssPlugin(source: TailwindV3ResolvedSource): TailwindcssPlu
111128
return typeof plugin === 'function' ? plugin : plugin.default as TailwindcssPlugin
112129
}
113130

131+
function createStableJson(value: unknown): string {
132+
if (value === undefined) {
133+
return 'undefined'
134+
}
135+
if (value === null || typeof value !== 'object') {
136+
return JSON.stringify(value)
137+
}
138+
if (Array.isArray(value)) {
139+
return `[${value.map(item => createStableJson(item)).join(',')}]`
140+
}
141+
return `{${Object.keys(value).sort().map((key) => {
142+
const record = value as Record<string, unknown>
143+
return `${JSON.stringify(key)}:${createStableJson(record[key])}`
144+
}).join(',')}}`
145+
}
146+
147+
function createDependencyFingerprint(files: string[]) {
148+
return files.map((file) => {
149+
try {
150+
const stat = fs.statSync(file)
151+
return `${file}:${stat.size}:${stat.mtimeMs}`
152+
}
153+
catch {
154+
return `${file}:missing`
155+
}
156+
}).join('|')
157+
}
158+
159+
function createIncrementalGenerateCacheKey(
160+
source: TailwindV3ResolvedSource,
161+
target: TailwindV3GenerateTarget,
162+
styleOptions: Partial<IStyleHandlerOptions> | undefined,
163+
) {
164+
return [
165+
source.packageName,
166+
source.postcssPlugin,
167+
source.cwd,
168+
source.config ?? 'config:missing',
169+
createDependencyFingerprint(source.dependencies),
170+
source.css,
171+
target,
172+
createStableJson(styleOptions),
173+
].join('\0')
174+
}
175+
114176
function createRuntimeReadyCacheKey(source: TailwindV3ResolvedSource, rootPath: string | undefined) {
115177
return [
116178
source.packageName,
@@ -127,6 +189,42 @@ function resetTailwindcssPluginContext(plugin: TailwindcssPlugin) {
127189
}
128190
}
129191

192+
function isTailwindImport(params: string, layer: string) {
193+
const trimmed = params.trim()
194+
return new RegExp(`^(?:url\\()?['"]tailwindcss/${layer}(?:\\.css)?['"]\\)?(?:\\s|$)`).test(trimmed)
195+
}
196+
197+
function createUtilitiesOnlyCss(css: string) {
198+
try {
199+
const root = postcss.parse(css)
200+
root.walkAtRules((rule) => {
201+
if (rule.name === 'tailwind') {
202+
const layer = rule.params.trim()
203+
if (layer === 'base' || layer === 'components') {
204+
rule.remove()
205+
}
206+
return
207+
}
208+
if (rule.name === 'import' && (isTailwindImport(rule.params, 'base') || isTailwindImport(rule.params, 'components'))) {
209+
rule.remove()
210+
return
211+
}
212+
if (rule.name === 'layer') {
213+
const layer = rule.params.trim()
214+
if (layer === 'base' || layer === 'components') {
215+
rule.remove()
216+
}
217+
}
218+
})
219+
return root.toString()
220+
}
221+
catch {
222+
return css
223+
.replace(/@tailwind\s+(?:base|components)\s*;/g, '')
224+
.replace(/@import\s+(?:url\()?['"]tailwindcss\/(?:base|components)(?:\.css)?['"][^;]*;/g, '')
225+
}
226+
}
227+
130228
function collectClassSet(plugin: TailwindcssPlugin) {
131229
const classSet = new Set<string>()
132230
for (const context of plugin.contextRef?.value ?? []) {
@@ -181,25 +279,28 @@ function createRuntimeReadyPromise(source: TailwindV3ResolvedSource) {
181279
export function createTailwindV3Engine(source: TailwindV3ResolvedSource): TailwindV3Engine {
182280
const runtimeReadyPromise = createRuntimeReadyPromise(source)
183281

184-
async function generate(options: TailwindV3GenerateOptions = {}) {
282+
async function generateOnce(
283+
generateSource: TailwindV3ResolvedSource,
284+
options: TailwindV3GenerateOptions = {},
285+
) {
185286
await runtimeReadyPromise
186287

187288
const {
188289
styleOptions,
189290
target = 'weapp',
190291
} = options
191-
const tailwindcss = loadTailwindcssPlugin(source)
292+
const tailwindcss = loadTailwindcssPlugin(generateSource)
192293
resetTailwindcssPluginContext(tailwindcss)
193-
const tailwindConfig = createTailwindConfig(source, options)
294+
const tailwindConfig = createTailwindConfig(generateSource, options)
194295
const result = await postcss([
195296
tailwindcss(tailwindConfig),
196-
]).process(source.css, {
297+
]).process(generateSource.css, {
197298
from: undefined,
198299
})
199300
const rawCss = result.css
200301
const css = await transformTailwindV3CssByTarget(rawCss, target, styleOptions)
201302
const dependencies = collectDependencyMessages(result)
202-
for (const dependency of source.dependencies) {
303+
for (const dependency of generateSource.dependencies) {
203304
dependencies.add(dependency)
204305
}
205306
const classSet = collectClassSet(tailwindcss)
@@ -208,7 +309,7 @@ export function createTailwindV3Engine(source: TailwindV3ResolvedSource): Tailwi
208309
css,
209310
rawCss,
210311
classSet,
211-
rawCandidates: new Set(options.candidates ?? []),
312+
rawCandidates: collectCandidates(options.candidates),
212313
dependencies: [...dependencies],
213314
sources: [],
214315
root: null,
@@ -217,6 +318,79 @@ export function createTailwindV3Engine(source: TailwindV3ResolvedSource): Tailwi
217318
}
218319
}
219320

321+
async function generateWithIncrementalCache(options: TailwindV3GenerateOptions = {}) {
322+
if ((options.sources?.length ?? 0) > 0) {
323+
return generateOnce(source, options)
324+
}
325+
326+
const target = options.target ?? 'weapp'
327+
const requestedCandidates = collectCandidates(options.candidates)
328+
const cacheKey = createIncrementalGenerateCacheKey(source, target, options.styleOptions)
329+
const cached = incrementalGenerateCache.get(cacheKey)
330+
if (cached) {
331+
const missingCandidates = [...requestedCandidates].filter(candidate => !cached.seenCandidates.has(candidate))
332+
if (missingCandidates.length === 0) {
333+
return {
334+
css: cached.css,
335+
rawCss: cached.rawCss,
336+
classSet: new Set(cached.classSet),
337+
rawCandidates: new Set(cached.seenCandidates),
338+
dependencies: cached.dependencies,
339+
sources: [],
340+
root: null,
341+
target: cached.target,
342+
version: 3 as const,
343+
}
344+
}
345+
346+
const utilitySource = {
347+
...source,
348+
css: createUtilitiesOnlyCss(source.css),
349+
}
350+
const generated = await generateOnce(utilitySource, {
351+
...options,
352+
candidates: missingCandidates,
353+
})
354+
for (const candidate of missingCandidates) {
355+
cached.seenCandidates.add(candidate)
356+
}
357+
for (const className of generated.classSet) {
358+
cached.classSet.add(className)
359+
}
360+
cached.css = [cached.css, generated.css].filter(Boolean).join('\n')
361+
cached.rawCss = [cached.rawCss, generated.rawCss].filter(Boolean).join('\n')
362+
cached.dependencies = [...new Set([...cached.dependencies, ...generated.dependencies])]
363+
return {
364+
css: cached.css,
365+
rawCss: cached.rawCss,
366+
classSet: new Set(cached.classSet),
367+
rawCandidates: new Set(cached.seenCandidates),
368+
dependencies: cached.dependencies,
369+
sources: [],
370+
root: null,
371+
target: cached.target,
372+
version: 3 as const,
373+
}
374+
}
375+
376+
const generated = await generateOnce(source, options)
377+
incrementalGenerateCache.set(cacheKey, {
378+
seenCandidates: new Set(requestedCandidates),
379+
classSet: new Set(generated.classSet),
380+
css: generated.css,
381+
rawCss: generated.rawCss,
382+
dependencies: generated.dependencies,
383+
target: generated.target,
384+
})
385+
return generated
386+
}
387+
388+
async function generate(options: TailwindV3GenerateOptions = {}) {
389+
return options.incrementalCache
390+
? generateWithIncrementalCache(options)
391+
: generateOnce(source, options)
392+
}
393+
220394
return {
221395
source,
222396
async validateCandidates(candidates) {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export interface TailwindV3ResolvedSource {
4040
export interface TailwindV3GenerateOptions {
4141
candidates?: Iterable<string> | undefined
4242
sources?: TailwindV3CandidateSource[] | undefined
43+
incrementalCache?: boolean | undefined
4344
target?: TailwindV3GenerateTarget | undefined
4445
styleOptions?: Partial<IStyleHandlerOptions> | undefined
4546
}

packages/weapp-tailwindcss/test/bundlers/vite-plugin.bundle.unit.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -645,7 +645,6 @@ const trace = "at App.vue:4"
645645
expect(secondJs).toContain(replaceWxml(secondClass))
646646
expect(secondCss).toContain(`.${replaceWxml(secondClass)}`)
647647
expect(secondCss).toContain(`.${replaceWxml(baselineClass)}`)
648-
expect(secondCss).not.toContain(`.${replaceWxml(firstClass)}`)
649648
expect(extractMock).toHaveBeenCalledTimes(1)
650649
}, TEST_TIMEOUT_MS)
651650

@@ -2425,10 +2424,11 @@ const cls = "w-[1.5px]"
24252424

24262425
const secondCss = (secondBundle['index.css'] as OutputAsset).source.toString()
24272426
expect(secondCss).toContain('view,text,::before,::after')
2428-
expect(secondCss).toContain('._f70')
2427+
expect(secondCss).toContain('.border-emerald-300_f70')
24292428
expect(secondCss).not.toContain('*,::before,::after')
24302429
expect(secondCss).not.toContain('border-emerald-200\\/70')
2431-
expect(currentContext.styleHandler).toHaveBeenCalledTimes(1)
2430+
expect(secondCss).not.toContain('border-emerald-300\\/70')
2431+
expect(currentContext.styleHandler).toHaveBeenCalledTimes(0)
24322432
}, TEST_TIMEOUT_MS)
24332433

24342434
it('reapplies cached css transform when css formatting changes only', async () => {

0 commit comments

Comments
 (0)