需求
公司不同项目有大量相同类型甚至相同的图标,会产生两个问题:
- 每个项目的图标库内需要重复上传icon
- 视觉升级时,无法进行统一修改
解决方案:提供通用图标react ui
组件化的能力
基础架构/技术栈:
- 构建工具:
为保持我司ui体系下统一技术和设计开发流程,减少后续介入学习成本。选择使用dumi + father
组件开发与构建方式 - 图标资源库:
iconfont
- 技术栈:
React
、Node
实现步骤
-
首先将
iconfont
上的图标,按与UI同学约定命名规范进行分类汇总。
手动将图标进行搬运和归类,并定义好对应模糊搜索关键词数组const common = [ ['WaitingForShipment', ['等待卖家发货']], ['WaitingForPayment', ['等待买家付款']], ['WaitingForReceipt', ['等待买家确认收货']], ['ConfirmOrder', ['等待卖家确认订单']], ['Completed', ['交易成功']], ... ]; const edit = [ ['Copy', ['复制']], ['LeftCircleArrow', ['左圈箭头']], ['RightCircleArrow', ['右圈箭头']], ['Edit', ['编辑']], ['Trashcan', ['垃圾桶']], ['Enlarge', ['放大']], ... ]; ... export const categories = { common, direction, edit, suggestion, data, };
-
创建
<script>
加载资源,其实就是引入一个svg的集合,方便开发者调用在 iconfont.cn 上自行管理的图标。function createScriptUrlElements( scriptUrls: string[], index: number = 0, ): void { const currentScriptUrl = scriptUrls[index]; if (isValidCustomScriptUrl(currentScriptUrl)) { const script = document.createElement('script'); script.setAttribute('src', currentScriptUrl); script.setAttribute('data-namespace', currentScriptUrl); if (scriptUrls.length > index + 1) { script.onload = () => { createScriptUrlElements(scriptUrls, index + 1); }; script.onerror = () => { createScriptUrlElements(scriptUrls, index + 1); }; } customCache.add(currentScriptUrl); document.body.appendChild(script); } }
-
设置通用的svg的基础样式
export const svgBaseProps = { width: '1em', height: '1em', fill: 'currentColor', 'aria-hidden': 'true', focusable: 'false', }; export const iconStyles = ` .anticon { display: inline-block; color: inherit; font-style: normal; line-height: 0; text-align: center; text-transform: none; vertical-align: -0.125em; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .anticon > * { line-height: 1; } .anticon svg { display: inline-block; } .anticon::before { display: none; } .anticon .anticon-icon { display: block; } .anticon[tabindex] { cursor: pointer; } .anticon-spin::before, .anticon-spin { display: inline-block; -webkit-animation: loadingCircle 1s infinite linear; animation: loadingCircle 1s infinite linear; } @-webkit-keyframes loadingCircle { 100% { -webkit-transform: rotate(360deg); transform: rotate(360deg); } } @keyframes loadingCircle { 100% { -webkit-transform: rotate(360deg); transform: rotate(360deg); } } `;
-
创建Icon图标
const Icon = React.forwardRef<HTMLSpanElement, IconComponentProps>( (props, ref) => { const { className, component: Component, viewBox, spin, rotate, tabIndex, onClick, // children children, ...restProps } = props; warning(Boolean(Component || children), '必须要有props或是children'); useInsertStyles(); const { prefixCls = 'anticon' } = React.useContext(Context); const classString = classNames(prefixCls, className); const svgClassString = classNames({ [`${prefixCls}-spin`]: !!spin, }); const svgStyle = rotate ? { msTransform: `rotate(${rotate}deg)`, transform: `rotate(${rotate}deg)`, } : undefined; const innerSvgProps: CustomIconComponentProps = { ...svgBaseProps, className: svgClassString, style: svgStyle, viewBox, }; if (!viewBox) { delete innerSvgProps.viewBox; } // component > children const renderInnerNode = () => { if (Component) { return <Component {...innerSvgProps}>{children}</Component>; } if (children) { warning( Boolean(viewBox) || (React.Children.count(children) === 1 && React.isValidElement(children) && React.Children.only(children).type === 'use'), 'Make sure that you provide correct `viewBox`' + ' prop (default `0 0 1024 1024`) to the icon.', ); return ( <svg {...innerSvgProps} viewBox={viewBox}> {children} </svg> ); } return null; }; let iconTabIndex = tabIndex; if (iconTabIndex === undefined && onClick) { iconTabIndex = -1; } return ( <span role="img" {...restProps} ref={ref} tabIndex={iconTabIndex} onClick={onClick} className={classString} > {renderInnerNode()} </span> ); }, ); export default Icon;
-
然后使用
<use>
标签来渲染图标的组件const Iconfont = React.forwardRef<HTMLSpanElement, IconFontProps<T>>( (props, ref) => { const { type, children, ...restProps } = props; // children > type let content: React.ReactNode = null; if (props.type) { content = <use xlinkHref={`#${type}`} />; } if (children) { content = children; } return ( <Icon {...extraCommonProps} {...restProps} ref={ref}> {content} </Icon> ); }, );
-
加载图标库,并使用
createFromIconfontCN
将图标组件化,并抛出export default function create<T extends string = string>( options: CustomIconOptions = {}, ): React.FC<IconFontProps<T>> { const { scriptUrl, extraCommonProps = {} } = options; if ( scriptUrl && typeof document !== 'undefined' && typeof window !== 'undefined' && typeof document.createElement === 'function' ) { if (Array.isArray(scriptUrl)) { // 因为iconfont资源会把svg插入before,所以前加载相同type会覆盖后加载,为了数组覆盖顺序,倒叙插入 createScriptUrlElements(scriptUrl.reverse()); } else { createScriptUrlElements([scriptUrl]); } } const Iconfont = React.forwardRef<HTMLSpanElement, IconFontProps<T>>( (props, ref) => { const { type, children, ...restProps } = props; // children > type let content: React.ReactNode = null; if (props.type) { content = <use xlinkHref={`#${type}`} />; } if (children) { content = children; } return ( <Icon {...extraCommonProps} {...restProps} ref={ref}> {content} </Icon> ); }, ); Iconfont.displayName = 'Iconfont'; // @ts-ignore return Iconfont; }
-
为了兼容老项目所使用的
@ant-design/icons
,创建createFromIconfontCN
同名方法,方便开发同学低成本替换。import { default as createFromIconfontCN } from '../components/IconFont'; export default createFromIconfontCN({ scriptUrl: [ '//at.alicdn.com/t/font_3161758_5l63iixepzh.js', // 通用icons - 线框风格 '//at.alicdn.com/t/font_3161092_3x12vl7pmw5.js', // 通用icons - 实底风格 ], });
实现背景
- 约束基础能力:因为此能力是针对通用图标的处理,且UI同学也不会直接交付svg文件来渲染,所以此版本屏蔽直接使用svg文件来自定义icon的能力,且后续版本也会不支持。
- 为了方便开发同学使用和快速定位所需图标,
@fle-ui/icons
抛出了IconDisplay
通用图标搜索组件,并在fle-ui文档 中嵌入。支持中英文联想,提供了比@ant-design/icons
更为人性化、友好的搜索体验~
所遇问题
- (待解决) 如何快速搬运/归类图标?
暂时找不到合适的方法,只能手动复制粘贴 - (已解决) 上百个图标能不能批量组件化输出?
为了将组件化动作简化,利用脚本+模板去自动化创建组件
结果如下:import allIconDefs from '../src/iconsTemplateName/iconNameList'; import * as path from 'path'; import * as fs from 'fs'; import { promisify } from 'util'; import { template } from 'lodash'; const writeFile = promisify(fs.writeFile); function walk<T>( fn: (iconModuleName: string, iconfontName: string) => Promise<T>, ) { return Promise.all( allIconDefs.map((iconIdentifier: string[]) => { return fn(iconIdentifier[0], iconIdentifier[1]); }), ); } async function generateIcons() { const iconsDir = path.join(__dirname, '../src/icons'); try { await promisify(fs.access)(iconsDir); } catch (err) { await promisify(fs.mkdir)(iconsDir); } const render = template( ` // GENERATE BY ../../scripts/generate.ts // DON NOT EDIT IT MANUALLY import React from 'react'; import Iconfont from '../iconsTemplateName/CreatIconfont'; import { IconComponentProps } from '../components/Icon'; const <%= iconModuleName %> = ( props: IconComponentProps, ref: React.MutableRefObject<HTMLSpanElement>, ) => { return ( <Iconfont {...props} ref={ref} type={'<%= iconfontName %>'} /> ) } <%= iconModuleName %>.display = '<%= iconModuleName %>'; export default React.forwardRef<HTMLSpanElement, IconComponentProps>(<%= iconModuleName %>) `.trim(), ); await walk(async (iconModuleName, iconfontName) => { // generate icon file await writeFile( path.resolve(__dirname, `../src/icons/${iconModuleName}.tsx`), render({ iconModuleName, iconfontName }), ); }); // generate icon index const entryText = allIconDefs .map( (iconIdentifier: string[]) => `export { default as ${iconIdentifier[0]} } from './${iconIdentifier[0]}';`, ) .join('\n'); await promisify(fs.appendFile)( path.resolve(__dirname, '../src/icons/index.ts'), ` // GENERATE BY ./scripts/generate.ts // DON NOT EDIT IT MANUALLY ${entryText} `.trim(), ); } if (process.argv[2] === '--target=icon') { generateIcons(); }
// GENERATE BY ../../scripts/generate.ts // DON NOT EDIT IT MANUALLY import React from 'react'; import Iconfont from '../iconsTemplateName/CreatIconfont'; import { IconComponentProps } from '../components/Icon'; const AccountBookFilled = ( props: IconComponentProps, ref: React.MutableRefObject<HTMLSpanElement>, ) => { return <Iconfont {...props} ref={ref} type={'Filled-a-Accountbook-Common'} />; }; AccountBookFilled.display = 'AccountBookFilled'; export default React.forwardRef<HTMLSpanElement, IconComponentProps>( AccountBookFilled, );