Ant-Design-Icons 的生成之旅(上)

Oct 25 · 13 min

#背景

​ 最近一直在捣鼓组件库的事情,首先要解决的难题是 Icons 的问题,因为组件库中每个组件基本都用得到 Icon 组件,于是找到了 Ant-Design-Icons,它的主要原理是将 SVG 文件转换成 AST 抽象节点,再分发给各个框架渲染。

#为什么需要 SVG to AST

将 SVG 抽象成 AST 抽象节点主要是为了适配各个框架的需要,比如 React 可以使用 createElement 函数渲染抽象节点,Vue 可以使用 h 函数来渲染抽象节点

#探索

Ant-Design-Icons4.x 是一个 Lerna + TS 管理的多包仓库,里面集成了各个框架的 Icons 组件包,我们今天的主角 icons-svg,是专门用来解析 SVG 图标文件,并将其抽象为 AST 抽象节点树,就像这样:

// This icon file is generated automatically.
 
import { IconDefinition } from '../types';
 
const AppstoreTwoTone: IconDefinition = {
  "icon": function render(primaryColor, secondaryColor) { 
    return {
      "tag":"svg",
      "attrs": {
        "viewBox":"64 64 896 896",
        "focusable":"false"
      },
      "children": [
        {
          "tag":"path",
          "attrs": {
            "d":"...",
            "fill":primaryColor
          }
        },
        {
          "tag":"path",
          "attrs": {
            "d":"...",
            "fill":secondaryColor
          }
        }
      ]
    }; 
  },
  "name":"appstore",
  "theme":"twotone"
};
 
export default AppstoreTwoTone;
ts

它将 SVG 解析成了一个抽象节点 AST ,这样我们就可以通过安装这个包来导入这个文件,生成对应的 ReactElementVNode,这样一个内置 Icon 组件就完成了

​ 打开 package.json,直奔 script 字段,我们可以看到两行命令,是跟生成上述的 AST 文件相关的。

"scripts": {
   "g": "npm run generate",
   "generate": "cross-env NODE_ENV=production gulp --require ts-node/register/transpile-only",
}
json

可以看到提到了 gulp 命令,它是执行目录下的 gulpfile.ts 文件的命令,所以生成文件的入口就在这个文件中,我只截取了一部分代码

export const generateIcons = ({
  from,
  toDir,
  svgoConfig,
  theme,
  extraNodeTransformFactories,
  stringify,
  template,
  mapToInterpolate,
  filename
}: GenerateIconsOptions) =>
  function GenerateIcons() {
    return src(from)
      .pipe(svgo(svgoConfig))
      .pipe(
        svg2Definition({
          theme,
          extraNodeTransformFactories,
          stringify
        })
      )
      .pipe(useTemplate({ template, mapToInterpolate }))
      .pipe(
        rename((file) => {
          if (file.basename) {
            file.basename = filename({ name: file.basename });
            file.extname = '.ts';
          }
        })
      )
      .pipe(dest(toDir));
  };
ts

这个函数主要是通过 gulp 将一系列的任务组装在一起,首先它使用 SVGO 这个库来优化一下 SVG 图标的体积,svgo 可以将不需要的SVG属性给剔除,将 SVG 文件进行瘦身操作。

​ 下一步就是将 SVG 转换成抽象节点树的过程了,也是生成 Icons 的核心方法,它在 plugins/svg2Definition/index.ts 文件中被导出:

import { createTrasformStream } from '../creator';
import { ThemeType, AbstractNode } from '../../templates/types';
import {
  pipe,
  clone,
  map,
  filter,
  where,
  equals,
  gt as greaterThan,
  both,
  unless,
  length,
  dissoc as deleteProp,
  reduce,
  path as get,
  __,
  applyTo,
  defaultTo,
  objOf,
  assoc
} from 'ramda';
import parseXML, { Element } from '@rgrove/parse-xml';
// SVG => IconDefinition
export const svg2Definition = ({
  theme,
  extraNodeTransformFactories,
  stringify
}: SVG2DefinitionOptions) =>
  createTrasformStream((SVGString, { stem: name }) =>
    applyTo(SVGString)(
      pipe(
        // 0. The SVG string is like that:
        // <svg viewBox="0 0 1024 1024"><path d="..."/></svg>
 
        parseXML,
 
        // 1. The parsed XML root node is with the JSON shape:
        // {
        //   "type": "document",
        //   "children": [
        //     {
        //       "type": "element",
        //       "name": "svg",
        //       "attributes": { "viewBox": "0 0 1024 1024" },
        //       "children": [
        //         {
        //           "type": "element",
        //           "name": "path",
        //           "attributes": {
        //             "d": "..."
        //           },
        //           "children": []
        //         }
        //       ]
        //     }
        //   ]
        // }
 
        pipe(
          // @todo: "defaultTo" is not the best way to deal with the type Maybe<Element>
          get<Element>(['children', 0]),
          defaultTo(({} as any) as Element)
        ),
 
        // 2. The element node is with the JSON shape:
        // {
        //   "type": "element",
        //   "name": "svg",
        //   "attributes": { "viewBox": "0 0 1024 1024" },
        //   "children": [
        //     {
        //       "type": "element",
        //       "name": "path",
        //       "attributes": {
        //         "d": "..."
        //       },
        //       "children": []
        //     }
        //   ]
        // }
 
        element2AbstractNode({
          name,
          theme,
          extraNodeTransformFactories
        }),
 
        // 3. The abstract node is with the JSON shape:
        // {
        //   "tag": "svg",
        //   "attrs": { "viewBox": "0 0 1024 1024", "focusable": "false" },
        //   "children": [
        //     {
        //       "tag": "path",
        //       "attrs": {
        //         "d": "..."
        //       }
        //     }
        //   ]
        // }
 
        pipe(objOf('icon'), assoc('name', name), assoc('theme', theme)),
        defaultTo(JSON.stringify)(stringify)
      )
    )
  );
ts

​ 作者使用了 ramda,有名的函数式编程的库,对于习惯了写命令式编程的我,看到这样的函数式范式编写的代码,当时的想法是为什么不使用命令式的编程呢?感觉那样好调试也更直观一些,其实有点望而却步的感觉😵‍💫

既然看到这里了,还是要继续看下去吧?仔细一下这个方法作者贴心的添加了代码的注释,给阅读代码的人展示了 SVG 是如何被转换成 AST 的过程。

​ 首先执行了 createTrasformStream 方法,将我们要执行的函数传入,createTrasformStream 本身是为了满足 gulp 的 pipe 管道方法的入参而封装的一个方法,其内部使用了闭包和 through2 包装了一个转换流(Transform):

import through from 'through2';
import File from 'vinyl';
 
export const createTrasformStream = (fn: (raw: string, file: File) => string) =>
  through.obj((file: File, encoding, done) => {
    if (file.isBuffer()) {
      const before = file.contents.toString(encoding);
      try {
        const after = fn(before, file);
        file.contents = Buffer.from(after);
        done(null, file);
      } catch (err) {
        done(err, null);
      }
    } else {
      done(null, file);
    }
  });
ts

其中,through.obj 中的回调 file 参数就是经过 SVGO 优化后的 SVG 字符串,之后通过闭包拿到我们传入 createTrasformStream 的回调函数执行。

​ 回到pipe中,使用 applyTo 方法将 SVGString 绑定,可以让 pipe 中的方法会被自动传入 SVGString 参数,而 pipe 中组装的方法的执行结果会传递给下一个函数的形参中,正如作者注释中写到的从0 -> 1的过程,使用 parseXML 库将 SVGString 抽象成一个 Node 节点树:

  {
    "type": "document",
    "children": [
      {
        "type": "element",
        "name": "svg",
        "attributes": { "viewBox": "0 0 1024 1024" },
        "children": [
          {
            "type": "element",
            "name": "path",
            "attributes": {
              "d": "..."
            },
            "children": []
          }
        ]
      }
    ]
  }
ts

​ 但这不是我们想要的结果,所以作者使用了 ramda 中的 get 方法拿到 children 数组中的第一个元素,并使用defaultTo 方法来限制一下如果 children 是一个空数组的时候,那么就将它的第一个元素设置成一个对象,来规避报错。

​ 接下来的 element2AbstractNode 方法,也是在这个文件中:

function element2AbstractNode({
  name,
  theme,
  extraNodeTransformFactories
}: XML2AbstractNodeOptions) {
  return ({ name: tag, attributes, children }: Element): AbstractNode =>
  {
    return applyTo(extraNodeTransformFactories)(
      pipe(
        // factory -> (option) => (asn) => asn
        map((factory: TransformFactory) => factory({ name, theme })),
        // [(asn) => {}, (asn) => {}]
        reduce(
          (transformedNode, extraTransformFn) =>
            extraTransformFn(transformedNode),
          applyTo({
            tag,
            attrs: clone(attributes),
            children: applyTo(children as Element[])(
              pipe(
                filter<Element, 'array'>(where({ type: equals('element') })),
                map(
                  element2AbstractNode({
                    name,
                    theme,
                    extraNodeTransformFactories
                  })
                )
              )
            )
          })(
            unless<AbstractNode, AbstractNode>(
              where({
                children: both(Array.isArray, pipe(length, greaterThan(__, 0)))
              }),
              deleteProp('children')
            )
          )
        )
      )
    );
  }
}
ts

这个函数是一个闭包函数,返回一个箭头函数,而箭头函数引用着闭包函数的变量们 name, theme, extraNodeTransformFactories

箭头函数中解构了 parseXML 生成的 AST 树,实际上,箭头函数中的形参就是上述注释第2步的结构,读到这里,我已经感受到闭包和函数式编程的魅力所在了🤩。

整个生成 AST 的过程就像流水线一样被组装起来,写法比起命令式的编程优雅了许多,而且感觉很顺手。

回归到代码中,首先使用了 applyTo 对 extraNodeTransformFactories 参数进行绑定,那么这个参数是什么东东?这个参数是在我们的入口文件 gulpfile.ts 中执行 generateIcons 方法传入的,文章的开头有贴出:

  // 2.2 generate abstract node with the theme "filled"
  generateIcons({
    theme: 'filled',
    from: ['svg/filled/*.svg'],
    toDir: 'src/asn',
    svgoConfig: generalConfig,
    extraNodeTransformFactories: [
      assignAttrsAtTag('svg', { focusable: 'false' }),
      adjustViewBox
    ],
    stringify: JSON.stringify,
    template: iconTemplate,
    mapToInterpolate: ({ name, content }) => ({
      identifier: getIdentifier({ name, themeSuffix: 'Filled' }),
      content
    }),
    filename: ({ name }) => getIdentifier({ name, themeSuffix: 'Filled' })
  })
ts

作者在入口文件使用 generateIcons 方法生成了三个主题的 Icons AST 文件,这里只贴出了一部分,有兴趣的朋友可以去源码里面翻一翻。

我们在上面可以看到 extraNodeTransformFactories 数组,里面执行了assignAttrsAtTag,它在 plugins/svg2Definition/tranforms/creator.ts 中:

export function assignAttrsAtTag(
  tag: string,
  extraPropsOrFn:
    | Dictionary
    | ((
        options: TransformOptions & { previousAttrs: Dictionary }
      ) => Dictionary)
): TransformFactory {
  return (options) => (asn) => {
    return when<AbstractNode, AbstractNode>(
      where({
        tag: equals(tag)
      }),
      evolve({
        attrs: pipe<Dictionary, Dictionary, Dictionary>(
          clone,
          mergeLeft(
            typeof extraPropsOrFn === 'function'
              ? extraPropsOrFn(
                  mergeRight(options, { previousAttrs: asn.attrs })
                )
              : extraPropsOrFn
          )
        )
      })
    )(asn)
  };
}
ts

这个函数也是返回一个箭头函数,主要的作用是对 SVGAST 中的 attrs 对象中的属性进行改动,回到 element2AbstractNode 方法:

function element2AbstractNode({
  name,
  theme,
  extraNodeTransformFactories
}: XML2AbstractNodeOptions) {
  return ({ name: tag, attributes, children }: Element): AbstractNode =>
  {
    return applyTo(extraNodeTransformFactories)(
      pipe(
        // factory -> (option) => (asn) => asn
        map((factory: TransformFactory) => factory({ name, theme })),
        // [(asn) => {}, (asn) => {}]
        reduce(
          (transformedNode, extraTransformFn) =>
            extraTransformFn(transformedNode),
          applyTo({
            tag,
            attrs: clone(attributes),
            children: applyTo(children as Element[])(
              pipe(
                filter<Element, 'array'>(where({ type: equals('element') })),
                map(
                  element2AbstractNode({
                    name,
                    theme,
                    extraNodeTransformFactories
                  })
                )
              )
            )
          })(
            unless<AbstractNode, AbstractNode>(
              where({
                children: both(Array.isArray, pipe(length, greaterThan(__, 0)))
              }),
              deleteProp('children')
            )
          )
        )
      )
    );
  }
}
ts

其中的 map 方法就是执行了分别 extraNodeTransformFactories 数组中的方法,reduce方法是将 applyTo 之后的值:

const SVGASt = {
  "tag": "svg",
  "attrs": { "viewBox": "0 0 1024 1024", "focusable": "false" },
  "children": [
    {
      "tag": "path",
      "attrs": {
        "d": "..."
      }
    }
  ]
}
ts

传入 extraTransformFn 方法中执行,这样就可以对 AST 的 attrs 对象进行额外的修改操作,这样我们已经可以看到一个比较完整的 AST 结构了,接着下面的操作:

pipe(objOf('icon'), assoc('name', name), assoc('theme', theme)),
defaultTo(JSON.stringify)(stringify)
ts

使用 objof 方法将 SVGAST 放到新对象的 icon 属性中,将新增 name、theme 属性,此时的数据结构就变成了这样:

const SVGASt = {
  icon: {
    "tag": "svg",
    "attrs": { 
      "viewBox": "0 0 1024 1024", 
      "focusable": "false" 
    },
    "children": [
      {
        "tag": "path",
        "attrs": {
          "d": "..."
        }s
      }
    ]  
  },
  name: "...",
  theme: '...'
}
ts

已经跟文中贴出的数据结构已经很像了对吧?还差最后一步,对双色图标的处理,双色图标的原理对填充 path 元素上的 fill 属性的颜色,我们要做的就是在 path 元素的 fill 上添加上我们自定义的颜色,两个 path 对应两个颜色变量:primaryColorsecondaryColor

代码中的最后一个步骤 defaultTo(JSON.stringify)(stringify) 就是做的这件事,在入口文件 gulpfile.ts 中对于双色图标作者会传入 twotoneStringify 函数,而对于单色图标则是传入 JSON.stringify 来将对象转为 JSON 字符串:

{
  "icon": function render(primaryColor, secondaryColor) {
    return {
      "icon": {
        "tag": "svg",
        "attrs": { 
          "viewBox": "0 0 1024 1024", 
          "focusable": "false" 
        },
        "children": [
            {
              "tag": "path",
              "attrs": {
              "d": "..."
              }
            }
          ]
      },
      "name": "...",
      "theme": '...'
    }
  }
}
ts

到此 SVG 文件的 AST 之旅也就完成了。

源码中后续还是生成 AST 的入口文件和将 SVGAST 重新转换成 SVG 文件的过程,本文篇幅也是有点长了,放在后面再写一篇文章记录一下吧😂。

#写在最后

之前是一直听说函数式编程这个概念的,但自己却没有实践过,这几天看了 Ant-Design-Icons 的源码后,深受感触,从刚开始的抗拒,到现在自己也在接纳、学习函数式编程,学习 Ramda,Rxjs 等库,学以致用,也会在后面的组件库编程中用上。