在这篇博客里面,主要讲述向npm发布Angular组件所必需的相关知识,这些组件具备以下特性:
- 平台中立的(比如可运行在浏览器和Web Workers环境)
- 可以将所有文件打包在一起,也可以多个文件的形式发布
- 可以使用Angular的预编译
- 使用TypeScript时允许IDE智能提示,可以进行编译时类型检查
这篇文章不会说明怎么开发一个npm模块,如果想了解这方面的知识,可以访问下面的链接:
下面,就以我最近发布的ngresizeable组件为例来说明,ngresizable是一个能够调整DOM元素大小的简单组件。
平台中立的组件
Angualr的一个突出优势就是平台中立的,基本上所有需要交互的模块都是通过一个抽象层来进行——Renderer,我们自己写的组件也应该依赖于抽象层,而不是具体平台的API(依赖倒置原则),换句话说,如果你为Web创建一个类库,不要直接操作DOM,因为这样将不能在Web Workers和服务器环境中运行。
看下面的例子:
@Component({
selector: 'my-zippy',
template: `
<section class="zippy">
<header #header class="zippy-header">{{ title }}</header>
<section class="zippy-content" id="zippy-content">
<ng-content></ng-content>
</section>
</section>
`
})
class ZippyComponent {
@Input() title = '';
@Input() toggle = true;
@ViewChild('header') header: ElementRef;
ngAfterViewInit() {
this.header.nativeElement.addEventListener('click', () => {
this.toggle = !this.toggle;
document.querySelector('#zippy-content').hidden = !this.toggle;
if (this.toggle) {
this.header.nativeElement.classList.add('toggled');
} else {
this.header.nativeElement.classList.remove('toggled');
}
});
}
}
这段代码和底层平台耦合的很紧,包含很多“反模式”,例如:
- 直接访问
header
元素的addEventListener
方法 - 没有清除添加在
header
上的事件监听 - 直接访问
header
的classList
属性 - 访问全局对象
document
,而document
对象在其它平台可能是无效的
重构一下,以实现平台中立:
@Component({
selector: 'my-zippy',
template: `
<section class="zippy">
<header #header class="zippy-header">{{ title }}</header>
<section #content class="zippy-content" id="zippy-content">
<ng-content></ng-content>
</section>
</section>
`
})
class ZippyComponent implements AfterViewInit, OnDestroy {
@ViewChild('header') header: ElementRef;
@ViewChild('content') content: ElementRef;
@Input() title = '';
@Input() toggle = true;
private cleanCallback: any;
constructor(private renderer: Renderer) {}
ngAfterViewInit() {
this.cleanCallback = this.renderer.listen(this.header.nativeElement, 'click', () => {
this.toggle = !this.toggle;
this.renderer.setElementProperty(this.content.nativeElement, 'hidden', !this.toggle);
this.renderer.setElementClass(this.header.nativeElement, 'toggled', this.toggle);
});
}
ngOnDestroy() {
if (typeof this.cleanCallback === 'function')
this.cleanCallback();
}
}
使用Renderer
代替直接操作DOM和全局对象访问,上面的代码看上去好多了,也可以在多个平台运行,但是手工操作还是太多,比如绑定和取消click
事件,因此还可以如下优化:
@Component({
selector: 'my-zippy',
template: `
<section class="zippy">
<header (click)="toggleZippy()" [class.toggled]="toggle"
class="zippy-header">{{ title }}</header>
<section class="zippy-content" [hidden]="!toggle">
<ng-content></ng-content>
</section>
</section>
`
})
class ZippyComponent implements AfterViewInit, OnDestroy {
@Input() title = '';
@Input() toggle = true;
toggleZippy() {
this.toggle = !this.toggle;
}
}
上面是一个最优实现,平台中立,并且容易测试(在toggleZippy
方法中切换组件的可见性,从而更容易测试)。
发布组件
发布组件并不是不重要的事,甚至Angular都发布了好几种不同结构的npm模块。
一般来说,编写我们自己的模块包时,需要考虑下面这些事项:
- 支持摇树优化。如果把系统当成一棵代码树,摇树优化指的就是把不需要的代码从系统发布包中移除,就想摇树一样,甩掉不需要的枝叶,摇树优化在发布产品包时非常重要。
- 开发者在开发模式下应该尽可能方便的使用,实际上就是既要支持产品模式下的体积小,又要支持开发模式下的易于调用。
- 需要保持发布包尽可能小,从而节约带宽和下载时间。
为了添加摇树优化特性,我们需要使用ES2015
的模块化方式(也称之为esm
),从而可以让诸如Rollup
或Webpack
之类的打包器能够处理未使用的exports。为了实现这种特性,可以在tsconfig.json
中如下配置(从ngresizable
项目中复制过来):
{
"compilerOptions": {
"target": "es5",
"module": "es2015",
"sourceMap": true,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"outDir": "./dist",
"lib": ["es2015", "dom"]
},
"files": [
"./lib/ngresizable.module.ts"
]
}
如果想具有更广泛的适用性,还应该提供ES5
的版本,有两种选择方案:
- 使用两个目录,分别存放
esm
和ES5
两个版本esm
:包括使用ES2015
模块化的部分ES5
:不使用ES2015
语法的部分(比如使用CommonJS、System或UMD等模块化方法来替代)
- 在一个包中同时提供
esm
和ES5
UMD两种版本
第二种方案有如下的优势:
- 不会有很多附加文件,只包含
esm
版的文件和一个包含所有功能的已经打好的单一包 - 开发人员在使用SystemJS格式开发时,只需要一次request请求,否则的话,SystemJS会为每个文件发起一次请求
不管使用哪个工具,例如,ngresizable
组件使用Google的rollup
,都可以方便的生成ES5
或ES2015
格式的模块包,因此,后续将不需要任何额外步凑,就可以生成ES2015
语法的组件包。
最后,因为没有使得目录结构更加复杂,我们可以在根路径简单的输出两种格式:esm
格式的代码和符合UMD
规范的包。
包配置
因此,我们有两种格式的包(esm
和UMD
),那么问题来了,在package.json
中应该配置哪个入口呢?我们想让理解esm
的打包器使用ES2015
模块化方案,而其它的则使用UMD
模块化方案。
为此,可以如下配置package.json
:
- 设置
main
属性指向ES5 UMD
规范包 - 设置
module
属性指向esm
版本的入口文件,module
是诸如rollup
、webpack
等打包器所期望的ES2015
模块引用入口,但是一些旧版本的打包器使用jsnext:main
属性,因此我们可以同时设置module
和jsnext:main
最终,package.json
配置成:
{
...
"main": "ngresizable.bundle.js",
"module": "ngresizable.module.js",
"jsnext:main": "ngresizable.module.js",
...
}
提供类型定义文件
因为类库的使用者很可能使用TypeScript,因此需要提供类型定义文件,从而实现IDE智能提示和类型检查,为此,需要打开tsconfig.json
中的declaration
标志,并且在package.json
中设置types
属性:
{
"compilerOptions": {
"target": "es5",
"module": "es2015",
"sourceMap": true,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"declaration": true,
"outDir": "./dist",
"lib": ["es2015", "dom"]
},
"files": [
"./lib/ngresizable.module.ts"
]
}
{
...
"main": "ngresizable.bundle.js",
"module": "ngresizable.module.js",
"jsnext:main": "ngresizable.module.js",
"types": "ngresizable.module.d.ts",
...
}
兼容Angular的预编译AOT
AOT是一个很强大的特性,我们开发/发布的组件包最好也能够兼容AOT特性。
如果我们发布一个不附加任何元数据的JavaScript模块,依赖于这个模块的Angular应用就不能AOT编译,但是我们怎么向ngc提供元信息呢?可以包括组件包所使用的TypeScript版本,但这并不是唯一的方式,我们还可以通过使用ngc预编译好组件包,同时启用tsconfig.json
中的angular编译选项中的skipTemplateCodegen
,最终,tsconfig.json
如下所示:
{
"compilerOptions": {
"target": "es5",
"module": "es2015",
"sourceMap": true,
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"declaration": true,
"outDir": "./dist",
"lib": ["es2015", "dom"]
},
"files": [
"./lib/ngresizable.module.ts"
],
"angularCompilerOptions": {
"skipTemplateCodegen": true
}
}
通过默认的ngc为组件和模块生成ngfactories
,并通过skipTemplateCodegen
选项仅生成*.metadata.json
文件。
扼要重述
应用上面的步骤后,ngresizable
结构如下:
.
├── README.md
├── ngresizable.actions.d.ts
├── ngresizable.actions.js
├── ngresizable.actions.js.map
├── ngresizable.actions.metadata.json
├── ngresizable.bundle.js
├── ngresizable.component.d.ts
├── ngresizable.component.js
├── ngresizable.component.js.map
├── ngresizable.component.metadata.json
├── ngresizable.module.d.ts
├── ngresizable.module.js
├── ngresizable.module.js.map
├── ngresizable.module.metadata.json
├── ngresizable.reducer.d.ts
├── ngresizable.reducer.js
├── ngresizable.reducer.js.map
├── ngresizable.reducer.metadata.json
├── ngresizable.store.d.ts
├── ngresizable.store.js
├── ngresizable.store.js.map
├── ngresizable.store.metadata.json
├── ngresizable.utils.d.ts
├── ngresizable.utils.js
├── ngresizable.utils.js.map
├── ngresizable.utils.metadata.json
└── package.json
最终的包内容:
- ngresizable.bundle.js - 组件的ES5 UMD发布包
- esm - 可以进行摇树优化的源码
- *.js.map - 便于调试的source map 文件
- *.metadata.json - ngc编译需要的元信息文件
- *.d.ts - 允许TypeScript编译器进行类型检查和智能提示的类型定义文件
其它说明
非常重要的一个主题是就是代码风格,模块应该遵循最佳实践,进一步的信息访问angular.io 的格式指南。
- 注意,不要使用ng作为组件的前缀,因为有可能和google官方的组件冲突。 *
结论
在这篇博客中,简短的说明了发布Angular组件库需要考虑的一些最重要的事项:怎么和底层平台解耦,怎么保持摇树优化特性,怎么最小化组件包,怎么对AOT预编译友好等等。