使用 Webpack Loader 加载 Icon Font 映射
最近在做的新项目是使用 React 构建一个新的网站,实现新的需求的同时慢慢将旧网站迁移过来。其中的一部分工作是建立一个可重用的前端组件库。
实现一个前端组件库需要非常多的工作量,这里有一份详细的 Checklist 可供参考。除此之外,我们还需要为这些可重用组件建立一份文档,这样大家就可以照着文档去使用这些组件了。在对比了一些文档工具后,我选择了 Storybook 这款非常小清新的可视化组件文档生成器。它支持各种主流框架。
集成 Storybook 到项目的过程遇到了不少坑,不过这篇博客我们暂时不讨论这些,有空的话我再另开一篇文章吧。本文我想聊聊写文档的时候遇到的一些需求。
¶背景
在项目中我们使用了一款自制的图标字体(Icon Font),以字体的形式将网站常用的图标打包成 Web Font,然后再在页面中使用。
从设计师同事那获得的素材文件如下:
~/Downloads/racing20_march
├── fonts
│ ├── racing20.eot
│ ├── racing20.svg
│ ├── racing20.ttf
│ └── racing20.woff
├── icons-reference.html
└── styles.css
其中 icons-reference.html
是说明文档,内里介绍了如何使用这个字体,以及一个图标名称及对应字符的映射关系。
所谓映射(Mapping),可以从 styles.css
中看到一些例子:
.icon-article:before {
content: "\61";
}
.icon-calendar:before {
content: "\64";
}
...
即 article
图标对应的字符是 \61
即字母 a
。
不过使用的时候我们并不需要关心这个映射。只要知道想用这个图标的话,引用对应的英文名即可:
<i class="icon icon-article"></i>
¶需求
我们要做的正是将这个说明文档中的映射关系集成到我们的 Storybook 组件文档中去。以便在文档中显示所有图标,还可以直接点击图标复制组件代码,方便引用。
一个简单的方法就是手动创建这个列表,把映射关系整理到一个数组中。但是考虑到后期的维护,如新增图标或者映射有变化,就需要重新校对这个列表,是一件很麻烦的事。
既然如此,何不一开始就将其自动化?我们只需要写一个脚本将这个 styles.css
中的映射关系提取出来,就可以为我所用。另外这个 styles.css
作为唯一数据源,更新起来也很方便,直接将设计师提供的新文件覆盖旧文件即可。符合 Single Source of Truth
原则。
¶设计
通过自顶向下设计,我希望在 Storybook 里直接引用这个 css 文件,然后得到一个映射关系的数组:
import charsets from './fonts/racing20/styles.css';
// charsets = [{key: 'article': value: '\\61'}, ...]
显然我们可以写一个自定义的 Webpack Loader 来完成这个工作。而这个功能非常特殊,其它地方也用不到,所以我们可以直接使用 inline loader 来简化配置:
import charsets from 'icon-font-loader!./fonts/racing20/styles.css';
由于我们输入的 css 将直接生成 javascript 数组,我们不希望它被当作普通 css 文件进行额外的处理。我们需要使用额外的修饰符来标记这个文件:
import charsets from '!icon-font-loader!./fonts/racing20/styles.css';
最前面的 !
表示略过 Webpack 配置文件中针对该类文件的标准 Loader 。这样,该文件只会被我们的自定义 Loader 处理。
¶实现
实现一个自定义 Loader 非常的简单,可以从官方的文档开始,也可以参考一些简单的现成的 Loader,例如 json5-loader。
简而言之只要写一个函数,接受一个字符串类型的 source 参数,并生成一个 Javasciprt 模块的源文件即可。
module.exports = function loader(source) {
let charsets = [];
try {
// parse the charsets from css file
} catch (error) {
this.emitError(error);
}
return `module.exports = ${JSON.stringify(charsets)};`;
};
styles.css
文件将被读入到字符串中。而这个 css 文件非常规则(参考上文的映射),使得我们可以使用简单的正则表达式直接提取出:
const parser = /.icon-([a-z-]+):before {\s+content: "(\\\w+)";\s+}/gm;
let ret = null;
while ((ret = parser.exec(source))) {
charsets.push({
key: ret[1],
value: ret[2],
});
}
但考虑到 css 本身的结构特性,我们还可以使用更加强大的解析器直接将 css 文件转化成语法树(AST)来提取我们需要的信息。解析出来的 ast 大概如下:
{
"type": "stylesheet",
"stylesheet": {
"rules": [
{
"type": "rule",
"selectors": [
".icon-article:before"
],
"declarations": [
{
"type": "declaration",
"property": "content",
"value": "\"\\61\"",
"position": {
"start": {
"line": 43,
"column": 3
},
"end": {
"line": 43,
"column": 17
}
}
}
],
"position": {
"start": {
"line": 42,
"column": 1
},
"end": {
"line": 44,
"column": 2
}
}
},
...
我们仍然需要用正则表达式去提取 selector/content 中有用的部分,但不再需要担心空格和换行带来的困扰。
const css = require('css');
const regKey = new RegExp(`\\.icon-([a-z-]+):before`);
const regValue = new RegExp(/"(\\\w+)"/);
const ast = css.parse(source);
charsets = ast.stylesheet.rules
.filter(r => r.type === 'rule' && r.selectors[0].startsWith(`.icon-`))
.map(r => {
const selector = r.selectors[0];
const key = selector.match(regKey)[1];
const content = r.declarations.find(d => d.property === 'content');
const value = content.value.match(regValue)[1];
return { key, value };
});
为了使这个脚本更加健壮,我们可以作如下改进:
- 增加
prefix
配置 - 更丰富的的选择器名字:
[a-zA-Z0-9-]
- 支持两种伪元素选择器:
:
和::
const options = getOptions(this) || {};
const { prefix } = options;
const regKey = new RegExp(`\\.${prefix}([a-zA-Z0-9-]+)::?before`);
由于在制作 Icon Font 的时候,设计师可以对不同的图标文件提供不同的前缀(prefix),我们可以将其作为一个配置项:
import charsets from '!icon-font-loader?prefix=icon-!./fonts/racing20/styles.css';
这样我们就可以在 Storybook 中遍历展示这个字体中的所有图标了:
const IconList = () => charsets.map(char =>
<ClickToCopySnippet>
<Icon type={char.key}/>
<Text>{char.value}</Text>
</ClickToCopySnippet>);
完整的 Loader 脚本见此。由于是本地使用,所以不必发布到 npm 上。只需要在 Webpack 配置中设置一下 resolveLoader ,让 Webpack 知道我们的 Loader 在何处即可。
module.exports = {
// ...
resolveLoader: {
modules: ['node_modules', 'internals/webpack/loaders'],
},
// ...
}