前置知识: HTML5、CSS

自定义插件机制

11 min入门

Tailwind CSS 自定义插件:plugin() 与 @plugin 加载、addComponents/addUtilities/matchUtilities 批量注册、addVariant/matchVariant 自定义变体、theme() 令牌联动,附 btn-primary、data-active 变体与 ticket-tier 实战

知识点地图

  • 知识类别:Tailwind 插件 API——用 plugin() 在构建期向引擎注册组件类、工具类与自定义变体。
  • 解决什么问题:官方插件(forms/typography,见官方插件:forms 与 typography)管不到的平台特有”批处理”需求——团队统一的组件类、按令牌批量生成的效果类、只有本平台才有的状态变体。
  • 什么时候用到:同一个组件类出现在三处以上、同一个状态前缀在每个使用点重复手写、设计系统要把”延伸件”挂在主题令牌上的时候。
  • 与 @utility 的分界:单个工具类用 CSS-first 的 @utility(见变体引擎与交互状态与 050 篇),一组类或需要 JS 逻辑的批量生成用插件。

前置知识

学习目标

  1. 能用 plugin() 编写插件,说清四个注册函数(addComponents/addUtilities+matchUtilities/addBase/addVariant)各对应哪类产物。
  2. 能用 matchUtilities 按主题令牌批量生成工具类,实现”令牌加一个色,类就多一组”。
  3. 能用 addVariant/matchVariant 注册自定义变体,把 data-[active]: 这类高频任意变体升为一等公民。
  4. 能判断一个需求该用插件还是 @utility,并说出插件 API 面向 JS 配置、@utility 面向 CSS-first 的分界。

0. 从”逐处手写”到”注册一次”

工具类的哲学是”每个使用点自己组合”,但同一组合重复到第三次,就该问一句:能不能注册一次、全站可用?这就是插件的角色:官方插件把”表单调校""长文排版”这类通用批处理做成了包;而当批处理是你平台特有的——粉丝团徽章、票档卡片、幻灯片的激活态——就该自己写插件。

心智模型:插件是一个构建期运行的 JS 函数,它拿着引擎递来的”注册工具箱”(addComponents、matchUtilities……)往样式表里塞规则。它不是运行时代码——不响应点击、不发请求,只负责”生成 CSS”。

1. plugin() 注册与 @plugin 加载

javascript
// plugins/fan-badge.js:自定义插件——注册粉丝团徽章组件类
import plugin from 'tailwindcss/plugin'

export const fanBadge = plugin(
  ({ addComponents, theme }) => {
    addComponents({
      '.fan-badge': {
        display: 'inline-flex',
        alignItems: 'center',
        borderRadius: theme('radius.sm'),    // 直角小圆角,遵守平台规范
        paddingInline: theme('spacing.2'),
        backgroundColor: theme('colors.primary'),
        color: '#ffffff',
        fontWeight: theme('fontWeight.bold'),
      },
    })
  },
  { darkMode: 'class' }, // 插件元信息:声明变体与暗色模式的选择器约定
)
css
/* global.css:加载自定义插件 */
@plugin "./plugins/fan-badge.js";
html
<!-- 使用:一个类名得到完整徽章样式 -->
<span class="fan-badge">粉丝团编号 3939</span>

逐段读这段插件:plugin(callback, options) 的回调参数是引擎递来的注册工具箱,解构出要用哪几个就写哪几个;addComponents 的入参是一个”类名到样式对象”的字典,键是完整类名(带点),值是驼峰命名的 CSS 属性(alignItems 而不是 align-items)——因为这一层是 JS 对象不是 CSS;theme('colors.primary') 读当前令牌值,@theme 里改了它这里自动跟上。第二个参数 options 目前主要用于声明 darkMode 约定,让插件里的 dark: 变体按团队的选择器策略生成。

什么时候用插件、什么时候用 Tailwind 4 原生的 @utility?简单判断:单个工具类用 @utility,一组带默认样式的类或需要批量生成的东西用插件。.fan-badge 这种”一个类名背后一整套样式 + 引用主题令牌”的组件类,插件是正确载体;而 .tabular-nums 这类单点工具类,@utility 更轻。再补一条技术性分界:@utility 写在 CSS 里、面向”CSS-first”的工作流;插件 API 是 JS 函数,面向需要循环、条件、读文件的编程式生成——需要”跑代码”才能造出来的类,只有插件能干。

插件的开发体验也有讲究:Tailwind 4 的 dev server 会监听 @plugin 引用的文件,改插件代码即自动重建,浏览器直接看效果;发布前用一份最小 demo 页面过一遍注册的所有类,确认没有”注册了但拼错类名”的死代码。插件注册是静默的,类名打错不会报错——demo 页面就是你的冒烟测试。

2. theme() 与 matchUtilities:令牌是那根线

插件与主题联动的关键就是 theme():它读取的是当前主题令牌的值,所以令牌一改,插件注册的所有类同步更新。更进一步,matchUtilities 可以按令牌批量生成工具类——主题里有多少个值,就生成多少个变体:

javascript
// plugins/glow.js:按主题色板批量生成"应援光晕"工具类
import plugin from 'tailwindcss/plugin'

export const glow = plugin(({ matchUtilities, theme }) => {
  matchUtilities(
    {
      // 值来自色板:colors 里的每个颜色都会生成一个 glow-text-* 类
      'glow-text': (value) => ({
        textShadow: `0 0 12px ${value}, 0 0 2px ${value}`,
      }),
    },
    { values: theme('colors') },
  )
})
html
<!-- 主题里有 primary / danger 等颜色,这里就有 glow-text-primary 等类 -->
<h2 class="glow-text-primary">魔法未来 2026 开票中</h2>
<p class="glow-text-danger">剩余 3 张,先到先得</p>

这层联动回答了一个架构问题:设计系统的”延伸件”(徽章、光晕、批注样式)应该挂在哪? 答案是挂在插件里、引用主题令牌。主题负责定义”平台有哪些设计决策”,插件负责把决策”批量实例化”成可用的类;新应援色加进色板的那一刻,glow-text-新色、.fan-badge 的选中态、表单焦点环全部就位,不需要任何人手动同步。

matchUtilities 的 values 不限于 colors——spacing、radius、fontWeight 都可以作为取值源。平台里”应援光晕”用色板、“卡片抬升”用阴影令牌、“徽章圆角”用 radius 令牌,全部走同一套”令牌到工具类”的批量通道。插件写多了会发现,它们其实是同一个模式的重复应用。

3. addVariant 与 matchVariant:状态维度升为一等公民

插件 API 里还有一个常被忽略的注册函数:把”只有本平台才有的状态”注册成变体,让它在任何工具类前像 hover: 一样可用。

3.1 addVariant:注册固定变体

115 篇的幻灯片项目里,slide 激活态的写法是 data-[active]:opacity-100 这样的任意属性变体——能用,但每个使用点都重复一遍 data-[active]:。用 addVariant 把它升为一等变体:

javascript
// plugins/slide-variant.js:把幻灯片激活态注册为 active-slide: 变体
import plugin from 'tailwindcss/plugin'

export const slideVariant = plugin(({ addVariant }) => {
  addVariant('active-slide', '&[data-active]')
})
html
<!-- 注册前:每个使用点手写任意属性变体 -->
<div class="pointer-events-none absolute inset-0 opacity-0
            data-[active]:pointer-events-auto data-[active]:opacity-100 data-[active]:scale-100">

<!-- 注册后:语义化前缀,读起来就是"激活时可见" -->
<div class="pointer-events-none absolute inset-0 opacity-0
            active-slide:pointer-events-auto active-slide:opacity-100 active-slide:scale-100">

两种写法生成的 CSS 完全一样,差别在可读性与可维护性:active-slide: 说出的是业务语义(幻灯片激活态),data-[active]: 说出的是实现细节(某个属性存在);将来幻灯片换实现(比如改成 class 切换),改插件一行,全部使用点跟着变——这正是”注册一次”对”逐处手写”的胜利。

3.2 matchVariant:按参数生成变体族

变体还可能带参数——比如”第 N 张幻灯片之前隐藏”或”主题为某套配色时”。matchVariant 让变体族按值生成:

javascript
// plugins/step-variant.js:注册 step-* 变体族,用模板字符串拼选择器
import plugin from 'tailwindcss/plugin'

export const stepVariant = plugin(({ matchVariant }) => {
  matchVariant('step', (value) => {
    const n = Number(value)
    return `&:not([data-step='${n + 1}'])`
  })
})
html
<!-- fragment 逐条入场:data-step 记录出场序号,step-2:hidden 让第 3 步之前的都藏着 -->
<p class="step-0:hidden step-1:hidden" data-step="1">第一点:HTML 是骨架</p>
<p class="step-2:hidden" data-step="2">第二点:CSS 是皮肤</p>

讲解:matchVariant(name, selectorFn) 的回调拿到变体携带的值(step-2 里的 “2”),返回一个选择器模板;之后 step-任意值: 都可用。它与 115 篇 Reveal.js 的 fragment fade-up 形成对照——同样的”逐条入场”,一个引第三方库、一个用两行插件 API 自己造,这正是插件 API 的定位:把平台的状态语义沉淀为类系统的一部分。

3.3 团队规范的批处理:统一的禁用态

约定”全站禁用按钮一律半透明 + 禁止点击光标”,散在每颗按钮上写 disabled:opacity-50 disabled:cursor-not-allowed 迟早有人漏。插件把它收拢:

javascript
// plugins/team-btn.js:团队按钮组件类,禁用态规则只写一次
import plugin from 'tailwindcss/plugin'

export const teamBtn = plugin(({ addComponents, theme }) => {
  addComponents({
    '.btn-primary': {
      backgroundColor: theme('colors.primary'),
      color: '#ffffff',
      borderRadius: theme('radius.md'),
      paddingInline: theme('spacing.4'),
      paddingBlock: theme('spacing.2'),
      '&:disabled': {
        opacity: '0.5',
        cursor: 'not-allowed',
      },
    },
  })
})
html
<button class="btn-primary" disabled>不可点时自动半透明</button>
<button class="btn-primary hover:bg-primary-hover">悬停加深仍是工具类的领地</button>

边界在这里:.btn-primary 收拢的是批处理默认值(颜色、圆角、禁用规则),而 hover:bg-primary-hover 这类状态微调仍然留在使用点的工具类里——插件给”全站必须一致”的底,工具类管”逐处可以不同”的面,两层职责不要互相越界。

4. 插件契约:让联动关系可检索

落地时给每个插件写一段说明注释能省掉未来的很多解释:声明它消费哪些令牌、注册哪些类前缀、与哪些插件有覆盖关系。这份”插件契约”随插件数量增长会变成设计系统的目录页——新人接手时先读契约再读实现,改令牌前先搜契约里的消费方,联动关系就从”口口相传”变成了”可检索的事实”。

javascript
/**
 * fan-badge 插件契约:
 * - 消费令牌:colors.primary、radius.sm、spacing.2、fontWeight.bold
 * - 注册类:.fan-badge(组件类)
 * - 覆盖关系:在 forms 之后加载,允许覆盖其表单外样式
 */

易错点与最佳实践

  1. 插件里硬编码颜色与间距。写死 backgroundColor: '#39C5BB' 后,主题换色插件纹丝不动,成为”设计系统里的钉子户”。修正:一律 theme('colors.primary') 引用令牌,让插件成为令牌的消费者。

  2. 把交互逻辑塞进插件。插件只在构建期运行,注册的是静态样式,不能响应点击或请求。修正:需要交互的是组件层(JS + data 属性驱动,见 115 篇幻灯片项目),插件只提供样式底座。

  3. 该用 @utility 的场景写了插件。为一个 .tabular-nums 单点类建一个插件文件,是杀鸡用牛刀,还把简单样式拖进了 JS 构建链。修正:单点工具类回 @utility(CSS-first,见 050 篇第 6 节);插件留给”一组类、批量生成、需要编程逻辑”的真批处理。

  4. 驼峰与连字符写混。addComponents 的样式对象里写 'background-color': ... 不报错但偏离惯例,写错驼峰(alighItems)则静默失效。修正:样式对象统一驼峰;demo 页面冒烟测试兜底。

  5. 类名注册了却”不存在”。glow-text-primary 写上去没效果,多数是 values 没含该色名或插件忘了在 CSS 里 @plugin。修正:按”加载了吗、values 传了吗、类名拼对了吗”三步排查。

本篇小结

  1. 插件是构建期运行的 JS 函数,用 @plugin 在 CSS 里加载;addComponents/addUtilities+matchUtilities/addBase/addVariant 四个注册函数对应四类产物。
  2. theme() 读令牌、matchUtilities 按令牌批量造类——“令牌加一个色,类就多一组”的联动靠它实现。
  3. addVariant 把高频任意变体(如幻灯片 data-[active]:)升为语义化一等变体,matchVariant 再按参数生成变体族。
  4. 单个工具类用 @utility(CSS-first),一组类与编程式批处理用插件(JS API)——分界看”要不要跑代码”。
  5. 给每个插件写”消费令牌 + 注册前缀 + 覆盖关系”的契约注释,让设计系统的延伸件可检索。

动手实践

任务一:写一个票档插件。 实现 ticket-tier 插件:用 addComponents 注册 .ticket-tier 卡片类(引用主题令牌),再用 matchUtilities 按色板生成 tier-glow-* 工具类,最后在页面用两个不同应援色验证。提示:先在 @theme 里确认有哪几个色板令牌可消费,再决定 values 传什么。

任务二:升级幻灯片变体。 把 115 篇幻灯片项目里所有 data-[active]: 前缀替换为 addVariant 注册的 active-slide:,并在插件里加一条契约注释。提示:注册语句只有两行;替换后用浏览器对比前后生成的 CSS 是否一致。

任务三:品牌禁用态收拢。 为团队的三种按钮(primary、secondary、danger)写一个插件,统一注册禁用态半透明规则与圆角,色值全部来自令牌;然后到 @theme 改一次 primary 验证联动。提示:三种按钮是三个组件类,禁用规则在插件里写一次;验证时开 DevTools 看 .btn-primary:disabled 的选择器确实存在。

先自己写,再对照参考实现:

任务一参考实现(ticket-tier 插件)
css
/* src/styles/global.css */
@plugin "./ticket-tier-plugin.cjs";
javascript
// src/styles/ticket-tier-plugin.cjs
const plugin = require('tailwindcss/plugin')

module.exports = plugin(
  ({ addComponents, matchUtilities, theme }) => {
    // 组件类:引用令牌,不写死颜色
    addComponents({
      '.ticket-tier': {
        borderRadius: theme('borderRadius.lg'),
        padding: theme('spacing.4'),
        borderWidth: '1px',
        borderColor: theme('colors.border'),
      },
    })
    // 按色板批量生成 tier-glow-*:色板有几个色就有几个类
    matchUtilities(
      { 'tier-glow': (value) => ({ boxShadow: `0 0 18px ${value}` }) },
      { values: theme('colors') },
    )
  },
)
html
<div class="ticket-tier tier-glow-primary">SS 档</div>
<div class="ticket-tier tier-glow-danger">内场票</div>

验收两点:.ticket-tier 的圆角、内边距、边框色全部来自令牌(在 @theme 改一次验证联动);tier-glow-* 的可用类数量与色板一致(没有注册过的色名写上去静默无效,这正是 matchUtilities 按 values 造类的边界)。写完把”消费令牌 + 注册前缀”两行契约注释加到文件头,第 4 节的落地建议就从阅读变成了习惯。

任务二参考实现(active-slide 变体)
javascript
// plugins/slide-variant.js
import plugin from 'tailwindcss/plugin'

/**
 * slide-variant 插件契约:
 * - 消费令牌:无(纯选择器注册)
 * - 注册变体:active-slide(对应 &[data-active])
 * - 服务于 115 篇幻灯片项目的 slide 激活态
 */
export const slideVariant = plugin(({ addVariant }) => {
  addVariant('active-slide', '&[data-active]')
})
html
<div class="pointer-events-none absolute inset-0 scale-95 opacity-0
            active-slide:pointer-events-auto active-slide:scale-100 active-slide:opacity-100">

验证方法:替换前后各开一次 DevTools,在 Styles 面板搜 data-active——生成规则应完全一致(选择器与声明一一对应)。差异只在类名本身:active-slide: 让模板里”什么时候可见”读起来是语义而不是实现。这个练习的价值在于体会”任意变体是逃生门,一等变体是常态”:逃生门天天走,就该修一条正门。

任务三参考实现(team-btn 插件)
javascript
// plugins/team-btn.cjs
const plugin = require('tailwindcss/plugin')

module.exports = plugin(
  ({ addComponents, theme }) => {
    const disabledRule = {
      '&:disabled': { opacity: '0.5', cursor: 'not-allowed' },
    }
    addComponents({
      '.btn-primary': {
        backgroundColor: theme('colors.primary'),
        ...disabledRule,
      },
      '.btn-secondary': {
        backgroundColor: theme('colors.secondary'),
        ...disabledRule,
      },
      '.btn-danger': {
        backgroundColor: theme('colors.danger'),
        ...disabledRule,
      },
    })
  },
)

展开运算符把禁用规则对象复用到三个类上——插件是 JS,这正是它相对 @utility 的独特能力:规则可以组合与复用。验证联动:@theme 里改 --color-primary,三个表单按钮里用 .btn-primary 的那批全部变色而 disabled 半透明不变——颜色走令牌、行为走规则,两条通道互不干扰。若发现某个按钮的禁用态没生效,先查它是不是被 .btn-primary 之外的组件类覆盖(注册顺序),再查它是否根本没挂 disabled 属性。

相关阅读