Browse Source

fix(@vben/vite-config): 修复生产构建下组件库 css 层顺序错误导致样式被覆盖

viteCssLayerPlugin 把组件库 css 包进 @layer 依赖 theme.css 中的层顺序声明
先被注册。生产构建下 css 按 chunk 注入,若组件库 css chunk 先于 theme.css
加载,层名会被注册为最低优先级,Tailwind base/utilities 覆盖组件样式。

由于 rolldown-vite 的 css 合并会剥离「层顺序声明 + @layer 规则」模块的层
(规则被脱层),压缩阶段也会丢弃 transform 输出的声明,改为在
generateBundle 阶段向含组件库层的 css 产物顶部统一注入层顺序声明,无论
chunk 加载顺序如何层优先级都正确(theme.css 先加载时声明为幂等 no-op)。
properties(Tailwind --tw-* 回退变量层)排在首位保持最低优先级,theme.css
中的声明同步更新以保持一致。
pull/8259/head
Harold Zhang 2 weeks ago
parent
commit
de78a87f38
  1. 9
      internal/tailwind-config/src/theme.css
  2. 159
      internal/vite-config/src/plugins/css-layer.test.ts
  3. 44
      internal/vite-config/src/plugins/css-layer.ts

9
internal/tailwind-config/src/theme.css

@ -1,11 +1,14 @@
/*
* 级联层顺序声明必须先于 @import 'tailwindcss' 首次出现
* preflight(base) < UI 库组件样式(antd / el / td) < Tailwind 工具类
* properties(Tailwind --tw-* 回退变量层) < preflight(base) <
* UI 库组件样式(antd / el / td) < Tailwind 工具类
* 使组件库样式antdv-next StyleProvider layer 注入 @layer antd
* element-plus / tdesign 经各自应用的 vite css-layer 插件包入对应层
* 能被工具类覆盖
* 能被工具类覆盖
* internal/vite-config/src/plugins/css-layer.ts 中的 LAYER_ORDER_STATEMENT
* 保持一致
*/
@layer theme, base, ant, antd, el, td, components, utilities;
@layer properties, theme, base, ant, antd, el, td, components, utilities;
@import 'tailwindcss';
@import 'tw-animate-css';

159
internal/vite-config/src/plugins/css-layer.test.ts

@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest';
import { viteCssLayerPlugin } from './css-layer';
interface FakeAsset {
fileName: string;
source: string;
type: string;
}
function createBundle(assets: FakeAsset[]) {
return Object.fromEntries(assets.map((asset) => [asset.fileName, asset]));
}
const LAYER_ORDER_STATEMENT =
'@layer properties, theme, base, ant, antd, el, td, components, utilities;';
describe('viteCssLayerPlugin', () => {
it('wraps matching css modules in the target layer', () => {
const plugin = viteCssLayerPlugin({
layerName: 'el',
packageName: 'element-plus',
});
const transform = plugin.transform;
if (typeof transform !== 'function') return;
const result = transform.call(
undefined as never,
'.el-button { color: red; }',
'/node_modules/element-plus/es/components/button/style/css/index.css?used',
);
expect(result).toEqual({
code: '@layer el {\n.el-button { color: red; }\n}',
map: null,
});
});
it('does not wrap css modules outside the target package', () => {
const plugin = viteCssLayerPlugin({
layerName: 'el',
packageName: 'element-plus',
});
const transform = plugin.transform;
if (typeof transform !== 'function') return;
const result = transform.call(
undefined as never,
'.local { color: red; }',
'/src/views/home/index.css',
);
expect(result).toBeUndefined();
});
it('injects the layer order statement into matching css assets', () => {
const plugin = viteCssLayerPlugin({
layerName: 'el',
packageName: 'element-plus',
});
const generateBundle = plugin.generateBundle;
if (typeof generateBundle !== 'function') return;
const bundle = createBundle([
{
fileName: 'element-x.css',
source: '@layer el{.el-button{color:red}}',
type: 'asset',
},
{
fileName: 'theme-x.css',
source: '.local { color: blue; }',
type: 'asset',
},
]);
generateBundle.call(undefined as never, {} as never, bundle);
expect(bundle['element-x.css'].source).toBe(
`${LAYER_ORDER_STATEMENT}\n@layer el{.el-button{color:red}}`,
);
expect(bundle['theme-x.css'].source).toBe('.local { color: blue; }');
});
it('does not inject the statement twice when already present', () => {
const plugin = viteCssLayerPlugin({
layerName: 'el',
packageName: 'element-plus',
});
const generateBundle = plugin.generateBundle;
if (typeof generateBundle !== 'function') return;
const bundle = createBundle([
{
fileName: 'element-x.css',
source: `${LAYER_ORDER_STATEMENT}\n@layer el{.el-button{color:red}}`,
type: 'asset',
},
]);
generateBundle.call(undefined as never, {} as never, bundle);
expect(bundle['element-x.css'].source).toBe(
`${LAYER_ORDER_STATEMENT}\n@layer el{.el-button{color:red}}`,
);
});
it('injects the statement even when the asset starts with a BOM', () => {
const plugin = viteCssLayerPlugin({
layerName: 'el',
packageName: 'element-plus',
});
const generateBundle = plugin.generateBundle;
if (typeof generateBundle !== 'function') return;
const bundle = createBundle([
{
fileName: 'element-x.css',
source: `\uFEFF@layer el{.el-button{color:red}}`,
type: 'asset',
},
]);
generateBundle.call(undefined as never, {} as never, bundle);
expect(bundle['element-x.css'].source).toBe(
`${LAYER_ORDER_STATEMENT}\n\uFEFF@layer el{.el-button{color:red}}`,
);
});
it('ignores non-css assets', () => {
const plugin = viteCssLayerPlugin({
layerName: 'el',
packageName: 'element-plus',
});
const generateBundle = plugin.generateBundle;
if (typeof generateBundle !== 'function') return;
const bundle = createBundle([
{
fileName: 'element-x.js',
source: '@layer el{.el-button{color:red}}',
type: 'chunk',
},
]);
generateBundle.call(undefined as never, {} as never, bundle);
expect(bundle['element-x.js'].source).toBe(
'@layer el{.el-button{color:red}}',
);
});
});

44
internal/vite-config/src/plugins/css-layer.ts

@ -11,12 +11,33 @@ function escapeRegExp(value: string): string {
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`);
}
/**
* internal/tailwind-config/src/theme.css
* properties(Tailwind --tw-* 退) < preflight(base) <
* UI (antd / el / td) < Tailwind
* css chunk css
* chunk theme.css
* Tailwind base/utilities
* css chunk
* theme.css no-op
* properties Tailwind @property
* --tw-* 退退
*
*/
const LAYER_ORDER_STATEMENT =
'@layer properties, theme, base, ant, antd, el, td, components, utilities;';
/**
* css @layer
* css @layer Tailwind
* theme.css utilities
* 使 Tailwind
*
* transform css rolldown-vite
* css + @layer
* generateBundle css
* LAYER_ORDER_STATEMENT
*
* @example
* ```ts
* plugins: [viteCssLayerPlugin({ packageName: 'element-plus', layerName: 'el' })]
@ -41,5 +62,28 @@ export function viteCssLayerPlugin(
return { code: `@layer ${matched.layerName} {\n${code}\n}`, map: null };
}
},
generateBundle(_options, bundle) {
for (const file of Object.values(bundle)) {
if (file.type !== 'asset' || !file.fileName.endsWith('.css')) {
continue;
}
const css = file.source.toString();
const matched = matchers.some((m) =>
css.includes(`@layer ${m.layerName}`),
);
if (!matched) {
continue;
}
// 先去除可能的 BOM 再判断,避免重复注入层顺序声明
const hasStatement = css
.replace(/^\uFEFF/, '')
.trimStart()
.startsWith(LAYER_ORDER_STATEMENT);
if (hasStatement) {
continue;
}
file.source = `${LAYER_ORDER_STATEMENT}\n${css}`;
}
},
};
}

Loading…
Cancel
Save