【山竹记账前端-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

1
> `jsx` 中写

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 (