wzhudev / blog

:book:
220 stars 14 forks source link

Few Things I Learned while Developing an Icon Library / 时隔一年回顾 Icon 组件库的开发 #28

Closed wzhudev closed 2 years ago

wzhudev commented 4 years ago

This article is posted on Medium. You may like reading it there.


We use icons everywhere in web applications and pages. An icon is a graphical representation of meaning. Icons can be used to express actions, state, and even to categorize data. The Ant Design specification has mature design guidance for icons. And as its implementation for Angular, ng-zorro-antd provides an icon component and hundreds of icons for developers.

In the past, these icons are encapsulated in a font file. However, from v1.7.0 ng-zorro-antd started to use SVG in its icon library @ant-design/icons-angular.

SVG icons are good in many cases compared to font-based icons such as Font Awesome (but it supports SVG icons nowadays, too). It looks sharper on low-resolution devices. It could have multiple colors (ng-zorro-antd does support that). It could be bundled into your JavaScript files so you won’t have to fire another HTTP request. But there’s a problem that is a pain in the ass:

SVG is too big.

https://miro.medium.com/max/3840/0*YFupRTSUmB8qV3n_.png

The picture shows how bad the situation would get if we do not take the problem seriously. See how much space the icons take. Quote.

A font file is binary so it could be small in size. However, the SVG file is just a plain text file with a unique suffix name (.svg). Though you could gzip it (and web servers like nginx would do that for you), it’s still big and takes a good bite on your bundle size budget. So the challenge for us was,

How can we ship these icons to browsers at the lowest cost without breaking anything?

This article is all about how we managed to do that and what I’ve learned in this journey. If you are building an icon library or just getting interested, keep reading. ;)

Two Approaches

There are two simple approaches.

The first is that we package all icons into the component library — just like the old days. But this issue of antd (Ant Design for React) stopped us from taking this approach — users do not want more 500KB of non-tree-shakable code in their bundles. (React community has come up with lots of solutions like this one to shrink the bundle size. But honestly, this is even more troublesome than the second approach that I am going to introduce below.)

The second is that we leave developers to import icons that they are going to use. By doing that, we won’t package anything unnecessary. This approach is neat and “how it should be”. However, we cannot go this way either because our users were used to render icons without importing them beforehand (thanks to the tiny, tiny font file). Adopting this approach would force users to write lots of import XXXIcon to fix their projects. BREAKING CHANGE ALERT! (BTW, this article is a good tutorial if you want to create an icon library just for yourself.)

The problem seems to get more complicated. It’s a paradox that:

So, are we running into a dead end? (Apparently not, because if so, you were not reading this right now! 😄)

Have you heard of Idle while Urgent or Lazy Initialization? We can instantiate a class just before we are going to use it, and we can also load an icon just before we are going to render one!

That’s why we adopted an approach that we called “dynamic loading”.

The Third Approach

You can get the source code of @ant-design/icons-angular here.

Dynamic loading means: when we want to render an icon that hasn’t been loaded, we load it from a remote server, cache it, and then render it.

Let’s explain this idea by diving into the source code. Don’t worry. I won’t cover every detail in this project, just the main process.

When we are going to render an icon, say an outlined “heart”, we first lookup the cache to see whether this icon has cached. If not, we will load it by calling _loadIconDynamically .

    // If `icon` is a `IconDefinition` of successfully fetch, wrap it in an `Observable`.    
    // Otherwise try to fetch it from remote.    
    const $iconDefinition = definitionOrNull      
      ? rxof(definitionOrNull)      
      : this._loadIconDynamically(icon as string);

The request simply asks the server for the icon (SVG string), assemble an icon object, and cache it.

      const source = !this._enableJsonpLoading
        ? this._http
            .get(safeUrl, { responseType: 'text' })
            .pipe(map(literal => ({ ...icon, icon: literal }))) // assemble an icon object, type as IconDefition
        : this._loadIconDynamicallyWithJsonp(icon, safeUrl); // jsonp-like loading

      inProgress = source.pipe(
        tap(definition => this.addIcon(definition)),
        finalize(() => this._inProgressFetches.delete(type)), // delete the request object
        catchError(() => rxof(null)),
        share() // share with other subscribers
      );

Now we get an icon object and area able to continue the rendering. Hooray! 🎊🎊🎊

But that’s just not enough. There are some details worth noticing.

If we have several same icons to render at the same time, it would be costly if we fire HTTP requests for each of them. So these icons should share the same request (named inProgess ). There’s a share operator that will share the icon object to all subscribers. And we remove the request after the request is over.

https://miro.medium.com/max/3048/1*b4lnigsma-eueVGsup6WvA.png

How could we know where to load icons? Thanks to Angular/CLI, we provide a schematic that could help users add icon assets to their bundle in a convenient way. When a developer is installing ng-zorro-antd with this command ng add ng-zorro-antd , it would ask if s/he would like to add icons assets and modify angular.json if the developer wants so.

What if users want to load icons from a CDN? We have to make URLs of requests configurable. So we provide a method named changeAssetsSource for users to set the URL prefix.

What if the CDN doesn’t support cross-domain XML requests? We provide a jsonp-like mechanism for this and developers could enable it by calling useJsonpLoading .

And so on.

So you can see that even the core idea is simple, you must take lots of scenarios into consideration, though some of those you may never run into!

There was still lots of work to do:

  1. The second approach is great, and we should support it as well. So we endorsed static loading.
  2. We needed some scripts to generate icon resources.
  3. Our old API was no API (literally). Users just needed to write an i tag in this way <i class="anticon anticon-clock"> . So we needed to support the old API as well (we used MutationObserver).
  4. We needed to implement features such as spinning, custom icon, namespaces and iconfont.
  5. Docs (very important).

Finally, I wrote @ant-design/icons-angular and rewrote the Icon component of ng-zorro-antd.

https://miro.medium.com/max/2560/0*sF7medQ3PrHC_76y.png

@ant-design/icons-angular, as an underlying dependency, provides icon resources and fundamental features such as static loading, dynamic loading, jsonp-like loading, and namespace.

The Icon component of ng-zorro-antd is responsible for the dirty work (adapting old API), and providing add-on features such as spinning.

https://miro.medium.com/max/5712/1*c2W1ILxsKRI0kcoucpmrCQ.png

Click on an icon add you can get a piece of template rendering that icon. ;)

Conclusion

In October 2018, we release v1.7.0 with the new icon component. And I wrote a detailed upgrade guide to explain why we replaced old font-based icons with SVG icons, and what should developers do if they want to upgrade to the new version. Thankfully, our users felt comfortable with this new version and willingly moved along with us. (You guys are awesome. Thanks!)

As a summary, what have we achieved?

Nice! It looks like we solved the problem in an elegant way. What about performance? You may ask.

Well, the doc webpage of the Icon component which has hundreds of icons is strong proof that the performance is merely harmed. In fact, you may not notice that the icons are loaded dynamically if you only use dozens of them. Your website is a PWA (like our official website)? End of the discussion!

Here is what I learned as an open source library author by working on this project:

  1. Think about how changes you make will influence your users, and think twice. Try not to make breaking changes. Adapt the old API and give users have time to migrate to the new. Stick to the semantic versioning system.
  2. Don’t make things hard to revert and definitely not make decisions for your users. Instead, provide choices. It’s not a wise decision for the antd team to import all icons and not to provide a more flexible way (such as dynamic loading). And we learned from their mistakes.
  3. Work hard before you reach a conclusion. Keep asking yourself “is there a better way to do this?”

That’s all. Thank you for reading.

wzhudev commented 4 years ago

图标是 UI 设计中必不可少的组成部分。通常我们理解图标设计的含义,是将某个概念转换成清晰易读的图形,从而降低用户的理解成本,提升界面的美观度。

Ant Design 有一套成熟的图标设计规范。作为它的 Angular 实现,ng-zorro-antd 提供了数以百计的图标给开发者们使用。

之前这些图标都被封装在一个字体文件中,但在 1.7.0 版本之后,ng-zorro-antd 开始在它的底层图标库 @ant-design/icons-angular 中使用 SVG 技术。

相对于基于字体文件的图标,例如 Font Awesome(当然它现在也支持 SVG 图标了),SVG 具有许多优点,比如在低分辨率屏幕上显示效果更好,支持多种颜色(ng-zorro-antd 提供了对双色图标的支持),并且它可以被打包在 JavaScript 文件中让用户无须发起额外请求去获得字体文件,等等,但是它也有一个不容忽视的致命缺陷:

SVG 太特喵的大了。

https://miro.medium.com/max/3840/0*YFupRTSUmB8qV3n_.png

如果不谨慎对待 SVG 的体积问题,结果可能会很糟糕。这个例子来自这里

字体文件实际上是二进制位文件,所以它的体积很小。但是 SVG 实际上就是纯文本文件(只是后缀名是 .svg),尽管你可以使用 gzip 压缩 SVG(并且主流的 web 服务器也会帮你这么做),但是它还是很大,会占用很多网络传输带宽。所以我们面临的挑战就是:

如何在不造成破坏性更新的前提下以最低的开销把这些 icon 送到用户的浏览器上。

这篇文章介绍了我们当时是如何解决这一问题的,以及作为它的主要开发者,我本人在做这个项目中得到的一些感想。如果你也在打造一个类似的库,或者仅仅是对我们的工作感兴趣,请继续阅读 ;)

两种方案

你可能想到下面两种方案:

第一种方案是:我们把所有的图标都打包到组件库里,就像以前用字体图标时那样。但是 antd 的这个 issue 警醒我们千万不要这么做:用户并不想在他们的包里多出 500KB 无法被 tree shake 的,当然最主要的是自己用不着的代码。React 社区提出了一些替代方案,比如这个,但是在我们看来,使用这些方法甚至比我接下来要介绍的第二种方案还要麻烦。

第二种方案是:我们让用户自己决定要打包哪些图标,这样的话就不会把用不着的图标打包进去了。这种实现非常简洁,逻辑上也是最说得通的。但是,我们也不能采纳这种方案,因为我们的用户都已经习惯了使用图标而无须事先引用它们(这多亏了字体文件的体积很小我们才能这么做)。这种方案将会迫使用户添加大量 import XXXIcon 这样的代码,才能让项目重新运行,即引入了破坏性更新,这也是我们无法接受的。(顺便推荐一下最近看到的一篇文章,介绍了方案二的具体实现。)

情况愈发复杂了,我们似乎走进了死胡同:

所以,真的无路可走了吗?(显然不是,毕竟我们的新图标组件都 release 一年多了 😄)

如果你听说过 Idle while Urgent 或者 iOS 社区的 Lazy Initialization 的话,你可能会想到这里蕴含了一个更通用的模式,即在你需要使用某项资源的时候,再去创建这项资源。同理,我们可以在需要渲染图标的时候,再去加载图标资源。

我们将这种方案称为“动态加载”。

第三种方案

你可以在这里找到 @ant-design/icons-angular 的源代码。

动态加载的意思就是:当我们需要渲染一个图标,但是这个图标还未加载的时候,我们就从服务器端获取这个图标,缓存它,再进行渲染。

接下来我会通过带你阅读源码来了解这个机制是如何实现的。别担心,我们不会去看那些繁杂的细节,而是专注于主要流程。

当我们需要渲染图标的时候,会先去缓存里查找这个图标是否加载了,如果没有的话,我们就通过调用 _loadIconDynamically 方法加载该图标。

// If `icon` is a `IconDefinition` of successfully fetch, wrap it in an `Observable`.
// Otherwise try to fetch it from remote.
const $iconDefinition = definitionOrNull
  ? rxof(definitionOrNull)
  : this._loadIconDynamically(icon as string);

发起的请求仅仅是向服务器请求图标的内容(其实就是一个字符串),然后将图标名称和其内容封装成 icon 对象,并缓存

const source = !this._enableJsonpLoading
  ? this._http
      .get(safeUrl, { responseType: 'text' })
      .pipe(map(literal => ({ ...icon, icon: literal }))) // assemble an icon object, type as IconDefinition
  : this._loadIconDynamicallyWithJsonp(icon, safeUrl); // jsonp-like loading

inProgress = source.pipe(
  tap(definition => this.addIcon(definition)),
  finalize(() => this._inProgressFetches.delete(type)), // delete the request object
  catchError(() => rxof(null)),
  share() // share with other subscribers
);

这样我们就得到了图标,可以继续之前的渲染过程了! 🎊🎊🎊

但这还远远不够,还有很多细节问题需要注意。

如果我们要同时渲染很多相同的图标,为每一个图标发起 HTTP 请求的话开销就太大了,所以我们应该在每个渲染流程之间分享请求(这个请求就是个名为 inProgress 的流)。有一个 share 操作符将会负责把图标对象广播给所有的订阅者。并且在请求完成之后我们要移除这个请求流。

https://miro.medium.com/max/3048/1*b4lnigsma-eueVGsup6WvA.png

我们怎么知道从哪里去加载图标呢?多亏了 Angular/CLI,我们可以通过提供一个 schematic 来帮助用户添加图标资源到他们的网站静态资源文件夹中。当用户通过命令 ng add ng-zorro-antd 安装 zorro 时,zorro 会询问用户是否需要修改 angular.json 文件以便添加图标资源。

如果用户想从 CDN 加载图标呢?我们必须让加载图标的 URL 变成可配置的。所以我们提供了一个名为 changeAssetsSource方法用于修改 URL 前缀。

如果 CDN 禁用了跨域 XML 请求呢?我们提供了一种类似于 JSONP 的加载机制帮助开发者们绕过跨域问题,可以通过调用 useJsonpLoading 方法开启。

等等。所以你可以看出,即使核心概念非常简单,我们也必须考虑用户可能面临的多种场景,即使部分场景我们自己可能都不会遇到。

除了动态加载,还有很多工作要做:

  1. 上面说到的第二种方案很棒,我们也想支持它,所以我们提供了静态加载方案。
  2. 需要一些脚本来生成图标资源。
  3. 我们之前的图标 API 就是没有 API,真的。用户们想要渲染图标的时候仅需要写一个有特定 class 的 i 标签,比如 <i class="anticon anticon-clock">,所以我们还要兼容这种基于类名的 API,我们使用了 MutationObserver
  4. 需要支持旋转、自定义图标、命名空间和 iconfont 等多种功能。
  5. 撰写文档(十分重要)。

最终,我写作了 @ant-design/icons-angular 和 ng-zorro-antd 的新 Icon 组件。

https://miro.medium.com/max/2560/0*sF7medQ3PrHC_76y.png

@ant-design/icons-angular 作为 ng-zorro-antd 的底层依赖,提供了图标资源,以及静态加载、动态加载、jsonp 加载和命名空间等基础功能。

ng-zorro-antd 的 Icon 组件则负责脏活(比如适配 API),以及提供旋转等扩展功能。

https://miro.medium.com/max/5712/1*c2W1ILxsKRI0kcoucpmrCQ.png

结论

在 2018 年 10 月发布的 1.7.0 版本中,我们发布了全新的图标系统,并且我写作了升级指南来解释为什么我们要用新图标替换旧图标,以及用户们要迁移到新图标应当做什么。令人高兴的是绝大部分用户接受了这一次的重大变更并顺利完成了升级(感谢!)。

总结一下,我们做到了什么?

好极了!我们以一种优雅的方式解决了开头提到的问题。

性能呢?”你可能会发出这样的疑问。

实际上,我们的 Icon 的文档页会一次渲染 300 多个图标,证明了动态加载并不会造成严重的性能问题。事实上,当你只使用少数几个图标的时候,访问者几乎感觉不到图标是动态加载的。如果你的网页是一个 PWA 的话,你就完全无须担心这个问题!因为 PWA 会将这样的请求进行本地缓存(就和我们的官网一样)。

作为开源项目的维护者,以下是我在这个项目中学到的一些东西:

  1. 谨慎思考你做的变更会给用户带来怎样的影响,避免破坏性变更,适配旧的 API 让用户有充足的时间去升级,严格遵守语义化版本规范
  2. 避免把状况搞得难以收拾,更不要替你的用户做决定,相反,让用户们有得选择。引入所有的 icon 就是一种不太明智的做法,这会让不想这么做的用户大伤脑筋。
  3. 在做出最终决定前要三思,多问自己“还有更好的解决方案吗”。

以上就是本文的全部内容,感谢阅读!