Tailwind css 的原子化实践

Tailwind CSS 在前端开发中逐渐成为了大家的首选样式处理方案,它的简单的集成性以及灵活的可配置性让它在前端开发中逐渐占据较高的地位,但是随着 Tailwind CSS 的使用,很多前端开发人员也出现了不适,classname 太多,导致代码看起来不整洁,样式复杂的情况下,书写 Tailwind CSS 很冗杂,不利于维护等,那么在使用Tailwind CSS 的时候遵循原子组件的理论能不能解决以上问题呢?


Atomic Component

在讨论如何结合原子化组件和Tailwind CSS之前,我们先了解一下原子元组件方法论,就是将页面分为以下5个层级

  1. Atoms
  2. Molecules
  3. Organisms
  4. Templates
  5. Pages

每个层级都是由前面的层级拼凑而成,最终形成页面,在这个方法论中,最主要的就是寻找到原子组件,下图帮助我们很好的标注出了原子组件有哪些(原生的 Html Element)。

注:图片来源 (atomicdesign.bradfrost.com/chapter-2/)

Atomic Component 与 Tailwind CSS 的契合

页面是由基础组件拼凑而成的,原子化组件方法论并不是将页面组件尽可能拆分的很小,而是通过原子组件组成分子组件,组织组件,再到页面模板,进而进行页面的搭建,是一个从小到大的过程。

在利用原子化组件开发方法论中,我们需要识别清楚识别到哪些是原子组件,然后对原子组件进行修饰,Tailwind CSS 正好就是利用的 Atomic CSS 的方法论,将冗杂的 css 样式变成单个的 css 属性,这就正好能够解决在代码中出现难以阅读的有很多 classname 的 html 代码,那就是对原子组件进行封装,然后使用原子组件进行分子组件的组合。

例如:

jsx 复制代码
function ShoppingCard() {
    return <div className="flex font-sans">
        <div className="flex-none w-48 relative">
            <img src="/classNameic-utility-jacket.jpg" alt="" className="absolute inset-0 w-full h-full object-cover" loading="lazy" />
        </div>
        <form className="flex-auto p-6">
            <div className="flex flex-wrap">
                <h1 className="flex-auto text-lg font-semibold text-slate-900">
                    Utility Jacket
                </h1>
                <div className="text-lg font-semibold text-slate-500">
                    $110.00
                </div>
                <div className="w-full flex-none text-sm font-medium text-slate-700 mt-2">
                    In stock
                </div>
            </div>
            <div className="flex items-baseline mt-4 mb-6 pb-6 border-b border-slate-200">
                <div className="space-x-2 flex text-sm">
                    <label>
                        <input className="sr-only peer" name="size" type="radio" value="xs" checked />
                        <div className="w-9 h-9 rounded-lg flex items-center justify-center text-slate-700 peer-checked:font-semibold peer-checked:bg-slate-900 peer-checked:text-white">
                            XS
                        </div>
                    </label>
                    <label>
                        <input className="sr-only peer" name="size" type="radio" value="s" />
                        <div className="w-9 h-9 rounded-lg flex items-center justify-center text-slate-700 peer-checked:font-semibold peer-checked:bg-slate-900 peer-checked:text-white">
                            S
                        </div>
                    </label>
                    <label>
                        <input className="sr-only peer" name="size" type="radio" value="m" />
                        <div className="w-9 h-9 rounded-lg flex items-center justify-center text-slate-700 peer-checked:font-semibold peer-checked:bg-slate-900 peer-checked:text-white">
                            M
                        </div>
                    </label>
                    <label>
                        <input className="sr-only peer" name="size" type="radio" value="l" />
                        <div className="w-9 h-9 rounded-lg flex items-center justify-center text-slate-700 peer-checked:font-semibold peer-checked:bg-slate-900 peer-checked:text-white">
                            L
                        </div>
                    </label>
                    <label>
                        <input className="sr-only peer" name="size" type="radio" value="xl" />
                        <div className="w-9 h-9 rounded-lg flex items-center justify-center text-slate-700 peer-checked:font-semibold peer-checked:bg-slate-900 peer-checked:text-white">
                            XL
                        </div>
                    </label>
                </div>
            </div>
            <div className="flex space-x-4 mb-6 text-sm font-medium">
                <div className="flex-auto flex space-x-4">
                    <button className="h-10 px-6 font-semibold rounded-md bg-black text-white" type="submit">
                        Buy now
                    </button>
                    <button className="h-10 px-6 font-semibold rounded-md border border-slate-200 text-slate-900" type="button">
                        Add to bag
                    </button>
                </div>
                <button className="flex-none flex items-center justify-center w-9 h-9 rounded-md text-slate-300 border border-slate-200" type="button" aria-label="Like">
                    <svg width="20" height="20" fill="currentColor" aria-hidden="true">
                        <path fill-rule="evenodd" clip-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" />
                    </svg>
                </button>
            </div>
            <p className="text-sm text-slate-700">
                Free shipping on all continental US orders.
            </p>
        </form>
    </div>
}

这段代码是官网的一个例子,我们就这个例子来进行重构

Button 组件抽离

首先我们可以提取的原子组件是大家最常见的 <Button>

我们期望的Button的使用方式为

tsx 复制代码
    <Button variant="filled">Buy now</Button>

所以我们可以将按钮做一个简单的封装

tsx 复制代码
import { ButtonHTMLAttributes } from "react"

type ButtonVariant =
    | 'filled'
    | 'outlined'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
    variant: ButtonVariant;
}

export default function Button(props: ButtonProps) {
    const {
        type,
        variant,
        className,
        children
    } = props;
    const variantClassNames: {[key: string]: string} = {
        filled: 'bg-black text-white',
        outlined: 'border border-slate-200 text-slate-900'
    }
    return <button
        className={`h-10 px-6 font-semibold rounded-md ${variantClassNames[variant]} ${className}`}
        type={type}>
        {children}
    </button>
}

然后我们就能够将这段代码

替换为

Icon 组件抽离

当然这里的 svg 也被抽离成为了一个原子组件

tsx 复制代码
interface IconProps {
    size?: number
}

export default function HeartIcon (props: IconProps) {
    const { size = 20 } = props;
    const rate = size / 20;
    return <svg style={{ transform: `scale(${rate})` }} width='20' height='20' fill="currentColor" aria-hidden="true">
        <path
            fillRule="evenodd"
            clipRule="evenodd"
            d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z"
        />
    </svg>
}

Radio 组件抽离

在代码的中间我们能够识别到有 Radio 单选组件,那么作为原子组件列表中的 <input type="radio" /> 自然也是需要对其进行修饰的。

tsx 复制代码
import { InputHTMLAttributes } from "react";

interface RadioProps extends InputHTMLAttributes<HTMLInputElement> {
    label: string;
}

export default function Radio(props: RadioProps) {
    const { label, name, value, checked, className, onChange } = props;
    return <label className="relative cursor-pointer" htmlFor={label}>
        <input
            className="sr-only peer"
            type="radio"
            id={label}
            value={value}
            name={name}
            checked={checked}
            onChange={onChange}
        />
        <div
            className={`min-w-[36px] min-h-[36px] p-2 rounded-lg flex items-center justify-center text-slate-700 peer-checked:font-semibold peer-checked:bg-slate-900 peer-checked:text-white ${className}`}
        >
            {label}
        </div>
    </label>
}

将原本单个的单选组件的html直接挪进我们的组件文件中,然后对其进行封装,最后的使用效果为

tsx 复制代码
function ShopingCard() {
  const sizeList = [
    'XS', 'S', 'M', 'L', 'XL'
  ];
  const onSizeChange = (event: ChangeEvent<HTMLInputElement>) => {
    setCurrentSize(event.target.value);
  }
  const [currentSize, setCurrentSize] = useState(sizeList[0])
  return (
    ...
            <div className="space-x-2 flex text-sm">
              {
                sizeList.map(size => <Radio
                  key={size}
                  name="size"
                  value={size}
                  label={size}
                  checked={currentSize === size}
                  onChange={onSizeChange}
                />)
              }
            </div>
     ...
  )
}

在上面我们完成了 Forms 原子组件的封装修饰,接下来需要识别到别的类型的原子组件,即 document sectionsgroup content 这类内容型的原子组件的封装修饰。

Typography 组件抽离

简单的文本组件抽离

tsx 复制代码
import { PropsWithChildren } from "react";

interface TypographyProps extends PropsWithChildren {
    className?: string;
    variant: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'lead' | 'paragraph'
}

interface TextProps extends PropsWithChildren {
    className?: string;
}

export default function Typography(props: TypographyProps) {
    const { variant, children, className } = props;
    const baseClassNames = 'block antialiased tracking-normal font-sans font-semibold';
    const typographies = {
        h1: (p: TextProps) => <h1 className={`${baseClassNames} text-5xl leading-tight ${p.className}`}>{p.children}</h1>,
        h2: (p: TextProps) => <h2 className={`${baseClassNames} text-4xl leading-[1.3]  ${p.className}`}>{p.children}</h2>,
        h3: (p: TextProps) => <h3 className={`${baseClassNames} text-3xl leading-snug ${p.className}`}>{p.children}</h3>,
        h4: (p: TextProps) => <h4 className={`${baseClassNames} text-2xl leading-snug ${p.className}`}>{p.children}</h4>,
        h5: (p: TextProps) => <h5 className={`${baseClassNames} text-xl leading-snug ${p.className}`}>{p.children}</h5>,
        h6: (p: TextProps) => <h6 className={`${baseClassNames} text-lg leading-relaxed ${p.className}`}>{p.children}</h6>,
        lead: (p: TextProps) => <p className={`${baseClassNames} text-base font-normal leading-relaxed ${p.className}`}>{p.children}</p>,
        paragraph: (p: TextProps) => <p className={`${baseClassNames} text-sm font-normal leading-relaxed ${p.className}`}>{p.children}</p>,
    }
    return typographies[variant]({
        className,
        children
    });
}

如果想要更加表意的写法,即不使用 Tailwind CSS 的方式书写,我们依然是可以使用样式文件对我们的样式进行进一步的封装,Tailwind CSS 提供了 @apply 装饰器,可以使的我们的 classname 更加表意。

  • style.css
css 复制代码
.base-font {
    @apply block antialiased tracking-normal font-sans font-semibold;
}

.heading1 {
    @apply text-5xl leading-tight;
}

.heading2 {
    @apply text-4xl leading-[1.3];
}

.heading3 {
    @apply text-3xl leading-snug;
}

.heading4 {
    @apply text-2xl leading-snug;
}

.heading5 {
    @apply text-xl leading-snug;
}

.heading6 {
    @apply text-lg leading-relaxed;
}

.lead {
    @apply text-base font-normal leading-relaxed;
}

.paragraph {
    @apply text-sm font-normal leading-relaxed;
}
  • typography.tsx
tsx 复制代码
import { PropsWithChildren, createElement } from "react";
import './style.css'; // 引入样式

interface TypographyProps extends PropsWithChildren {
    className?: string;
    variant: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'lead' | 'paragraph';
}

export default function Typography(props: TypographyProps) {
    const { variant, children, className } = props;
    const headingElements = ['h1' , 'h2' , 'h3' , 'h4' , 'h5' , 'h6'];
    const typographiesClassName = {
        h1: 'heading1',
        h2: 'heading2',
        h3: 'heading3',
        h4: 'heading4',
        h5: 'heading5',
        h6: 'heading6',
        lead: 'lead',
        paragraph: 'paragraph'
    }
    // 根据不同标签创建不同样式的元素
    return createElement(
        headingElements.includes(variant) ? variant : 'p',
        { className: `base-font ${typographiesClassName[variant]} ${className}` },
        children
    );
}

Card 组件

简单的面板组件

tsx 复制代码
import { HTMLAttributes } from "react";

interface CardProps extends HTMLAttributes<HTMLDivElement> { }

export default function Card(props: CardProps) {
    const { children, className } = props;
    return <div className={`flex font-sans shadow-md p-2 rounded-xl overflow-hidden ${className}`}>
        {children}
    </div>
}

最终的样子

最后我们的这个购物卡片的代码变得比之前容易维护并且易读,这里面其实还有一些小组件能够进行抽离,比如分割线,图片。

tsx 复制代码
function ShoppingCard() {
  const sizeList = [
    'XS', 'S', 'M', 'L', 'XL'
  ];
  const onSizeChange = (event: ChangeEvent<HTMLInputElement>) => {
    setCurrentSize(event.target.value);
  }
  const [currentSize, setCurrentSize] = useState(sizeList[0]);
  
  return (
    <div className="flex items-center justify-center h-screen">
      <Card>
        <div className="flex-none w-48 relative">
          <img
            src="https://www.tailwindcss.cn/_next/static/media/classic-utility-jacket.82031370.jpg"
            className="w-full h-full object-cover"
            loading="lazy"
          />
        </div>
        <form className="flex-auto p-6">
          <div className="flex flex-wrap">
            <Typography variant="h6" className="flex-auto text-slate-900" >Utility Jacket</Typography>
            <Typography variant="h6" className="text-slate-500" >$110.00</Typography>
            <Typography variant="lead" className="text-slate-700 mt-2 w-full" >In stock</Typography>
          </div>
          <div className="flex items-baseline mt-4 mb-6 pb-6 border-b border-slate-200">
            <div className="space-x-2 flex text-sm">
              {
                sizeList.map(size => <Radio
                  key={size}
                  name="size"
                  value={size}
                  label={size}
                  checked={currentSize === size}
                  onChange={onSizeChange}
                />)
              }
            </div>
          </div>
          <div className="flex space-x-4 mb-6 text-sm font-medium">
            <div className="flex-auto flex space-x-4">
              <Button variant="filled">Buy now</Button>
              <Button variant="outlined"> Add to bag</Button>
            </div>
            <Button variant="outlined" className="px-2.5">
              <HeartIcon size={24} />
            </Button>
          </div>

          <Typography variant="paragraph" className="text-slate-700" >
            Free shipping on all continental US orders.
          </Typography>
        </form>
      </Card>
    </div>
  )
}

在官网中也有对应的指导,怎么样去做组件的拆分


随着原子组件的抽离,拼凑的页面将会变得直观明了,这是 Atomic Component 和 Atomic CSS 的结合,一个是逻辑层面的原子化,一个是样式层面的原子化,这样子才能真正的达到 Atomic methodology。

参考

  1. 【Atomic Design Methodology】
  2. 【Tallwind CSS】
  3. 【material-tailwind-react源码】
  4. 【本文样例代码仓库】
相关推荐
小镇程序员几秒前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐2 分钟前
前端图像处理(一)
前端
程序猿阿伟10 分钟前
《智能指针频繁创建销毁:程序性能的“隐形杀手”》
java·开发语言·前端
疯狂的沙粒11 分钟前
对 TypeScript 中函数如何更好的理解及使用?与 JavaScript 函数有哪些区别?
前端·javascript·typescript
瑞雨溪20 分钟前
AJAX的基本使用
前端·javascript·ajax
力透键背23 分钟前
display: none和visibility: hidden的区别
开发语言·前端·javascript
程楠楠&M34 分钟前
node.js第三方Express 框架
前端·javascript·node.js·express
weiabc34 分钟前
学习electron
javascript·学习·electron
盛夏绽放42 分钟前
Node.js 和 Socket.IO 实现实时通信
前端·后端·websocket·node.js
想自律的露西西★1 小时前
用el-scrollbar实现滚动条,拖动滚动条可以滚动,但是通过鼠标滑轮却无效
前端·javascript·css·vue.js·elementui·前端框架·html5