Repository: IduxFE/components
Pull request #23 (2020-12-16 12:31:32)

feat(comp:icon): add icon component
## PR Checklist

Please check if your PR fulfills the following requirements:

- [x] The commit message follows [our guidelines](https://github.com/IduxFE/components/blob/main/docs/contributing.zh.md#commit)
- [x] Tests for the changes have been added/updated or not needed
- [x] Docs and demo have been added/updated or not needed

## PR Type

What kind of change does this PR introduce?

- [ ] Bugfix
- [x] Feature
- [ ] Component style update
- [ ] Code style update (formatting, local variables)
- [ ] Refactoring (no functional changes, no api changes)
- [ ] Build related changes
- [ ] CI related changes
- [ ] Documentation content changes
- [ ] Application (the showcase website) / infrastructure changes
- [ ] Other... Please describe:

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying, or link to a relevant issue. -->

## What is the new behavior?

## Other information


codecov[bot]: # [Codecov](https://codecov.io/gh/IduxFE/components/pull/23?src=pr&el=h1) Report
> Merging [#23](https://codecov.io/gh/IduxFE/components/pull/23?src=pr&el=desc) (52b2ee3) into [main](https://codecov.io/gh/IduxFE/components/commit/eeb9448337a9fb564190c11f590d39339e74e7b3?el=desc) (eeb9448) will **decrease** coverage by `23.83%`.
> The diff coverage is `69.29%`.

[![Impacted file tree graph](https://codecov.io/gh/IduxFE/components/pull/23/graphs/tree.svg?width=650&height=150&src=pr&token=PGAUXP06V3)](https://codecov.io/gh/IduxFE/components/pull/23?src=pr&el=tree)

```diff
@@             Coverage Diff             @@
##             main      #23       +/-   ##
===========================================
- Coverage   96.22%   72.38%   -23.84%     
===========================================
  Files           7        8        +1     
  Lines         106      134       +28     
  Branches       15       22        +7     
===========================================
- Hits          102       97        -5     
- Misses          0       29       +29     
- Partials        4        8        +4     
```


| [Impacted Files](https://codecov.io/gh/IduxFE/components/pull/23?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [packages/components/core/logger/index.ts](https://codecov.io/gh/IduxFE/components/pull/23/diff?src=pr&el=tree#diff-cGFja2FnZXMvY29tcG9uZW50cy9jb3JlL2xvZ2dlci9pbmRleC50cw==) | `85.71% <0.00%> (ø)` | |
| [packages/components/icon/src/utils.ts](https://codecov.io/gh/IduxFE/components/pull/23/diff?src=pr&el=tree#diff-cGFja2FnZXMvY29tcG9uZW50cy9pY29uL3NyYy91dGlscy50cw==) | `59.09% <59.09%> (ø)` | |
| [packages/components/icon/src/helper.ts](https://codecov.io/gh/IduxFE/components/pull/23/diff?src=pr&el=tree#diff-cGFja2FnZXMvY29tcG9uZW50cy9pY29uL3NyYy9oZWxwZXIudHM=) | `80.00% <80.00%> (ø)` | |
| [packages/components/icon/src/Icon.vue](https://codecov.io/gh/IduxFE/components/pull/23/diff?src=pr&el=tree#diff-cGFja2FnZXMvY29tcG9uZW50cy9pY29uL3NyYy9JY29uLnZ1ZQ==) | `84.61% <84.61%> (ø)` | |
| [packages/components/core/config/useGlobalConfig.ts](https://codecov.io/gh/IduxFE/components/pull/23/diff?src=pr&el=tree#diff-cGFja2FnZXMvY29tcG9uZW50cy9jb3JlL2NvbmZpZy91c2VHbG9iYWxDb25maWcudHM=) | `100.00% <100.00%> (ø)` | |
| [packages/components/core/constant/index.ts](https://codecov.io/gh/IduxFE/components/pull/23/diff?src=pr&el=tree#diff-cGFja2FnZXMvY29tcG9uZW50cy9jb3JlL2NvbnN0YW50L2luZGV4LnRz) | `100.00% <100.00%> (ø)` | |
| [packages/cdk/clipboard/src/pendingCopy.ts](https://codecov.io/gh/IduxFE/components/pull/23/diff?src=pr&el=tree#diff-cGFja2FnZXMvY2RrL2NsaXBib2FyZC9zcmMvcGVuZGluZ0NvcHkudHM=) | | |
| [packages/components/button/src/Button.vue](https://codecov.io/gh/IduxFE/components/pull/23/diff?src=pr&el=tree#diff-cGFja2FnZXMvY29tcG9uZW50cy9idXR0b24vc3JjL0J1dHRvbi52dWU=) | | |
| [packages/cdk/clipboard/src/clipboard.ts](https://codecov.io/gh/IduxFE/components/pull/23/diff?src=pr&el=tree#diff-cGFja2FnZXMvY2RrL2NsaXBib2FyZC9zcmMvY2xpcGJvYXJkLnRz) | | |
| [packages/cdk/clipboard/src/useClipboard.ts](https://codecov.io/gh/IduxFE/components/pull/23/diff?src=pr&el=tree#diff-cGFja2FnZXMvY2RrL2NsaXBib2FyZC9zcmMvdXNlQ2xpcGJvYXJkLnRz) | | |
| ... and [9 more](https://codecov.io/gh/IduxFE/components/pull/23/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/IduxFE/components/pull/23?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/IduxFE/components/pull/23?src=pr&el=footer). Last update [eeb9448...2a12392](https://codecov.io/gh/IduxFE/components/pull/23?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/.eslintrc.js b/.eslintrc.js
index 89d98261..7c32cccb 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -20,9 +20,9 @@ module.exports = {
     },
   },
   rules: {
+    // common
     'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
     'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
-
     'eol-last': 'error',
     'no-trailing-spaces': 'error',
     'comma-style': ['error', 'last'],
@@ -34,6 +34,7 @@ module.exports = {
     'object-curly-spacing': ['error', 'always'],
     'arrow-parens': ['error', 'as-needed'],
 
+    // ts
     '@typescript-eslint/member-delimiter-style': [
       'error',
       {
@@ -48,8 +49,10 @@ module.exports = {
       },
     ],
 
-    'vue/max-attributes-per-line': 'off',
+    // vue
     'vue/html-closing-bracket-spacing': 'error',
+
+    'vue/max-attributes-per-line': 'off',
     'vue/require-default-prop': 'off',
     'vue/attribute-hyphenation': 'off',
   },
diff --git a/.gitignore b/.gitignore
index 7bb3e645..3ed4034d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,7 @@ output
 
 package-lock.json
 yarn.lock
+
+# temp icons
+packages/components/icon/src/svg
+packages/components/icon/src/definitions
diff --git a/.prettierignore b/.prettierignore
index bc5bc12e..caa0b08f 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -12,3 +12,6 @@ package.json
 package-lock.json
 yarn.lock
 
+# temp icons
+packages/components/icon/src/svg
+packages/components/icon/src/definitions
diff --git a/jest.config.js b/jest.config.js
index 69b23647..3bac29c3 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,7 +1,7 @@
 module.exports = {
   // u can change this option to a more specific folder for test single component or util when dev
   // for example, ['<rootDir>/packages/components/button']
-  roots: ['<rootDir>'],
+  roots: ['<rootDir>/packages/components/icon'],
 
   testEnvironment: 'jsdom',
   transform: {
diff --git a/package.json b/package.json
index 602b2a2f..2cf9e3b3 100644
--- a/package.json
+++ b/package.json
@@ -35,7 +35,7 @@
     "@types/marked": "^1.2.1",
     "@types/parse5": "^5.0.3",
     "@types/prismjs": "^1.16.2",
-    "@types/sanitize-html": "^1.27.0",
+    "@types/svgo": "^1.3.3",
     "@types/yaml-front-matter": "^4.1.0",
     "@typescript-eslint/eslint-plugin": "^4.8.0",
     "@typescript-eslint/parser": "^4.8.0",
@@ -73,10 +73,10 @@
     "remark": "^13.0.0",
     "resolve-bin": "^0.4.0",
     "rimraf": "^3.0.0",
-    "sanitize-html": "^2.2.0",
     "stylelint": "^13.0.0",
     "stylelint-config-prettier": "^8.0.0",
     "stylelint-config-standard": "^20.0.0",
+    "svgo": "^1.3.2",
     "ts-jest": "^26.4.0",
     "ts-node": "^9.1.0",
     "tslib": "^2.0.3",
diff --git a/packages/components/core/config/types.ts b/packages/components/core/config/types.ts
index 4cbf0906..cf225278 100644
--- a/packages/components/core/config/types.ts
+++ b/packages/components/core/config/types.ts
@@ -4,9 +4,14 @@ export type GlobalConfigKey = keyof GlobalConfig
 
 export interface GlobalConfig {
   button: ButtonConfig
+  icon: IconConfig
 }
 
 export interface ButtonConfig {
   mode: ButtonMode
   size: ComponentSize
 }
+
+export interface IconConfig {
+  loadIconDynamically?: (iconName: string) => Promise<string>
+}
diff --git a/packages/components/core/config/useGlobalConfig.ts b/packages/components/core/config/useGlobalConfig.ts
index c44edde2..83221062 100644
--- a/packages/components/core/config/useGlobalConfig.ts
+++ b/packages/components/core/config/useGlobalConfig.ts
@@ -1,15 +1,18 @@
 import { inject, shallowReactive, provide, readonly } from 'vue'
 import type { DeepReadonly } from 'vue'
-import type { ButtonConfig, GlobalConfig, GlobalConfigKey } from './types'
+import type { ButtonConfig, GlobalConfig, GlobalConfigKey, IconConfig } from './types'
 
 const button: ButtonConfig = shallowReactive({ mode: 'default', size: 'medium' })
+const icon: IconConfig = shallowReactive({})
 
 const defaultConfig: GlobalConfig = {
   button,
+  icon,
 }
 
 const globalConfigToken: Record<GlobalConfigKey, symbol> = {
   button: Symbol(),
+  icon: Symbol(),
 }
 
 /**
@@ -22,10 +25,8 @@ export function useGlobalConfig<T extends GlobalConfigKey>(
   config: Partial<GlobalConfig[T]>,
 ): GlobalConfig[T]
 export function useGlobalConfig<T extends GlobalConfigKey>(compName: T): DeepReadonly<GlobalConfig[T]>
-export function useGlobalConfig<T extends GlobalConfigKey>(
-  compName: T,
-  config?: Partial<GlobalConfig[T]>,
-): GlobalConfig[T] | DeepReadonly<GlobalConfig[T]> {
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export function useGlobalConfig<T extends GlobalConfigKey>(compName: T, config?: Partial<GlobalConfig[T]>) {
   const token = globalConfigToken[compName]
   const parentConfig = inject(token, defaultConfig[compName])
 
diff --git a/packages/components/core/constant/index.ts b/packages/components/core/constant/index.ts
new file mode 100644
index 00000000..b161af0e
--- /dev/null
+++ b/packages/components/core/constant/index.ts
@@ -0,0 +1,1 @@
+export const IDUX_COMPONENTS_PREFIX = '[@idux/components]:'
diff --git a/packages/components/core/logger/index.ts b/packages/components/core/logger/index.ts
index 768c088a..d095dee0 100644
--- a/packages/components/core/logger/index.ts
+++ b/packages/components/core/logger/index.ts
@@ -1,11 +1,10 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
-import { isDevMode } from '../utils/isDevMode'
-
-const prefix = '[@idux/components]:'
+import { IDUX_COMPONENTS_PREFIX } from '../constant'
+import { isDevMode } from '../utils'
 
 const logWrapper = (args: any[], log: (...args: any[]) => void) => {
   if (isDevMode) {
-    log(prefix, ...args)
+    log(IDUX_COMPONENTS_PREFIX, ...args)
   }
 }
 
diff --git a/packages/components/core/utils/index.ts b/packages/components/core/utils/index.ts
index 777816aa..ed661eff 100644
--- a/packages/components/core/utils/index.ts
+++ b/packages/components/core/utils/index.ts
@@ -1,1 +1,2 @@
 export * from './installComponent'
+export * from './isDevMode'
diff --git a/packages/components/icon/demo/basic.md b/packages/components/icon/demo/basic.md
new file mode 100644
index 00000000..6c5ec25d
--- /dev/null
+++ b/packages/components/icon/demo/basic.md
@@ -0,0 +1,49 @@
+---
+order: 0
+title:
+  zh: 基本使用
+  en: Basic usage
+---
+
+## zh
+
+通过指定 `name` 属性来加载不同的图标, 可以使用 `rotate` 来开启旋转或者直接指定旋转角度。
+支持动态加载和静态加载, 推荐在首屏加载的图标使用静态加载的方式，其他图标使用动态加载。  
+同时还支持直接引入 iconfont 的脚本, 使用较为方便。
+
+## demo
+
+```html
+<template>
+  <ix-icon name="up" />
+  <ix-icon name="down" rotate />
+  <ix-icon name="left" rotate="90" />
+  <ix-icon name="right" rotate />
+  <ix-icon name="ix-icon-up" iconfont />
+  <ix-icon name="ix-icon-down" iconfont />
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue'
+import { useGlobalConfig } from '@idux/components'
+import { addIconDefinitions, Down, fetchFromIconfont } from '@idux/components/icon'
+
+export default defineComponent({
+  setup() {
+    // 静态加载：无需发请求,但是会增加包体积, 推荐首屏加载的图标使用静态加载，减少 http 请求数量
+    addIconDefinitions([Down])
+
+    // 动态加载：不会被打包，可以减小包体积，需要加载的时候时候 http 请求加载
+    const loadIconDynamically = (iconName: string) => {
+      // TODO： fix with vite
+      return fetch(`packages/components/icon/src/svg/${iconName}.svg`).then(res => res.text())
+    }
+    useGlobalConfig('icon', { loadIconDynamically })
+
+    // 直接引入 iconfont script, 使用时需要注明 iconfont
+    fetchFromIconfont('https://at.alicdn.com/t/font_2269256_s10i6xhg8l.js')
+  },
+})
+</script>
+
+```
diff --git a/packages/components/icon/docs/index.zh.md b/packages/components/icon/docs/index.zh.md
new file mode 100644
index 00000000..3750f02a
--- /dev/null
+++ b/packages/components/icon/docs/index.zh.md
@@ -0,0 +1,30 @@
+---
+category: components
+type:
+title: Icon
+subtitle:
+cover:
+---
+
+语义化的矢量图形。
+
+## 何时使用
+
+## API
+
+### ix-icon
+
+> 当存在 @click 的时候，会被渲染为一个 `button` 元素，其他情况为 `i` 元素
+
+| 参数 | 说明 | 类型 | 全局配置 |
+| --- | --- | --- | --- |
+| `[name]`| 图标名称 | `string` |
+| `[rotate]` | 图标旋转角度, 为 `true` 时会循环旋转 | `boolean|number|string` | - |
+| `[iconfont]` | 图标是否来自 `iconfont` | `boolean` | - |
+
+### [helper 函数]
+
+| 方法 | 说明 | 参数 |
+| --- | --- | --- |
+| `addIconDefinitions()` | 用于静态引入图标 | `IconDefinition[]` |
+| `fetchFromIconfont()` | 用于从 [`iconfont`](https://www.iconfont.cn) 获取图标资源文件 | `string|string[]` |
diff --git a/packages/components/icon/index.ts b/packages/components/icon/index.ts
new file mode 100644
index 00000000..c34aa981
--- /dev/null
+++ b/packages/components/icon/index.ts
@@ -0,0 +1,9 @@
+import { installComponent } from '@idux/components/core/utils'
+import Icon from './src/Icon.vue'
+
+Icon.install = installComponent(Icon)
+
+export { Icon }
+export type { IconComponent, IconDefinition } from './src/types'
+export * from './src/helper'
+export * from './src/definitions'
diff --git a/packages/components/icon/src/Icon.vue b/packages/components/icon/src/Icon.vue
new file mode 100644
index 00000000..c0f710c3
--- /dev/null
+++ b/packages/components/icon/src/Icon.vue
@@ -0,0 +1,80 @@
+<template>
+  <component :is="tag" ref="root" class="ix-icon" :class="{ 'ix-icon-spin': isSpin }" role="img" :ariaLabel="name">
+    <slot></slot>
+  </component>
+</template>
+<script lang="ts">
+import type { Ref, SetupContext } from 'vue'
+import type { IconConfig } from '@idux/components/core/config'
+import type { IconProps } from './types'
+
+import { computed, defineComponent, onMounted, onUpdated, ref, watch } from 'vue'
+import { useGlobalConfig } from '@idux/components/core/config'
+import { clearSVGElement, loadIconFontSvgElement, loadSVGElement } from './utils'
+
+export default defineComponent({
+  name: 'IxIcon',
+  props: {
+    name: String,
+    rotate: [Boolean, Number, String],
+    iconfont: Boolean,
+  },
+  setup(props: IconProps, { attrs }: SetupContext) {
+    const root = ref((null as unknown) as HTMLElement)
+    const iconConfig = useGlobalConfig('icon')
+    onMounted(() => appendChild(props, iconConfig, root))
+
+    watchName(props, iconConfig, root)
+    watchRotate(props, root)
+
+    const tag = ref(attrs.onClick ? 'button' : 'i')
+    onUpdated(() => {
+      tag.value = attrs.onClick ? 'button' : 'i'
+    })
+
+    const isSpin = computed(() => typeof props.rotate === 'boolean' && props.rotate)
+
+    return { root, tag, isSpin }
+  },
+})
+
+function watchName(props: IconProps, iconConfig: IconConfig, root: Ref<HTMLElement>) {
+  watch([() => props.name, () => props.iconfont], () => {
+    clearSVGElement(root.value)
+    appendChild(props, iconConfig, root)
+  })
+}
+
+function watchRotate(props: IconProps, root: Ref<HTMLElement>) {
+  watch(
+    () => props.rotate,
+    rotate => {
+      const firstChild = root.value.firstElementChild as SVGElement
+      if (firstChild) {
+        handleRotate(firstChild, rotate)
+      }
+    },
+  )
+}
+
+async function appendChild(props: IconProps, iconConfig: IconConfig, root: Ref<HTMLElement>): Promise<void> {
+  const { name, iconfont, rotate } = props
+  if (name) {
+    const svgElement = iconfont
+      ? loadIconFontSvgElement(name)
+      : await loadSVGElement(name, iconConfig.loadIconDynamically)
+    if (svgElement) {
+      handleRotate(svgElement, rotate)
+      root.value.appendChild(svgElement)
+    }
+  }
+}
+
+function handleRotate(svg: SVGElement, rotate?: number | string | boolean): void {
+  if (typeof rotate === 'number' || typeof rotate === 'string') {
+    svg.setAttribute('style', `transform: rotate(${rotate}deg)`)
+  } else {
+    svg.removeAttribute('style')
+  }
+}
+</script>
diff --git a/packages/components/icon/src/helper.ts b/packages/components/icon/src/helper.ts
new file mode 100644
index 00000000..f8a0c4de
--- /dev/null
+++ b/packages/components/icon/src/helper.ts
@@ -0,0 +1,11 @@
+import type { IconDefinition } from './types'
+import { iconDefinitions, createScriptElements } from './utils'
+
+export const addIconDefinitions = (icons: IconDefinition[]): void => {
+  icons.forEach(icon => iconDefinitions.set(icon.name, icon))
+}
+
+export const fetchFromIconfont = (iconFontUrl: string | string[]): void => {
+  const urls = Array.isArray(iconFontUrl) ? iconFontUrl : [iconFontUrl]
+  createScriptElements(urls)
+}
diff --git a/packages/components/icon/src/types.ts b/packages/components/icon/src/types.ts
new file mode 100644
index 00000000..75aadf20
--- /dev/null
+++ b/packages/components/icon/src/types.ts
@@ -0,0 +1,18 @@
+export interface IconProps {
+  readonly name?: string
+  readonly rotate?: boolean | number | string
+  readonly iconfont?: boolean
+}
+
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface IconComponent extends IconProps {}
+
+export interface IconDefinition {
+  name: string
+  svgString: string
+}
+
+export interface IconRendered {
+  name: string
+  svg: SVGElement
+}
diff --git a/packages/components/icon/src/utils.ts b/packages/components/icon/src/utils.ts
new file mode 100644
index 00000000..c431267a
--- /dev/null
+++ b/packages/components/icon/src/utils.ts
@@ -0,0 +1,115 @@
+import type { IconDefinition, IconRendered } from './types'
+
+import { IDUX_COMPONENTS_PREFIX } from '@idux/components/core/constant'
+import { Logger } from '@idux/components/core/logger'
+
+export const iconDefinitions = new Map<string, IconDefinition>()
+const iconRenderedCache = new Map<string, IconRendered>()
+const iconfontCache = new Set<string>()
+
+export const clearSVGElement = (el: HTMLElement): void => {
+  const children = el.childNodes
+  const length = children.length
+  for (let i = length - 1; i >= 0; i--) {
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const child = children[i] as any
+    if (child.tagName?.toLowerCase() === 'svg') {
+      child.remove()
+    }
+  }
+}
+
+export const loadSVGElement = async (
+  iconName: string,
+  loadIconDynamically?: (iconName: string) => Promise<string>,
+): Promise<SVGElement> => {
+  let svg: SVGElement | null
+  const cached = iconRenderedCache.get(iconName)
+  if (cached) {
+    svg = cached.svg
+  } else {
+    const icon = await loadIconDefinition(iconName, loadIconDynamically)
+    svg = createSVGElement(icon.svgString)
+    if (svg) {
+      setSVGAttribute(svg, iconName)
+      iconRenderedCache.set(iconName, { name: iconName, svg })
+    } else {
+      throw new Error(`${IDUX_COMPONENTS_PREFIX} The icon [${iconName}] create failed.`)
+    }
+  }
+
+  return svg.cloneNode(true) as SVGElement
+}
+
+export const loadIconFontSvgElement = (iconName: string): SVGElement => {
+  let svg: SVGElement | null
+  const cached = iconRenderedCache.get('iconfont-' + iconName)
+  if (cached) {
+    svg = cached.svg
+  } else {
+    const svgString = `<svg><use xlink:href="#${iconName}"></svg>`
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    svg = createSVGElement(svgString)!
+    setSVGAttribute(svg, iconName)
+    iconRenderedCache.set('iconfont-' + iconName, { name: iconName, svg })
+  }
+
+  return svg.cloneNode(true) as SVGElement
+}
+
+async function loadIconDefinition(iconName: string, loadIconDynamically?: (iconName: string) => Promise<string>) {
+  let icon = iconDefinitions.get(iconName)
+
+  if (!icon) {
+    if (loadIconDynamically) {
+      const svgString = await loadIconDynamically(iconName)
+      if (validSVGString(svgString)) {
+        icon = { name: iconName, svgString }
+        iconDefinitions.set(iconName, icon)
+      } else {
+        throw new Error(`${IDUX_COMPONENTS_PREFIX} The Dynamically loaded icon [${iconName}] is not valid.`)
+      }
+    }
+    if (!icon) {
+      throw new Error(`${IDUX_COMPONENTS_PREFIX} The icon [${iconName}] load failed.`)
+    }
+  }
+  return icon
+}
+
+function validSVGString(svgString: string): boolean {
+  return !!svgString && svgString.includes('<svg')
+}
+
+function createSVGElement(svgString: string): SVGElement | null {
+  const div = document.createElement('div')
+  div.innerHTML = svgString
+  return div.querySelector('svg')
+}
+
+function setSVGAttribute(svg: SVGElement, iconName: string): void {
+  svg.setAttribute('width', '1em')
+  svg.setAttribute('height', '1em')
+  svg.setAttribute('focusable', 'false')
+  svg.setAttribute('aria-hidden', 'true')
+  svg.setAttribute('data-icon', iconName)
+}
+
+export function createScriptElements(urls: string[], index = 0): void {
+  const currentUrl = urls[index]
+  if (!iconfontCache.has(currentUrl)) {
+    const scriptElement = document.createElement('script')
+    scriptElement.setAttribute('src', currentUrl)
+    if (urls.length > index + 1) {
+      scriptElement.onload = () => createScriptElements(urls, index + 1)
+      scriptElement.onerror = () => {
+        Logger.warn(`The url ${currentUrl} failed to load`)
+        createScriptElements(urls, index + 1)
+      }
+    }
+    iconfontCache.add(currentUrl)
+    document.body.appendChild(scriptElement)
+  } else {
+    createScriptElements(urls, index + 1)
+  }
+}
diff --git a/packages/components/icon/style/index.less b/packages/components/icon/style/index.less
new file mode 100644
index 00000000..3b9dcf23
--- /dev/null
+++ b/packages/components/icon/style/index.less
@@ -0,0 +1,8 @@
+@import '../../style/themes/index';
+@import '../../style/mixins/index';
+
+@icon-prefix: ~'@{idux-prefix}-icon';
+
+.@{icon-prefix} {
+  color: #fff;
+}
diff --git a/packages/components/index.ts b/packages/components/index.ts
index a70fd082..3dd9fe8c 100644
--- a/packages/components/index.ts
+++ b/packages/components/index.ts
@@ -1,9 +1,10 @@
 import type { App } from 'vue'
 import { Button, ButtonGroup } from './button'
+import { Icon } from './icon'
 
 const version = '0.0.0'
 
-const components = [Button, ButtonGroup]
+const components = [Button, ButtonGroup, Icon]
 
 const install = (app: App): void => {
   components.forEach(component => {
@@ -20,3 +21,5 @@ export * from './core/config'
 export * from './core/types'
 
 export * from './button'
+export { Icon } from './icon'
+export type { IconComponent, IconDefinition } from './icon'
diff --git a/scripts/gen/template.ts b/scripts/gen/template.ts
index cbf1df6c..a66547f3 100644
--- a/scripts/gen/template.ts
+++ b/scripts/gen/template.ts
@@ -6,12 +6,13 @@ export function getLessTemplate(compName: string): string {
     
 .@{${compName}-prefix} {
   color: #fff;
-}`
+}
+`
 }
 
 export function getTypesTemplate(compName: string): string {
   return `export interface ${compName}Props {
-
+ // please add readonly for every prop
 }
 
 // eslint-disable-next-line @typescript-eslint/no-empty-interface
@@ -56,12 +57,12 @@ import ${compName} from '../src/${compName}.vue'
 import { ${compName}Props } from '../src/types'
 
 describe('${compName}.vue', () => {
-  let buttonMount: (
+  let ${compName}Mount: (
     options?: MountingOptions<Partial<${compName}Props>>,
   ) => VueWrapper<InstanceType<DefineComponent<${compName}Props>>>
 
   beforeEach(() => {
-    buttonMount = (options = {}) => {
+    ${compName}Mount = (options = {}) => {
       return mount<${compName}Props>(${compName}, {
         ...options,
       })
@@ -124,17 +125,15 @@ export function getDomeTemplate(compName: string, moduleName: string): string {
   return `---
 order: 0
 title:
-  zh: 基本
-  en: Basic
+  zh: 基本使用
+  en: Basic usage
 ---
 
-
-
 ## zh
 
 
 
-## en-US
+## en
 
 
   
diff --git a/scripts/gulp/buildConfig.ts b/scripts/gulp/buildConfig.ts
index 99e79a1e..f5cf3726 100644
--- a/scripts/gulp/buildConfig.ts
+++ b/scripts/gulp/buildConfig.ts
@@ -2,7 +2,7 @@ import { join } from 'path'
 
 export interface BuildConfig {
   version: string
-  root: string
+  packageRoot: string
   docsDir: string
   outputDir: string
   publishDir: string
@@ -11,16 +11,17 @@ export interface BuildConfig {
 }
 
 const packageRoot = join(__dirname, '../../packages')
-const output = join(__dirname, '../../output')
-const docs = join(__dirname, '../../docs')
-const site = join(__dirname, '../../site')
+
+const docsDir = join(__dirname, '../../docs')
+const siteDir = join(__dirname, '../../site')
+const outputRoot = join(__dirname, '../../output')
 
 export const buildConfig: BuildConfig = {
   version: '0.0.1',
-  root: packageRoot,
-  outputDir: join(output, 'dist'),
-  publishDir: join(output, 'publish'),
-  libDir: join(output, 'lib'),
-  docsDir: docs,
-  siteDir: site,
+  packageRoot,
+  docsDir,
+  siteDir,
+  outputDir: join(outputRoot, 'dist'),
+  publishDir: join(outputRoot, 'publish'),
+  libDir: join(outputRoot, 'lib'),
 }
diff --git a/scripts/gulp/gulpfile.ts b/scripts/gulp/gulpfile.ts
index b82ae001..1f4fffda 100644
--- a/scripts/gulp/gulpfile.ts
+++ b/scripts/gulp/gulpfile.ts
@@ -2,5 +2,6 @@ import { series, task } from 'gulp'
 
 import './tasks/clean'
 import './tasks/site'
+import './tasks/icons'
 
-task('start:dev', series('clean', 'start:site'))
+task('start:dev', series('clean:site', 'icons:init', 'site:start'))
diff --git a/scripts/gulp/icons/assets/angle-down.svg b/scripts/gulp/icons/assets/angle-down.svg
new file mode 100644
index 00000000..5ddc2490
--- /dev/null
+++ b/scripts/gulp/icons/assets/angle-down.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg class="icon"
+    width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg">
+    <path fill="#333333" d="M256.70631 383.835161h447.562927l-223.781463 255.750244-223.781464-255.750244z" />
+</svg>
\ No newline at end of file
diff --git a/scripts/gulp/icons/assets/angle-left.svg b/scripts/gulp/icons/assets/angle-left.svg
new file mode 100644
index 00000000..2ee8300a
--- /dev/null
+++ b/scripts/gulp/icons/assets/angle-left.svg
@@ -0,0 +1,1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path fill="#333333" d="M640 736V288L384 512l256 224z" /></svg>
\ No newline at end of file
diff --git a/scripts/gulp/icons/assets/angle-right.svg b/scripts/gulp/icons/assets/angle-right.svg
new file mode 100644
index 00000000..ca319cbb
--- /dev/null
+++ b/scripts/gulp/icons/assets/angle-right.svg
@@ -0,0 +1,1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path fill="#333333" d="M384 288v448l256-224-256-224z" /></svg>
\ No newline at end of file
diff --git a/scripts/gulp/icons/assets/angle-up.svg b/scripts/gulp/icons/assets/angle-up.svg
new file mode 100644
index 00000000..3ee2c21f
--- /dev/null
+++ b/scripts/gulp/icons/assets/angle-up.svg
@@ -0,0 +1,1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path fill="#333333" d="M256 640h448L480 384 256 640z" /></svg>
\ No newline at end of file
diff --git a/scripts/gulp/icons/assets/double-down.svg b/scripts/gulp/icons/assets/double-down.svg
new file mode 100644
index 00000000..2c2dd514
--- /dev/null
+++ b/scripts/gulp/icons/assets/double-down.svg
@@ -0,0 +1,1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path fill="#333333" d="M266.782 534.127l224.22 224.22c12.496 12.497 32.758 12.497 45.254 0l222.871-222.871c12.497-12.497 12.497-32.758 0-45.255-12.496-12.497-32.758-12.497-45.254 0L522.68 681.414c-4.999 4.998-13.103 4.998-18.102 0l-192.54-192.541c-12.498-12.497-32.759-12.497-45.256 0-12.497 12.496-12.497 32.758 0 45.254z m0-224l224.22 224.22c12.496 12.497 32.758 12.497 45.254 0l222.871-222.871c12.497-12.497 12.497-32.758 0-45.255-12.496-12.497-32.758-12.497-45.254 0L522.68 457.414c-4.999 4.998-13.103 4.998-18.102 0l-192.54-192.541c-12.498-12.497-32.759-12.497-45.256 0-12.497 12.496-12.497 32.758 0 45.254z" /></svg>
\ No newline at end of file
diff --git a/scripts/gulp/icons/assets/double-left.svg b/scripts/gulp/icons/assets/double-left.svg
new file mode 100644
index 00000000..7b67a5be
--- /dev/null
+++ b/scripts/gulp/icons/assets/double-left.svg
@@ -0,0 +1,1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path fill="#333333" d="M489.621 758.173l-224.219-224.22c-12.497-12.497-12.497-32.758 0-45.255l222.871-222.871c12.497-12.496 32.758-12.496 45.255 0 12.496 12.497 12.496 32.758 0 45.255L342.335 502.275c-4.999 4.999-4.999 13.103 0 18.102l192.541 192.541c12.497 12.497 12.497 32.758 0 45.255-12.497 12.496-32.758 12.496-45.255 0z m224 0l-224.219-224.22c-12.497-12.497-12.497-32.758 0-45.255l222.871-222.871c12.497-12.496 32.758-12.496 45.255 0 12.496 12.497 12.496 32.758 0 45.255L566.335 502.275c-4.999 4.999-4.999 13.103 0 18.102l192.541 192.541c12.497 12.497 12.497 32.758 0 45.255-12.497 12.496-32.758 12.496-45.255 0z" /></svg>
\ No newline at end of file
diff --git a/scripts/gulp/icons/assets/double-right.svg b/scripts/gulp/icons/assets/double-right.svg
new file mode 100644
index 00000000..9d028f51
--- /dev/null
+++ b/scripts/gulp/icons/assets/double-right.svg
@@ -0,0 +1,1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path fill="#333333" d="M535.584 758.173l224.22-224.22c12.496-12.497 12.496-32.758 0-45.255L536.933 265.827c-12.497-12.496-32.758-12.496-45.255 0-12.497 12.497-12.497 32.758 0 45.255L682.87 502.275c4.999 4.999 4.999 13.103 0 18.102L490.33 712.918c-12.497 12.497-12.497 32.758 0 45.255 12.496 12.496 32.758 12.496 45.254 0z m-224 0l224.22-224.22c12.496-12.497 12.496-32.758 0-45.255L312.933 265.827c-12.497-12.496-32.758-12.496-45.255 0-12.497 12.497-12.497 32.758 0 45.255L458.87 502.275c4.999 4.999 4.999 13.103 0 18.102L266.33 712.918c-12.497 12.497-12.497 32.758 0 45.255 12.496 12.496 32.758 12.496 45.254 0z" /></svg>
\ No newline at end of file
diff --git a/scripts/gulp/icons/assets/double-up.svg b/scripts/gulp/icons/assets/double-up.svg
new file mode 100644
index 00000000..1f92a56b
--- /dev/null
+++ b/scripts/gulp/icons/assets/double-up.svg
@@ -0,0 +1,1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path fill="#333333" d="M266.782 489.092l224.22-224.22c12.496-12.496 32.758-12.496 45.254 0l222.871 222.872c12.497 12.496 12.497 32.758 0 45.254-12.496 12.497-32.758 12.497-45.254 0L522.68 341.806c-4.999-4.999-13.103-4.999-18.102 0l-192.54 192.54c-12.498 12.498-32.759 12.498-45.256 0-12.497-12.496-12.497-32.757 0-45.254z m0 224l224.22-224.22c12.496-12.496 32.758-12.496 45.254 0l222.871 222.872c12.497 12.496 12.497 32.758 0 45.254-12.496 12.497-32.758 12.497-45.254 0L522.68 565.806c-4.999-4.999-13.103-4.999-18.102 0l-192.54 192.54c-12.498 12.498-32.759 12.498-45.256 0-12.497-12.496-12.497-32.757 0-45.254z" /></svg>
\ No newline at end of file
diff --git a/scripts/gulp/icons/assets/down.svg b/scripts/gulp/icons/assets/down.svg
new file mode 100644
index 00000000..ef264609
--- /dev/null
+++ b/scripts/gulp/icons/assets/down.svg
@@ -0,0 +1,1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path fill="#333333" d="M265.827 438.627l224.22 224.22c12.497 12.496 32.758 12.496 45.255 0l222.871-222.871c12.496-12.497 12.496-32.758 0-45.255-12.497-12.497-32.758-12.497-45.255 0L521.725 585.914c-4.999 4.998-13.103 4.998-18.102 0L311.082 393.373c-12.497-12.497-32.758-12.497-45.255 0-12.496 12.496-12.496 32.758 0 45.254z" /></svg>
\ No newline at end of file
diff --git a/scripts/gulp/icons/assets/left.svg b/scripts/gulp/icons/assets/left.svg
new file mode 100644
index 00000000..2d3a6398
--- /dev/null
+++ b/scripts/gulp/icons/assets/left.svg
@@ -0,0 +1,1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="200px" height="199.80px" viewBox="0 0 1025 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path fill="#333333" d="M585.592 758.173l-224.219-224.22c-12.497-12.497-12.497-32.758 0-45.255l222.871-222.871c12.496-12.496 32.758-12.496 45.254 0 12.497 12.497 12.497 32.758 0 45.255L438.306 502.275c-4.999 4.999-4.999 13.103 0 18.102l192.541 192.541c12.496 12.497 12.496 32.758 0 45.255-12.497 12.496-32.758 12.496-45.255 0z" /></svg>
\ No newline at end of file
diff --git a/scripts/gulp/icons/assets/menu-collapsed.svg b/scripts/gulp/icons/assets/menu-collapsed.svg
new file mode 100644
index 00000000..6b9f4a1f
--- /dev/null
+++ b/scripts/gulp/icons/assets/menu-collapsed.svg
@@ -0,0 +1,1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path fill="#333333" d="M96 192h832c17.673 0 32 14.327 32 32 0 17.673-14.327 32-32 32H96c-17.673 0-32-14.327-32-32 0-17.673 14.327-32 32-32z m0 640h832c17.673 0 32 14.327 32 32 0 17.673-14.327 32-32 32H96c-17.673 0-32-14.327-32-32 0-17.673 14.327-32 32-32z m0-320h448c17.673 0 32 14.327 32 32 0 17.673-14.327 32-32 32H96c-17.673 0-32-14.327-32-32 0-17.673 14.327-32 32-32z m683.8 41.833a12.753 12.753 0 0 1-1.639-1.639c-4.526-5.43-3.792-13.502 1.639-18.027l159.206-132.672c2.3-1.917 5.2-2.966 8.194-2.966 7.069 0 12.8 5.73 12.8 12.8v265.343a12.8 12.8 0 0 1-2.967 8.194c-4.525 5.431-12.597 6.164-18.027 1.639L779.8 553.833z" /></svg>
\ No newline at end of file
diff --git a/scripts/gulp/icons/assets/menu-expanded.svg b/scripts/gulp/icons/assets/menu-expanded.svg
new file mode 100644
index 00000000..7ec06694
--- /dev/null
+++ b/scripts/gulp/icons/assets/menu-expanded.svg
@@ -0,0 +1,1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path fill="#333333" d="M96 192h832c17.673 0 32 14.327 32 32 0 17.673-14.327 32-32 32H96c-17.673 0-32-14.327-32-32 0-17.673 14.327-32 32-32z m0 640h832c17.673 0 32 14.327 32 32 0 17.673-14.327 32-32 32H96c-17.673 0-32-14.327-32-32 0-17.673 14.327-32 32-32z m0-320h448c17.673 0 32 14.327 32 32 0 17.673-14.327 32-32 32H96c-17.673 0-32-14.327-32-32 0-17.673 14.327-32 32-32z m852.2 41.833L788.994 686.505c-5.43 4.525-13.502 3.792-18.027-1.639a12.8 12.8 0 0 1-2.967-8.194V411.328c0-7.069 5.731-12.8 12.8-12.8a12.8 12.8 0 0 1 8.194 2.967L948.2 534.167c5.431 4.525 6.165 12.597 1.639 18.027a12.753 12.753 0 0 1-1.639 1.639z" /></svg>
\ No newline at end of file
diff --git a/scripts/gulp/icons/assets/right.svg b/scripts/gulp/icons/assets/right.svg
new file mode 100644
index 00000000..127c9210
--- /dev/null
+++ b/scripts/gulp/icons/assets/right.svg
@@ -0,0 +1,1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="200px" height="199.80px" viewBox="0 0 1025 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path fill="#333333" d="M438.627 758.173l224.22-224.22c12.496-12.497 12.496-32.758 0-45.255L439.976 265.827c-12.497-12.496-32.758-12.496-45.255 0-12.497 12.497-12.497 32.758 0 45.255l191.193 191.193c4.998 4.999 4.998 13.103 0 18.102L393.373 712.918c-12.497 12.497-12.497 32.758 0 45.255 12.496 12.496 32.758 12.496 45.254 0z" /></svg>
\ No newline at end of file
diff --git a/scripts/gulp/icons/assets/up.svg b/scripts/gulp/icons/assets/up.svg
new file mode 100644
index 00000000..c1f7652b
--- /dev/null
+++ b/scripts/gulp/icons/assets/up.svg
@@ -0,0 +1,1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg class="icon" width="200px" height="200.00px" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"><path fill="#333333" d="M265.827 617.592l224.22-224.219c12.497-12.497 32.758-12.497 45.255 0l222.871 222.871c12.496 12.496 12.496 32.758 0 45.254-12.497 12.497-32.758 12.497-45.255 0L521.725 470.306c-4.999-4.999-13.103-4.999-18.102 0L311.082 662.847c-12.497 12.497-32.758 12.497-45.255 0-12.496-12.497-12.496-32.758 0-45.255z" /></svg>
\ No newline at end of file
diff --git a/scripts/gulp/icons/index.ts b/scripts/gulp/icons/index.ts
new file mode 100644
index 00000000..67b380a3
--- /dev/null
+++ b/scripts/gulp/icons/index.ts
@@ -0,0 +1,63 @@
+import { ensureDirSync, readdirSync, readFileSync, writeFileSync } from 'fs-extra'
+import { camelCase, upperFirst } from 'lodash'
+import { join } from 'path'
+import SVGO from 'svgo'
+import { buildConfig } from '../buildConfig'
+
+const definitionTemplate = `import { IconDefinition } from '../types'
+
+export const {{definitionName}}: IconDefinition = {
+  name: '{{name}}',
+  svgString: '{{svgString}}',
+}
+`
+const { packageRoot } = buildConfig
+const assetsDirname = join(__dirname, 'assets')
+const outputAssetsDirname = join(packageRoot, 'components/icon/src/svg')
+const outputDefinitionsDirname = join(packageRoot, 'components/icon/src/definitions')
+const outputDefinitionNames: string[] = []
+
+const options: SVGO.Options = {
+  plugins: [{ removeAttrs: { attrs: ['fill', 'class'] } }, { sortAttrs: true }, { removeDimensions: true }],
+}
+
+const svgo = new SVGO(options)
+
+export async function generateIcons(): Promise<void> {
+  const iconDirname = join(assetsDirname)
+  const iconPaths = readdirSync(iconDirname)
+
+  const outputIcons = iconPaths.map(async iconName => {
+    const iconFile = join(assetsDirname, iconName)
+    const iconFileContent = readFileSync(iconFile, 'utf8')
+    await output(iconFileContent, iconName)
+  })
+  await Promise.all(outputIcons)
+
+  const indexContent = outputDefinitionNames.map(item => `export * from './${item}'`).join('\n')
+  writeFileSync(join(outputDefinitionsDirname, `index.ts`), indexContent, 'utf8')
+}
+
+async function output(content: string, iconName: string) {
+  const { data } = await svgo.optimize(content)
+  outputIcons(iconName, data)
+  outputDefinitions(iconName, data)
+}
+
+function outputIcons(iconName: string, data: string) {
+  ensureDirSync(outputAssetsDirname)
+  writeFileSync(join(outputAssetsDirname, iconName), data, 'utf8')
+}
+
+function outputDefinitions(iconName: string, data: string) {
+  ensureDirSync(outputDefinitionsDirname)
+  const _iconName = `${iconName.replace('.svg', '')}`
+  const camelCaseName = camelCase(_iconName)
+  const definitionName = upperFirst(camelCaseName)
+  const iconDefinition = definitionTemplate
+    .replace('{{definitionName}}', definitionName)
+    .replace('{{name}}', _iconName)
+    .replace('{{svgString}}', data)
+  writeFileSync(join(outputDefinitionsDirname, `${camelCaseName}.ts`), iconDefinition, 'utf8')
+  outputDefinitionNames.push(`${camelCaseName}`)
+}
diff --git a/scripts/gulp/site/generateSite.ts b/scripts/gulp/site/generateSite.ts
index 504ee9c5..794050b8 100644
--- a/scripts/gulp/site/generateSite.ts
+++ b/scripts/gulp/site/generateSite.ts
@@ -12,7 +12,7 @@ import { buildConfig } from '../buildConfig'
 import { withoutSuffix } from './utils/tools'
 import { DemoInfo, DocsLanguage, Meta } from './utils/types'
 
-const { siteDir, root, docsDir } = buildConfig
+const { siteDir, packageRoot, docsDir } = buildConfig
 const siteOutputDirname = `${siteDir}/docs/src`
 
 function initDir(target: string): void {
@@ -34,12 +34,12 @@ export function generateSite(target: string, targetName?: string): void {
   const docsMetaMap: Record<string, Record<string, Meta>> = {}
 
   const filterComponentName = ['style', 'core', 'locale', 'i18n', 'version']
-  readdirSync(root).forEach(packageName => {
+  readdirSync(packageRoot).forEach(packageName => {
     if (isSyncSpecific && packageName !== target) {
       return
     }
     docsMetaMap[packageName] = {}
-    const packageDirname = join(root, packageName)
+    const packageDirname = join(packageRoot, packageName)
     readdirSync(packageDirname).forEach(componentName => {
       if (filterComponentName.includes(componentName) || (isSyncSpecific && componentName !== targetName)) {
         return
diff --git a/scripts/gulp/tasks/clean.ts b/scripts/gulp/tasks/clean.ts
index f99dfdd4..902b3e9c 100644
--- a/scripts/gulp/tasks/clean.ts
+++ b/scripts/gulp/tasks/clean.ts
@@ -2,5 +2,6 @@ import { task } from 'gulp'
 import { buildConfig } from '../buildConfig'
 import { cleanTask } from '../util/task-helpers'
 
-/** Deletes the dist/ publish/ directory. */
-task('clean', cleanTask([buildConfig.outputDir, buildConfig.publishDir]))
+task('clean', cleanTask([buildConfig.outputDir, buildConfig.publishDir, buildConfig.siteDir]))
+
+task('clean:site', cleanTask([buildConfig.siteDir]))
diff --git a/scripts/gulp/tasks/icons.ts b/scripts/gulp/tasks/icons.ts
new file mode 100644
index 00000000..e45a2e61
--- /dev/null
+++ b/scripts/gulp/tasks/icons.ts
@@ -0,0 +1,7 @@
+import { task } from 'gulp'
+import { generateIcons } from '../icons'
+
+task('icons:init', async done => {
+  await generateIcons()
+  done()
+})
diff --git a/scripts/gulp/tasks/site.ts b/scripts/gulp/tasks/site.ts
index 15028e5f..9aa48fc7 100644
--- a/scripts/gulp/tasks/site.ts
+++ b/scripts/gulp/tasks/site.ts
@@ -8,10 +8,10 @@ import detectPort from 'detect-port'
 
 import { generateSite } from '../site/generateSite'
 
-const markdownGlob = join(buildConfig.root, `**/+(docs|demo)/*.md`)
+const markdownGlob = join(buildConfig.packageRoot, `**/+(docs|demo)/*.md`)
 
 /** Parse demos and docs to site directory. */
-task('init:site', done => {
+task('site:init', done => {
   generateSite('init')
   done()
 })
@@ -20,7 +20,7 @@ task('init:site', done => {
  * Development app watch task,
  * to ensures the demos and docs have changes are rebuild.
  */
-task('watch:site', () => {
+task('site:watch', () => {
   // Globs accepts the Unix-style path separators only
   const globs = [markdownGlob].map(p => p.replace(/\\/g, '/'))
   watch(globs).on(
@@ -38,12 +38,12 @@ task('watch:site', () => {
   )
 })
 
-/** Run `ng serve` */
-task('serve:site', done => {
+/** Run `vite` */
+task('site:serve', done => {
   detectPort(3000).then((port: number) => {
     execNodeTask('vite', 'vite', ['--port', port === 3000 ? '3000' : '0', '--open'])(done)
   })
 })
 
 /** Init site directory, and start watch and vite */
-task('start:site', series('init:site', parallel('watch:site', 'serve:site')))
+task('site:start', series('site:init', parallel('site:watch', 'site:serve')))
diff --git a/vite.config.ts b/vite.config.ts
index 4a812fce..73e39adf 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,7 +1,8 @@
 import { UserConfig } from 'vite'
 
 const config: UserConfig = {
-  entry: '/site/docs/index.html',
+  entry: './site/docs/index.html', // not work
+  base: './site/docs/', // not work
   resolvers: [
     {
       alias(path: string): string {


Test patch:
diff --git a/packages/components/icon/__tests__/__snapshots__/icon.spec.ts.snap b/packages/components/icon/__tests__/__snapshots__/icon.spec.ts.snap
new file mode 100644
index 00000000..cb9ce8da
--- /dev/null
+++ b/packages/components/icon/__tests__/__snapshots__/icon.spec.ts.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Icon.vue render work 1`] = `"<i class=\\"ix-icon\\" role=\\"img\\" arialabel=\\"up\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" viewBox=\\"0 0 1024 1024\\" width=\\"1em\\" height=\\"1em\\" focusable=\\"false\\" aria-hidden=\\"true\\" data-icon=\\"up\\"><path d=\\"M265.827 617.592l224.22-224.219c12.497-12.497 32.758-12.497 45.255 0l222.871 222.871c12.496 12.496 12.496 32.758 0 45.254-12.497 12.497-32.758 12.497-45.255 0L521.725 470.306c-4.999-4.999-13.103-4.999-18.102 0L311.082 662.847c-12.497 12.497-32.758 12.497-45.255 0-12.496-12.497-12.496-32.758 0-45.255z\\"></path></svg></i>"`;
diff --git a/packages/components/icon/__tests__/icon.spec.ts b/packages/components/icon/__tests__/icon.spec.ts
new file mode 100644
index 00000000..c43c9793
--- /dev/null
+++ b/packages/components/icon/__tests__/icon.spec.ts
@@ -0,0 +1,148 @@
+import { useGlobalConfig } from '@idux/components/core/config'
+import { flushPromises, mount, MountingOptions, VueWrapper } from '@vue/test-utils'
+import { DefineComponent } from 'vue'
+import { addIconDefinitions, fetchFromIconfont } from '../src/helper'
+import Icon from '../src/Icon.vue'
+import { IconProps, IconDefinition } from '../src/types'
+
+const Up: IconDefinition = {
+  name: 'up',
+  svgString:
+    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M265.827 617.592l224.22-224.219c12.497-12.497 32.758-12.497 45.255 0l222.871 222.871c12.496 12.496 12.496 32.758 0 45.254-12.497 12.497-32.758 12.497-45.255 0L521.725 470.306c-4.999-4.999-13.103-4.999-18.102 0L311.082 662.847c-12.497 12.497-32.758 12.497-45.255 0-12.496-12.497-12.496-32.758 0-45.255z"/></svg>',
+}
+
+const Down: IconDefinition = {
+  name: 'down',
+  svgString:
+    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M265.827 438.627l224.22 224.22c12.497 12.496 32.758 12.496 45.255 0l222.871-222.871c12.496-12.497 12.496-32.758 0-45.255-12.497-12.497-32.758-12.497-45.255 0L521.725 585.914c-4.999 4.998-13.103 4.998-18.102 0L311.082 393.373c-12.497-12.497-32.758-12.497-45.255 0-12.496 12.496-12.496 32.758 0 45.254z"/></svg>',
+}
+
+describe('Icon.vue', () => {
+  let IconMount: (options?: MountingOptions<Partial<IconProps>>) => VueWrapper<InstanceType<DefineComponent<IconProps>>>
+
+  beforeEach(() => {
+    IconMount = (options = {}) => {
+      return mount<IconProps>(Icon, {
+        ...options,
+      })
+    }
+  })
+
+  test('render work', async () => {
+    addIconDefinitions([Up])
+    const wrapper = IconMount({ props: { name: 'up' } })
+    await flushPromises()
+    expect(wrapper.html()).toMatchSnapshot()
+  })
+
+  test('static load work', async () => {
+    addIconDefinitions([Up, Down])
+    const wrapper = IconMount({ props: { name: 'up' } })
+    await flushPromises()
+
+    expect(wrapper.find('svg').attributes()['data-icon']).toEqual('up')
+
+    await wrapper.setProps({ name: 'down' })
+    await flushPromises()
+
+    expect(wrapper.find('svg').attributes()['data-icon']).toEqual('down')
+  })
+
+  test('dynamic load work', async () => {
+    const loadIconDynamically = async (iconName: string) => {
+      if (iconName === 'dynamic-up') {
+        return Up.svgString
+      } else if (iconName === 'dynamic-down') {
+        return Down.svgString
+      } else {
+        return ''
+      }
+    }
+
+    const wrapper = mount({
+      components: { Icon },
+      template: `<Icon name="dynamic-up" />`,
+      setup() {
+        useGlobalConfig('icon', { loadIconDynamically })
+      },
+    })
+
+    await flushPromises()
+
+    expect(wrapper.find('svg').attributes()['data-icon']).toEqual('dynamic-up')
+
+    await wrapper.setProps({ name: 'dynamic-down' })
+    await flushPromises()
+
+    expect(wrapper.find('svg').attributes()['data-icon']).toEqual('dynamic-down')
+  })
+
+  test('iconfont load work', async () => {
+    fetchFromIconfont('https://at.alicdn.com/t/font_2269256_s10i6xhg8l.js')
+    fetchFromIconfont([
+      'https://at.alicdn.com/t/font_2269256_s10i6xhg8l.js',
+      'https://at.alicdn.com/t/font_2269253_ogsttp6ftdp.js',
+      'https://at.alicdn.com/t/font_2269256_s10i6xhg8l2.js',
+    ])
+    jest.runAllTimers()
+
+    const wrapper = IconMount({ props: { name: 'ix-icon-up', iconfont: true } })
+    await flushPromises()
+
+    expect(wrapper.find('svg').attributes()['data-icon']).toEqual('ix-icon-up')
+
+    await wrapper.setProps({ name: 'ix-icon-down' })
+    await flushPromises()
+
+    expect(wrapper.find('svg').attributes()['data-icon']).toEqual('ix-icon-down')
+
+    await wrapper.setProps({ name: 'ix-icon-up' })
+    await flushPromises()
+
+    expect(wrapper.find('svg').attributes()['data-icon']).toEqual('ix-icon-up')
+  })
+
+  test('rotate work', async () => {
+    addIconDefinitions([Up])
+    const wrapper = IconMount({ props: { rotate: true } })
+
+    expect(wrapper.classes()).toContain('ix-icon-spin')
+
+    await wrapper.setProps({ name: 'up', rotate: 90 })
+    await flushPromises()
+
+    expect(wrapper.classes()).not.toContain('ix-icon-spin')
+    expect(wrapper.find('svg').attributes()['style']).toEqual('transform: rotate(90deg)')
+
+    await wrapper.setProps({ rotate: '180' })
+
+    expect(wrapper.find('svg').attributes()['style']).toEqual('transform: rotate(180deg)')
+  })
+
+  test('tag work', async () => {
+    const wrapper = mount({
+      components: { Icon },
+      template: `<Icon @click="onClick"/>`,
+      props: {
+        onClick: { type: Function, default: () => void 0 },
+      },
+    })
+    expect(wrapper.find('i').exists()).toBeFalsy()
+    expect(wrapper.find('button').exists()).toBeTruthy()
+
+    await wrapper.setProps({ onClick: () => void 0 })
+
+    expect(wrapper.find('i').exists()).toBeFalsy()
+    expect(wrapper.find('button').exists()).toBeTruthy()
+  })
+
+  test('slot work', async () => {
+    const wrapper = IconMount({
+      slots: {
+        default: Up.svgString,
+      },
+    })
+    await flushPromises()
+    expect(wrapper.find('svg').exists()).toBeTruthy()
+  })
+})