用过unplugin-icons的小伙伴应该都对这个插件不陌生,这是一个用来导入svg图标成为各个框架组件的一个插件。
下面带大家看看这个插件是怎么实现的。
简单通过vitest调用一下插件
拉完代码之后,我们想运行一下这个插件的方法其实很简单,因为这是基于unplugin实现的插件,所以可以任选一个打包工具,vite、webpack、rollup啥的,因为vite配置简单所以我比较喜欢vite,这里我们选个vite来调用插件吧。
我的想法是基于vitest调用一下vite的build方法,当然你直接写个js,然后调用也是一样的,不过既然项目已经配好了vitest那我们直接用vitest,更方便ide启动(vscode需要安装vitest插件),下面我们创建下文件
1 2 3 4
| import logo from '~icons/logos/react'
console.log(logo)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import * as path from 'node:path' import { it } from 'vitest' import { build } from 'vite' import icons from '../src/vite'
it('should build', async () => { const res = await build({ plugins: [icons({ compiler: 'jsx', })], root: path.resolve(__dirname), build: { outDir: 'dist', rollupOptions: { external: ['vue', 'react'], input: path.resolve(__dirname, 'main.ts'), }, }, }) })
|
然后如果是webstorm调试就很简单了:
![image.png]()
点击下三角箭头,选择调试就可以了。
如果是vscode的话,需要先装一下vitest插件,然后重启一下。但是应该会报错,说项目的vitest版本太老了不支持,这时候只需要去package.json将vitest的版本改成latest,然后重装下依赖,然后点下刷新
![image.png]()
但是我猜你的vitest插件应该还是报错,是example里面项目的配置问题,为了方便不折腾别的,我们直接删掉examples目录就好了。
ws调试的话如果断点停留超过5000ms,vitest可能会报错,我们可以加个时间,随便给的大一点就行了。
![image.png]()
resolveId
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| resolveId(id) { if (isIconPath(id)) { const normalizedId = normalizeIconPath(id) const queryIndex = normalizedId.indexOf('?') const res = `${(queryIndex > -1 ? normalizedId.slice(0, queryIndex) : normalizedId) .replace(/\.\w+$/i, '') .replace(/^\//, '')}${queryIndex > -1 ? `?${normalizedId.slice(queryIndex + 1)}` : ''}` const resolved = resolveIconsPath(res) const compiler = resolved?.query?.raw === 'true' ? 'raw' : options.compiler if (compiler && typeof compiler !== 'string') { const ext = compiler.extension if (ext) return `${res}.${ext.startsWith('.') ? ext.slice(1) : ext}` } else { switch (compiler) { case 'astro': return `${res}.astro` case 'jsx': return `${res}.jsx` case 'qwik': return `${res}.jsx` case 'marko': return `${res}.marko` case 'svelte': return `${res}.svelte` case 'solid': return `${res}.tsx` } } return res } return null },
|
用到的工具函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| const URL_PREFIXES = ['/~icons/', '~icons/', 'virtual:icons/', 'virtual/icons/'] const iconPathRE = new RegExp(`${URL_PREFIXES.map(v => `^${v}`).join('|')}`)
export interface ResolvedIconPath { collection: string icon: string query: Record<string, string | undefined> }
export function isIconPath(path: string) { return iconPathRE.test(path) }
export function normalizeIconPath(path: string) { return path.replace(iconPathRE, URL_PREFIXES[0]) }
export function resolveIconsPath(path: string): ResolvedIconPath | null { if (!isIconPath(path)) return null
path = path.replace(iconPathRE, '')
const query: ResolvedIconPath['query'] = {} const queryIndex = path.indexOf('?') if (queryIndex !== -1) { const queryRaw = path.slice(queryIndex + 1) path = path.slice(0, queryIndex) new URLSearchParams(queryRaw).forEach((value, key) => { if (key === 'raw') query.raw = (value === '' || value === 'true') ? 'true' : 'false' else query[key] = value }) }
path = path.replace(/\.\w+$/, '')
const [collection, icon] = path.split('/')
return { collection, icon, query, } }
|
很简单的几个函数,就不多做解析了。就是判断我们import的值是否是icon。
![image.png]()
resolveId这个hook基本就是用来处理我们模块的id名,比如最后我们按需要加上后缀,对于vue、react这里我们不需要加后缀。
load
处理完import的id,然后下面就开始实现代码生成的逻辑,这里用了两个hook,loadInclude
和load
,loadInclude
是用来过滤哪个需要调用load
的id:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| loadInclude(id) { return isIconPath(id) }, async load(id) { const config = await resolved const code = await generateComponentFromPath(id, config) || null if (code) { return { code, map: { version: 3, mappings: '', sources: [] } as any, } } },
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| import { loadNodeIcon } from '@iconify/utils/lib/loader/node-loader'
export async function generateComponentFromPath(path: string, options: ResolvedOptions) { const resolved = resolveIconsPath(path) if (!resolved) return null return generateComponent(resolved, options) }
export async function generateComponent({ collection, icon, query }: ResolvedIconPath, options: ResolvedOptions) { const warn = `${collection}/${icon}` const { scale, defaultStyle, defaultClass, customCollections, iconCustomizer: providedIconCustomizer, transform, autoInstall = false, collectionsNodeResolvePath, } = options
const iconifyLoaderOptions: IconifyLoaderOptions = { addXmlNs: false, scale, customCollections, autoInstall, defaultClass, defaultStyle, cwd: collectionsNodeResolvePath, warn: undefined, customizations: { transform, async iconCustomizer(collection, icon, props) { await providedIconCustomizer?.(collection, icon, props) Object.keys(query).forEach((p) => { const v = query[p] if (p !== 'raw' && v !== undefined && v !== null) props[p] = v }) }, }, } const svg = await loadNodeIcon(collection, icon, iconifyLoaderOptions) if (!svg) throw new Error(`Icon \`${warn}\` not found`)
const _compiler = query.raw === 'true' ? 'raw' : options.compiler
if (_compiler) { const compiler = typeof _compiler === 'string' ? compilers[_compiler] : (await _compiler.compiler) as Compiler
if (compiler) return compiler(svg, collection, icon, options) }
throw new Error(`Unknown compiler: ${_compiler}`) }
|
![image.png]()
可以看到这个方法主要还是通过iconify的工具函数加载svg,然后交给compiler去处理成组件或者raw字符串
compiler
可以点击进入compilers看到各种框架的实现,先看看是怎么处理raw的:
1 2 3 4 5 6
| import type { Compiler } from './types'
export const RawCompiler = ((svg: string) => { return `export default ${JSON.stringify(svg)}` }) as Compiler
|
很简单,就在字符串外面加一个export default用来给别的模块导入。
看看react的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| import { importModule } from 'local-pkg' import { camelize } from '@iconify/utils/lib/misc/strings' import type { Compiler } from './types'
export const JSXCompiler = (async ( svg, collection, icon, options, ) => { const svgrCore = await importModule('@svgr/core') const svgr = svgrCore.transform || (svgrCore.default ? (svgrCore.default.transform ?? svgrCore.default) : svgrCore.default) || svgrCore let res = await svgr( svg, { plugins: ['@svgr/plugin-jsx'], }, { componentName: camelize(`${collection}-${icon}`) }, ) if (options.jsx !== 'react') res = res.replace('import * as React from "react";', '') return res }) as Compiler
|
很熟悉的svgr,直接生成了适合react的代码
![image.png]()
然后看看vue3的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| import { importModule } from 'local-pkg' import { handleSVGId } from '../svgId' import type { Compiler } from './types'
export const Vue3Compiler = (async (svg: string, collection: string, icon: string) => { const { compileTemplate } = await importModule('@vue/compiler-sfc') const { injectScripts, svg: handled } = handleSVGId(svg)
let { code } = compileTemplate({ source: handled, id: `${collection}:${icon}`, filename: `${collection}-${icon}.vue`, })
code = `import { markRaw } from 'vue'\n${code}` code = code.replace(/^export /gm, '') code += `\n\nexport default markRaw({ name: '${collection}-${icon}', render${ injectScripts ? `, data() {${injectScripts};return { idMap }}` : '' } })` code += '\n/* vite-plugin-components disabled */'
return code }) as Compiler
|
![image.png]()
总结
可以看到这个插件很简单,基本就三部分,处理resolveId,调用iconify的工具加载svg字符串,最后调用compiler生成组件代码。看完这个插件相信你也能学会写一些简单的插件。