本文内容提取自 《2017成都WEB前端交流大会》 中的主题演讲。
众所周知,Angular 以官方提供的一体化解决方案(全家桶)而闻名,官方团队提供了构建 Web App 所需的大部分类库和工具支持。
不过,「大而全」的「大」指的是覆盖范围广,而并非应用体积大,这里我们以 TodoMVC 为例,将其优化到比 jQuery 更小的体积1。
1. 本文写作时 jQuery 的最新版本为 3.2.1,非 slim 版本 min+gzip 后大小为 33,861B。为 Chrome 中的实际传输大小,可能与本地压缩结果略有差异。
AOT 编译
Angular 模版采用了编译到 JavaScript 结构化数据的方式2,虽然组件模版使用 .html
文件3定义,但浏览器并不能见到这个 HTML 文件,而只能见到编译后的 JavaScript 文件。
例如,一个简单的表单控件:
<div class="form-group">
<label for="exampleInput">Email address</label>
<input type="email" class="form-control" id="exampleInput" [value]="email" (change)="onEmailChange($event)">
<small id="emailHelp" class="form-text text-muted">
We'll never share your email with anyone else.
</small>
</div>
复制代码
将会被编译为4:
export function View_AppComponent() {
return viewDef(
ViewFlags.None, [
elementDef('div', [['class', 'form-group']]),
elementDef('label', [['for', 'exampleInput']]),
textDef(['Email address']),
elementDef('input',
[['class', 'form-control'], ['id', 'exampleInput'], ['type', 'email']],
[[BindingFlags.TypeProperty, 'value']],
[[null, 'change']],
(viewData, eventName, $event) => {
if ((eventName === 'change')) {
viewData.component.onEmailChange($event)
}
}
),
elementDef('small', [['class', 'form-text text-muted'], ['id', 'emailHelp']]),
textDef([`We'll never share your email with anyone else.`]),
],
(check, viewData) => {
const currVal = viewData.component.email
check(viewData, currVal)
}
)
}
复制代码
对于 Angular 而言,如果这个编译过程发生在应用启动之前,就叫做 AOT 编译;反之若是在应用启动之后,则为 JIT 编译。
由于 AOT 编译过程,我们的发布内容仅有 JavaScript,而不包含任何 HTML,有利于进一步的优化。
2. 仅适用于当前的 4.x 和 5.x 版本,2.x 版本中模版编译为完整的视图操作语句,6.x 版本中模版将编译为逻辑化的模版函数(甚至可以手写)。
3. 也可能位于内联在逻辑代码中的字符串里。
4. 为了可读性略有简化。
Closure Compiler
Closure Compiler 是 Google 推出的一款 JavaScript 优化编译器,用于优化应用体积和执行性能。被视为 Angular 的下一代构建工具5之一。
在 ADVANCED
模式下,Closure Compiler 会进行最大程度的内联,例如以下代码:
const items = [
{ val: 1 },
{ val: 2 },
{ val: 3 },
{ val: 4 },
{ val: 5 },
]
const item = items[2]
console.log(item.val)
复制代码
会被优化为:
console.log(3)
复制代码
可以参考这里的在线示例。
Angular 自身的代码(及编译器生成的代码)提供了对 Closure Compiler 的 ADVANCED
模式的兼容性保证,因此可以利用 Closure Compiler 来大程度优化编译体积。
5. What Angular is doing with Bazel and Closure。
去除 Zone.js
曾经的 AngularJS 中,为了保证框架能够知晓用户的异步操作,所有操作都需要使用 $scope.$apply
进行包装,例如通过 $timeout
、$interval
执行延时任务,从而能够触发 AngularJS 的变化检测。
而 Angular 为了改变这一现状,引入了 Zone.js 来解决这一问题,通过拦截所有可能的异步任务触发过程,从而能够知晓所有异步回调的发生。因此,在 Angular 中可以无需任何额外配置的情况下使用纯命令式的操作来修改 ViewModel。
在 Angular v4 及之前的版本中,我们需要提供一个什么都不做的 Zone 对象来规避 Angular 的依赖检查。不过从 v5 开始,Angular 自身提供了不使用 Zone.js 的支持6,仅仅需要在启动代码中配置 { ngZone: 'noop' }
,因此并不需要太担心兼容性部分,仅仅考虑业务上的实现即可。
所以,为了不用 Zone.js,我们只需要:
- 不使用自动触发的变化检测;
- 不使用命令式的操作。
对于 1),我们可以使用 ChangeDetectorRef
API 来手动触发变化检测。但对于大型应用而言,难免会增加应用的复杂度。
所以更可行的方式是 2),可以像某些其它框架一样,放弃命令式的状态修改,全部使用方法调用的方式来修改数据。虽然事实上仍然是手动触发 trigger,但只要美名其曰响应式,一切就会顺其自然。例如在 Angular No-Zone setState Demo 中,简单地实现了一个具备批处理能力的 setState7 方法,可以在不使用 Zone.js 的情况下较为自然地实现变化检测。
另一个更适用于 Angular 的实现方式是利用 Observable,由于 Angular 本身具备对 RxJS 的良好集成,引入 Observable 并不需要任何额外的成本。而 Observable 基于事件流的方式工作,只要把内容更新托管给 Pipe,那么也可以完全规避命令式操作。例如在 Angular No-Zone Observable Demo 中就有使用自定义 Pipe 来自动应用 Observable 状态更新的例子。
6. 【SNF-A】Angular 增加不使用 Zone.js 的支持。
7. 事实上 Dart 版本的 Angular 原生提供了 setState 方法:angular/component_state.dart at 51cf8625ad35d09f349dc9cac40cd983bd1274d4 · dart-lang/angular,这里仅针对 JavaScript 版本。
去除 BrowserModule
Angular 自身是一个平台无关的数据绑定框架,为此如果需要让 Angular 在浏览器中运行,需要引入浏览器平台相关的部分,即 @angular/platform-browser
。其中具备两个重要类型,一个是 platformBrowser
,另一个是 BrowserModule
。platformBrowser
是一个 PlatformFactory
,其中包含了一系列预制的基础 Provider
;而 BrowserModule
是一个 NgModule
,通常由应用根模块导入,除了包含了一些 Provider
外,也导入了另一些其它的 NgModule
。
由于 Angular 良好的工程化特性,真正实现了模块间的解耦,因此我们完全可以不引入 BrowserModule
,而是自行提供其中的必要部分。
最终8,我们仅仅需要自行实现一个 Renderer
,使用不到一百行的代码就能完全规避对 BrowserModule
的导入,避免引入其它无用部分。
8. 该部分完成过程可以参考 按官方说法,angular2是基于Web组件的开发平台,为什么却把它当做前端框架来用? - Trotyl Yu的回答 - 知乎。
去除 Debug Helpers
在 Angular 中,我们通过 enableProdMode
API 来进入生产模式,关闭不必要的调试功能,提供应用性能。不过,由于该方法通过修改模块局部变量来保存状态,所以即便是在生产模式中,调试相关的代码仍然无法被去除9,即使是 Closure Compiler 这样恐怖如斯的斗宗强者也无能为力。
而对于查询应用是否在生产模式,是通过 isDevMode
API 来进行的,不仅是用户,内部判断也同样是通过这个。为此,我们只需要将 isDevMode
修改为 return false
,即可切断调试服务与内部状态间的依赖,使其被成功去除。
9. 问题记录见 [angular/core] make DebugServices treeshakable (~10KB savings)。由于 v6 版本中使用了全新的渲染引擎,将使用全局变量来判断 devMode,因此能够原生支持大部分构建工具的优化,所以最终可能并不需要通过 Build Optimizer 来解决。
通过多种优化的组合,我们便可以将 Todo MVC 的应用大小控制在 30KB 左右,在绝大多数 3G 甚至部分 2G 网络上都能立即加载。
当然,实际工程实践中并不是所有这些优化都有必要(部分固定大小的优化项,随着应用自身大小的增大可能不再作为瓶颈)。
完整的 Demo 可以参见 trotyl/ng-slim-demo: Count Angular project size for Todo MVC with different optimization。