|
|
@@ -14,6 +14,77 @@
|
|
|
import SvgIconLib from '@/static/svg-icons-lib.js'
|
|
|
import { rpx2px } from '../../utils/utils'
|
|
|
|
|
|
+/**
|
|
|
+ * ------------------------------------------------------------------
|
|
|
+ * 模块级共享缓存(跨所有 <zui-svg-icon> 组件实例共享, 随应用生命周期常驻内存)
|
|
|
+ *
|
|
|
+ * 性能问题背景: 页面里经常会用 v-for 循环渲染同一个图标(如列表行的删除/编辑
|
|
|
+ * 按钮), 优化前每一个组件实例都会各自重新执行一遍"颜色正则匹配 -> 字符串替换
|
|
|
+ * -> encodeURIComponent 编码"的全部逻辑, 图标复用次数越多、单个图标 svg 越大,
|
|
|
+ * 重复计算的开销就越明显, 表现为图标批量渲染/切换页面时明显变慢。
|
|
|
+ *
|
|
|
+ * 这里引入三级缓存, 让"内容完全相同的计算"只做一次:
|
|
|
+ * 1. iconMetaCache: 缓存某个图标的原始颜色列表 + 换色用的正则对象。
|
|
|
+ * 这两项只取决于图标本身, 与调用方传入的目标颜色值无关, 因此可以按
|
|
|
+ * `collection::iconId` 维度在所有实例间共享, 避免重复构建 RegExp。
|
|
|
+ * 2. svgRawCache: 缓存 `collection::iconId::颜色组合` 对应的换色后 svg 源码,
|
|
|
+ * 相同的 icon+color 组合每次替换出的结果必然一致, 命中后直接跳过正则替换。
|
|
|
+ * 3. dataUrlCache: 缓存 svg 源码对应的 data:image/svg+xml 编码结果, Key 为
|
|
|
+ * svg 源码内容本身, 命中后跳过 encodeURIComponent 运算。
|
|
|
+ *
|
|
|
+ * 为避免极端场景(如页面动态生成大量一次性 svg 源码图标)导致缓存无限膨胀占用
|
|
|
+ * 内存, 设置了简单的容量上限, 超出后清空重新缓存。
|
|
|
+ * ------------------------------------------------------------------
|
|
|
+ */
|
|
|
+const MAX_CACHE_SIZE = 500
|
|
|
+
|
|
|
+const iconMetaCache = new Map()
|
|
|
+const svgRawCache = new Map()
|
|
|
+const dataUrlCache = new Map()
|
|
|
+
|
|
|
+/**
|
|
|
+ * 获取(或构建并缓存)某个图标的原始颜色列表与换色正则
|
|
|
+ *
|
|
|
+ * @param {object} svgIconLib 当前 collection 对应的图标库对象
|
|
|
+ * @param {string} collection 图标集合名, 用于区分缓存命名空间
|
|
|
+ * @param {string} iconId 预处理后的图标 id
|
|
|
+ * @returns {{ oriColors: string[], colorPlaceholder: RegExp | null }}
|
|
|
+ */
|
|
|
+function getIconMeta(svgIconLib, collection, iconId) {
|
|
|
+ const cacheKey = `${collection}::${iconId}`
|
|
|
+ const cached = iconMetaCache.get(cacheKey)
|
|
|
+ if (cached) return cached
|
|
|
+
|
|
|
+ const iconPreset = svgIconLib.icons[iconId]
|
|
|
+ const oriColors = iconPreset ? iconPreset.slice(1).map(idx => svgIconLib.$_colorPalette[idx]) : []
|
|
|
+ // colorPlaceholder 正则含 g 标记, String.replace 每次调用都会重置 lastIndex,
|
|
|
+ // 因此可以安全地在多个组件实例间共享同一个 RegExp 对象
|
|
|
+ const colorPlaceholder = oriColors.length
|
|
|
+ ? new RegExp(`(${oriColors.map(item => item.replace(/([\(\)])/g, '\\$1')).join('|')})([^\\w])`, 'gi')
|
|
|
+ : null
|
|
|
+
|
|
|
+ const meta = { oriColors, colorPlaceholder }
|
|
|
+ if (iconMetaCache.size >= MAX_CACHE_SIZE) iconMetaCache.clear()
|
|
|
+ iconMetaCache.set(cacheKey, meta)
|
|
|
+ return meta
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 将 svg 源码转换为 data:image/svg+xml 格式的 url, 并做全局缓存
|
|
|
+ *
|
|
|
+ * @param {string} svgContent 编码前的 svg 源码(已完成换色替换)
|
|
|
+ * @returns {string} data:image/svg+xml,... 格式的 url
|
|
|
+ */
|
|
|
+function toSvgDataUrl(svgContent) {
|
|
|
+ const cached = dataUrlCache.get(svgContent)
|
|
|
+ if (cached !== undefined) return cached
|
|
|
+
|
|
|
+ const url = `data:image/svg+xml,${encodeURIComponent(svgContent)}`
|
|
|
+ if (dataUrlCache.size >= MAX_CACHE_SIZE) dataUrlCache.clear()
|
|
|
+ dataUrlCache.set(svgContent, url)
|
|
|
+ return url
|
|
|
+}
|
|
|
+
|
|
|
export default {
|
|
|
name: 'zui-svg-icon',
|
|
|
|
|
|
@@ -101,9 +172,34 @@ export default {
|
|
|
},
|
|
|
|
|
|
computed: {
|
|
|
+ /**
|
|
|
+ * 是否文件来源
|
|
|
+ *
|
|
|
+ * 包含 url, svg原始字符串, 未进行 base64 编码的 data:image/svg+xml uri
|
|
|
+ */
|
|
|
+ isFileSource() {
|
|
|
+ if (/^https?\:\/\//i.test(this.icon)) return true
|
|
|
+ if (/^data:image\//i.test(this.icon)) return true
|
|
|
+ if (/\.svg([?#].*)?$/i.test(this.icon)) return true
|
|
|
+ if (this.icon.indexOf('/') > -1) return true
|
|
|
+
|
|
|
+ return false
|
|
|
+ },
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 对用户输入 ID 进行预处理, 以转换为正确的 ID
|
|
|
+ *
|
|
|
+ * 仅处理当不是文件(SVG raw), url 时才进行处理 id
|
|
|
+ */
|
|
|
+ iconId() {
|
|
|
+ return !this.isFileSource ? this.icon.replace(/[\/\\]/g, '-').toLowerCase() : this.icon
|
|
|
+ },
|
|
|
+
|
|
|
cWidth() {
|
|
|
- const wid = /rpx$/i.test(this.width) ? rpx2px(this.width, true) : this.width
|
|
|
- return typeof wid === 'number' ? `${wid}px` : wid
|
|
|
+ const width = this.width
|
|
|
+ if (typeof width === 'number') return `${width}px`
|
|
|
+ if (/^\d+rpx$/i.test(width)) return rpx2px(width, true) + 'px'
|
|
|
+ return width
|
|
|
},
|
|
|
|
|
|
cHeight() {
|
|
|
@@ -122,58 +218,75 @@ export default {
|
|
|
return typeof hei === 'number' ? `${hei}px` : hei
|
|
|
},
|
|
|
|
|
|
- svgIconLib() {
|
|
|
- return SvgIconLib.getCollection(this.collection || 'default')
|
|
|
- },
|
|
|
-
|
|
|
/**
|
|
|
- * 是否文件来源
|
|
|
- *
|
|
|
- * 包含 url, svg原始字符串, 未进行 base64 编码的 data:image/svg+xml uri
|
|
|
+ * 图标集合名, 统一处理默认值, 供缓存 key 与图标库查询复用
|
|
|
*/
|
|
|
- isFileSource() {
|
|
|
- if (/^https?\:\/\//i.test(this.icon)) return true
|
|
|
- if (/^data:image\//i.test(this.icon)) return true
|
|
|
- if (/\.svg([?#].*)?$/i.test(this.icon)) return true
|
|
|
- if (this.icon.indexOf('/') > -1) return true
|
|
|
+ cCollection() {
|
|
|
+ return this.collection || 'default'
|
|
|
+ },
|
|
|
|
|
|
- return false
|
|
|
+ svgIconLib() {
|
|
|
+ return SvgIconLib.getCollection(this.cCollection)
|
|
|
},
|
|
|
|
|
|
svgRaw() {
|
|
|
if (this.isFileSource) return this.icon
|
|
|
|
|
|
- const iconId = this.icon.toLowerCase()
|
|
|
- const iconPreset = this.svgIconLib.icons[iconId]
|
|
|
+ const iconPreset = this.svgIconLib.icons[this.iconId]
|
|
|
if (!iconPreset) {
|
|
|
- console.warn(`Svg icon [${iconId}] not defined and no fallback icon set.`)
|
|
|
+ console.warn(`Svg icon [${this.iconId}] not defined and no fallback icon set.`)
|
|
|
return
|
|
|
}
|
|
|
- let svg = iconPreset[0]
|
|
|
|
|
|
- if (this.color && this.isColorCountMatch) {
|
|
|
- svg = svg.replace(this.colorPlaceholder, (_, a, b) => {
|
|
|
+ // 未指定颜色(或颜色数量不匹配)时无需替换, 直接复用图标库原始字符串
|
|
|
+ //
|
|
|
+ // 注意: colorPlaceholder/colorMap 是在 mounted() 里才异步初始化的(见 initialIconColor),
|
|
|
+ // 组件首次渲染(mounted 之前)时两者仍是 data() 里的初始值 null, 此时如果继续往下走缓存分支,
|
|
|
+ // `iconPreset[0].replace(null, ...)` 会因匹配不到而直接返回未换色的原始字符串,
|
|
|
+ // 一旦被写入全局缓存, 后续该 icon+color 组合会永远命中这个"未换色"的错误缓存,
|
|
|
+ // 导致颜色设置彻底失效(表现为一直显示 svg 文件自带的原色)。因此这里必须等
|
|
|
+ // colorPlaceholder/colorMap 就绪后才进入换色与缓存逻辑, 未就绪时直接返回原始字符串且不写缓存,
|
|
|
+ // 等 mounted() 完成初始化后 computed 会因依赖变化自动重新计算。
|
|
|
+ if (!this.color || !this.isColorCountMatch || !this.colorPlaceholder || !this.colorMap) {
|
|
|
+ return iconPreset[0]
|
|
|
+ }
|
|
|
+
|
|
|
+ // 相同 icon + 相同目标颜色组合, 换色结果必然一致, 用全局缓存跨实例复用,
|
|
|
+ // 避免同一图标在列表中大量重复出现时反复执行正则替换
|
|
|
+ const colorKey = Array.isArray(this.color) ? this.color.join(',') : this.color
|
|
|
+ const cacheKey = `${this.cCollection}::${this.iconId}::${colorKey}`
|
|
|
+ let svg = svgRawCache.get(cacheKey)
|
|
|
+ if (svg === undefined) {
|
|
|
+ svg = iconPreset[0].replace(this.colorPlaceholder, (_, a, b) => {
|
|
|
return this.colorMap[a.toLowerCase()] + b
|
|
|
})
|
|
|
+ if (svgRawCache.size >= MAX_CACHE_SIZE) svgRawCache.clear()
|
|
|
+ svgRawCache.set(cacheKey, svg)
|
|
|
}
|
|
|
|
|
|
return svg
|
|
|
},
|
|
|
|
|
|
svgDataurl() {
|
|
|
- if (!this.isFileSource) {
|
|
|
- return `data:image/svg+xml,${encodeURIComponent(this.svgRaw)}`
|
|
|
- }
|
|
|
-
|
|
|
- if (/^data:image\/svg\+xml,<svg/i.test(this.icon)) {
|
|
|
- return `data:image/svg+xml,${encodeURIComponent(this.icon.substring(19))}`
|
|
|
+ // 实例级快速通道: props 未变化时, 跳过下面的 Map 查找, 直接复用上次结果
|
|
|
+ if (this._svgDataurlCache === this.svgRaw) {
|
|
|
+ return this._svgDataurlCacheResult
|
|
|
}
|
|
|
|
|
|
- if (/^<svg/i.test(this.icon)) {
|
|
|
- return `data:image/svg+xml,${encodeURIComponent(this.icon)}`
|
|
|
+ let result
|
|
|
+ if (!this.isFileSource) {
|
|
|
+ result = toSvgDataUrl(this.svgRaw)
|
|
|
+ } else if (/^data:image\/svg\+xml,<svg/i.test(this.icon)) {
|
|
|
+ result = toSvgDataUrl(this.icon.substring(19))
|
|
|
+ } else if (/^<svg/i.test(this.icon)) {
|
|
|
+ result = toSvgDataUrl(this.icon)
|
|
|
+ } else {
|
|
|
+ result = this.icon
|
|
|
}
|
|
|
|
|
|
- return this.icon
|
|
|
+ this._svgDataurlCache = this.svgRaw
|
|
|
+ this._svgDataurlCacheResult = result
|
|
|
+ return result
|
|
|
},
|
|
|
|
|
|
clazz() {
|
|
|
@@ -190,43 +303,22 @@ export default {
|
|
|
'--zui-svg-icon-height': this.cHeight,
|
|
|
}
|
|
|
|
|
|
- if (this.borderRadius) {
|
|
|
- let br = this.borderRadius
|
|
|
- if (typeof this.borderRadius === 'string') {
|
|
|
- if (!/[^a-z%]/i.test(this.borderRadius)) {
|
|
|
- const v = parseFloat(this.borderRadius)
|
|
|
- if (v < 1) {
|
|
|
- br = `${v * 100}%`
|
|
|
- } else {
|
|
|
- br = `${v}px`
|
|
|
- }
|
|
|
- }
|
|
|
- } else {
|
|
|
- if (this.borderRadius < 1) {
|
|
|
- br = `${this.borderRadius * 100}%`
|
|
|
- } else {
|
|
|
- br = `${this.borderRadius}px`
|
|
|
- }
|
|
|
- }
|
|
|
+ if (this.borderRadius != null) {
|
|
|
+ const br = this.formatBorderRadius(this.borderRadius)
|
|
|
style['--zui-svg-icon-border-radius'] = br
|
|
|
}
|
|
|
|
|
|
if (this.gray) {
|
|
|
- if (typeof this.gray === 'number') {
|
|
|
- style['filter'] = `grayscale(${this.gray})`
|
|
|
- } else {
|
|
|
- style['filter'] = 'grayscale(1)'
|
|
|
- }
|
|
|
+ style.filter = `grayscale(${typeof this.gray === 'number' ? this.gray : 1})`
|
|
|
}
|
|
|
|
|
|
if (this.spin) {
|
|
|
- const rotateDur = this.spin === true ? 5 : Math.abs(this.spin)
|
|
|
- style['--zui-svg-icon-rotate-duration'] = `${rotateDur}s`
|
|
|
+ style['--zui-svg-icon-rotate-duration'] = `${Math.abs(this.spin === true ? 5 : this.spin)}s`
|
|
|
}
|
|
|
|
|
|
- return Object.keys(style)
|
|
|
- .map(key => `${key}:${style[key]}`)
|
|
|
- .join('; ')
|
|
|
+ return Object.entries(style)
|
|
|
+ .map(([key, value]) => `${key}:${value}`)
|
|
|
+ .join(';')
|
|
|
},
|
|
|
},
|
|
|
|
|
|
@@ -239,6 +331,11 @@ export default {
|
|
|
},
|
|
|
},
|
|
|
|
|
|
+ created() {
|
|
|
+ this._svgDataurlCache = ''
|
|
|
+ this._svgDataurlCacheResult = ''
|
|
|
+ },
|
|
|
+
|
|
|
mounted() {
|
|
|
this.initialIcon()
|
|
|
},
|
|
|
@@ -248,26 +345,26 @@ export default {
|
|
|
// #ifdef MP-ALIPAY || MP-DINTTALK || MP-DINGDING
|
|
|
this.$emit('tap', evt)
|
|
|
// #endif
|
|
|
- setTimeout(() => {
|
|
|
+ this.$nextTick(() => {
|
|
|
this.$emit('click', evt)
|
|
|
- }, 1)
|
|
|
+ })
|
|
|
},
|
|
|
|
|
|
doTap(evt) {
|
|
|
- setTimeout(() => {
|
|
|
+ this.$nextTick(() => {
|
|
|
this.$emit('click', evt)
|
|
|
- }, 1)
|
|
|
+ })
|
|
|
},
|
|
|
|
|
|
initialIconColor() {
|
|
|
// if (this.isFileSource && !!this.color) {
|
|
|
// console.warn(`<zui-svg-icon /> 使用了未经过预处理的图标格式, 将不支持更换颜色. 未经过预处理的图标格式包括: URI, base64 图片, 原始SVG代码`)
|
|
|
// }
|
|
|
- // Initial color map
|
|
|
- const oriColors = this.getOriginalColors()
|
|
|
+ // 图标原始颜色列表 + 换色正则只与图标本身有关, 走全局缓存避免重复构建
|
|
|
+ const { oriColors, colorPlaceholder } = getIconMeta(this.svgIconLib, this.cCollection, this.iconId)
|
|
|
if (this.color && oriColors.length) {
|
|
|
const newColors = typeof this.color === 'string' ? this.color.split(',') : this.color
|
|
|
- this.colorPlaceholder = new RegExp(`(${oriColors.map(item => item.replace(/([\(\)])/g, '\\$1')).join('|')})([^\\w])`, 'gi')
|
|
|
+ this.colorPlaceholder = colorPlaceholder
|
|
|
this.colorMap = oriColors.reduce((a, b, idx) => {
|
|
|
return {
|
|
|
...a,
|
|
|
@@ -287,8 +384,19 @@ export default {
|
|
|
},
|
|
|
|
|
|
getOriginalColors() {
|
|
|
- const iconPreset = this.svgIconLib.icons[this.icon]
|
|
|
- return iconPreset ? iconPreset.slice(1).map(idx => this.svgIconLib.$_colorPalette[idx]) : []
|
|
|
+ return getIconMeta(this.svgIconLib, this.cCollection, this.iconId).oriColors
|
|
|
+ },
|
|
|
+
|
|
|
+ formatBorderRadius(value) {
|
|
|
+ if (typeof value === 'number') {
|
|
|
+ return value < 1 ? `${value * 100}%` : `${value}px`
|
|
|
+ }
|
|
|
+ if (typeof value === 'string') {
|
|
|
+ const num = parseFloat(value)
|
|
|
+ if (isNaN(num)) return value
|
|
|
+ return /[%]$/.test(value) ? value : `${num}px`
|
|
|
+ }
|
|
|
+ return '0'
|
|
|
},
|
|
|
},
|
|
|
}
|