編寫自己的SVG 圖標庫

blank

編寫自己的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,包括默認樣式、 sizefill等。如下所示:

// 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流程如下:

blank

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

blank

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

blank

上圖中合併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

What do you think?

Written by marketer

你也想做掌控全局的React 大師嗎?

blank

精讀《React Hooks》