Photo by Boxed Water Is Better on Unsplash
太长不读版
从 7.3.0 版本开始,ng-zorro-antd 允许用户从二级入口(secondary entry point)引入组件及其样式,从而能够减小打包体积并避免和其他组件库的冲突。https://ng.ant.design/docs/getting-started/zh#%E5%8D%95%E7%8B%AC%E5%BC%95%E5%85%A5%E6%9F%90%E4%B8%AA%E7%BB%84%E4%BB%B6
Ant Design Of Angularng.ant.design
背景
在 7.3.0 版本之前,ng-zorro-antd 限制用户统一引入
NgZorroAntdModule
而不支持引入组件对应的 module(比如
NzButtonModule
),即不支持二级入口,这样的设计是出于以下几点考虑:
- 使用方便,用户引入一个 module 就可以使用全部的组件
- tree shake 可以有效的的控制包体积,ng-zorro-antd 从第一版起就完全使用 TypeScript 编写,tree shake 有效的避免了打入多余代码,特别是对于使用了较多组件的大型项目而言
后来社区反馈的几个问题和一些工具链的变动促使我们重新考虑支持二级入口:
- 仅仅使用了几个组件就带来了 180KB 左右的 js 代码(对于一些小项目和 mobile 项目来说影响较大),以及体积 300KB 左右的 CSS 代码(有一部分 base 代码没有被 tree shake 掉,参考延伸阅读)
- 在某些情况下和其他组件库冲突,例如这个 issue
- 使用 ng-packagr 作为打包工具后,支持二级入口变得较为容易
所以在 7.3.0 版本中,我们为大家带来了二级入口的支持。
这篇文章会举一个例子来向大家展示使用二级入口之后的收益,帮助大家判断是否要使用(或者迁移)到二级入口,感兴趣的同学还可以进一步了解到我们是如何实现这一功能的,以及为什么使用二级入口能进一步减小打包体积。
例子
此处仅仅作为例子展示,更加详细的使用方式请参考官网文档。
大家可以在这里找到该例子,它仅仅在界面上渲染了一个 nz-button,template 如下:
<button nz-button nzType="primary">Hello Zorro</button>
我们分别看看使用旧的方式和使用新的二级入口应该如何编写代码。
使用旧方式,你需要在 module 中引入
NgZorroAntdModule
,并在 style.css 中引入样式:
import { NgZorroAntdModule } from 'ng-zorro-antd';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, NgZorroAntdModule],
bootstrap: [AppComponent]
})
export class AppModule {}
/* You can add global styles to this file, and also import other style files */
@import '~ng-zorro-antd/ng-zorro-antd.min.css';
使用二级入口,我们应该引入
NzButtonModule
而不是
NgZorroAntdModule
,并引入基础样式和 button 组件特有的样式文件:
import { NzButtonModule } from 'ng-zorro-antd';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, NzButtonModule],
bootstrap: [AppComponent]
})
export class AppModule {}
/* You can add global styles to this file, and also import other style files */
@import '~ng-zorro-antd/style/index.min.css'; /* 基础样式 */
@import '~ng-zorro-antd/button/style/index.min.css';
运行 ng serve,你可以看到展示效果完全相同。
接下来我们比较一下打包后的文件的体积和文件内容。配置 angular.json 中
"sourceMap": true
,然后运行
ng build —prod
,可以看到打包日志。
使用旧方式的打包日志:
Date: 2019-04-17T03:55:41.321Z
Hash: d9350d83e93a6b110d6d
Time: 53455ms
chunk {0} runtime.26209474bfa8dc87a77c.js, runtime.26209474bfa8dc87a77c.js.map (runtime) 1.46 kB [entry] [rendered]
chunk {1} es2015-polyfills.18155782d41984482c9b.js, es2015-polyfills.18155782d41984482c9b.js.map (es2015-polyfills) 56.5 kB [initial] [rendered]
chunk {2} main.6c0b0c2ea9cc4e66e1bd.js, main.6c0b0c2ea9cc4e66e1bd.js.map (main) 404 kB [initial] [rendered]
chunk {3} polyfills.a531a96cecea302cf122.js, polyfills.a531a96cecea302cf122.js.map (polyfills) 41.1 kB [initial] [rendered]
chunk {4} styles.c6051f607c1062f3af9d.css, styles.c6051f607c1062f3af9d.css.map (styles) 410 kB [initial] [rendered]
使用二级入口的打包日志:
Date: 2019-04-17T03:53:15.246Z
Hash: f27d2968076d355865b4
Time: 35999ms
chunk {0} runtime.26209474bfa8dc87a77c.js, runtime.26209474bfa8dc87a77c.js.map (runtime) 1.46 kB [entry] [rendered]
chunk {1} es2015-polyfills.18155782d41984482c9b.js, es2015-polyfills.18155782d41984482c9b.js.map (es2015-polyfills) 56.5 kB [initial] [rendered]
chunk {2} main.14597b256a2017488623.js, main.14597b256a2017488623.js.map (main) 221 kB [initial] [rendered]
chunk {3} polyfills.a531a96cecea302cf122.js, polyfills.a531a96cecea302cf122.js.map (polyfills) 41.1 kB [initial] [rendered]
chunk {4} styles.6ed6021586bb21f08fe9.css, styles.6ed6021586bb21f08fe9.css.map (styles) 67.4 kB [initial] [rendered]
可以看到,使用二级入口之后有以下好处:
- 打包速度变快,因为要处理的代码减少了
- main.js 体积减小了 183KB,styles.css 体积减小了 342.6 KB
我们使用 source-map-explorer 进一步对 main.js 的内容进行分析,运行
source-map-explorer main.xxxxxxx.js
命令,得到包内容的分解图:
可以看到使用了二级入口之后:
- ng-zorro-antd 本身的大小降低了 92KB,其中一些组件的代码被移除了
- 一些 ng-zorro-antd 的依赖,例如 @angular/cdk 的体积大大降低,date-fns 直接被移除了
我是否应该使用二级入口?
我们先来对比分析一下旧方式和二级入口的优缺点。
旧方式- 心智负担:
一把梭NgZorroAntdModule
- 打包体积:不彻底的 tree shake,多余的样式,较大
- 打包速度:一般
- 兼容其他组件库:大部分情况下不会发生冲突
- 心智负担:需要按照使用的组件来引入 module,不过可以通过封装一个 share module 来降低代码量
- 打包体积:彻底的 tree shake,没有冗余的样式,较小
- 打包体积:快一些
- 兼容其他组件库:可以通过避免引入冲突的组件来解决冲突
知乎居然不支持表格……
可以看到除了需要根据使用的组件来引入 module 这一点比较麻烦之外,二级入口相比之前的方式都有不错的改进,所以我们的建议是:
推荐使用二级入口,除非你的项目庞大,用了很多组件,不那么在乎打包体积,而且改造的成本比较高。
实现
Angular 关于组件库的打包有一套称为 Angular Package Format (APF) 的规范,其中规定了二级入口的文件格式要求。
建立二级入口点
ng-zorro-antd 使用 ng-packagr 进行打包。基于这个打包工具,要支持 APF 所规定的二级入口是相对容易的,只需要在每个组件底下都创一个名为 package.json 的文件并输入以下内容:
{
"ngPackage": {
"lib": {
"entryFile": "public-api.ts"
}
}
}
ng-packagr 就会知道你想要在此处创建二级入口了。
修改组件之间的引用方式
ng-zorro-antd 的部分组件之间存在依赖,换用二级入口之后,各个组件之间会独立打包,所以引用方式必须要修改,否则 ng-packagr 会告诉你所需的全部资源文件不在当前目录下,参考这个 issue。
以 popover 组件为例,该组件继承了 tooltip 组件,所以要这样修改其引用:
- import { NzToolTipComponent } from '../tooltip/nz-tooltip.component';
+ import { NzToolTipComponent } from 'ng-zorro-antd/tooltip';
对于 core 文件夹底下的一些可复用的代码也是如此,都需要修改成从 ng-zorro-antd/core 引入。
import { isNotNil, zoomBigMotion, NzNoAnimationDirective } from 'ng-zorro-antd/core';
配置 tsconfig
修改了引用方式后,需要修改 tsconfig.json 的
paths
字段,让 TypeScript 能正确定位想要引入的文件。
修改组件库的 tsconfig:
{
+ "paths": {
+ "ng-zorro-antd": ["./ng-zorro-antd.module"],
+ "ng-zorro-antd/*": ["./*"]
+ }
}
官网的 tsconfig 也需要进行修改:
{
"paths": {
"ng-zorro-antd": [ "../components/ng-zorro-antd.module.ts" ],
+ "ng-zorro-antd/*": [ "../components/*" ],
}
}
ng-zorro-antd 有一段脚本会在打包时替换官网的 tsconfig,使得我们可以在开发时引用组件的源码以享受 hot reload,在构建官网时使用打包后的组件库确保组件库确实可用。
解除组件之间的循环依赖
有些组件之间由于之间抽象不够充分,在不支持单独入口时,打包并不会暴露这个问题,但是在支持二级入口之后,TypeScript 就会因为循环依赖而出错。
menu 和 dropdown,tree 和 tree-select 这两对组件之间存在循环依赖,而出现循环依赖的原因是相同的,都是
可被嵌套在 A 中的 B 组件想要知道它是否被嵌套在 A 组件里,在嵌套或不嵌套的两种情况下,B 组件应该被注入不同的服务。以 tree 和 tree-select 为例,当 tree 被嵌套在 tree-select 中时,它应该被注入
NzTreeSelectService
,否则应该被注入
NzTreeService
。解决方法是将两个 service 的逻辑抽象为一个名为
NzTreeBaseService
的类,放到 core/tre 目录底下,然后创建这样一个令牌:
export const NzTreeHigherOrderServiceToken = new InjectionToken<NzTreeBaseService>('NzTreeHigherOrder');
在向 NzTreeComponent 提供 service 时,我们提供的是返回一个 NzTreeBaseService 的工厂方法:
{
provide: NzTreeBaseService,
useFactory: NzTreeServiceFactory,
deps: [[new SkipSelf(), new Optional(), NzTreeHigherOrderServiceToken], NzTreeService]
},
export function NzTreeServiceFactory(
higherOrderService: NzTreeBaseService,
treeService: NzTreeService
): NzTreeBaseService {
return higherOrderService ? higherOrderService : treeService;
}
而在
NzTreeSelectComponent
里,我们通过ef="https://github.com/NG-ZORRO/ng-zorro-antd/blob/53724be9bd0f8f1e33a45c927c408f3f3a45dc05/components/tree-select/nz-tree-select.component.ts#L55-L60">提供一个 NzTreeHigherOrderServiceToken 来让
NzTreeComponent
知道它被封装在了 tree-select 里:
@Component({
selector: 'nz-tree-select',
animations: [slideMotion, zoomMotion],
templateUrl: './nz-tree-select.component.html',
providers: [
NzTreeSelectService,
{
provide: NzTreeHigherOrderServiceToken,
useFactory: higherOrderServiceFactory,
deps: [[new Self(), Injector]]
},
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => NzTreeSelectComponent),
multi: true
}
]
})
export class NzTreeSelectComponent {}
重新导出二级入口的内容
在确保二级入口能够正确打包之后,我们还必须确保一级入口能够重新导出(re-export)所有二级入口的内容,以保持对旧的引入方式的兼容。方法是修改 ng-zorro-antd.module.ts 中对组件的导出方式:
+ export * from 'ng-zorro-antd/affix';
- export * from './affix/';
然而 ng-packagr 的一个 bug 会造成导出同名变量的冲突。原因是,ngc 要求导出一个 module 下所有的 directive component service 和子 module,如果开发者没有在入口文件中显式的导出它们,ngc 就会隐式地导出,例如 date picker 组件的入口文件 ng-zorro-antd-date-picker.d.ts 里就有一些隐式导出的 component 和 module:
export * from './public-api';
export { AbstractPickerComponent as ɵp } from './abstract-picker.component';
export { DateRangePickerComponent as ɵo } from './date-range-picker.component';
export { HeaderPickerComponent as ɵr } from './header-picker.component';
export { CalendarFooterComponent as ɵd } from './lib/calendar/calendar-footer.component';
export { CalendarHeaderComponent as ɵb } from './lib/calendar/calendar-header.component';
export { CalendarInputComponent as ɵc } from './lib/calendar/calendar-input.component';
export { OkButtonComponent as ɵe } from './lib/calendar/ok-button.component';
export { TimePickerButtonComponent as ɵf } from './lib/calendar/time-picker-button.component';
export { TodayButtonComponent as ɵg } from './lib/calendar/today-button.component';
export { DateTableComponent as ɵh } from './lib/date/date-table.component';
export { DecadePanelComponent as ɵl } from './lib/decade/decade-panel.component';
export { LibPackerModule as ɵa } from './lib/lib-packer.module';
export { MonthPanelComponent as ɵj } from './lib/month/month-panel.component';
export { MonthTableComponent as ɵk } from './lib/month/month-table.component';
export { DateRangePopupComponent as ɵn } from './lib/popups/date-range-popup.component';
export { InnerPopupComponent as ɵm } from './lib/popups/inner-popup.component';
export { YearPanelComponent as ɵi } from './lib/year/year-panel.component';
export { NzPickerComponent as ɵq } from './picker.component';
这在没有二级入口的情况下不会触发 bug,但是如果我们在一级入口重新导出,而且其他 module 也有这种隐式导出的话,就会发生导出重名变量的冲突。解决的办法是显式地导出这些变量。然而这带来了另外一个问题,即一些内部实现的东西也暴露给用户了。
比较合理解决方案是 ng-packagr 给 SEP 起一个有两级结构的 alias,比如 alias而不是
as θda
。我们会在以后尝试修复掉这个瑕疵。
as θa
单独打包 CSS
在实现了 js 部分的二级入口之后,我们还需要为各个组件打包 CSS 来实现样式的按需引用。
各个组件之间的样式也存在依赖关系,比如 form 组件内置了 grid 的功能(通过 nzSpan 用户可以对表单内元素进行布局),所以它需要依赖 grid 的样式。原来各个组件的 index.less 文件不包含这些依赖信息,不能作为打包样式文件或单独引入的入口,所以我们在每个组件的 style 文件夹底下创建了 entry.less 文件用以记录依赖关系。
修改构建脚本,去查询这些 entry.less 文件并打包成 CSS,除此之外,还需要为所有组件通用的基本样式进行单独打包。
到这里,二级入口的实现就完成了。
延伸阅读:为什么二级入口带来了更好的 tree shake?
我们在比较旧方法和二级入口的时候说二级入口带来了更加彻底的 tree shake,这从何说起呢?
首先我们要了解 Angular tree shake 的原理,即了解什么情况下 module directive component 和 service 会被 tree shake 掉,推荐阅读这篇文章,简单来说:
写在 entryComponent 的不会被摇树优化,而 declaration 里的,只要没有被使用(在模板中被声明过至少一次),就会被优化 。 对于 service 来说,只有 providedIn: 'root'
才能被摇树优化,写在 providers 里的都不会被优化。 对于 imports 里面的 submodule 来说,module 本身不会被优化,其 metadata 里的信息会被递归处理。
之前 ng-zorro-antd 所要求引用的
NgZorroAntdModule
的 imports 中有
NzModalModule
等,而
NzModalModule
又引用了来自 @angular/cdk 的
OverlayModule
,这就是为什么 demo 中第一个包(即用旧方法的包)含有 @angular/cdk 的代码。另外
NzModalModule
还 provide 了
NzModalService
,所以该 service 的代码也会被打包进去。而使用二级入口之后,上面这些代码就不会被打包了。
参考链接
- Angular Package Format
- Tree shaking
- Angular AOT