图标组件的封装与管理(React/svg)

一 概要

1.1 背景

最近在项目中使用了很多从iconfont拿到的图标。使用官网的导入方法有些繁琐,也不易管理。于是捣鼓了一下...

1.2 目的

  1. 像组件一样使用,具有规范性。比如暴露一个type属性,根据不同的type使用不同的主题色。
  2. 高自由度。直接在项目中管理图标,只关注svg。(这个图标我看不爽,我直接换svg!什么配置啊库啊样式表啊,影响不到我!)
  3. 提供一个页面总览全部图标,方便使用。

1.3 实现效果

  1. 图标组件

    import { BillIcon } from '@/components/icons';  
    
    <BillIcon type="primary" size={16} badge={false}/>
    
  2. Js调用(基于前者,如果图标需要根据某种条件来选用,这种方式很推荐)

    import { getIcon } from '@/utils';
    
    <div> { 
        getIcon('bill', 
            { type:'primary', size:16, badge:false })
        }
    </div>
    
  3. 图标总览(不重要,无所谓)

    项目使用的是webpack打包,所以在配置文件中新增了一个入口和出口,让图标库跟项目绑定,项目用到了什么,就显示什么。如图所示,我直接通过icon.html来访问,这在调试项目的时候非常好用。

1.4 实现步骤

  1. 从免费网站中拿到svg。比如我在iconfont拿。最好找些外观特点相差不大的,保证图标一致性。

  2. (重点)把svg封装成组件:每个svg对应一个tsx/jsx文件,存储在项目的目录中(比如我存储在src/components/icons中)

  3. 创建一个入口文件,导出所有的图标组件。同时创建一个字典(map),让每一个图标对应一个key,比如bill对应的是<BillIcon/>(控制图标和key的命名会让后续管理更方便,比如我这里的key取的是图标名称前面第一个单词,同时小写。)

二 图标组件封装与管理

2.1 了解svg的属性

在使用图标的时候,我们实际上只关注它的颜色和大小(我们不设计图标,我们只是图标的搬运工)。

下面是复制过来的一段svg代码,我们只需要关注它的widthheight和里面path标签的fill属性(颜色)。

<svg
   t="1720764743617"
   class="icon"
   viewBox="0 0 1024 1024"
   version="1.1"
   xmlns="http://www.w3.org/2000/svg"
   p-id="4480"
   width="16"
   height="16"
>
   <path
      d="M853.333333 938.666667H170.666667a42.666667 42.666667 0 0 1-42.666667-42.666667V128a42.666667 42.666667 0 0 1 42.666667-42.666667h682.666666a42.666667 42.666667 0 0 1 42.666667 42.666667v768a42.666667 42.666667 0 0 1-42.666667 42.666667zM341.333333 384v85.333333h341.333334V384H341.333333z m0 170.666667v85.333333h341.333334v-85.333333H341.333333z"
      p-id="4481"
      fill=""
   ></path>
</svg>

其中t属性和class属性在jsx/tsx中会显示有问题,删掉即可。

2.2 封装图标组件

最简单的,就是直接在svg外层包一层:

  • 需要给svg的widthheightfill赋值
  • 宽高是一致的,意味着需要传大小size和颜色color
type Props =  {size: number, color: string}
const BackIcon = (props: Props ) => {
  return <svg
    // ...
    width={props.size}
    height={props.size}
  >
    <path 
      // ...
      fill={props.color}
    ></path>
  </svg>
}

但是!如果要求再高一些,不想开发者使用随随便便的颜色,不想维护起来那么麻烦。这时候可以把color换成type属性,让开发者只能使用type规定的值,让type去决定用什么颜色。

type TIconType = 'primary' | 'disabled' | 'white' | 'danger' 
const getColor = (type: TIconType) => {
   switch (type) {
      case 'primary':
         return '#13227a';
      case 'white':
         return '#ffffff';
      case 'danger':
         return '#ef6b6b';
      default:
         return '#8f8f8f';
   }
};

行,那改写一下:

// BackIcon.tsx

import getColor from './getColor'
import { TIconType } from '@/types'

type Props =  {size: number, type: TIconType}

const BackIcon = (props: Props) => {
   const color = getColor(props.type);
   return (
      <svg
         viewBox="0 0 1024 1024"
         version="1.1"
         xmlns="http://www.w3.org/2000/svg"
         p-id="2674"
         width={props.size}
         height={props.size}
      >
         <path
            d="M672 896c-8.533333 0-17.066667-2.133333-21.333333-8.533333l-362.666667-352c-6.4-6.4-10.666667-14.933333-10.666667-23.466667 0-8.533333 4.266667-17.066667 10.666667-23.466667L652.8 136.533333c12.8-12.8 32-12.8 44.8 0s12.8 32 0 44.8L356.266667 512l339.2 328.533333c12.8 12.8 12.8 32 0 44.8-6.4 8.533333-14.933333 10.666667-23.466667 10.666667z"
            fill={color}
            p-id="2675"
         ></path>
      </svg>
   );
};

目前看着好像没什么问题。

但如果,我还想对这个图标组件进行扩展呢......比如加个点击事件、加个hover、在右上角加个小红点、加点闪烁什么的?

我还要把这些扩展属性传进来处理吗?如果每个图标组件都那么多东西要管的话,维护起来不是很yue吗?

没错!这时候就用到高阶组件了,让多余的事交给高阶组件处理,因为我们只关注图标本身。

//  @/types/index.ts

// 图标颜色类型
type TIconType = 'primary' | 'disabled' | 'white' | 'danger' 

// 图标组件属性
interface IconProps {
    type?: TIconType;
    size?: number;
    badge?: boolean; // 扩展的,给图标右上角加红点,它跟svg无关
// withIconColor.tsx
import { TIconType, IconProps} from '@/types'

const getColor = (type: TIconType) => {
   switch (type) {
      case 'primary':
         return '#13227a';
      case 'white':
         return '#ffffff';
      case 'danger':
         return '#ef6b6b';
      default:
         return '#8f8f8f';
   }
};

// 参数是一个组件。
// withIconColor的作用是处理外层传递进来的props,转化成一定内容后还给原组件
// 同时给原组件加上固定特性
function withIconColor(WrappedIcon: React.ComponentType<any>) {
   return function (props: IconProps) {
      const {
         type,
         size,
         badge = false,
         className = '',
         ...others
      } = props;
      const color = getColor(props.type);
      const containerCls = 'relative inline-block ' + className // taildwind的特性,需要拼接好再传,不然会无效

      return (
         {/* 其他乱七八糟的属性交给外层 */}
         <span className={containerCls} {...others}>
            {badge && (
               <div className="absolute w-2 h-2 text-[4px] text-white bg-red-500 rounded-full right-0"></div>
            )}
            {/* Svg我啊,只关注颜色和大小呢~ */}
            <WrappedIcon color={color} size={size || 24} />
         </span>
      );
   };
}

export default withIconColor;

怎么使用呢?

//  @/types/index.ts

type SvgIconProps = {
    color: string,
    size: number
}
import withIconColor from './withIconColor';
import { SvgIconProps } from '@/types'

const BackIcon = (props: SvgIconProps ) => {
   return (
      <svg
         viewBox="0 0 1024 1024"
         version="1.1"
         xmlns="http://www.w3.org/2000/svg"
         p-id="2674"
         width={props.size}
         height={props.size}
      >
         <path
            d="M672 896c-8.533333 0-17.066667-2.133333-21.333333-8.533333l-362.666667-352c-6.4-6.4-10.666667-14.933333-10.666667-23.466667 0-8.533333 4.266667-17.066667 10.666667-23.466667L652.8 136.533333c12.8-12.8 32-12.8 44.8 0s12.8 32 0 44.8L356.266667 512l339.2 328.533333c12.8 12.8 12.8 32 0 44.8-6.4 8.533333-14.933333 10.666667-23.466667 10.666667z"
            fill={props.color}
            p-id="2675"
         ></path>
      </svg>
   );
};

export default withIconColor(BackIcon); // 在这里用!

此后,如果还要加什么图标,就创建一个文件,把上面的内容复制一份,换个命名,把中间的svg替换掉就好了!(当然还要把svg原来的width、height和fill属性换成动态传进来的。)

2.3 整体结构

结构因人而异,这里列下我的项目的结构

- src
    - types
        index.ts
    - components
        - icons
            - BillIcon.tsx
            - UserIcon.tsx
            - index.ts
            - withIconColor.tsx
   - utils
       - getIcon
  1. 组件入口

    // src\components\icons\index.ts
    
    type IconKey = "bill" | "user"  // 图标的key
    
    import BillIcon from './BillIcon'
    import UserIcon from './UserIcon'
    
    const keyToIconMap = {
        bill: BillIcon,
        user: UserIcon,
    }
    
    const ICON_KEYS = Object.keys(keyToIconMap)  as Array<IconKey>
    
    export {
        BillIcon,
        UserIcon,
    
        keyToIconMap,
        ICON_KEYS
    }
    

    之后就能在其他地方使用了

    import { BillIcon } from '@/components/icons';  
    
    <BillIcon />
    
  2. getIcon

    import { IconKey, IconProps } from '@/types';
    import { keyToIconMap } from '@/components/icons'
    
    export default (key: IconKey, props?: IconProps) => {
       const Icon = keyToIconMap[key];
       return <Icon {...props} />;
    };
    

    之后就能在其他地方使用了

    import { getIcon } from '@/utils';
    
    <div> { getIcon('bill', { type:'primary'}) } </div>
    

三 图标总览页面

在根目录加个入口(其他地方也行)

- src
    - iconIndex.tsx

这一步也没那么重要了,有需要就copy吧,样式根据自己需求自定义了。

import './style/index.css';
import { createRoot } from 'react-dom/client';
import { getIcon } from '@/utils';
import { ICON_KEYS } from '@/components/icons';
import toast, { Toaster } from 'react-hot-toast';
import { RadioButton } from './components';
import { useState } from 'react';

const root = createRoot(document.getElementById('root')); // 因为我webpack定义的模板html有个root,所以这样写,因人而异了

function capitalizeFirstLetter(string: string) {
   if (!string) return string;
   return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase();
}

const IconPage = () => {
   const handleCopy = async (value) => {
      await navigator.clipboard.writeText(value);
      toast.success(`复制成功: ${value}`);
   };

   const [type, setType] = useState('js');

   const handleChange = (value) => {
      setType(value);
   };

   return (
      <div className="p-2 font-mono">
         <Toaster />
         <div className="my-2">
            <RadioButton
               value={type}
               onChange={handleChange}
               options={[
                  { value: 'component', label: '使用组件' },
                  { value: 'js', label: '使用js' },
               ]}
            />
            <div className="flex justify-center space-x-2">
               {type === 'component' ? (
                  <div className="p-2 border bg-slate-900 text-slate-50 min-w-[430px]">
                     {`import { BillIcon } from '@/components/icons'; `}
                     <br />
                     <br />
                     {` <BillIcon type="primary" size={16}/> `}
                  </div>
               ) : (
                  <div className="p-2 border bg-slate-900 text-slate-50 w-fit min-w-[430px]">
                     {`import { getIcon } from '@/utils'; `}
                     <br />
                     <br />
                     {` <div> { getIcon('bill', { type:'primary', size: 16 }) } </div> `}
                  </div>
               )}
            </div>
         </div>
         <div className="grid-cols-2 lg:grid-cols-8 grid gap-2 ">
            {ICON_KEYS.map((key) => {
               const value =
                  type === 'component'
                     ? `${capitalizeFirstLetter(key)}Icon`
                     : key;
               return (
                  <div
                     className="rounded-sm  flex justify-center cursor-pointer items-center pt-4 shadow-sm flex-col hover:scale-105 transition duration-300 ease-out hover:bg-indigo-100"
                     onClick={handleCopy.bind(null, value)}
                  >
                     {getIcon(key, {
                        type: 'primary',
                     })}
                     <span className="text select-none">{value}</span>
                  </div>
               );
            })}
         </div>
      </div>
   );
};

root.render(<IconPage />);

配置webpack

{
    // entry: path.ENTRY,   // 之前单入口是这样写的
    entry: {
         main: path.ENTRY,
         icon: path.ICON_ENTRY  // path.resolve(__dirname,  'src', 'iconIndex.tsx')
    },
    // ...
    plugins: [
         new HtmlWebpackPlugin({
            template: path.TEMPLATE,  // path.resolve(__dirname, 'public', 'index.html'),
            filename: 'index.html', // 输出文件名
            chunks: ['main'], // 对应entry里边定义的
         }),
         new HtmlWebpackPlugin({
            template: path.TEMPLATE,
            filename: 'icon.html', // 输出文件名
            chunks: ['icon'], // 对应entry里边定义的
         }),
   ],
}
posted @ 2024-07-12 15:21  sanhuamao  阅读(25)  评论(0编辑  收藏  举报