編寫自己的SVG 圖標庫
在做one-react組件庫時,思考如何對組件庫各組件中用到的圖標作統一管理。在這之前,都是直接「hard code」 寫入SVG code,一旦出現同一個圖標在不同組件中引入時,本質上是在反复複製一段代碼,後續圖標更新時會產生不小的工作量,獨立的圖標庫勢在必行。
調研了幾種社區常見方案:
iconfont
- 這種方案在SVG圖標文件的基礎上,利用腳本生成幾種不同格式的字體文件(ttf/woff/eot),亦可通過iconfont.cn管理圖標文件,然後通過
@font-face
加載字體文件,最後在偽元素的content 中填寫對應圖標的編碼渲染出圖標。 - 兼容性好,理論上能兼容到IE6。
- 繁瑣之處在於處理SVG 生成多個字體文件,編寫生成多個圖標字體編碼的className 樣式表,字體文件要按需加載會比較麻煩。
SVG Sprite
- 通過將SVG文件合併成一個SVG sprite並加載到一個
display: none
的元素中,使用時則通過SVG的use傳入圖標id後渲染出圖標。 - 在webpack配置中引入svg-sprite-loader即可,配置簡單,可按需加載和打包。
- 若在組件庫中使用該方案,組件庫的用戶在使用時,需自行在webpack 配置中引入對應loader,處理對應node_modules 下組件庫裡的SVG 文件。
- svg-sprite-loader 有自己的runtime(svg-baker-runtime)。
SVG to React
- 可使用如svg-react-loader ,直接require一個SVG轉換成React組件。
- 同上一個方案,組件庫若引入,用戶也得額外配置
svg-react-loader
。 - 可按需引入。
按需加載,不引入runtime 和額外配置,從一個組件庫來說,關乎其易用性,這樣下來上述三種方案都各有優缺點。思來想去,最好是每個組件都是一個純粹的React 組件,其render 返回SVG 的XML,也就是把SVG icon 改寫成一個React 組件。類似這種:
export default props => { return <svg viewBox="0 0 512 512" width="1em" height="1em" {...props}> <path d="M424.935 108.296L108.858 424.373l-20.506-20.507L404.43 87.79z" /> <path d="M108.858 87.79l316.077 316.076-20.507 20.506L88.352 108.296z" /> </svg> }
重新梳理下思路,明確要做的事情:
- 從Sketch 等工具中導出繪製好的SVG 文件。
- 將SVG 的XML 寫入對應的React 組件的render 函數中。
- 將所有component 通過index.ts 統一export 出來。
- 通過標準的
Semantic Release
流程進行測試、打包和發布。
將SVG 轉化成React 組件
上面提到了一個步驟「將SVG 的XML 寫入對應React 組件的render 函數中」,最初驗證整體方案可行性時,是手動複製粘貼的,但是我們現在要把這一步變成自動化的,即每次增加一個新圖標,跑一個腳本即可生成對應的React 組件。
剛好之前star過svgr這個項目,這個項目能基於h2x將HTML編譯成JSX,支持custom template,支持svgo壓縮SVG,支持Prettier,有個在線的REPL可以體驗。
注意:本文初次整理時svgr@v4 尚未發布,因此下方提到的svgr 相關都是基於svgr@v3 的,後續筆者考慮將本文的內容和關聯的項目升級到最新版本的svgr。
將從Sketch中導出的SVG文件放到src/assets
目錄下,通過svgr
將SVG編譯成tsx文件,並輸出到src/icons
目錄中。
svgr src/assets --ext tsx --out-dir src/icons --config-file svgr.config.js
不得不提,svgr的配置參數十分神奇,比如它支持config文件,但是
out-dir
,ext
等參數必須通過命令行參數傳進去。
其中用到的custom template 如下:
// svgr.config.js const template = (code, options, state) => { return ` // Generated from ${state.filePath} import React, { PureComponent } from 'react' interface Props { className?: string; size?: string | number; fill?: string; onClick?: React.MouseEventHandler<SVGSVGElement>; } const style = { display: 'block', flex: '0 0 auto', cursor: 'pointer' } export class ${state.componentName} extends PureComponent<Props, {}> { render() { const props = this.props const { size, fill } = props return ${code} } } ` } ...
在svgr.config.js
文件中定義好SVG標籤上的props,包括默認樣式、 size
和fill
等。如下所示:
// svgr.config.js ... module.exports = { icon: true, expandProps: 'start', template, svgProps: { preserveAspectRatio: `xMidYMid meet`, fontSize: `{size || 32}`, fill: `{fill || 'currentColor'}`, style: '{style}' } }
執行對應的npm scripts,將SVG 編譯成React 組件。如下範例:
// Generated from src/assets/Close.svg import React, { PureComponent } from 'react' interface Props { className?: string; size?: string | number; fill?: string; onClick?: React.MouseEventHandler<SVGSVGElement>; } const style = { display: 'block', flex: '0 0 auto', cursor: 'pointer' } export class SvgClose extends PureComponent<Props, {}> { render() { const props = this.props const { size, fill } = props return ( <svg {...props} preserveAspectRatio="xMidYMid meet" fontSize={size || 32} fill={fill || 'currentColor'} style={style} viewBox="0 0 512 512" width="1em" height="1em" > <path d="M424.935 108.296L108.858 424.373l-20.506-20.507L404.43 87.79z" /> <path d="M108.858 87.79l316.077 316.076-20.507 20.506L88.352 108.296z" /> </svg> ) } }
將編譯好的React 組件統一export
通過腳本,讀取src/icons
目錄下的所有tsx文件,以export { SvgAbc } from './icons/abc'
的形式寫入src/index.ts
中。
腳本代碼如下:
const { promisify } = require('util') const fs = require('fs') const path = require('path') const prettier = require('prettier') const readdirAsync = promisify(fs.readdir) const writeFileAsync = promisify(fs.writeFile) const sourceDir = path.resolve(__dirname, 'src/icons') const targetFile = path.resolve(__dirname, 'src/index.ts') async function readConfig() { const filenames = await readdirAsync(sourceDir) const result = [] for (const filename of filenames) { const basename = path.basename(filename, '.tsx') result.push(`export { Svg${basename} } from './icons/${basename}'`) } return result } async function boot() { const config = await readConfig() const text = config.join('n') // read prettier options from local config `.prettierrc` const options = await prettier.resolveConfig(path.resolve(__dirname, '.prettierrc')) const formatted = prettier.format(text, { ...options, parser: 'babylon' }) await writeFileAsync(targetFile, formatted, 'utf-8') console.log('export svg content -->', config) } boot()
在CI 階段執行編譯
我們期望的是每次新增或修改了SVG 文件,無需本地手動執行腳本編譯成React 組件並export,直接commit 並push,讓CI 把編譯好的結果提交回來並執行發布流程。
- 在
build
之前先執行npm run getIcons
命令,將SVG文件轉化成React組件並統一導出。 - 使用@semantic-release/git自動提交在CI上編譯生成的
.ts/.tsx
文件。 - 發佈到NPM。
對應的.travis.yml
配置如下:
... before_script: - npm run getIcons after_success: - npm run build - npm run coverage - npm install -g travis-deploy-once - travis-deploy-once "npm run semantic-release"
通過release.config.js
文件配置semantic-release
:
module.exports = { plugins: [ '@semantic-release/commit-analyzer', '@semantic-release/release-notes-generator', [ '@semantic-release/git', { assets: [ 'src/**/*.{ts,tsx}' ], message: 'feat(release): release ${nextRelease.version} with updated icons' } ], '@semantic-release/npm', '@semantic-release/github' ] }
semantic-release
默認的plugins有四個:
- @semantic-release/commit-analyzer :基於conventional-changelog分析commit訊息。
- @semantic-release/release-notes-generator :基於conventional-changelog生成release日誌的內容。
- @semantic-release/npm :發布NPM包。
- @semantic-release/github :發布GitHub release並且寫入release日誌。
要注意的是需要將@semantic-release/git
插件放在@semantic-release/npm
之前執行,因為要保證release的版本對應的是已完成編譯的代碼,即基於@semantic-release/git
自動commit後的最新版本。
配置好的semantic-release
流程如下:

完成上述配置後, @semantic-release/git
會在CI流程中,將生成新的組件代碼commit回來:

在GitHub 上也能看到bot 自動提交回來的commit:

上圖中合併mr 時會觸發一次Travis CI build,而這個build 中,若bot 提交一個新的commit 回來的話,會再產生一個新的build,但這個build 並不會發布代碼,因為上一個build 中的release 已經是基於最新代碼(包含這個commit的),這個build 看起來有點「浪費」。
至此,我們達到了目的,只需要將SVG文件放到src/assets
目錄下並提交到遠程,其他的事就交給CI處理即可。
使用範例
import Button from 'or-button' import React, { PureComponent } from 'react' import { SvgClose } from 'or-icons' export default class SingleExample extends PureComponent { state = { isOpen: true } render() { return ( <div> <h1>default props:</h1> <SvgClose /> <h1>prop #size:</h1> <div> <SvgClose size="25" /> <SvgClose /> <SvgClose size="38" /> </div> <h1>prop #fill:</h1> <div> <SvgClose fill="#4FC3F7" /> <SvgClose fill="#03A9F4" /> <SvgClose fill="#0288D1" /> </div> <div> <h1>prop #onClick:</h1> { this.state.isOpen ? <SvgClose onClick={this.handleClick} /> : <Button onClick={this.handleButtonClick}>show svg icon</Button> } </div> </div> ) } handleClick = () => { this.setState({ isOpen: false }) } handleButtonClick = () => { this.setState({ isOpen: true }) } }
完整的項目代碼請見我的GitHub Repo