如何评价 Angular 的新Ivy Render的编译结果只有3.2k?

Hello World应用编译出来只有3.2k https://twitter.com/IgorMinar/status/96148413442508…
关注者
197
被浏览
28,801

4 个回答

先总结几个常见问题:

  1. 3.2 KiB 是 Minify + Gzip 的结果(也是一般用于相互比较的约定方式),也就是实际网络传输的大小(考虑到更先进的压缩方案如 brotli,现代浏览器上压缩结果会更小);
  2. Ivy Renderer 并不会在 Angular v6 默认启用,可以通过参数手动开启;如果后续问题不多可能会在 v7 作为默认实现;
  3. 代码完成度和可用性并不成正比,完成了 50% 的代码不意味着有 50% 的功能可用,如果关键代码缺失结果可能是 0%,虽然代码完成度看着高,仍然无法用于基本项目,哪怕是简单的性能评测(手写完编译结果发现离能跑还差得很远);
  4. 相比于之前版本的编译结果而言,Ivy 使用的编译后代码几乎完全可以手写(但实际项目肯定不会这么做);
  5. NgModule 的存在意义又受到了一定的挑战;
  6. 优化后的大小并不与原有大小成比例,比如硬嵌入的组件 CSS 目前没有受到任何影响,仍然会占用大量空间;

既然问题是评价编译结果只有 3.2KiB,那么主要从体积优化的方面进行回答,即「Ivy Renderer 做了哪些优化?」。

需要特别强调的是,这次的编译后大小不再只是用 Closure Compiler 在 ADVANCED 模式下的结果,而且 Rollup 也能达到同等的水平(不到 1KiB 差距),可以说是真正的面向构建工具友好,而非面向 Closure Compiler 友好。


提前声明,所有的内容都基于现有实现,以及已公布的 Roadmap 等,如果后续版本进行了修改可能会导致部分内容或观点不正确,恕不另行通知。


一、去除了 platform-browser 的依赖

作为一个平台无关的框架,为什么能够不依赖平台相关代码运行呢?答案当然是不可能,只是把 DOM Renderer 的支持内嵌,如果只需要在浏览器上以正常方式运行(不考虑 Worker),那么可以不引入平台相关部分,例如绑定文本内容的代码为:

value !== NO_CHANGE &&
  ((renderer as ProceduralRenderer3).setValue ?
    (renderer as ProceduralRenderer3).setValue(existingNode.native, stringify(value)) :
    existingNode.native.textContent = stringify(value));


可以明显地看出 Fallback 部分,如果没有 Renderer 的存在,那么就会直接修改 textContent 属性的内容。

当前的所有 Ivy 性能测试代码都是基于无 Platform 的情况实现的,目前可以遇见的问题有:

  • 没有 Event Plugin 的支持,比如键盘事件的语法糖 (keydown.enter)、Hammer 事件支持等,这些都是 platform-browser 额外提供的内容;
  • 没有 Sanitizer 支持,Angular 虽然本身不是字符串模版,对 XSS 有天然抗性,但是架不住总有人喜欢用 innerHTML 直接绑定内容,这种情况下会对内容进行过滤,而 Sanitizer (目前)也是 platform-browser 的内容,因此无 Platform 的情况下并没有同级别的安全性;
  • 没有 Component Style 支持,不论是何种 View Encapsulation,组件样式都是通过 platform-browser 中的不同 Renderer 来实现的,因此没有 Component Style 支持的话,只能靠 inline style 来为组件添加样式(更好的方式是独立 css 以及动态 class);

对于部分用户来说,可能现在的 Hello World 中的使用方式并不能够算是一个全功能的 Angular。


二、不再产生 NgFactory 文件

新的 Ivy 模式下,模版的编译结果会直接存储于类的静态属性中,而非产生新的包装类型,例如(省略号的部分并不相同):

组件 -> ngComponentDef

@Component({ /*...*/ })
class MyComponent { }

// ->

class MyComponent {
  static ngComponentDef = defineComponent({ /*...*/ })
}

指令 -> ngDirectiveDef

@Directive({ /*...*/ })
class MyDirective { }

// ->

class MyDirective {
  static ngDirectiveDef = defineDirective({ /*...*/ })
}

NgModule -> ngInjectorDef

@NgModule({ /*...*/ })
class MyModule { }

// ->

class MyModule {
  static ngInjectorDef = defineInjector({ /*...*/ })
}

<sub>咦,堂堂的大 NgModule 怎么就只出来一个 Injector?(逃</sub>

由于编译结果直接处于 class 中,新的编译方式可以完全享受打包工具的正常优化(主要是 Tree-Shaking),极大地减少了对 build-optimizer 的特殊优化需求。

其实相比于体积优化来说,这一步改进对构建工具的意义明显要大得多。新的方式下是单文件编译(一个 ts 对应一个 js),不再需要考虑原文件到 ngfactory 文件的映射处理。

此外,JIT 和 AOT 方式消费的内容得到了统一,一旦编译结果稳定,所有 Lib 可以直接在发布时编译,而不需要统一在 App 端进行,能够有效提升项目的构建速度。


三、极大极大简化了引导代码

原有的启动过程需要(其实不一定需要,但其它方式更复杂)配置 bootsrap 组件,然后通过 Platform 来启动一个 NgModule[Factory],类似于:

import { NgModule } from '@angular/core'
import { platformBrowser } from '@angular/platform-browser'
import { AppComponent } from './app.component'

@NgModule({
  bootstrap: [AppComponent]
})
class AppModule { }

platformBrowserDynamic().bootstrapModule(AppModule)

新的启动代码直接基于组件本身:

// 目前还未开放
import { renderComponent } from '@angular/core'
import { AppComponent } from './app.component'

renderComponent(AppComponent)

<sub>咦,那 AppModule 为什么还要留着?(逃</sub>

目前的 Ivy 代码都是基于无 NgModule 的方式启动的,虽然目前编译器还并没有完成,但是可以预见,(如果有的话)NgModule 会用来提供 Injector,即作为 renderComponent 函数的一个可选配置项

另外对于在同一个页面启动多个 Angular 实例的用法也更加友好。


四、重新设计了 DevMode 的配置和检查

之前版本的 Angular 中,由于 DevMode 的关闭是通过函数动态操作的:

import { enableProdMode } from '@angular/core'

enableProdMode()

platformFoo().bootstrapBar(baz)

换句话说,是否处于 DevMode 完全是 Angular 代码的内部状态,因此所有 Debug 相关内容都被动态依赖,无法在编译时排除(即便用 Closure Compiler 都无能为力)。

而在 Ivy 模式下 DevMode 完全基于编译时的配置,所有的检查代码只对 ngDevMode 这个全局变量进行判定,类似于

ngDevMode && assertEqual((state as LView).node, null, 'lView.node');

因此只要在构建工具中进行替换(例如 Webpack 的 DefinePlugin),相应的 Debug 辅助代码就会被判定为不可访问(Dead code),从而被优化器(例如 UglifyJS)移除。


五、叫 Feature 的 Feature

新的 Ivy 模式下支持一个叫 Feature 的新 Feature,或者说,引入了一个新概念叫 Feature。简单地说就是对 DirectiveDef 的预处理器,可以认为是专门针对 DirectiveDef 设计的 Decorator Pattern。

例如在 Ivy 中,OnChanges 的支持并不是 Renderer 本身提供的,而是一个预设的 Feature通过为 @Input 属性使用 defineProperty 进行监听,自动在实例的隐藏字段中存储修改前内容(previousValue)并生成相应的 SimpleChange(s),并且自身的触发也是通过劫持 OnInit 和 DoCheck 来自行驱动,而非由 Angular 主动执行。

换句话说,OnChanges 的相关代码同样是 Tree-Shakable 的,如果没有组件声明了 ngOnChanges 这个生命周期(编译器识别),那么也就没有哪个 DirectiveDef 中会引入 NgOnChangesFeature,从而能够被优化器合理移除。

<sub>咦,说好的只做脏检查呢?以后说明 Angular 的变化检测要加特例了。(逃</sub>

因此,很容易发现 OnChanges 在 Ivy 里已经不再是一个真正的生命周期了,而是自行模拟出来生命周期。换句话说,其实用户可以自行扩展生命周期,怎么喜欢怎么配。


不过引入 Feature 概念最直接的目的还是即将支持的 LifeCycle as Observable 特性,无需声明方法而是将 Life-Cycle Hooks 暴露成 Observable 供用户自行使用。实际上通过拦截 DirectiveDef,哪怕是用户或者第三方库都可以在运行时修改 Life-Cycle Hooks,甚至可以支持通过 Decorator 来定义 Life-Cycle Hooks。


总体而言,Feature 可以认为是 Higher Order Components 的一类变体(相对于 class factory 形式而言),即基于运行时要求修改组件类本身,而非基于组件逻辑修改内部状态。传统的 class factory 形式目前仍然无法得到 AOT 编译器的支持,因此当前不具实用性。


六、新的 Injectable API

这点其实和 Hello World 没有关系,甚至也和 Ivy 没有关系(可以用于非 Ivy 模式),不过对于 Angular 代码大小的影响很大。

对于上面列出的 Def 系列属性,还有一个 ngInjectableDef,类似于:

@Injectable()
class MyService { }

// ->

class MyService {
  static ngInjectableDef = defineInjectable({ /*...*/ })
}

在传统的观念中(如果有人关心过的话),很容易发现依赖注入从基本思想上就完全与 Dead-Code Elimination 的理念相背

  • DI 的本质就是副作用,Provider 通过配置影响环境,Consumer 通过环境获取内容;
  • DCE 的基本要求就是没有副作用,Consumer 直接引入 Provider,如果 Consumer 不存在,那么 Provider 就可以被一并移除。

所有很明显,理论上基于 DI 的代码都应当无法进行 DCE 优化。

原有的方式下,我们会在 NgModule 内配置 Provider:

// lib.service.ts
@Injectable()
class LibService { }

// lib.module.ts
import { LibService } from './lib.service'

@NgModule({
  providers: [LibService],
})
class LibModule {}

// app.component.ts
import { LibService } from './lib.service'

@Component({ /*...*/ })
class AppComponent {
  constructor(libService: LibService) {}
}

// app.module.ts
import { LibModule } from './lib.module'
import { AppComponent } from './app.component'

@NgModule({
  declarations: [AppComponent],
  imports: [LibModule],
})
class AppModule {}

这时的 JavaScript 类型依赖关系是:

  • 类库模块 -> 类库服务;
  • 应用组件 -> 类库服务;
  • 应用模块 -> 应用组件;
  • 应用模块 -> 类库模块。

因此即便没有在组件中使用,服务也无法被移除,因为在模块中的配置过程中产生了额外的依赖关系。

Angular 为了解决这个问题,允许对配置的依赖关系进行反转,新的 API 为:

// lib.module.ts
@NgModule({ /*...*/ })
class LibModule {}

// lib.service.ts
import { LibModule } from './lib.module'

@Injectable({ scope: LibModule })
class LibService { }

// app.component.ts
import { LibService } from './lib.service'

@Component({ /*...*/ })
class AppComponent {
  constructor(libService: LibService) {}
}

// app.module.ts
import { LibModule } from './lib.module'
import { AppComponent } from './app.component'

@NgModule({
  declarations: [AppComponent],
  import: [LibModule],
})
class AppModule {}

现在的依赖关系变为:

  • 类库服务 -> 类库模块;
  • 应用组件 -> 类库服务;
  • 应用模块 -> 应用组件;
  • 应用模块 -> 类库模块。


因此,只要应用组件被移除,类库服务将完全不被依赖,从而被一同移除。

<sub>咦,那类库模块放在那里是做装饰的?(逃</sub>


此外,还可以使用可选的 useClass、useFactory 等 Provider 设置来提供其它内容而非实例化当前类:

@Injectable({
  scope: SomeModule,
  useValue: { valueOfLife: 42 },
})
abstract class MyService {
  abstract valueOfLife: number
}


当然,这里仅限于 声明的依赖 即为 提供的依赖 的理想情况,不过这也是最常见的情况。如果在中间 Injector 节点对 Provider 进行了覆盖,那这时仍然会产生不可避免的副作用,从而影响代码体积的优化。(不过既然选择了进行覆盖,那多半是确实要用到


七、新的模版编译方式

宏观地看,模版编译的结果只有两种类型

  • (结构)数据;
  • (操作)指令。

举个简单的栗子,假设将模版(或其等价物)编译到某个 render 方法,那么如果:

  • 调用 render 后没有任何行为发生,但是返回值是需要渲染的内容,这时就是前者;
  • 调用 render 后视图就已经发生更新不需要任何返回值,这时就是后者。

最早的 v2 版本选择了编译到指令的方式,编译行为可以参考 Trotyl Yu:如何评价 angular 2 中的 AoT?。这里的编译方式和 Svelte 十分接近,即尽可能编译出更为详细的内容,从而尽可能避免增加基本依赖(共享代码)。

但这种方式的问题显而易见,随着模版内容的增长,编译后的体积的增加远高于原有模版的内容增长。(题外话,哪怕是以 0KB 运行时为卖点的 Svelte 后期也提供了 shared 编译选项来使用库内容

之后的 v4 代码使用了编译到数据的方式,同时引入了 View Engine 作为公共依赖部分,编译结果可以参考 Angular 应用瘦身记——比 jQuery 更小的 TodoMVC。这时实际上已经与 Virtual DOM 相当接近,不过这里的类 DOM 结构数据仍然是 Per Type 的静态信息,而非 Per Instance 的动态内容。

v6 中引入的 Ivy Renderer 重新选择了编译到指令的方式,不过相比于 v2 不同,采用的是最小化编译结果的方案,类似于:

export class AppComponent  {
    static ngComponentDef = defineComponent({
        type: AppComponent,
        tag: 'my-app',
        template: function AppComponent_Template(comp: AppComponent, cm: boolean) {
            if (cm) {
                E(0, 'div', ['class', 'container'])
                    E(1, 'div', ['class', 'jumbotron'])
                        E(2, 'div', ['class', 'row'])
                            E(3, 'div', ['class', 'col-md-6'])
                                E(4, 'h1')
                                    T(5, 'Angular v6.x.x (Ivy Renderer)')
                                e()
                            e()
                            E(6, 'div', ['class', 'col-md-6'])
                                E(7, 'div', ['class', 'col-sm-6 smallpad'])
                                    E(8, 'button', ['type', 'button', 'id', 'run', 'class', 'btn btn-primary btn-block'])
                                        L('click', () => {
                                            comp.run()
                                            detectChanges(comp)
                                        })
                                        T(9, 'Create 1,000 rows')
                                    e()
                                    //...
                                e()
                            e()
                        e()
                    e()
                    E(20, 'table', ['class', 'table table-hover table-striped test-data'])
                        E(21, 'tbody')
                            C(22, [NgForOf], trTemplate)
                        e()
                    e()
                    E(24, 'span', ['aria-hidden', 'true', 'class', 'preloadicon glyphicon glyphicon-remove'])
                    e()
                e()
            }
            p(22, 'ngForOf', b(comp.data))
            p(22, 'ngForTrackBy', b(comp.itemById))
            cR(22)
                r(23, 0)
            cr()

            function trTemplate(row: NgForOfContext<Data>, cm: boolean) {
                if (cm) {
                    E(0, 'tr')
                        E(1, 'td', ['class', 'col-md-1'])
                            T(2)
                        e()
                        E(3, 'td', ['class', 'col-md-4'])
                            E(4, 'a', ['href', '#'])
                                L('click', (e: MouseEvent) => {
                                    comp.select(row.$implicit, e)
                                    detectChanges(comp)
                                })
                                T(5)
                            e()
                        e()
                        //...
                    e()
                }
                p(0, 'class.danger', b(row.$implicit.id === comp.selected))
                t(2, b(row.$implicit.id))
                t(5, b(row.$implicit.label))
            }
        },
        factory: () => new AppComponent()
    })
}

(看着多只是因为手写留白容易看,其实信息密度和 HTML 差不多高。)


其实编译到指令相比于编译到数据还有一个很大的优势,那就是公共依赖部分仍然对 DCE 友好,使用了的操作都会被直接依赖,而没有被依赖的操作都可以被优化器移除。而在编译到数据的方式中,所有操作都需要被 Processor 所依赖,无法静态优化。

所以说 Ivy 采用的编译方式(不考虑编译器的实现成本)应当算是体积最小的方案之一。


内存和速度方面

  • 新的 View Layer 采用了更为紧凑的二进制帧结构设计(以及更多的位运算);
  • 新的 View Layer 尽可能的使用序列(数组)而非键值对(对象)来存储数据(以及约定的位置关系);
  • 新的 View Layer 更多地倾向于在已有类型(指令/组件实例或者是 DOM 对象)上添加隐藏字段来扩展数据,而非新增包装类型;
  • DI 中对 Directive 的查找过程使用了 布隆过滤器(Bloom Filter),提升了依赖查找速度。

(换句话说视图层对 PR 越来越不友好)


总结

新的 Ivy 模式基本将「对构建友好」发挥到了极致,但依然可以认为「对不构建不友好」,对于只能手写 <script> tag 的项目仍然没有太大优势。


其实 Ivy 最重要的目的之一,是配合 Angular Elements 使用。即通过将 Angular Components 封装为 Custom Elements(Web Components),可以实现独立发布、独立引入、独立使用的 Widgets 系统。(一定程度上扼杀了 Svelte 的存在意义?

而这个方案最重要的部分就是体积,即便在不引入公共依赖库的情况下,也能以极小的体积正常工作。

此外,对于用作 Angular Elements 的组件,对 forms、router 等外部模块几乎不会有依赖,因此对视图层的体积优化可以产生极大的价值。


关于 NgModule 的存在意义,对于代码组织而言确实是由一定价值的。不过考虑到 Dart 版本从未引入过 NgModule 的设定,可以认为不使用 NgModule 也能保证应用的结构(至少对于 Googler 而言)。

随着 NgModule 的实际使用场景(用到它的 API)不断减少,有可能会在未来变成可选,不过可以确定在 v6 版本中 NgModule 的存在不会有任何改变。(我个人支持 NgModule 的存在,但反对强制要求使用 NgModule)


对于大量引入第三方模块的堆砌型应用,可能体积的主要问题并不来自于 Angular。比如以不正当的方式使用 RxJS,碰巧引入了 moment.js,或者组件库把所有样式都写在 Component Styles 中。具体的体积问题需要自己分析,不要想当然地认为使用了更高等级的优化就能解决。


整个 Ivy 中最「糟糕」的部分,就是 OnChanges 现在是通过 Object.defineProperty 来实现的了,所以再说 Angular 只有脏检查已经不正确了,所有 Change Detection 相关的文章都得跟着改,而且还得每个部分单独解释。


之后的生命周期部分可能会有大动作,不过应该会等到 Ivy 变成 default,也就是至少 v7 以后。其实是 Angular 整体的开放性得到了极大提升,对运行时的预处理友好。

————————————————————————

归功于IgorMinar 开发的新的Ivy Renderer,目前已经处于beta状态,可以追踪这个issue查看最新的进度,完成度看起来已经相当高了

这里有一篇简易教程,欢迎试用

正经的答案看Trotyl的回答

为什么?