在Web开发中常常会用到SVG图标,下面就是如何用Vue3来打造一个自己专属的SVG设计器+图标库。先上效果图与代码,之后再对核心代码片段进行说明。
SVG设计器+图标库:sps-svg-lib
注:系统基于笔者自己写的模板 sps-vite-simple 开发。
SVG设计器
基础代码
首先来看一下一个典型的SVG图标代码。SVG图标的HTML节点是由多个图形节点构成的。
<svg viewBox="0 0 1024 1024">
<circle fill="currentColor" stroke="currentColor" stroke-width="0" r="100" cx="100" cy="100" />,
<rect fill="currentColor" stroke="currentColor" stroke-width="0" x="300" y="100" width="150" height="100" />
</svg>
于是,在设计器中定义SvgConfig
类型,其属性包含对svg
节点的配置(baseConfig
)及其所包含的每个component
的配置,且每个component
的配置项由于其类型不同也会有所区别。
// components/svgDesign/type.d.ts
export interface SvgComponent {
id?: string
type: SvgComponentType
fill?: string
stroke?: string
['stroke-width']?: number
}
// 圆形
export interface CircleComponet extends SvgComponent {
r: number
cx: number
cy: number
}
// 矩形
export interface RectComponent extends SvgComponent {
x: number
y: number
width: number
height: number
}
// ... 其他组件
export interface SvgConfig {
baseConfig: SvgBaseConfig
components: SvgComponent[]
}
通过provide / inject
的方式来进行局部状态管理。可参考:Vue3+TS 优雅地使用状态管理
在根组件中通过provide
注册局部状态(svgState
),并将局部状态相关的操作全部封装成一个hook
函数。
// components/svgDesign/index.tsx
import { defineComponent, provide } from 'vue'
import { createSvgState, injectSvgStateKey } from './hooks/useSvgState'
export default defineComponent({
name: 'SvgDesign',
setup () {
provide(injectSvgStateKey, createSvgState())
//...
}
})
// components/svgDesign/hooks/useSvgState
import { inject, InjectionKey, reactive } from 'vue'
import { SvgComponent, SvgState } from '../type'
import { cloneDeep } from 'lodash-es'
export const createSvgState = () => {
const svgState: SvgState = reactive({
baseConfig: {
name: 'newIcon'
},
components: [],
currentComponent: null,
previewColor: '#1890ff'
})
return svgState
}
export const injectSvgStateKey: InjectionKey<SvgState> = Symbol('svg-state')
export const useSvgState = () => {
const svgState = inject(injectSvgStateKey)!
// 切换当前编辑组件
const setCurrentComponent = (component: SvgComponent) => {
svgState.currentComponent = cloneDeep(component)
}
// 新增组件
const addComponent = (component: SvgComponent) => {
svgState.components.push(component)
}
// 删除组件
const deleteComponent = (index: number) => {
const { components, currentComponent } = svgState
if (currentComponent && components[index].id === currentComponent.id) {
svgState.currentComponent = null
}
components.splice(index, 1)
}
// 保存对当前编辑组件的更改
const saveComponent = () => {
const { currentComponent } = svgState
if (!currentComponent) return
const index = svgState.components.findIndex(item => item.id === svgState.currentComponent!.id)
index >= 0 && svgState.components.splice(index, 1, cloneDeep(currentComponent))
}
return {
svgState,
setCurrentComponent,
addComponent,
deleteComponent,
saveComponent
}
}
图标预览
右侧的图标预览组件
// components/svgDesign/components/SvgPreview.ts
import { defineComponent } from 'vue'
import { useSvgState } from '../hooks/useSvgState'
export default defineComponent({
name: 'SvgPreview',
setup () {
const { svgState } = useSvgState()
/* render 函数 */
return () => {
const { components, previewColor } = svgState
const svgComponents = components.map(component => {
const { type, ...options } = component
return (
<type { ...options } />
)
})
return (
<div class="w-96 h-96 border-2 border-gray-400" style={{ color: previewColor }}>
<svg viewBox="0 0 1024 1024">{ svgComponents }</svg>
</div>
)
}
}
})
图标配置
左侧的图标配置组件,由于该组件代码较多,且大多数代码都较为简单,这里就不贴完整代码,只对动态配置表单进行下说明。
组件有圆形、矩形等多种类型,每种类型的配置项是不完全相同的,所以配置项表单需要根据组件的类型动态生成。
组件的公共配置项:
// components/svgDesign/components/SvgConfig.ts
let form: JSX.Element | null = null
if (currentComponent) {
form = (
<a-form class="mt-5" model={ currentComponent }>
<a-form-item label="填充色">
<a-input v-model={[ currentComponent.fill, 'value' ]} />
</a-form-item>
<a-form-item label="边框宽度">
<a-input-number v-model={[ currentComponent['stroke-width'], 'value' ]} min={ 0 } />
</a-form-item>
<a-form-item label="边框颜色">
<a-input v-model={[ currentComponent.stroke, 'value' ]} />
</a-form-item>
{ renderFormItem(currentComponent) }
<a-form-item>
<a-button type="primary" onClick={ saveComponent }><i class="far fa-save" />保存</a-button>
</a-form-item>
</a-form>
)
}
根据组件类型生成对应的动态表单项:
// components/svgDesign/utils/renderFormItem.tsx
const renderCircleConfig = (model: CircleComponet) => {
return (<>
<a-form-item label="半径">
<a-input-number v-model={[ model.r, 'value' ]} />
</a-form-item>
<a-form-item label="X偏移">
<a-input-number v-model={[ model.cx, 'value' ]} />
</a-form-item>
<a-form-item label="Y偏移">
<a-input-number v-model={[ model.cy, 'value' ]} />
</a-form-item>
</>)
}
const renderRectConfig = (model: RectComponent) => {
return (<>
<a-form-item label="宽度">
<a-input-number v-model={[ model.width, 'value' ]} min={ 0 } />
</a-form-item>
<a-form-item label="高度">
<a-input-number v-model={[ model.height, 'value' ]} min={ 0 } />
</a-form-item>
<a-form-item label="X偏移">
<a-input-number v-model={[ model.x, 'value' ]} />
</a-form-item>
<a-form-item label="Y偏移">
<a-input-number v-model={[ model.y, 'value' ]} />
</a-form-item>
</>)
}
// ...其他组件
const renderMap = {
circle: renderCircleConfig,
rect: renderRectConfig,
// ...其他组件
}
export default function renderFormItem (model: SvgComponent) {
const handler = renderMap[model.type]
return handler(model as any)
}
SVG图标库
在SVG设计器页面提供HTML、Symbol和Json三种不同类型的输出方式。其分别对应静态局部引入,静态全局引入,动态引入三种引入方式,可根据实际需要自行选用。
在下面的测试页面中就通过不同方式来使用SVG图标。
// views/sys/test/index.tsx
import { defineComponent } from 'vue'
export default defineComponent({
name: 'Test',
setup () {
return () => {
return (
<div class="flex-center h-screen">
<div class="flex justify-around">
<svg class="w-40 h-40 text-green-400" viewBox="0 0 1024 1024">
<path fill="currentColor" stroke="currentColor" stroke-width="0" d="M288 352.256l448 0c17.664 0 32-14.336 32-32s-14.336-32-32-32L288 288.256c-17.664 0-32 14.336-32 32S270.336 352.256 288 352.256L288 352.256zM736 479.744 288 479.744c-17.664 0-32 14.336-32 32 0 17.664 14.336 32 32 32l448 0c17.664 0 32-14.336 32-32C768 494.08 753.664 479.744 736 479.744L736 479.744zM736 671.744 288 671.744c-17.664 0-32 14.336-32 32 0 17.664 14.336 32 32 32l448 0c17.664 0 32-14.336 32-32C768 686.08 753.664 671.744 736 671.744L736 671.744zM418.688 160l160 0c26.528 0 48-21.504 48-48 0-26.496-21.504-48-48-48l-160 0c-26.496 0-48 21.504-48 48C370.688 138.496 392.192 160 418.688 160L418.688 160zM832 96l-32 0c-17.664 0-32 14.336-32 32 0 17.664 14.336 32 32 32l32 0c35.296 0 64 28.704 64 64l0 608c0 35.296-28.704 64-64 64L192 896c-35.296 0-64-28.704-64-64L128 224c0-35.296 28.704-64 64-64l32 0c17.664 0 32-14.336 32-32 0-17.664-14.336-32-32-32L192 96C121.312 96 64 153.312 64 224l0 608c0 70.688 57.312 128 128 128l640 0c70.688 0 128-57.312 128-128L960 224C960 153.312 902.688 96 832 96L832 96zM832 96" />
</svg>
<svg-icon class="w-40 h-40 text-red-400" name="order" />
<dynamic-svg-icon class="w-40 h-40 text-blue-400" name="order" />
</div>
</div>
)
}
}
})
静态局部引入
将HTML输出方式的结果直接使用,或者封装成一个Vue组件中。
<svg viewBox="0 0 1024 1024">
<path fill="currentColor" stroke="currentColor" stroke-width="0" d="M288 352.256l448 0c17.664 0 32-14.336 32-32s-14.336-32-32-32L288 288.256c-17.664 0-32 14.336-32 32S270.336 352.256 288 352.256L288 352.256zM736 479.744 288 479.744c-17.664 0-32 14.336-32 32 0 17.664 14.336 32 32 32l448 0c17.664 0 32-14.336 32-32C768 494.08 753.664 479.744 736 479.744L736 479.744zM736 671.744 288 671.744c-17.664 0-32 14.336-32 32 0 17.664 14.336 32 32 32l448 0c17.664 0 32-14.336 32-32C768 686.08 753.664 671.744 736 671.744L736 671.744zM418.688 160l160 0c26.528 0 48-21.504 48-48 0-26.496-21.504-48-48-48l-160 0c-26.496 0-48 21.504-48 48C370.688 138.496 392.192 160 418.688 160L418.688 160zM832 96l-32 0c-17.664 0-32 14.336-32 32 0 17.664 14.336 32 32 32l32 0c35.296 0 64 28.704 64 64l0 608c0 35.296-28.704 64-64 64L192 896c-35.296 0-64-28.704-64-64L128 224c0-35.296 28.704-64 64-64l32 0c17.664 0 32-14.336 32-32 0-17.664-14.336-32-32-32L192 96C121.312 96 64 153.312 64 224l0 608c0 70.688 57.312 128 128 128l640 0c70.688 0 128-57.312 128-128L960 224C960 153.312 902.688 96 832 96L832 96zM832 96" />
</svg>
静态全局引入
封装
将Symbol方式输出symbol
节点拷贝到SvgIconProvider.tsx
中:
// components/svgIcon/SvgIconProvider.tsx
import { defineComponent, renderSlot } from 'vue'
export default defineComponent({
name: 'SvgIconProvider',
setup (_, { slots }) {
/* render 函数 */
return () => {
return (<>
<svg width="0" height="0">
{/* symbol节点都拷贝到此处 */}
<symbol id="order" viewBox="0 0 1024 1024">
<path fill="currentColor" stroke="currentColor" stroke-width="0" d="M288 352.256l448 0c17.664 0 32-14.336 32-32s-14.336-32-32-32L288 288.256c-17.664 0-32 14.336-32 32S270.336 352.256 288 352.256L288 352.256zM736 479.744 288 479.744c-17.664 0-32 14.336-32 32 0 17.664 14.336 32 32 32l448 0c17.664 0 32-14.336 32-32C768 494.08 753.664 479.744 736 479.744L736 479.744zM736 671.744 288 671.744c-17.664 0-32 14.336-32 32 0 17.664 14.336 32 32 32l448 0c17.664 0 32-14.336 32-32C768 686.08 753.664 671.744 736 671.744L736 671.744zM418.688 160l160 0c26.528 0 48-21.504 48-48 0-26.496-21.504-48-48-48l-160 0c-26.496 0-48 21.504-48 48C370.688 138.496 392.192 160 418.688 160L418.688 160zM832 96l-32 0c-17.664 0-32 14.336-32 32 0 17.664 14.336 32 32 32l32 0c35.296 0 64 28.704 64 64l0 608c0 35.296-28.704 64-64 64L192 896c-35.296 0-64-28.704-64-64L128 224c0-35.296 28.704-64 64-64l32 0c17.664 0 32-14.336 32-32 0-17.664-14.336-32-32-32L192 96C121.312 96 64 153.312 64 224l0 608c0 70.688 57.312 128 128 128l640 0c70.688 0 128-57.312 128-128L960 224C960 153.312 902.688 96 832 96L832 96zM832 96" />
</symbol>
</svg>
{ renderSlot(slots, 'default') }
</>)
}
}
})
SvgIcon
组件根据传入的name
来引入SvgIconProvider
组件中注册的图标。
// components/svgIcon/SvgIcon.tsx
import { defineComponent } from 'vue'
export default defineComponent({
name: 'SvgIcon',
props: {
name: {
type: String,
required: true
},
},
setup (props, { attrs }) {
/* render 函数 */
return () => {
const { name } = props
return (
<svg class="svg-icon" { ...attrs }>
<use href={ `#${name}` } />
</svg>
)
}
}
})
使用
在App.tsx
中全局注册图标库:
// App.tsx
import { defineComponent } from 'vue'
import SvgIconProvider from '@/components/svgIcon/SvgIconProvider'
export default defineComponent({
name: 'App',
setup () {
return () => {
return (
<SvgIconProvider>
<router-view />
</SvgIconProvider>
)
}
}
})
传入name
使用对应的图标。
<svg-icon class="w-40 h-40 text-red-400" name="order" />
动态引入
封装
根据name
从后端动态获取图标的配置信息,即Json格式输出的内容。
// components/svgIcon/DynamicSvgIcon.tsx
import { getIconApi } from '@/api/icon'
import { useRemoteData } from '@/hooks/useRemoteData'
import { defineComponent, onMounted } from 'vue'
export default defineComponent({
name: 'DynamicSvgIcon',
props: {
name: {
type: String,
required: true
}
},
setup (props) {
const { state, fetchData } = useRemoteData(getIconApi)
onMounted(() => {
fetchData(props.name)
})
/* render 函数 */
return () => {
const { data } = state
let svgComponents: JSX.Element[] | null = null
if (data) {
const { components } = data
svgComponents = components.map(component => {
const { type, ...options } = component
return (
<type { ...options } />
)
})
}
return (
<svg viewBox="0 0 1024 1024">{ svgComponents }</svg>
)
}
}
})
使用
使用方式与静态全局引入一样,只是不需要全局注册图标。
dynamic-svg-icon class="w-40 h-40 text-blue-400" name="order" />
拓展思路
- 增加对图标的基础配置,如视口大小(目前固定为1024 * 1024)。
- 新增更多的组件类型,为每种类型新增更丰富的配置项。
- 新增图标动画配置,这也是笔者接下来准备做的。