木匣子

Web/Game/Programming/Life etc.

使用 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'],
  },
  // ...
}