Vue3打造SVG设计器+图标库

1,749 阅读4分钟

在Web开发中常常会用到SVG图标,下面就是如何用Vue3来打造一个自己专属的SVG设计器+图标库。先上效果图与代码,之后再对核心代码片段进行说明。

1628327277(1).png

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>
      )
    }
  }
})

微信截图_20210808101450.png

静态局部引入

将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" />

拓展思路

  1. 增加对图标的基础配置,如视口大小(目前固定为1024 * 1024)。
  2. 新增更多的组件类型,为每种类型新增更丰富的配置项。
  3. 新增图标动画配置,这也是笔者接下来准备做的。