Life and freedom Ge Lin ——— Draw by Razzh

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

Oct 25 · 18 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;

它将 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",
}

可以看到提到了 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));
  };

这个函数主要是通过 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)
      )
    )
  );

​ 作者使用了 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);
    }
  });

其中,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": []
          }
        ]
      }
    ]
  }

​ 但这不是我们想要的结果,所以作者使用了 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')
            )
          )
        )
      )
    );
  }
}

这个函数是一个闭包函数,返回一个箭头函数,而箭头函数引用着闭包函数的变量们 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' })
  })

作者在入口文件使用 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)
  };
}

这个函数也是返回一个箭头函数,主要的作用是对 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')
            )
          )
        )
      )
    );
  }
}

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

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

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

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

使用 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: '...'
}

已经跟文中贴出的数据结构已经很像了对吧?还差最后一步,对双色图标的处理,双色图标的原理对填充 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": '...'
    }
  }
}

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

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

#写在最后

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

浙ICP备2024129591号-1
春秋(Live) - 张敬轩
--:-- / --:--
  1. 1春秋(Live)张敬轩
  2. 2不吐不快(live)张敬轩
  3. 3男孩最痛(live)张敬轩
  4. 4粤语残片(live)陈奕迅
  5. 5几分之几(live)卢广仲
  6. 6地球很危险古巨基
  7. 7樱花树下(live)张敬轩

春秋 (Live) - 张敬轩 (Hins Cheung)

词:林夕

曲:Edmond Tsang

那夜谁将酒喝掉

因此我讲得多了

然后你摇着我手拒绝我

动人像友情深了

我没权终止见面

只因你友善依然

仍用接近甜蜜那种字眼通电

没人应该 怨地怨天

得到这结局

难道怪罪神没有更伪善的祝福

我没有为你伤春悲秋不配有憾事

你没有共我踏过万里

不够剧情延续故事

头发未染霜 着凉亦错在我幼稚

应快活像个天使

有没有运气再扮弱者 玩失意

有没有道理为你落发

必须得到世人同意

心灰得极可耻 心伤得无新意

那一线眼泪 欠大志

爱若能堪称伟大 再难挨照样开怀

如令你发现为你而活到失败

令人不安 我品性坏

我没有为你伤春悲秋不配有憾事

你没有共我踏过万里

不够剧情延续故事

头发未染霜 着凉亦错在我幼稚

应快活像个天使

有没有运气再扮弱者玩失意

有没有道理为你落发

必须得到世人同意

心灰得极可耻 心伤得无新意

那一线眼泪 欠大志 太没意思

若自觉这叫痛苦未免过份容易

我没有被你改写一生怎配有心事

我没有被你害过恨过

写成情史 变废纸

春秋只转载要事

如果爱你欠意义

这眼泪 无从安置

我没有运气放大自私的失意

更没有道理在这日

你得到真爱制造恨意

想心酸 还可以 想心底 留根刺

至少要见面上万次