前置知识: Tailwind CSS

官方插件:forms 与 typography

9 min中级

Tailwind CSS 官方插件:@plugin 指令加载 forms/typography、三种重置策略选择、prose 长文排版兜底与中文微调,附购票表单与专栏排版的联动验证

工具类擅长”精确打击”,但有两类需求天然不适合逐个类名去凑:表单控件的出厂统一调校(每个 input 都要同款边框、焦点环)与长文正文的排版兜底(CMS 与 MDX 来的内容没法逐段加类)。官方插件体系正是为这两类”批处理”需求准备的。本篇覆盖两个最常用的官方插件 forms 与 typography;等官方插件覆盖不到平台特有需求时,怎么用 plugin() API 写自己的插件,见自定义插件机制。

前置知识

学习目标

  1. 能用 @plugin 指令加载插件,说出 Tailwind 4 与旧版加载方式的差异。
  2. 能配置 @tailwindcss/forms 的策略,让购票表单的控件观感统一。
  3. 能用 @tailwindcss/typography 的 prose 类为 MDX / CMS 正文提供排版兜底。

1. 插件生态与加载方式

Tailwind 插件是一个函数包,在构建期向引擎注册新工具类、组件类或基础样式。Tailwind 4 的加载方式是 CSS 里的 @plugin 指令——配置已经 CSS-first,插件的入口自然也搬进了样式文件:

css
/* src/styles/global.css:Tailwind 4 用 @plugin 指令加载插件 */
@import "tailwindcss";

@plugin "@tailwindcss/forms" {
  strategy: base; /* 只重置基础样式,控件外观交给工具类(见第 2 节) */
}
@plugin "@tailwindcss/typography";

旧版(Tailwind 3)的方式是在 tailwind.config.js 的 plugins 数组里注册,升级到 4 后迁移成上面的 @plugin 写法即可。官方插件按”批处理一类需求”组织:forms 管表单控件、typography 管长文排版;社区插件同理。挑选原则与接入 Vite 插件时一样:官方优先、按需引入——每个插件都会扩大样式产物与团队认知面,装一个就要让全队知道它注册了哪些类。

插件的注册顺序会影响覆盖关系:后注册的插件可以覆盖先注册插件写入的同类规则,与工具类在样式表中的先后顺序一致。团队里若有多个插件都要碰表单样式,把”打底型”(forms)放在前面、“个性化型”(自己的组件插件)放在后面,覆盖方向就永远是”个性覆盖打底”,不会出现反向打架。

2. @tailwindcss/forms:表单控件的出厂调校

原生表单控件的默认样式跨浏览器差异大:checkbox 在不同系统里长得不一样,select 的内边距参差不齐。forms 插件先做一遍统一重置(去掉默认外观、统一内边距与基线),再开放足够的工具类空间。三种策略决定重置的力度:

  • base(推荐默认):重置所有控件的基础观感,不加任何”默认美化”,外观完全由工具类决定;
  • class:基础样式只作用于带 .form-input、.form-select 等标记类的控件,未标记的不动;
  • full:接近浏览器原生观感的美化版本,开箱好看但难定制。

平台的购票表单在 strategy: base 下的典型写法:

html
<!-- 购票表单:forms 插件负责"打底",工具类负责"化妆" -->
<form class="space-y-4">
  <label class="block">
    <span class="text-sm text-text-secondary">购票人姓名</span>
    <input
      type="text"
      name="name"
      class="mt-1 block w-full rounded-md border-border focus:border-primary focus:ring-primary"
    />
  </label>

  <fieldset>
    <legend class="text-sm text-text-secondary">票档</legend>
    <label class="flex items-center gap-2">
      <input type="radio" name="ticketId" value="stand-s" class="text-primary" />
      S 区站票 ¥680
    </label>
    <label class="flex items-center gap-2">
      <input type="radio" name="ticketId" value="seat-a" class="text-primary" />
      A 区坐票 ¥480
    </label>
  </fieldset>

  <button class="rounded-md bg-primary px-4 py-2 text-white hover:bg-primary-hover">
    提交订单
  </button>
</form>

两个细节值得注意:插件重置后,focus:border-primary focus:ring-primary 的焦点样式在各浏览器表现一致,不再需要为 Firefox 单写 -moz- 规则;checkbox 与 radio 的选中色直接用 text-primary 控制(控件继承 color 属性),这就是”插件与主题联动”的日常体现——主题里换掉 primary 的值,表单控件的选中色、焦点环、按钮底色一起变。

插件只管样式,语义还得自己补:每个 input 都应有可关联的 label(for 与 id 成对),radio 组要用 fieldset 与 legend 分组,错误提示通过 aria-describedby 挂到对应控件。forms 插件让控件”好看”,这层语义结构让控件”可用”——屏幕阅读器用户听到的表单,全靠这些属性组织。评审购票表单时把”样式统一”与”语义完整”当成两行 checklist,比事后返工便宜得多。

strategy 的选择还可以按”团队构成”来定:全员熟悉 Tailwind 用 base(外观决策全在类名里,一致性强);有大量非前端成员写表单的团队用 class,.form-input 这类”开箱样式”类学习成本更低。两条路线没有对错,但全站只能选一种——两种策略混用的项目,迟早出现”这个输入框为什么和那个不一样”的悬案。

再补一个暗色模式的细节:插件重置后 input 的默认底色是白色,暗色界面里会突兀地亮起一块——记得给控件补 dark:bg-surface 这类底色类。插件提供跨浏览器一致的”形状”,亮暗两套”颜色”仍归主题与工具类管,职责不要弄混。评审暗色模式时,表单区域是必查项,它是”亮块残留”的高发地。

3. @tailwindcss/typography:长文的排版兜底

歌曲评测、访谈专栏的正文来自 MDX 或 CMS,你无法要求写作者给每个段落、每级标题手工加类。typography 插件提供 prose 组件类:一个类名给所有子元素套上合理的排版预设(标题层级、行高、列表、引用块、代码块),再用修饰类做微调:

html
<!-- 歌曲评测正文:内容来自 MDX/CMS,无法逐段加类,交给 prose 兜底 -->
<article
  class="prose prose-slate max-w-none dark:prose-invert
         prose-headings:border-l-4 prose-headings:border-primary
         prose-a:text-primary"
>
  <!-- 里面的 h2、p、blockquote 不需要任何类名 -->
  <h2>千本樱:和声里的那道切分</h2>
  <p>整曲最"电"的瞬间出现在 2:14 的和声切分……</p>
  <blockquote> words cut like knives, 言叶は刃のように。 </blockquote>
  <a href="/songs/senbonzakura">跳转收听</a>
</article>

用法上有三层可调。第一层是灰度与暗色:prose-slate 换灰度体系,dark:prose-invert 在暗色模式整体反色。第二层是元素级修饰:prose-headings: 修饰所有标题、prose-a: 修饰链接、prose-code: 修饰行内代码,粒度覆盖常见元素。第三层是间距与宽度:prose 默认自带 65ch 最大宽度,嵌进平台自己的栅格布局时用 max-w-none 释放,交给外层容器控制。原则是:prose 只兜”内容里不该有类名”的底,一旦某块内容你能控制(自己写的组件),还是回到普通工具类。

中文长文还有两个本地化微调:段落首行缩进可以用 prose-p:[text-indent:2em] 这类任意值修饰表达;中英文混排的间距交给属性级工具类自动处理。另外 prose 的修饰粒度覆盖到几乎所有元素——prose-p:、prose-li:、prose-blockquote:、prose-hr:——遇到”全局都对、就一个元素不对”的情况,优先找对应修饰类,而不是写一段覆盖 CSS 去打补丁。

prose 的预设基于英文排版习惯,中文项目通常还要动行高与字重:标题行高压到 1.3 左右、正文从 400 字重起步,避免中文标题”撑”得太高。这些调整写进 prose-* 修饰类一次到位,比逐篇文章覆盖可靠得多——排版预设的价值就在”改一处、全站生效”。

易错点与最佳实践

  1. Tailwind 4 里还去 config 的 plugins 数组注册。CSS-first 之后配置文件不再被默认加载,插件静默不生效:

    javascript
    // 错误:Tailwind 4 下这样写没有任何效果
    module.exports = { plugins: [require('@tailwindcss/forms')] }

    修正:移到样式文件里 @plugin "@tailwindcss/forms";。

  2. forms 策略选 full 又想自定义。full 策略的默认美化会与工具类互相打架(border-border 盖不过它)。修正:统一用 base,外观完全交给工具类;或用 class 策略把重置范围收窄到显式标记的控件。

  3. prose 与自己的栅格打架。prose 自带 65ch 最大宽度,嵌进窄侧栏或宽主区时都显得突兀。修正:布局受外层控制时加 max-w-none,让宽度职责归容器。

本篇小结

  1. Tailwind 4 用 @plugin 指令在 CSS 里加载插件,取代旧版配置文件的 plugins 数组;插件注册顺序决定覆盖关系,“打底型”在前、“个性化型”在后。
  2. forms 插件统一表单控件观感,推荐 strategy: base 把外观决策交给工具类;控件选中色用 text-* 跟随主题;暗色界面记得给控件补底色类。
  3. typography 的 prose 为”没有类名的内容”提供排版兜底,元素级修饰(prose-headings: 等)做微调,宽度归外层容器;中文项目补行高字重的本地化修饰。
  4. 官方插件管不到平台特有需求时,用 plugin() API 写自己的插件——机制详见自定义插件机制。

动手实践

任务一:表单统一化。 接入 forms 插件(strategy: base),把本篇第 2 节的购票表单、粉丝团报名表、P主投稿表三个表单的控件换成统一焦点环,然后到 @theme 里把 primary 改成另一个颜色,验证三个表单焦点环全部联动。提示:radio 的选中色就是 text-primary;改令牌前先在浏览器确认当前颜色,改完硬刷新对比。

任务二:专栏排版。 为歌姬访谈专栏启用 prose,加 prose-headings 竖条修饰与 dark:prose-invert,对比接 CMS 原始内容前后的可读性。提示:别忘了 max-w-none 与外层容器宽度的分工——先故意不加 max-w-none 塞进 280px 侧栏看看会发生什么。

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

任务一参考实现(forms 接入与联动验证)
css
/* src/styles/global.css */
@import "tailwindcss";
@plugin "@tailwindcss/forms" {
  strategy: base;
}
html
<!-- 购票表单控件:外观全部工具类,焦点环跟主题走 -->
<input
  type="email"
  class="w-full rounded-md border border-border bg-surface px-3 py-2
         text-text-primary focus:border-primary focus:ring-primary"
/>
<label class="inline-flex items-center gap-2">
  <input type="radio" name="tier" value="ss" class="text-primary focus:ring-primary" />
  SS 档(含签名色纸)
</label>

联动验证:@theme 里把 --color-primary 从初始值改成新色,保存后三个表单的边框焦点、radio 圆点、ring 光晕应同时变色——因为它们都只引用 primary 令牌,没有一个硬编码。这就是”改一次令牌、全站跟随”的表单版。

任务二参考实现(prose 专栏排版)
astro
---
// 访谈专栏页(Astro 组件片段):content 是 CMS 来的原始 HTML
const content = await fetchInterviewHtml()
---

<article
  class="prose prose-headings:border-l-4 prose-headings:border-primary prose-headings:pl-3
         dark:prose-invert max-w-none mx-auto px-6"
  set:html={content}
/>

对照组实验:去掉 max-w-none 把这段塞进 280px 侧栏,prose 自带的 65ch 最大宽度会与容器冲突,内容区被压到只剩一小条或溢出——这一眼就看清”宽度职责归外层容器”这条纪律的由来。set:html 输出的内容没有类名可写,prose 的元素级选择器正是为这种”碰不到类名的 HTML”准备的。

相关阅读