阅读视图

发现新文章,点击刷新页面。

解决Tailwind任意值滥用:规范化CSS开发体验

背景 eslint-plugin-tailwindcss插件的no-unnecessary-arbitrary-value无法对所有的任意值进行校验,比如h-[48px]text-[#f5f5f5]无法校验出来。但tailwindcss的预设值太多了,一个不小心可能就又写了一个没有必要的任意值。为了避免这种情况,我们需要自己实现一个检测任意值的eslint插件。

插件地址:eslint-plugin-tailwind-no-preset-class

首先来看下效果

no-unnecessary-arbitrary-value 无法检测的情况

image.png

使用自定义的:eslint-plugin-tailwind-no-preset-class插件,完美完成了校验

image.png

创建eslint插件标准目录结构

  • 安装Yeoman
npm install -g yo
  • 安装Yeoman generator-eslint
npm install -g generator-eslint
  • 创建项目
mkdir eslint-plugin-my-plugin
yo eslint:plugin

生成目录结构如下:

eslint-plugin-my-plugin/
├── lib/                    # 核心源代码目录
│   ├── index.js           # 插件的入口文件,在这里导出所有规则
│   └── rules/             # 存放所有自定义规则的目录
│       └── my-rule.js     # 生成器为你创建的一条示例规则文件
├── tests/                 # 测试文件目录
│   └── lib/
│       └── rules/
│           └── my-rule.js # 示例规则对应的测试文件
├── package.json           # 项目的 npm 配置文件,依赖和元信息都在这里
└── README.md              # 项目说明文档

根据实际项目的tailwindcss配置文件和tailwindcss默认配置生成全量定制化配置,用于后续eslint插件的校验依据

实现配置文件生成并加载方法:

// lib/tailwind-config-loader.js
// 配置文件生成
...
...
// 动态加载 Tailwind 预设配置
let tailwindPresetConfig = null;
...
async function generateTailwindConfig(projectRootPath) {
  try {
    // 动态导入tailwindcss
    const resolveConfigModule = await import('tailwindcss/lib/public/resolve-config.js');
    const resolveConfig = resolveConfigModule.default.default
    // 尝试加载项目配置
    let projectConfig = {};
    try {
      const projectConfigPath = join(projectRootPath||process.cwd(), 'tailwind.config.js');
      const projectConfigModule = await import(projectConfigPath);
      projectConfig = projectConfigModule.default || projectConfigModule;
    } catch (error) {
      console.log('⚠️ 未找到项目 tailwind.config.js,使用默认配置');
      throw error;
    }

    // 使用tailwindcss的resolveConfig函数
    const finalConfig = resolveConfig(projectConfig);

    console.log('✅ Tailwind preset config generated successfully!');
    
    return finalConfig;
  } catch (error) {
    console.error('❌ 生成Tailwind配置失败:', error.message);
    throw error;
  }
}


// 加载配置到内存中
async function loadTailwindPresetConfig(projectRootPath) {
  if (configLoading) {
    console.log('⏳ 配置正在加载中,跳过重复请求');
    return;
  }

  configLoading = true;

  try {
    // 直接动态生成配置
    tailwindPresetConfig = await generateTailwindConfig(projectRootPath);
    console.log('✅ Tailwind 预设配置已动态生成并加载');
    onConfigLoaded();
  } catch (error) {
    console.error('❌ 动态生成 Tailwind 预设配置失败:', error.message);
    onConfigLoadFailed(error);
    throw error;
  }
}


...
// 导出配置
export const TailwindConfigLoader = {
  getConfig: () => tailwindPresetConfig,
  isLoaded: () => configLoaded,
  ensureLoaded: ensureConfigLoaded,
  reload: loadTailwindPresetConfig,
  generateConfig: generateTailwindConfig
};
...
...

创建校验规则函数

  • 实现校验规则函数checkAndReport
...
// 使用 WeakMap 来跟踪每个文件的已报告类名,避免重复报告
const reportedClassesMap = new WeakMap();
...
// 检查并报告
async function checkAndReport(context, node, className) {
  // 如果配置尚未加载,尝试等待加载
  if (!TailwindConfigLoader.isLoaded()) {
    try {
        const projectRootPath = context.getCwd();
        console.log(`正在等待加载配置文件 ${projectRootPath}...`);
      const loaded = await TailwindConfigLoader.ensureLoaded(projectRootPath);
      if (!loaded) {
        console.warn('⚠️ Tailwind 预设配置尚未加载,跳过检查');
        return;
      }
    } catch (error) {
      console.warn('⚠️ 配置加载失败,跳过检查');
      return;
    }
  }

  const filePath = context.getFilename();
  const filePathWrapper = new FilePathWrapper(filePath);

  if (!reportedClassesMap.has(filePathWrapper)) {
    reportedClassesMap.set(filePathWrapper, new Set());
  }
  const reportedClasses = reportedClassesMap.get(filePathWrapper);

  if (reportedClasses.has(className)) {
    return;
  }

  const propertyInfo = extractProperty(className);
  if (!propertyInfo) {
    return;
  }

  const { property, value, originalPrefix } = propertyInfo;

  // 只检查任意值
  if (isArbitraryValue(value)) {
    const arbitraryValue = value.slice(1, -1);
    const presetClass = findPresetClass(property, arbitraryValue);

    if (presetClass) {
      reportedClasses.add(className);
      // 使用原始前缀显示正确的类名格式(如 h-14 而不是 height-14)
      const suggestedClass = `${originalPrefix}${presetClass}`;
      context.report({
        node,
        message: `类名 "${className}" 使用了任意值,但存在对应的预设类名 "${suggestedClass}"。请使用预设类名替代。`,
      });
    }
  }
}

  • 实现属性提取,将classname解析为tailwindcss的property和value
// 提取属性值
function extractProperty(className) {
  // 处理响应式前缀(如 max-md:, md:, lg: 等)
  const responsivePrefixes = [
    'max-sm:',
    'max-md:',
    'max-lg:',
    'max-xl:',
    'max-2xl:',
    'max-',
    'min-',
    'sm:',
    'md:',
    'lg:',
    'xl:',
    '2xl:',
  ];

  // 移除响应式前缀,保留核心类名
  let coreClassName = className;
  let responsivePrefix = '';

  for (const prefix of responsivePrefixes) {
    if (className.startsWith(prefix)) {
      responsivePrefix = prefix;
      coreClassName = className.slice(prefix.length);
      break;
    }
  }

  // 按前缀长度降序排序,优先匹配更长的前缀
  const sortedPrefixes = Object.keys(prefixToProperty).sort(
    (a, b) => b.length - a.length
  );

  for (const prefix of sortedPrefixes) {
    if (coreClassName.startsWith(prefix)) {
      return {
        property: prefixToProperty[prefix],
        value: coreClassName.slice(prefix.length),
        originalPrefix: responsivePrefix + prefix, // 包含响应式前缀
      };
    }
  }

  return null;
}
  • 将提取的property和前面生成的全量的tailwindcss进行映射
// 简化属性映射,只保留常用的属性
const prefixToProperty = {
  // 尺寸相关
  "w-": "width",
  "h-": "height",
  "min-w-": "minWidth",
  "min-h-": "minHeight",
  "max-w-": "maxWidth",
  "max-h-": "maxHeight",

  // 间距相关
  "m-": "margin",
  "mt-": "marginTop",
  "mr-": "marginRight",
  "mb-": "marginBottom",
  "ml-": "marginLeft",
  "mx-": "margin",
  "my-": "margin",
  "p-": "padding",
  "pt-": "paddingTop",
  "pr-": "paddingRight",
  "pb-": "paddingBottom",
  "pl-": "paddingLeft",
  "px-": "padding",
  "py-": "padding",

  // 边框相关(新增)
  "border-": "borderWidth;borderColor",
  "border-t-": "borderWidth;borderColor",
  "border-r-": "borderWidth;borderColor",
  "border-b-": "borderWidth;borderColor",
  "border-l-": "borderWidth;borderColor",
  "border-x-": "borderWidth;borderColor",
  "border-y-": "borderWidth;borderColor",

  // 圆角相关(新增)
  "rounded-": "borderRadius",
  "rounded-t-": "borderRadius",
  "rounded-r-": "borderRadius",
  "rounded-b-": "borderRadius",
  "rounded-l-": "borderRadius",
  "rounded-tl-": "borderRadius",
  "rounded-tr-": "borderRadius",
  "rounded-br-": "borderRadius",
  "rounded-bl-": "borderRadius",

  // 文字相关
  "text-": "fontSize;color",
  "leading-": "lineHeight",
  "tracking-": "letterSpacing",
  "font-": "fontWeight",

  // 背景相关
  "bg-": "backgroundColor",

  // SVG相关
  "fill-": "fill",
  "stroke-": "stroke",
  "stroke-w-": "strokeWidth",

  // 定位相关
  "z-": "zIndex",
  "inset-": "inset",
  "top-": "top",
  "right-": "right",
  "bottom-": "bottom",
  "left-": "left",

  // 布局相关(新增)
  "gap-": "gap",
  "gap-x-": "gap",
  "gap-y-": "gap",
  "space-x-": "gap",
  "space-y-": "gap",

  // 透明度
  "opacity-": "opacity",

  // 变换相关(新增)
  "scale-": "scale",
  "scale-x-": "scale",
  "scale-y-": "scale",
  "rotate-": "rotate",
  "translate-x-": "translate",
  "translate-y-": "translate",
  "skew-x-": "skew",
  "skew-y-": "skew",

  // 阴影相关(新增)
  "shadow-": "boxShadow",

  // 网格相关(新增)
  "grid-cols-": "gridTemplateColumns",
  "grid-rows-": "gridTemplateRows",
  "col-": "gridColumn",
  "row-": "gridRow",
  "col-start-": "gridColumnStart",
  "col-end-": "gridColumnEnd",
  "row-start-": "gridRowStart",
  "row-end-": "gridRowEnd",

  // Flexbox相关(新增)
  "flex-": "flex",
  "basis-": "flexBasis",
  "grow-": "flexGrow",
  "shrink-": "flexShrink",
  "order-": "order",

  // 动画相关(新增)
  "duration-": "transitionDuration",
  "delay-": "transitionDelay",
  "ease-": "transitionTimingFunction",

  // 其他(新增)
  "aspect-": "aspectRatio",
  "cursor-": "cursor",
};

// 动态构建支持的 Tailwind 属性映射
function getSupportedProperties() {
  const config = TailwindConfigLoader.getConfig();
  if (!config) {
    return {};
  }

  return {
    width: config.theme.width,
    height: config.theme.height,
    minWidth: config.theme.minWidth,
    minHeight: config.theme.minHeight,
    maxWidth: config.theme.maxWidth,
    maxHeight: config.theme.maxHeight,
    margin: config.theme.margin,
    marginTop: config.theme.margin,
    marginRight: config.theme.margin,
    marginBottom: config.theme.margin,
    marginLeft: config.theme.margin,
    padding: config.theme.padding,
    paddingTop: config.theme.padding,
    paddingRight: config.theme.padding,
    paddingBottom: config.theme.padding,
    paddingLeft: config.theme.padding,
    fontSize: config.theme.fontSize,
    lineHeight: config.theme.lineHeight,
    borderRadius: config.theme.borderRadius,
    color: config.theme.colors,
    backgroundColor: config.theme.backgroundColor,
    borderColor: config.theme.borderColor,
    fill: config.theme.fill,
    stroke: config.theme.stroke,
    borderWidth: config.theme.borderWidth,
    zIndex: config.theme.zIndex,
    gap: config.theme.gap,
    inset: config.theme.inset,
    top: config.theme.spacing,
    right: config.theme.spacing,
    bottom: config.theme.spacing,
    left: config.theme.spacing,
    opacity: config.theme.opacity,
  };
}

整体实现流程

graph TD
    A[ESLint 执行插件] --> B[遍历代码中的类名]
    B --> C{是否为 Tailwind 类名?}
    C -->|否| D[跳过检查]
    C -->|是| E{是否包含任意值?}
    E -->|否| F[使用预设值 通过检查]
    E -->|是| G[提取类名前缀和任意值]
    
    G --> H[通过 prefixToProperty 映射到CSS属性]
    H --> I[检查Tailwind配置是否已加载]
    I -->|已加载| J[获取支持的属性预设值]
    I -->|未加载| K[加载项目Tailwind配置]
    
    K --> L[读取项目tailwind.config.js]
    L --> M{配置是否存在?}
    M -->|不存在| N[使用Tailwind默认配置]
    M -->|存在| O[解析项目配置]
    
    O --> P[合并默认配置和项目配置]
    N --> P
    
    P --> Q[生成全量Tailwind配置]
    Q --> R[缓存配置到内存]
    R --> J
    
    J --> S{判断属性类型}
    S -->|颜色相关| T[调用 findColorPreset]
    S -->|数值相关| U[调用 findNumericPreset]
    
    T --> V{是否匹配预设?}
    U --> V
    
    V -->|是| W[找到对应预设类名]
    V -->|否| X[未找到预设类名]
    
    W --> Y[生成建议消息]
    X --> Z[通过检查 无匹配预设]
    
    Y --> AA[报告建议]
    Z --> BB[检查完成]
    
    AA --> BB

CSS3 clip-path+animation实现不规则容器中的粒子下落

使用CSS3的clip-path实现不规则图形裁剪,结合CSS3 animation实现粒子下落动画效果,如下:

html: 创建不规则容器及下落的粒子节点;

<div class="particle">
  <i v-for="item of 20" :key="item" class="particle-item"></i>
</div>

style: 1、此demo使用less实现样式;

/* 不规则容器样式 */
.particle {
  position: absolute;
  top: 90px;
  left: 110px;
  width: 200px;
  height: 236px;
  background: linear-gradient(180deg, #F44336 0%, rgba(250, 33, 245, 0.4) 100%);
  clip-path: polygon(0 0, 100px 0, 100px 200px, 46px 236px, 0 200px);
}

/* 下落粒子样式 */
.particle-item {
  &::before,
  &::after {
    position: absolute;
    width: 4px;
    height: 4px;
    background: #fff;
    border-radius: 50%;
    content: '';
    box-shadow: 0 0 5px 0 rgba(255, 255, 255, 0.5);
  }
  /* 调用粒子下落样式函数 */
  .particle-selectors(20);
}

2、粒子下落样式函数主要计算粒子的初始位置及下落路径;

.particle-selectors(@n, @i:1) when (@i <= @n) {
  &:nth-child(@{i}) {
    &::before ,
    &::after {
      @w: `Math.floor(Math.random() * 100) `;
      @h: `Math.floor(Math.random() * -100) `;
      @d: `Math.random() * 0.2 `;
      @du: calc(~'@{d}s + 5s');
      @t: `Math.random() * -10 `;
      @ti: calc(~'@{t} * 0.6s');

      left: calc(~'@{w} * 1px');
      transform: translateY(calc(~'@{h} * 2px'));
      .animation(@du, @ti);
    }
  }
  .particle-selectors(@n,(@i + 1));
}

3、粒子下落动画;

.animation(@du, @de) {
  @keyframes frame {
    from {
      transform: translateY(-20px);
    }
    to {
      opacity: 0;
      transform: translateY(280px);
    }
  }
  animation: frame 10s infinite;
  animation-delay: @de;
  animation-duration: @du;
}

博客园地址:www.cnblogs.com/wttt123/p/1…

以上。

CSS3 实现16:9大屏居中显示

大屏项目中,一般需要在不同分辨率上显示居中显示大屏内容,且不出现滚动条。实际展示大屏的硬件设备比例不一,为了兼容,并且不出现UI被拉伸的情况,发现可以使用CSS3的transfrom-scale属性,并配合CSS变量实现。 其中transfrom-scale用在大屏绘制最外层盒子上,盒子内的样式按照UI给出的16:9的比例绘制。 效果图:

代码展示最外层盒子的缩放样式及比例计算:

style

<style>
  :root {
    --transformScale: 1;
    --positionWidth: 1920px;
    --positionHeight: 1080px;
  }

  * {
    margin: 0;
    padding: 0;
  }

  .container {
    display: flex;
    align-items: center;
    justify-content: center;
    overflow: hidden;
    height: 100vh;
    width: 100vw;
  }

  .position {
    width: var(--positionWidth);
    height: var(--positionHeight);
  }

  .box {
    height: 1080px;
    width: 1920px;
    background-color: aquamarine;
    transform: scale(var(--transformScale));
    transform-origin: 0% 0%;
  }
</style>

html

<!-- 为了获取屏幕宽高添加的元素 -->
<div class="container">
  <!-- 为了定位添加的元素 -->
  <div class="position">
    <div class="box"></div>
  </div>
</div>

script

<script>
  // 全局缩放比基础宽
  const width = 1920;
  // 全局缩放比基础高
  const height = 1080;
  // 宽高比
  const ratio = 16 / 9;

  const getBaseScale = () => {
    const element = document.getElementsByClassName("container")[0];
    // 获取可视区域的宽度
    const w = element.clientWidth;
    // 获取可视区域的高
    const h = element.clientHeight;
    // 根据宽高计算比例
    let s = 1;
    if (w / h >= ratio) {
      // 设备左右留白 以高度为基础计算缩放比
      s = h / height;
    } else {
      s = w / width;
    }

    const pw = s * 1920 + "px";
    const ph = s * 1080 + "px";

    // 赋值
    document
      .getElementsByTagName("body")[0]
      .style.setProperty("--transformScale", s);
    document
      .getElementsByTagName("body")[0]
      .style.setProperty("--positionWidth", pw);
    document
      .getElementsByTagName("body")[0]
      .style.setProperty("--positionHeight", ph);
  };

  // 窗口变化
  onresize = getBaseScale;

  // 加载
  onload = getBaseScale;
</script>

补充

一、JavaScript 操作 CSS 变量
const root = document.querySelector(":root");
// 设置 CSS 变量
root.style.setProperty("--transformScale", 0.2);
// 读取 CSS 变量
const computedStyle = getComputedStyle(root);
const transformScale = computedStyle.getPropertyValue("--transformScale");
console.log(transformScale);
// 删除 CSS 变量
root.style.removeProperty("--transformScale");
二、CSS3 transform: scale

语法:transform: scale(x, y) transform: scaleX(x) transform: scaleY(y) 1、scale(x, y) 对元素进行缩放; ① x表示水平方向,y表示竖直方向; ② y是一个可选参数,如果不写的话,X,Y 两个方向缩放一样; 2、scaleX(x) 对元素只在x轴(水平方向)进行缩放; 3、scaleY(y) 对元素只在y轴(竖直方向)进行缩放; 4、存在 2D 转换或 3D 转换。

兼容性:参考 caniuse.com/?search=tra…

三、CSS3 transform-origin

语法:transform-origin: x y z; 1、改变被转换元素的位置; 2、存在 2D 转换或 3D 转换; 3、相对于父节点改变位置。

属性值
x leftcenterrightlength%
y topcenterbottomlength%
z length

兼容性:参考 caniuse.com/?search=tra…

博客园地址:www.cnblogs.com/wttt123/p/1…

以上。

CSS 有什么奇技淫巧?

转载自作者:独元殇

aspect-ratio

宽高比

<style>
    div { 
        width: 190px; /* 注意,这个数字要能容纳完内容才有效 */ 
        aspect-ratio: 16 / 9; 
        background: tomato; 
    }
</style> 
<div>我永远保持 16:9</div>

如果,你 aspect-ratio 的值写成 1 ,那么就可以得到一个完美的正方形了!

object-fit

它有两个值,一个是 cover (图片完全覆盖容器,且图片长宽不失真),一个是 scale-down (只保证图片长宽不失真)。

<style> 
    img { 
        width: 200px; 
        height: 200px; 
        object-fit: cover; /* 会裁切,只剩中间 */ 
        border: 2px solid #000; 
    } 
</style> 
<img src="https://placehold.co/300x200" alt="demo">

color-scheme: dark light;

启动浏览器自适应 深浅色模式!

<style> 
    :root { 
        color-scheme: dark light; /* 自动变色 */ 
    } 
</style> 
<h3>切换系统深浅模式来测试</h3> 
<p>在深色模式下,下面的原生控件会自动变黑,文字变白:</p> 
<label> 输入框: <input type="text"> </label>

accent-color:red;

它会自动计算出不同焦点下,表单控件的颜色

<style>
    body { 
        accent-color:red; 
    } 
</style> 
<input type="checkbox" checked> 选择框
<br><br> 
<input type="radio" checked> 单选 
<br><br> 
<input type="range"> 拖动滑块

fit-content

它是根据内容,来控制容器的大小的

<style>
    div {
        background: skyblue; 
        width: fit-content; 
        margin: auto; 
        padding: 20px; 
    } 
</style>
<div>我是一个 div,使用缩水大法</div>

overscroll-behavior: contain;

解决一个 div,有自己的滚动条。然后用户在这个 div 里滚动到底部时,,整个页面会开始滚动。

<style> 
    body { 
        height: 150vh; 
        background: #eee; 
        padding: 50px; 
    } 
    .scroll-box { 
        width: 200px; 
        height: 200px; 
        overflow: auto;
        border: 3px solid #333;
        background: white; 
        overscroll-behavior: contain; /* 关键代码 */ 
    } 
    .inner { 
        height: 500px; 
        background: linear-gradient(to bottom, tomato, gold); 
    } 
</style> 
<div class="scroll-box"> 
    <div class="inner">内部滚动条</div>
</div> 
<p>滚动上面的盒子到底部,再继续滚动试试...</p>

text-wrap: balance;

它可以平衡行数之前的词语长度,使其做到尽可能的均衡,整体观感上,要舒服很多! 注意,这个属性,只支持 6 行以内,所以尽可能用在一些短小的地方,比如标题!

<style> 
    h2 { 
        width: 200px; 
        background: gold; 
        text-align: center; 
        padding: 10px; 
    } 
    #test { 
        text-wrap: balance; 
    } 
</style> 
<h2 id=test>很长很长 so long 很长的 titletitle 标题</h2>
<h2>很长很长 so long 很长的 titletitle 标题</h2>

text-underline-offset: 0.25em;

英文中的 g、y ,是不是这些字母,下面会拖一个尾巴。而 < a > 的原生效果是,这个 underline 线会重叠到这些小尾巴上。

<style> 
    a:not([class]) {
        text-underline-offset: 0.25em; 
    } 
</style> 
<p> <a href="#">这是一个正文链接 (g/y)</a> </p> 
<p> <a href="#" class="btn">这是一个按钮链接(不使用该 CSS) (g/y)</a> </p>

outline-offset !

不计入盒模型尺寸的轮廓线。 把鼠标移上去,就会有扩散效果了。而且,只是扩散,不会影响各种尺寸。

<style>
    button { 
        margin: 30px; 
        padding: 10px 20px;
        border: 1px solid #333; 
        outline: 2px dashed blue;
        outline-offset: var(--offset, 2px);
        transition: outline-offset 0.2s; 
    } 
    button:hover { 
        --offset: 10px; 
    } 
</style>
<button>鼠标悬停看扩散效果</button>

scroll-margin-top

怎么形容这个场景呢?你有个固定的顶部导航栏。它 高度是 100px 。然后你单击链接,是一个滚动到某个 #part 标题的链接,你会发现默认滚动后,它是紧挨着顶部的。那么导航栏就挡住了(我也不太能说清,大家看下面的例子吧)..... 如何不介入 js 来解决这个问题呢?就是scroll-margin-top !

注意,要在本地建一个 html 来运行,否则会乱跳转!

<style> 
    nav { 
        position: fixed;
        top: 0; 
        width: 100%; 
        height: 100px; 
        background: rgba(255,0,0,0.5); 
    } 
    h2[id] { 
        scroll-margin-top: 110px; /* 100px + 10px 缝隙 */ 
        background: yellow; 
    } 
    body { 
        height: 2000px; 
        padding-top: 120px; 
    } 
</style> 
<nav>我是 100px 高的固定栏 (半透明)</nav> 
<a href="#part-1">>>> 点我跳转到目标 <<<</a> 
<div style="height: 500px;"></div> 
<h2 id="part-1">目标标题:你看不到红色的遮挡</h2>

scrollbar-gutter: stable;

解决 一个滚动条跳动 Bug 。就是页面的内容,动态变多,会突然出现滚动条。然后画面会跳动一下。

<style>
    div { 
        width: 200px; 
        height: 150px; 
        overflow-y: auto; 
        border: 2px solid #333; 
        scrollbar-gutter: stable; /* 关键 */
    }
</style> 
<div> 虽然内容很少,不需要滚动, 但请注意右侧预留的空白槽。 这保证了内容增加时不会发生位移。 </div>

Coco AI 技术演进:Shadcn UI + Tailwind CSS v4.0 深度迁移指南 (踩坑实录)

摘要:本文深度复盘了 Coco AI 项目在引入 shadcn/ui 组件库的同时,激进升级至 Tailwind CSS 4.0 的技术细节。重点剖析了在 Vite + Tsup (Esbuild) 双构建工具链下的兼容性方案,以及如何处理 tailwind.config.js 与 CSS-first 配置模式的冲突,为维护大型遗留项目的开发者提供一份“硬核”避坑指南。

前言:为什么要自找麻烦?

在 Coco AI 的开发过程中,我们面临着大多数成长期项目都会遇到的痛点:

  1. UI 碎片化:早期的手写 CSS 与后期的 Tailwind Utility Class 混杂,维护成本极高。
  2. 重复造轮子:为了一个带键盘导航的 Dropdown,我们可能写了 500 行代码,且 Bug 频出。

引入 shadcn/ui 是为了解决组件复用问题,而升级 Tailwind CSS v4.0 则是为了追求极致的构建性能(Rust 引擎)。当这两者在这个拥有大量遗留代码的项目中相遇时,一场“构建工程化的风暴”不可避免。

本文不谈虚的,直接上干货。

难点一:Vite 与 Tsup 的“双轨制”构建困局

Coco AI 不仅是一个 Web 应用,还包含一个对外提供的 SDK。这就导致我们有两套构建流程:

  • Web App: 使用 Vite (Rollup)。
  • Web SDK: 使用 Tsup (Esbuild)。

Tailwind v4 推荐使用 @tailwindcss/vite 插件,这在 Web App 中运行良好。但在 SDK 构建中,Esbuild 并不支持该插件。

解决方案:混合编译策略

我们被迫采用了一套“混合”方案:Web 端享受 v4 的插件红利,SDK 端则回退到 PostCSS 处理。

1. Web 端 (Vite)

一切从简,使用官方插件。

// vite.config.ts
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  // 这里的 tailwindcss() 会自动扫描文件,性能极快
  plugins: [tailwindcss() as any, react()],
});

2. SDK 端 (Tsup/PostCSS)

这是最坑的地方。Tsup 基于 Esbuild,而 Esbuild 默认无法解析 v4 的 @import "tailwindcss";。我们需要手动配置 PostCSS 管道。

首先,配置 postcss.config.js,显式使用 v4 的 PostCSS 插件:

// postcss.config.js
export default {
  plugins: {
    // ⚠️ 注意:Tailwind v4 的 PostCSS 插件包名变了
    '@tailwindcss/postcss': {}, 
    autoprefixer: {},
  },
}

然后,在 tsup.config.ts 中施展“魔法”:

// tsup.config.ts
export default defineConfig({
  esbuildOptions(options) {
    // 🔥 关键黑魔法:启用 'style' 条件,让 esbuild 能找到 tailwindcss 的入口
    (options as any).conditions = ["style", "browser", "module", "default"];
  },
  async onSuccess() {
    // 构建后手动运行 PostCSS,处理 CSS 文件中的 @import "tailwindcss"
    // ...代码略,见源码...
  }
});

难点二:JS 配置与 CSS 配置的“博弈”

Tailwind v4 推崇 CSS-first,即把配置都写在 CSS 的 @theme 块中。但 shadcn/ui 强依赖 tailwindcss-animate 插件,且我们有大量复杂的自定义动画(如打字机效果、震动效果)写在 tailwind.config.js 中。

如果完全迁移到 CSS,工作量巨大且易出错。

解决方案:JS 与 CSS 共存

我们保留了 tailwind.config.js,主要用于存放插件复杂动画,而将颜色变量迁移到 CSS 中。

保留的 tailwind.config.js (部分)

import animate from "tailwindcss-animate";

export default {
  // v4 会自动检测并合并这个配置
  theme: {
    extend: {
      // 复杂的 Keyframes 还是写在这里比较清晰
      animation: {
        typing: "typing 1.5s ease-in-out infinite",
        shake: "shake 0.5s ease-in-out",
      },
      keyframes: {
        typing: {
          "0%": { opacity: "0.3" },
          "50%": { opacity: "1" },
          "100%": { opacity: "0.3" },
        },
        // ...
      },
      // 映射 border-radius 到 CSS 变量,适配 shadcn
      borderRadius: {
        lg: "var(--radius)",
        md: "calc(var(--radius) - 2px)",
        sm: "calc(var(--radius) - 4px)",
      },
    },
  },
  plugins: [animate], // shadcn 必需的插件
};

新的 src/main.css (v4 风格)

@import "tailwindcss";

/* ⚠️ 必坑点:显式指定扫描源,否则可能漏掉 HTML 或特定目录 */
@source "../index.html";
@source "./**/*.{ts,tsx}";

@theme {
  /* 在 CSS 中通过变量映射颜色,不仅支持 shadcn,还能兼容旧代码 */
  --color-background: var(--background);
  --color-primary: var(--primary);
  /* ... */
}

难点三:颜色空间与暗色模式的“大一统”

Coco AI 的旧代码使用 RGB 值(如 rgb(149, 5, 153)),而 shadcn 使用 HSL(如 222.2 84% 4.9%),Tailwind v4 默认又倾向 OKLCH。

解决方案:变量映射层

我们在 main.css 中建立了一个“中间层”,让新老变量和谐共存。

:root {
  /* === Shadcn 系统 (HSL) === */
  --primary: 221.2 83.2% 53.3%;
  
  /* === Coco Legacy 系统 (RGB) === */
  /* 即使是旧变量,也可以根据需要调整,或者直接硬编码保留 */
  --coco-primary-color: rgb(149, 5, 153);
}

/* ⚠️ v4 暗色模式新语法:废弃了 darkMode: 'class' */
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));

.dark.coco-container,
[data-theme="dark"] {
  /* 重新定义 HSL 值实现暗色模式 */
  --background: 222.2 84% 4.9%;
  
  /* 同时覆盖旧系统的变量 */
  --coco-primary-color: rgb(149, 5, 153);
}

难点四:Web SDK 的 CSS 变量兼容性黑科技

在开发 Web SDK 时,我们遇到一个隐蔽的问题:CSS 变量的初始值丢失

Tailwind v4 会生成大量的 CSS Houdini @property 规则来定义变量的类型和初始值:

@property --tw-translate-x {
  syntax: "*";
  inherits: false;
  initial-value: 0;
}

这在现代浏览器中运行完美。但由于我们的 SDK 会被嵌入到各种宿主环境中,部分环境可能不支持 @property,导致变量因为没有显式的赋值而失效(initial-value 被忽略)。

解决方案:构建后脚本补全 (Post-build Script)

为了保证“即插即用”的稳定性,我们编写了一个专门的构建后处理脚本 scripts/buildWebAfter.ts

它的作用是:扫描生成的 CSS,提取所有 @propertyinitial-value,并将它们显式注入到 .coco-container 作用域中。

// scripts/buildWebAfter.ts (精简版)
const extractCssVars = () => {
  const cssContent = readFileSync(filePath, "utf-8");
  const vars: Record<string, string> = {};
  
  // 正则提取所有 @property 的 initial-value
  const propertyBlockRegex = /@property\s+(--[\w-]+)\s*\{([\s\S]*?)\}/g;
  while ((match = propertyBlockRegex.exec(cssContent))) {
    const initialValueMatch = /initial-value\s*:\s*([^;]+);/.exec(match[2]);
    if (initialValueMatch) {
      vars[match[1]] = initialValueMatch[1].trim();
    }
  }

  // 生成标准的 CSS 变量赋值块
  const cssVarsBlock =
    `.coco-container {\n` +
    Object.entries(vars)
      .map(([k, v]) => `  ${k}: ${v};`) // 显式赋值:--var: value;
      .join("\n") +
    `\n}\n`;

  writeFileSync(filePath, `${cssContent}\n${cssVarsBlock}`, "utf-8");
};

效果:即使浏览器不支持 @property,变量也能通过标准的 CSS 级联机制获得正确的初始值,确保 SDK 在任何环境下样式都不崩坏。

避坑清单 (Checklist)

在迁移过程中,我们踩了无数坑,以下是血泪总结:

  1. 样式莫名丢失?

    • 原因:Tailwind v4 的自动扫描可能没覆盖到你的文件结构。
    • 解法:使用 @source 指令显式添加路径,如 @source "./**/*.{ts,tsx}";
  2. VS Code 满屏报错?

    • 原因:VS Code 的 Tailwind 插件版本过低,不认识 @theme@source 等新指令。
    • 解法:升级插件到最新版,并确保设置中关联了正确的文件类型。
  3. 构建时报错 Cannot find module

    • 原因postcss.config.js 中引用了不存在的插件。
    • 解法:确认安装了 @tailwindcss/postcss 并在配置中正确引用(注意包名变化)。
  4. 动画不生效?

    • 原因tailwind.config.js 未被 Vite 插件读取。
    • 解法:在使用 @tailwindcss/vite 时,它通常会自动检测根目录下的配置文件。如果位置特殊,需手动指定。

小结

技术债是还不完的,但每一次还债都是一次成长的机会。

通过这次适配,Coco AI 不仅拥有了更现代化的 UI 架构,也为未来的跨平台(Web/Desktop/Mobile)统一体验打下了基础。特别是 Tailwind CSS v4.0 的引入,虽然初期配置略显折腾,但其带来的构建速度提升和开发体验优化,绝对是“真香”定律的又一次验证。

如果你也想体验一下这个“整容”后的全能生产力工具,欢迎来我们的 GitHub 看看:

WEB端小屏切换纯CSS实现

背景

最近开发了一个内部问答平台(deepseek套层皮🐷),前端实现很简单,用户输入一个问题,跟后端建立一个SSE连接,后端将结果流式输出,前端流式渲染展示。
突然有一天老板想搞点事:小明,页面这个流式内容能不能支持缩小到页面右下角,让用户更加聚焦输入框内容?
WTF,只见过全屏展示的,还没见过小屏展示的。
好像也不是,暂停播放的时候,各大视频网站就喜欢小屏放内容,让你更加聚焦于弹窗广告👻。
没有办法,在AI的冲击下前端已经死了至少七次了,可不敢惹恼boss,不然就等不到被AI的第八次kill,已经被老板一次KO了🫡。
秉着负(牛)责(马)的态度,帮助boss梳理交互细节~
希望缩小后流式内容还在动态输出...
希望缩小后右侧区域自动扩张到全部区域,然后小窗“悬浮”在右下角...
希望可以丝滑地还原...
明确了需求,直接开干。

方案思路

希望缩小后流式内容还在动态输出...

把当前内容区的容器整个缩小不就行了transform: scale(0.x),也就是从视觉上缩小流式内容区,其他渲染逻辑完全不动,SSE流的解析和渲染还是在内容区组件里完成,太easy了🥰

希望缩小后右侧区域自动扩张到全部区域,然后小窗“悬浮”在右下角...

啧啧,也好实现,在内容区多包一层div,让这个div脱离文档流position:absolute,再设置一下相对位置就可以悬浮到指定位置啦。

希望可以丝滑地还原...

CSS加上过渡效果,transition: transform 0.3s ease简单需求简单做嘛,哈哈哈

技术实现

容器结构

分为两层容器,父容器负责定位,实际缩小内容放置在 .right-panel-content__container 中:

Clipboard_Screenshot_1765869186.png 为了拥有过渡效果,通过setTimeout制造时间差,让过渡动画完成后才让父容器.right-panel-content浮动到右下角为止。看下实现效果:

Kapture 2025-12-16 at 12.01.08.gif

唉,唉!唉?缩小的内容怎么不见了?
F12打开调试面板看下DOM结构:

Clipboard_Screenshot_1765869271.png

DOM结构正常啊,内容都在正确位置,但是展示怎么就不对呢?
继续捣鼓CSS...猛然间看到div.right-panel-content.min-mode 的大小,突然感到一丝诡异,按理说待缩放区域设置的缩小比例为scale(0.2),计算下来容器的宽高至少应该是:90x158。怎么会是64x46呢?

Clipboard_Screenshot_1765867173.png 继续分析DOM结构及大小,发现.right-panel-content__header的大小是:46x28,加上div.right-panel-content.min-mode自己的padding: 8px,好家伙,刚好凑成62x44(=64x46- 1px border😭)。
感情.right-panel-content__container被缩小得根本不占空间了嘛~

问题分析

为什么会这样呢?因为被.right-panel-content__container被二次缩小了,原因在于container的父容器为了定位而做出的自身宽高调整。下面分步骤还原一下内容缩放过程:

设置以下别名:
.right-panel-content__container => content__container
.right-panel-content__header => content__header
.right-panel-content => panel-content

  1. 初始时宽高正常,content__container缩放后达到了目标大小90x158;
  2. 然后panel-content进入min-mode,宽高被强制设置为auto !important,因为内容区坍塌,panel-content被迫坍塌到跟内容一样的高度90x158;
  3. panel-content高度变化激活了content__container的 scale(0.2),content__container继续缩小;
  4. content__container的缩小又引发panel-content的高度坍塌...直到 content__header "独自"撑起panel-content的内容。

一切都解释清楚了,核心问题变成了怎么解决循环坍塌。

解决方案

既然问题父子容器间高度变化引发的循环坍塌,那可以不可以在中间加一层,阻断这种循环效果呢?
而且要阻断循环,那增加的一层应该脱离文档流,让其大小不受父容器和其子容器干扰。 新的DOM结果如下:

小屏DOM结构图.png 为了让操作栏样式更合理,根据minMode状态对 ContentHeader 进行动态渲染:

<div className="right-panel-middle-wrapper">
  {minMode && <ContentHeader minMode={minMode} setMinMode={setMinMode} />}
  <div className={`right-panel-content__container ${containerClassname}`}>
    {!minMode && <ContentHeader minMode={minMode} setMinMode={setMinMode} />}
    <ContentSection />
  </div>
</div>;

完整实现

import { useEffect, useRef, useState } from "react";
import { Layout, Button } from "@arco-design/web-react";
import { IconExpand, IconShrink } from "@arco-design/web-react/icon";

import "./App.less";

const Sider = Layout.Sider;
const Header = Layout.Header;
const Footer = Layout.Footer;
const Content = Layout.Content;

function App() {
  const [minMode, setMinMode] = useState(false);
  const [panelClassname, setPanelClassname] = useState("");
  const [containerClassname, setContainerClassname] = useState("");
  const classnameTimer = useRef<number>(0);

  useEffect(() => {
    if (minMode) {
      // 立即启动缩小动效
      setContainerClassname("min-mode-transition");

      // 动效结束后,再设置 min-mode 类名
      if (classnameTimer.current) {
        clearTimeout(classnameTimer.current);
      }
      // 动画约350ms,需要等动画结束后再设置min-mode
      classnameTimer.current = setTimeout(() => {
        setPanelClassname("min-mode");
      }, 350);
    } else {
      setPanelClassname("");
      setContainerClassname("");
    }
    return () => {
      if (classnameTimer.current) {
        clearTimeout(classnameTimer.current);
      }
    };
  }, [minMode]);

  return (
    <div className="app-layout">
      <Layout>
        <Header>Header</Header>
        <Layout>
          <Sider>Sider</Sider>
          <Content>
            <div className="left-panel-content">左侧内容栏</div>
            <div className={`right-panel-content ${panelClassname}`}>
              <div className="right-panel-middle-wrapper">
                {minMode && (
                  <ContentHeader minMode={minMode} setMinMode={setMinMode} />
                )}
                <div
                  className={`right-panel-content__container ${containerClassname}`}
                >
                  {!minMode && (
                    <ContentHeader minMode={minMode} setMinMode={setMinMode} />
                  )}
                  <ContentSection />
                </div>
              </div>
            </div>
          </Content>
        </Layout>
        <Footer>Footer</Footer>
      </Layout>
    </div>
  );
}

function ContentHeader({
  minMode,
  setMinMode,
}: {
  minMode: boolean;
  setMinMode: (minMode: boolean) => void;
}) {
  return (
    <div className="right-panel-content__header">
      {minMode ? (
        <Button type="text" size="small" onClick={() => setMinMode(false)}>
          <IconExpand style={{ color: "var(--color-border-4)" }} />
        </Button>
      ) : (
        <Button type="text" size="small" onClick={() => setMinMode(true)}>
          <IconShrink style={{ color: "var(--color-border-4)" }} />
        </Button>
      )}
    </div>
  );
}

function ContentSection() {
  return (
    <div className={`right-panel-content__section`}>
      <div className="right-panel-content__section-title">
        张若虚《春江花月夜》
      </div>
      春江潮水连海平,海上明月共潮生。 <br />
      滟滟随波千万里,何处春江无月明。
      <br />
      江流宛转绕芳甸,月照花林皆似霰。
      <br /> 空里流霜不觉飞,汀上白沙看不见。
      <br />
      江天一色无纤尘,皎皎空中孤月轮。
      <br /> 江畔何人初见月?江月何年初照人?
      <br />
      人生代代无穷已,江月年年望相似。
      <br />
      不知江月待何人,但见长江送流水。
      <br />
      白云一片去悠悠,青枫浦上不胜愁。
      <br />
      谁家今夜扁舟子?何处相思明月楼?
      <br />
      可怜楼上月裴回,应照离人妆镜台。
      <br />
      玉户帘中卷不去,捣衣砧上拂还来。
      <br /> 此时相望不相闻,愿逐月华流照君。
      <br />
      鸿雁长飞光不度,鱼龙潜跃水成文。
      <br /> 昨夜闲潭梦落花,可怜春半不还家。
      <br />
      江水流春去欲尽,江潭落月复西斜。
      <br /> 斜月沉沉藏海雾,碣石潇湘无限路。
      <br />
      不知乘月几人归,落月摇情满江树。
    </div>
  );
}

export default App;

样式文件:

.app-layout {
  height: 100%;
  width: 100%;

  .arco-layout {
    height: 100%;
  }

  .arco-layout-header,
  .arco-layout-footer,
  .arco-layout-sider,
  .arco-layout-sider-children,
  .arco-layout-content {
    color: var(--color-white);
    text-align: center;
    font-stretch: condensed;
    font-size: 16px;
    display: flex;
    flex-direction: column;
    justify-content: center;
  }

  .arco-layout-content {
    flex-direction: row;
  }

  .arco-layout-header,
  .arco-layout-footer {
    height: 64px;
    background-color: var(--color-primary-light-4);
  }

  .arco-layout-sider {
    width: 206px;
    background-color: var(--color-primary-light-3);
  }

  .arco-layout-content {
    background-color: rgb(var(--arcoblue-6));

    .left-panel-content {
      flex: auto;
    }

    .right-panel-content {
      background-color: var(--color-primary-light-3);
      height: 100%;
      flex: 1;

      .right-panel-middle-wrapper {
        width: 100%;
        height: 100%;
      }

      .right-panel-content__container {
        width: 100%;
        height: 100%;

        .right-panel-content__header {
          height: 40px;
          line-height: 40px;
          background-color: var(--color-warning-light-1);
          text-align: right;
          padding: 0 10px;
        }

        .right-panel-content__section {
          width: 100%;
          height: calc(100% - 40px);
          color: #000;

          .right-panel-content__section-title {
            font-size: 20px;
            padding: 10px 0;
          }
        }
      }

      .min-mode-transition {
        position: absolute;
        z-index: 1999;
        width: 442px;
        height: 794px;
        transform: scale(0.2);
        transform-origin: right bottom;
        transition: transform 0.3s ease;
      }

      &.min-mode {
        position: absolute;
        z-index: 499;
        bottom: 25px;
        right: 25px;
        border-radius: 6px;
        padding: 8px;
        width: auto !important;
        height: auto !important;
        display: flex;
        flex-direction: column;
        align-items: center;
        transition: none;
        border: 1px solid var(--color-border);
        box-sizing: border-box;

        .right-panel-middle-wrapper {
          width: 90px;
          height: 158px;
          overflow: hidden;
          position: relative;

          .right-panel-content__header {
            height: fit-content;
            line-height: unset;
            background-color: unset;
            padding: 0;
          }

          .right-panel-content__container {
            transform-origin: top left;
          }
        }
      }
    }
  }
}

最终效果

Kapture 2025-12-16 at 16.05.22.gif

小结

文中的小窗效果虽然谈不上尽善尽美,但胜在简单,只需要调整css样式就可以实现。在实现中需要注意的点便是父容器与待缩放内容间的循环依赖问题。本文通过增加一个wrapper层达到依赖解耦的目的。方案有点不够优雅,如果有哪位大佬知道更好的方案,欢迎在评论区给出,让笔者也学习学习🖖

深入理解 CSS 伪类和伪元素的本质区别

在写前端CSS样式的时候,经常用到这种:hover或者::before有冒号的写法。我很疑惑,为什么有些是单冒号,有些又是双冒号呢?

后来我才知道这种写法也区分为伪类伪元素

伪类和伪元素是什么?

伪类(Pseudo-classes):用于选择处于特定状态的元素。也可以理解为,当元素处于某种状态的时候,给它加上一个“类”来定义样式。

  • 语法:单个冒号:,例如:hover

伪元素(Pseudo-elements):用于选择元素的特定部分。可以理解为,它在文档中创建了一个虚拟的“元素”来设置样式。

  • 语法:双冒号::,例如::before

只要是单个冒号的就一定是伪元素吗?

在现代CSS3点规范中,所有使用双冒号::语法的选择器都被定义为伪元素。这是W3C为了明确区分伪类和伪元素而引入的约定。

但在早期的CSS中,伪元素也使用单冒号,因为当时没有区分语法。所以为了向后兼容,大多数的浏览器还是会支持:before:after等单冒号的写法。

但新的伪元素只支持双冒号,比如:::selection


伪类(Pseudo-classes)

伪类用于选择处于特定状态的元素,比如用户交互状态、结构位置等。

常见伪类示例:

  • :hover:鼠标悬停时
  • :focus:元素获得焦点时(如输入框)
  • :active:元素被激活时(如点击按钮)
  • :visited:链接已被访问过
  • :first-child / :last-child:第一个/最后一个子元素
  • :nth-child(n):第 n 个子元素
  • :not(selector):排除匹配 selector 的元素

示例:

a:hover {
  color: red;
}

input:focus {
  border: 2px solid blue;
}

li:first-child {
  font-weight: bold;
}

伪类使用单冒号


伪元素(Pseudo-elements)

伪元素用于创建并样式化文档中不存在的虚拟元素,比如段落首字母、选中文本、元素前后插入内容等。

常见伪元素:

  • ::before:在元素内容前插入内容
  • ::after:在元素内容后插入内容
  • ::first-letter:段落的第一个字母
  • ::first-line:段落的第一行
  • ::selection:用户选中的文本(部分浏览器需加前缀)

示例:

p::first-letter {
  font-size: 2em;
  color: gold;
}

.quote::before {
  content: "“";
}

.quote::after {
  content: "”";
}

::selection {
  background: yellow;
  color: black;
}

伪元素使用双冒号::before),这是CSS3的规范写法。出于兼容性考虑,旧代码可能仍用单冒号(如:before),现代项目建议用双冒号。


主要区别

特性 伪类(Pseudo-class) 伪元素(Pseudo-element)
作用对象 已存在的元素的状态或位置 创建不存在的虚拟内容或部分
语法 单冒号(如 :hover 双冒号(如 ::before
是否生成内容 是(常配合 content 属性)
示例 a:hover, :nth-child(2) ::first-letter, ::after

示例效果

一个简单的待办事项列表 在这里插入图片描述

完整代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>伪类vs伪元素示例</title>
    <style>
        .container {
            max-width: 500px;
            margin: 0 auto;
            background: white;
            padding: 30px;
            border-radius: 15px;
            box-shadow: 0 10px 30px rgba(0,0,0,0.2);
        }

        h1 {
            text-align: center;
            color: #2c3e50;
            margin-bottom: 30px;
        }

        .explanation {
            background: #f8f9fa;
            padding: 15px;
            border-radius: 8px;
            margin-bottom: 20px;
            border-left: 4px solid #3498db;
        }

        .todo-list {
            list-style: none;
            padding: 0;
        }

        .todo-list li {
            padding: 15px;
            margin: 8px 0;
            background: white;
            border: 2px solid #e9ecef;
            border-radius: 8px;
            cursor: pointer;
            transition: all 0.3s ease;
            position: relative;
        }

        /* === 伪类样式 === */
        /* 第一个子元素 */
        .todo-list li:first-child {
            border-left: 4px solid #e74c3c;
        }

        /* 偶数项 */
        .todo-list li:nth-child(even) {
            background-color: #f8f9fa;
        }

        /* 悬停效果 */
        .todo-list li:hover {
            background-color: #aab49b;
            color: white;
            transform: translateX(10px);
            border-color: #aab49b;
        }

        /* 点击效果 */
        .todo-list li:active {
            background-color: #2ecc71;
            transform: scale(0.98);
        }

        /* === 伪元素样式 === */
        /* 前面的图标 */
        .todo-list li::before {
            content: "📌";
            margin-right: 10px;
            transition: all 0.3s ease;
        }

        /* 悬停时图标变化 */
        .todo-list li:hover::before {
            content: "🔥";
            transform: scale(1.2);
        }

        /* 后面的装饰线 */
        .todo-list li::after {
            content: "";
            position: absolute;
            left: 0;
            bottom: 0;
            width: 0;
            height: 3px;
            background: linear-gradient(90deg, #e74c3c, #669521);
            transition: width 0.3s ease;
        }

        .todo-list li:hover::after {
            width: 100%;
        }

        /* 首字母样式 */
        .todo-list li::first-letter {
            font-size: 1.3em;
            color: #2c3e50;
            font-weight: bold;
        }

        .todo-list li:hover::first-letter {
            color: white;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>伪类 vs 伪元素 演示</h1>
        
        <div class="explanation">
            <p><strong>伪类(Pseudo-class)</strong>:选择元素的特定<strong>状态</strong></p>
            <p><strong>伪元素(Pseudo-element)</strong>:选择元素的特定<strong>部分</strong></p>
        </div>

        <ul class="todo-list">
            <li>学习 CSS 伪类</li>
            <li>理解伪元素</li>
            <li>完成项目练习</li>
            <li>复习知识点</li>
        </ul>
    </div>

    <script>
        // 添加点击切换完成状态的功能
        document.querySelectorAll('.todo-list li').forEach(item => {
            item.addEventListener('click', function() {
                this.classList.toggle('completed');
            });
        });
    </script>
</body>
</html>

在这个示例中,可以清晰地看到:

伪类(操作的是整个元素)

  • :first-child:操作第一个<li>元素的整体样式
  • :nth-child(even):操作偶数位置<li>的整体背景
  • :hover:操作鼠标悬停时<li>的整体状态变化

伪元素(操作的是元素的一部分)

  • ::before:在<li>内容之前插入新内容(图标)
  • ::after:在<li>内容之后插入装饰线条
  • ::first-letter:只样式化<li>文本的第一个字母

简单记忆:伪类是状态选择器,伪元素是内容生成器。


总结

伪类

  • 选择元素的特定状态(如:hover、:focus)
  • 语法使用单冒号(如:hover
  • 不生成新内容,只针对元素本身的状态变化
  • 常见用途:用户交互反馈、结构位置选择

伪元素

  • 创建并样式化元素的特定部分虚拟内容
  • 语法使用双冒号(如::before
  • 常配合content属性生成新内容
  • 常见用途:插入装饰元素、样式化文本部分

通过上面的内容,我也终于搞懂了伪类和伪元素的区别。

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《async/await 到底要不要加 try-catch?异步错误处理最佳实践》

《Vue3 和 Vue2 的核心区别?很多开发者都没完全搞懂的 10 个细节》

《Java 开发必看:什么时候用 for,什么时候用 Stream?》

《这 10 个 MySQL 高级用法,让你的代码又快又好看》

❌