【山竹记账前端-react】10.封装 Icon、封装 Loading
大纲链接 §
[toc]
1. 新建三个组件,拆分重构 ⇧
@/components/InLoading.tsx
1
2
3
4
5
6
|
export const InLoading: React.FC = () => {
return (
<div text-6xl>加载中...</div>
)
}
|
@/components/AddItemFloatButton.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
import { cn } from "cn"
import add from '@/assets/icons/add.svg'
export const AddItemFloatButton: React.FC = () => {
return (
<button
className={cn(
'w-56px h-56px',
'bg-[var(--button-primary-color)] rounded-full',
'text-[var(--button-primary-text-color)] text-6xl',
'p-4px text-center flex place-content-center place-items-center-safe',
'fixed bottom-.5em right-.5em',
)}>
<span className="hidden">Add</span>
<img src={add} alt="add" max-w="80%" max-h="80%" translate-y="-3px" />
</button>
)
}
|
@/pages/Home.tsx
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
|
import pig from '@/assets/icons/pig.svg'
import { cn } from 'cn'
import useSWR from 'swr'
import { request } from '@/lib/request/request'
import type { Item, Resource, Resources, ResponseData, User } from '@/global'
import { Navigate } from 'react-router-dom'
import { useTitle } from '@/hooks/useTitle'
import { InLoading } from '@/components/InLoading'
import { AddItemFloatButton } from '@/components/AddItemFloatButton'
interface Props {
title?: string
}
export const Home: React.FC<Props> = ({ title }) => {
useTitle(title ?? '记账首页')
const { data: meData, /* error: meError, */ isLoading: isLoadingMe } = useSWR('/api/v1/me', async path =>
(await request.get<ResponseData<Resource<User>>>(path))?.data?.data)
const { data: itemsData, /* error: itemsError, */ isLoading: isLoadingItems } = useSWR(meData ? '/api/v1/items' : null, async path =>
(await request.get<ResponseData<Resources<Item>>>(path))?.data?.data)
if (isLoadingMe || isLoadingItems) { return <InLoading /> }
if (itemsData?.resources?.[0]) { return <Navigate to="/items" /> }
return (
<main flex flex-col justify-center items-center>
<div flex justify-center items-center>
<img src={pig} alt="pig" w-128px h-130px mt-20vh mb-20vh block />
</div>
<div px-16px self-stretch>
<button
w="100%"
className={cn(
'h-48px bg-[var(--button-primary-color)] b-none rounded-8px',
'text-[var(--button-primary-text-color)]',
)}>
开始记账
</button>
</div>
<AddItemFloatButton />
</main>
)
}
|
占位 @/components/SvgIcon.tsx
1
2
3
4
5
6
|
export const SvgIcon: React.FC = () => {
return (
<div>SvgIcon</div>
)
}
|
2. 引入 Vite SVG Sprites 插件 ⇧
思路与SVG统一处理方案 ⇧
以前引入图片资源的思路
CSS 精灵图 过时啦
font-icon
svg symbols 与精灵图的原理如出一辙,所有 svg 放入一个大的 SVG 标签中
- 再用
js 生成0宽高不可见的标签插入 body
- 使用时 通过
<use> 标签引用,添加 name 属性
目前现代前端 vite 项目的思路
- 将
svg 图片统一放入 @/assets/icons/ 目录下
- 通过插件全部引入到项目入口文件中,创建一个0宽高不可见的总的
svg标签插到 body
封装vite svg插件,添加配置 ⇧
安装依赖
- 运行
pnpm add -D svgo@4.1.0 svgstore@3.0.1
添加插件 @/plugins/svgsprites.ts
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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
|
import path from 'path'
import fs from 'fs'
import store from 'svgstore'
import { optimize } from 'svgo'
import type { Plugin, ViteDevServer } from 'vite'
interface Options {
id?: string
inputFolder?: string
inline?: boolean
noOptimizeList?: string[]
}
export const svgsprites = (options: Options = {}): Plugin => {
const virtualModuleId = `virtual:svgsprites${options.id ? `-${options.id}` : ''}`
const resolvedVirtualModuleId = `\0${virtualModuleId}`
const { inputFolder = 'src/assets/icons', inline = false } = options
const generateCode = () => {
const sprites = store(options)
const iconsDir = path.resolve(inputFolder)
for (const file of fs.readdirSync(iconsDir)) {
if (!file.endsWith('.svg')) { continue }
const filepath = path.join(iconsDir, file)
const svgId = path.parse(file).name
const code = fs.readFileSync(filepath, { encoding: 'utf-8' })
const symbol = options.noOptimizeList?.includes(svgId)
? code
: optimize(code, {
plugins: [
'cleanupAttrs', 'removeDoctype', 'removeComments', 'removeTitle', 'removeDesc', 'removeEmptyAttrs',
{ name: 'removeAttrs', params: { attrs: '(data-name|fill)' } },
],
}).data
sprites.add(svgId, symbol)
}
return sprites.toString({ inline })
}
const handleFileCreationOrUpdate = (file: string, server: ViteDevServer) => {
if (!file.includes(inputFolder)) { return }
const code = generateCode()
server.ws.send('svgsprites:change', { code })
const mod = server.moduleGraph.getModuleById(resolvedVirtualModuleId)
if (!mod) { return }
server.moduleGraph.invalidateModule(mod, undefined, Date.now())
}
return {
name: 'svgsprites',
configureServer(server) {
server.watcher.on('add', (file) => {
handleFileCreationOrUpdate(file, server)
})
server.watcher.on('change', (file) => {
handleFileCreationOrUpdate(file, server)
})
},
resolveId(id: string) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId
}
},
load(id: string) {
if (id === resolvedVirtualModuleId) {
const code = generateCode()
return `!function(){
const div = document.createElement('div')
div.innerHTML = \`${code}\`
const svg = div.getElementsByTagName('svg')[0]
const updateSvg = (svg) => {
if (!svg) { return }
svg.style.position = 'absolute'
svg.style.width = 0
svg.style.height = 0
svg.style.overflow = 'hidden'
svg.setAttribute("aria-hidden", "true")
}
const insert = () => {
if (document.body.firstChild) {
document.body.insertBefore(div, document.body.firstChild)
} else {
document.body.appendChild(div)
}
}
updateSvg(svg)
if (document.body){
insert()
} else {
document.addEventListener('DOMContentLoaded', insert)
}
if (import.meta.hot) {
import.meta.hot.on('svgsprites:change', (data) => {
const code = data.code
div.innerHTML = code
const svg = div.getElementsByTagName('svg')[0]
updateSvg(svg)
})
}
}()
`
}
},
}
}
|
- 默认识别
svg 文件存放路径 const { inputFolder = 'src/assets/icons', inline = false } = options
- 提供虚拟引入路径
const virtualModuleId = `virtual:svgsprites${options.id ? `-${options.id}` : ''}
const resolvedVirtualModuleId = `\0${virtualModuleId}`
添加类型提示,消除类型报错 @/vite-env.d.ts
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
|
/// <reference types="vite/client" />
// vite-env.d.ts
declare const __IS_DEV__: boolean
declare module 'svgstore' {
export interface SvgStoreOptions {
cleanDefs?: boolean | string[]
cleanSymbols?: boolean | string[]
inline?: boolean
svgAttrs?: boolean | Record<string, string | number | boolean>
symbolAttrs?: boolean | Record<string, string | number | boolean>
copyAttrs?: boolean | string[]
renameDefs?: boolean
[key: string]: unknown
}
export interface SvgStoreInstance {
element: unknown
add(id: string, file: string, options?: SvgStoreOptions): SvgStoreInstance
toString(options?: { inline?: boolean;[key: string]: unknown }): string
}
function svgstore(options?: SvgStoreOptions): SvgStoreInstance
export default svgstore
}
declare module 'virtual:svgsprites' {
export * from '@svgoStore'
const defaultExport: readonly ["add", "back-arrow", "backspace", "chart", "clock", "cloud", "date", "decimal_point", "export", "mangosteen", "menu", "notes", "notify", "pig", "radix_point", "tech_stack"]
export default defaultExport
}
|
在 vite.config.ts 中引入并使用 svgsprites 插件
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
|
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, UserConfigExport, ConfigEnv } from 'vite'
import react from '@vitejs/plugin-react'
import UnoCSS from 'unocss/vite'
import jsxScoped from '@10coding/vite-plugin-jsx-scoped'
import { viteMockServe } from 'vite-plugin-mock'
import { consoleBanner } from "vite-plugin-console-banner"
import { svgsprites } from './src/plugins/svgsprites.ts'
// https://vitejs.dev/config/
export default defineConfig(({ command }: ConfigEnv) => ({
// base: '/mangosteen-react-1-preview/',
define: {
__IS_DEV__: command === 'serve',
},
plugins: [
UnoCSS(),
jsxScoped({ scopedIdAttributeName: 'scopedid' }),
react(),
viteMockServe({
mockPath: './src/mock',
enable: command === 'serve',
}),
consoleBanner({/*...*/}),
svgsprites(),
],
server: {
host: true
},
resolve: {
// 开启 Vite 8 的原生 tsconfig 路径解析支持
tsconfigPaths: true,
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
}
}
})) satisfies UserConfigExport
|
更新 tsconfig.json 配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}
|
添加 tsconfig.vitest.json 配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
{
"extends": "./tsconfig.app.json",
// Override to include only test files and clear exclusions.
// Include application declarations required by code imported in tests.
"include": ["src/**/__tests__/*", "src/**/*.d.ts", "env.d.ts"],
"exclude": [],
"compilerOptions": {
// Vitest runs in a different environment than the application code.
// Adjust lib and types accordingly.
"lib": [],
"types": ["node", "jsdom"],
"allowImportingTsExtensions": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo"
}
}
|
- 添加
allowImportingTsExtensions 和 `noEmit,并将 src/plugins/*.ts 纳入 include 范围
- 更新
tsconfig.vitest.json 配置,添加 allowImportingTsExtensions
在入口文件 main.tsx 中引入,使用虚拟引入路径 virtual:svgsprites
```tsx
import React from ‘react’
import ReactDOM from ‘react-dom/client’
import { RouterProvider } from ‘react-router-dom’
import { router } from ‘@/route/router’
import ‘@/main.scss’
import ‘virtual:uno.css’
import ‘virtual:svgsprites’
// const div = document.getElementById(‘root’) as HTMLElement
const div = document.getElementById(‘root’)
const root = ReactDOM.createRoot(div!)
root.render(
,
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
- 需要重启 `vite` 服务
- 服务重启后,查看页面元素,`body` 中多出一个标签 `<svg>`,用于存放所有图标资源,即包含所有分散图标的 `symbol` 标签
- 宽高 0,不可见,预加载,只下载一个资源 `/@id/__x00__virtual:svgsprites`
> `svgo` 优化处理
- 已去除部分图标的 `fill` 属性,可自定义配色
- 源码可自定义去除属性 `{ name: 'removeAttrs', params: { attrs: '(data-name|fill)' } }`
- 可配置不用去除的 `icon`:`noOptimizeList: ['pig']`
---
> 参考
- [FrankFang/svgsprites.ts](https://gist.github.com/FrankFang/8538a24696f0eb9ae093918abfd936a5)
- [vite 引入一个虚拟文件](https://cn.vite.dev/guide/api-plugin#importing-a-virtual-file)
- [虚拟模块约定 Virtual Modules Convention](https://rolldown.rs/apis/plugin-api#virtual-modules)
- [virtual:example](https://rolldown.rs/apis/plugin-api#example)
---
### 简单的使用方式,替换 `<img />` 标签 <a href="#catalogue"> ⇧ </a> |
html
tsx
1
2
3
4
|
- 通过 `id` 即图标名称 使用 `<use xlinkHref="#id" />`
- 文件名和 `id` 一致,例如 `add.svg` 对应 `id="add"`,不可使用中文
> 无法显示,需要指定颜色和尺寸大小 |
tsx
1
2
3
4
5
|
---
## 3. 封装 Icon 组件,使用 where 伪类 <a href="#catalogue"> ⇧ </a>
> `@/components/SvgIcon.tsx` |
tsx
import { cn } from “cn”
interface Props {
name: string
}
export const SvgIcon: React.FC = ({ name, }: Props) => {
return (
)
}
1
2
3
|
- 使用 `fill-current-color` 写图标颜色
> 类型报错 `w="80%"` 鼠标浮动上去查看报错信息 |
text
不能将类型“{ children: Element; w: string; className: string; “max-w”: string; “max-h”: string; “fill-current-color”: true; “translate-y”: string; }”分配给类型“SVGProps”。
类型“SVGProps”上不存在属性“w”
1
2
3
4
5
|
- `SVGProps` 是 `React` 提供的类型,用于定义 SVG 元素的属性,点击查看类型文件
- 或引入 `import type { SVGProps } from "react"` 查看类型定义 `type X = SVGProps<SVGSVGElement>` 泛型类型“SVGProps”需要 1 个类型参数
- `interface SVGProps<T> extends SVGAttributes<T>, ClassAttributes<T>` 抄到声明文件 `src/shims.d.ts` 中即可
> `@/shims.d.ts` |
ts
import { HTMLAttributes } from ‘react’
import type { AttributifyAttributes } from ‘@unocss/preset-attributify’
declare module ‘react’ {
interface HTMLAttributes extends AriaAttributes, AttributifyAttributes, DOMAttributes {
// 允许传入 UnoCSS 属性
bold?: boolean
text?: string
bordered?: boolean
bg?: string
flex?: string | boolean
block?: boolean
relative?: boolean
absolute?: boolean
scopedId?: string
// 或者直接使用通配符或特殊定义
[key: string]: unknown
}
interface SVGProps extends SVGAttributes, ClassAttributes {
// 允许传入 UnoCSS 属性
w?: string
h?: string
// 或者直接使用通配符或特殊定义
[key: string]: unknown
}
}
1
2
3
|
- 扩展了接口类型 `SVGProps`
> 项目中替换使用 `@/components/AddItemFloatButton.tsx` |
tsx
import { SvgIcon } from “@/components/SvgIcon”
import { cn } from “cn”
export const AddItemFloatButton: React.FC = () => {
return (
1
2
3
|
<SvgIcon name="add" w-48px h-48px />
</button> |
)
}
1
2
3
4
5
6
7
8
|
- 写了宽高尺寸,但图标中无变化
- `React` 不会自动接受子组件中没有定义 `props` 的属性;那么添加 `className` 属性到 `SvgIcon` 中
- 但是原来的 `'w-1.2em h-1.2em'` 的央视优先级更高
- 需要将 `SvgIcon` 组件中的样式的优先级尽可能地低,方便外部覆盖样式,可使用 `:where`
- 一种方案是通过 `CSS Module` 引入模块样式,在其中写 `:where(.icon) {...}`
- 另外方案可以通过配置 `uno.config.ts` 中的 `variants` 和 `transformerVariantGroup` 来实现 `where:()` 的组合写法
> `uno.config.ts` |
tsx
import { defineConfig, presetAttributify, presetIcons, presetTypography, transformerAttributifyJsx, transformerVariantGroup } from ‘unocss’
import { presetWind4 } from ‘@unocss/preset-wind4’
export default defineConfig({
presets: [
presetWind4(),
presetAttributify(),
// …
],
transformers: [
// 支持 where:(a b c) 这种分组写法
transformerVariantGroup(),
transformerAttributifyJsx(),
],
theme: {
colors: {
// 绑定你的 CSS 变量…
}
},
variants: [
// where:xxx -> :where(.where:xxx) { … },优先级 0,0,0
(matcher) => {
if (!matcher.startsWith(‘where:‘))
return matcher
return {
matcher: matcher.slice(6),
selector: s => :where(${s}),
}
},
],
})
1
|
> `@/components/SvgIcon.tsx` |
tsx
import { cn } from “cn”
interface Props {
name: string
className?: string
}
export const SvgIcon: React.FC = ({ name, className }: Props) => {
return (
)
}
1
|
> 可以覆盖子组件本身的样式了 `@/components/AddItemFloatButton.tsx` |
tsx
export const AddItemFloatButton: React.FC = () => {
return (
1
2
3
|
<SvgIcon name="add" className="size-[2em]" />
</button> |
)
}
1
2
3
4
5
6
7
8
9
10
11
|
---
## 4. 使用 preflight 和 layer 改变 CSS 顺序 <a href="#catalogue"> ⇧ </a>
> `unocss` 的 `layer` 怎么用
- 用来调整 `CSS` 的顺序
- 使用普通的 `CSS`,不用考虑浏览器的兼容
- 使用 `preflight` 来预定义
> 设置最低层级的预设层级 `components` 优先级 `-1` |
ts
export default defineConfig({
presets: [
/* … */
],
preflights: [
{
layer: ‘components’,
getCSS: () =>
.icon {
fill: currentColor;
width: 1.2em;
height: 1.2em;
}
}
],
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
- 缺点是必须使用全局的样式了,只能用 `BEM` 命名添加前缀来区分样式了
- 这个技术只是把加载的样式提前到了 `unocss` 默认层级之前或者之后
- 可以打开浏览器面板加载的 `css style` 文件查看

> 参考
- [unocss Layers](https://unocss.dev/config/layers#layers)
- [unocss Preflights](https://unocss.dev/config/preflights)
---
## 5. 返璞归真,使用 import 决定 CSS 顺序 <a href="#catalogue"> ⇧ </a>
> 或者可以声明一个默认的样式文件 `default.scss`,通过引入顺序来处理样式覆盖问题
---
## 6. 封装 Loading 组件 <a href="#catalogue"> ⇧ </a>
> `@/components/InLoading.tsx` |
tsx
import { SvgIcon } from “@/components/SvgIcon”
import { cn } from “cn”
interface Props {
className?: string
}
export const InLoading: React.FC = ({ className, }) => {
return (
)
}
tsx
// import …
interface Props {
title?: string
}
export const Home: React.FC = ({ title }) => {
useTitle(title ?? ‘记账首页’)
const { data: meData, /* error: meError, / isLoading: isLoadingMe } = useSWR(‘/api/v1/me’, async path =>
(await request.get>>(path))?.data?.data)
const { data: itemsData, / error: itemsError, */ isLoading: isLoadingItems } = useSWR(meData ? ‘/api/v1/items’ : null, async path =>
(await request.get>>(path))?.data?.data)
if (isLoadingMe || isLoadingItems) { return }
if (itemsData?.resources?.[0]) { return }
return (
1
2
3
4
5
6
7
8
9
10
11
12
13
|
<div px-16px self-stretch>
<button
w="100%"
className={cn(
'h-48px bg-[var(--button-primary-color)] b-none rounded-8px',
'text-[var(--button-primary-text-color)]',
)}>
开始记账
</button>
</div>
<AddItemFloatButton />
</main> |
)
}
```
7. 组件化的优点 ⇧
组件化
封装 Icon 组件可以减少请求资源;可以自定义修改属性
·未完待续·
参考文章
相关文章
- 作者: Joel
- 文章链接:
- 版权声明
- 非自由转载-非商用-非衍生-保持署名