標籤:

編寫自己的 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}
stylex={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(
)
// 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


推薦閱讀:

TAG:圖標 | SVG | React |