編寫自己的 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}
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
推薦閱讀: