Why Angular Uses NgModules With ESModules

Saturday, September 14, 2019

一月份面试时遇到位面试官,他问道:为什么 Angular 至今依然坚持使用 NgModules 而不是 ESModules 呢?当时回答得比较肤浅,主要提到“Angular 对模块理念的重视与推崇,因而需要一套更好的机制解决模块的依赖问题,而这套机制遵循了控制反转原则并以依赖注入的方式实现,这是 ESModules 无法做到的”。

现在回想起来,这个问题本身有点瑕疵,因为 Angular 没有放弃使用 ESModules 而 NgModules 还依赖了 ESModules。时隔半年,我想也是时候重新思考这个问题了。

说明:(1) 以下 NgModules 主要指 Angular 2+ 的模块管理机制,有时也指 AngularJS 1.x 的,从模块管理角度来说,无需刻意区分它们;(2) ECMAScript 6 Modules 简称为 ESModules;(3) 提及框架名称时,我把 AngularJS 1.x 简称为 AngularJS,而 Angular 2+ 则简称为 Angular

在 AngularJS “苟延残喘”之际与 Angular 2 Final Release 震撼来袭之时,我肤浅地想过这个问题,以为 ESModule 才刚被提出来,距离定稿到发布再到推广还需要些时日,这多少拖慢了亟待重整旗鼓的 Angular 进程,于是我便草率地认为:没有规范化的模块加载机制,迫使 Angular 需要设计一套自己的模块管理机制。

然而,时间狠狠地扇了我一巴掌(疼 T_T)。

我想当时的客观条件多少也起到了一定的推动作用,只是 Angular 团队的决定不可能如此敷衍,而且 Angular 在后续的迭代中坚持并持续优化了模块系统 NgModules,哪怕 Webpack 加持 Babel 席卷了整个前端圈。

现在让我们一起探究这个问题吧:Angular 团队额外引入 NgModules 而非单纯使用 ESModules 的原因可能会是什么?是一意孤行,还是审时度势?是画蛇添足,还是画龙点睛?我们又能否从中收获些什么?

首先回顾 AngularJS 的历史背景。当时前端领域的模块管理机制分为几个派系:以 NodeJS 为代表的 CommonJS、以 SeaJS 为代表的 Common Module Definition、以 RequireJS 为代表的 Asynchronous Module Definition、跨浏览器与 NodeJS 服务器的 Universal Module Definition,还有基于 JavaScript 闭包机制的 IIFE。然而,作为一站式服务的 AngularJS 却最终制定了一套属于自己的模块机制,同时在早期又赋予了它强大的特性:依赖管理能力 Dependency Injection。

Inversion of Control

熟悉 Java Web 的开发者对于“依赖注入”模式并不会感到陌生。这种模式是控制反转 Inversion of Control 思想的具体实践方式。

控制反转,简言之,“Don't call us, we'll call you”,也就是说,它规范了框架的使用行为并要求系统把程序的控制权转交给框架处理。于是,框架摇身成了系统的中心,它掌控着各个部件的运作。Martin Fowler 在 Inversion of Control 文中所提及的 FrameworkLibrary 的显著区别能够更好地帮助我们理解“控制权的反转”的概念:

Inversion of Control is a key part of what makes a framework different to a library. A library is essentially a set of functions that you can call, these days usually organized into classes. Each call does some work and returns control to the client. – Inversion of Control, by Martin Fowler

看上去 Inversion of Control 形似 Convention over Configuration 模式:不仅向开发者隐藏了系统运行的细节,保持了系统的整体性和一致性,同时规范了一系列行为来提高开发效率,避免重复性劳动,也使得开发人员能够把更多的精力放在业务的功能开发上。

然而,需要注意的是,Inversion of Control 在“信任”层面存在着一定的问题。

在前端的异步领域以 Ajax 技术为例,它也是一种 Inversion of Control。通过异步回调,我们能够把 HTTP 请求的控制权转交给第三方库,这意味着我们与第三方库之间形成了一份隐形契约。此时,我们只能信任第三方会履行它的承诺:异步回调可以被正确地调用。倘若第三方没有遵守契约,程序将会失控,比如异步回调过早/过晚调用。当然,后来 Promise 诞生了,这项技术成功地把 Inversion of Control 反转回来,同时借助 Generator 有效地解决了几乎所有异步问题。不过,这个话题已经超出本文讨论范围,更多相关的内容可以阅读《你不知道的 JavaScript(下卷)》。

大概了解 Inversion of Control 的概念,也就大体明白了 Angular(JS) 身为一站式框架需要设计一套模块管理机制的原因:你不需要了解 Angular 的运作流程,只需要按照约定实现接口,如 ComponentsServicesPipes 等,Angular 将负责把这些接口以某种规则组织起来,然后通过某种流程控制并管理这些接口的运行。

以 Angular 提供的生命周期为例,如以下代码,PeekABoo 组件需要在初始化期间记录日志。由于 IoC 机制,我们无需关心该组件在何处、以何种形式创建并完成初始化,我们只需要通过框架提供的组件生命周期 ngOnInit 即可在初始化阶段完成日志的记录操作。

export class PeekABoo implements OnInit {
  constructor(private logger: LoggerService) { }

  // implement OnInit's `ngOnInit` method
  ngOnInit() { this.logIt(`OnInit`); }

  logIt(msg: string) {
    this.logger.log(`#${nextId++} ${msg}`);
  }
}

由此可见,在控制权反转之后,生命周期提供了一种允许使用者自定义行为的机制。

Dependency Injection

Any nontrivial application is made up of two or more classes that collaborate with each other to perform some business logic. Traditionally, each object is responsible for obtaining its own references to the objects it collaborates with (its dependencies). When applying DI, the objects are given their dependencies at creation time by some external entity that coordinates each object in the system. In other words, dependencies are injected into objects. – Spring in Action, 2nd Edition, by Craig Walls

What Is Dependency

依赖注入提供了一种管理模块与模块之间依赖关系的机制,使得接口与实现得到分离,进而解耦模块与模块之间的依赖。

开发 API 服务时,我们经常按以下的模式开发:Controller 接收、响应 HTTP 请求;Service 提供管理业务数据流的接口;DAO<Entity> 负责操作数据源,为上层提供数据服务。这种分层产生了如下图的逐层依赖关系:Controller 依赖于 ServiceService 依赖于 DAO<Entity>

An Example of Dependency

通常我们会以如下代码管理它们的依赖,这种实现方式直接导致模块之间耦合在一起:

class TodoController {
  constructor() {
    this.todoService = new TodoService();
  }
}

class ArchiveController {
  constructor() {
    this.archiveService = new ArchiveService();
    this.todoService = new TodoService();
  }
}

class TodoService {
  constructor() {
    this.todoDao = new TodoDao();
  }
}

class ArchiveService {
  constructor() {
    this.archiveDao = new ArchiveDao():
    this.todoDao = new TodoDao();
  }
}
An Example of Tight Coupling

这种紧耦合最终使得整个系统的灵活性大大降低且不易于应对变化,因为任意模块的变更会影响到被依赖的其他模块,进而波及到整个系统的各个模块。另外,我们也发现测试非常难写,因为每个模块因为直接引用而耦合在一起,导致对 Controller 的测试需要同时 Mock Service & DAO。如果 Service 或 DAO 内部又耦合了更多的模块,将致使 Controller 的测试代码无法编写。

因此,我们希望把这种模块依赖关系的管理控制权转交给外部程序(容器)来做,然后以某种注入的方式将具体的依赖实现传入到目标模块中。这样模块与模块之间以接口的形式通讯,达到松耦合的目的,增强了整个系统应对变化的能力。

这就是所谓的“依赖注入”及其背后的 IoC 思想。

How Dependency Injection Works

我们把被依赖的模块称为 Service,因为它们向外部提供了服务,而把依赖这些 Service 的模块称为 Client,因为它们相对于 Service 是服务的客户。那么,在依赖注入这种机制中,我们需要关注四个方面:

  1. 明确 Service:定义服务的提供者
  2. 明确 Client:定义服务的使用者
  3. 建立 Service 与 Client 之间的接口契约:Client 只使用这个接口,而 Service 负责实现这些接口
  4. 设立 Injector 角色:由 Injector 负责初始化 Service 然后依托接口契约将 Service 注入到相应的 Client

在实际的开发中我们并不关系第四点,因为 Injector 一般由框架提供了。至于 Injector 注入 Service 的方式,Martin Fowler 归纳了三种:Constructor Injection,Setter Injection 和 Interface Injection。

  • Constructor Injection 是指 Service 通过 Client 的构造体传入;通过这种方式,在初始化 Client 时 Injector 便完成了 Service 的注入
  • Setter Injection 是指 Client 向 Injector 提供 Setter 方法,然后 Injector 通过该方法把 Service 传入;通过这种方式,需要先完成 Client 的初始化操作之后才能完成 Service 的注入
  • Interface Injection 是指 Service 提供一个注入自身实现的接口,然后 Client 实现这些接口(如 Setter 方法),最后由 Injector 通过这些接口把 Service 注入到 Client;通过这种方式,同样需要先完成 Client 的初始化操作

我们把上述提到的例子采用 Constructor Injection 方式用 TypeScript 重新实现一边,如下代码:

class TodoController {
  constructor(
    @inject(TodoService)
    public todoService: TodoSerivce
  ) {}
}

class ArchiveController {
  constructor(
    @inject(ArchiveService)
    public archiveService: ArchiveService,
    @inject(TodoService)
    public todoService: TodoService
  ) {}
}

class TodoService {
  constructor(
    @inject(TodoDao)
    public todoDao: TodoDao
  ) {}
}

class ArchiveService {
  constructor(
    @inject(ArchiveDao)
    public archiveDao: ArchiveDao,
    @inject(TodoDao)
    public todoDao: TodoDao
  ) {}
}
An Example of Loose Coupling

这样开发 Client 时无需关心 Service 的实现细节,而 Service 也无需维护与 Client 之间的引用关系。Service 与 Client 之间的通讯都共同遵循接口契约。这种面向接口编程也使得测试编写更加容易,因为我们只需按照接口 Fake 出简单的模块即可。

TypeDI: A DI tool for JavaScript & TypeScript

TypeDI 是一款面向 JavaScript 和 TypeScript 的 DI 工具。由于 TypeScript 提供了装饰器特性,我们可以更轻便地通过 TypeDI 实现依赖管理:

/***********************
 *     注册 Service     *
 ***********************/

@Service()
class BeanFactory {
    create() {
    }
}

@Service()
class SugarFactory {
    create() {
    }
}

@Service()
class WaterFactory {
    create() {
    }
}

@Service()
class CoffeeMaker {

   /***********************
    *     注入 Service     *
    ***********************/

    @Inject()
    beanFactory: BeanFactory;
    
    @Inject()
    sugarFactory: SugarFactory;
    
    @Inject()
    waterFactory: WaterFactory;

    make() {
        this.beanFactory.create();
        this.sugarFactory.create();
        this.waterFactory.create();
    }

}

/***********************
 *     获取 Service     *
 ***********************/
 
let coffeeMaker = Container.get(CoffeeMaker);
coffeeMaker.make();

Off Topic: RequireJS as DI

如果你对 RequireJS 还有印象的话,你可能会想,RequireJS 也能实现 Dependency Injection。

确实如此,虽然 RequireJS 本质是个 JavaScript File Loader,但通过配置 paths 为不同的 JavaScript 模块定义相应的 alias,这样就相当于实现 RequireJS-based Dependency Injection。不过,这个话题也超出本文的范畴,相关内容可以阅读这篇文章:Basic Dependency Injection with RequireJS

// mobile/main.js
requirejs.config({
  paths : {
    ...
    // shared module
    , 'Application': '../shared/app/Application'
    // context specific application helper; i.e. mobile
    , 'AppHelper'  : 'app/helpers/MobileHelper'
    // additional mobile module dependencies ...
    , ...
  },
  ...
});

// desktop/main.js
requirejs.config({
  paths : {
    ...
    // shared modules
    , 'Application': '../shared/app/Application'
    // context specific application helper; i.e. desktop
    , 'AppHelper'  : 'app/helpers/DesktopHelper'
    // additional desktop module dependencies ...
    , ...
  },
  ...
});

NgModules vs ESModules

随着 Angular 发展,IoC 思想已不再其光鲜的特性,前端工程的问题也不再只是模块加载的问题。在这种环境下,Angular 团队祭出了 NgModules

首先,我们需要确定的是:NgModules 不等价于 ESModules

它们其实是两个维度的产物:NgModules 是依据 Angular 运行环境对 JavaScript Module Loaders 的增强。进一步说,ESModules 提供了最基础的文件加载机制之一,它是框架无关的,所以也可以用 RequireJS 等其他模块加载机制配合 AngularJS;NgModules 则提供了组织并管理这些文件所定义的接口的机制,它只服务于 Angular 运行环境。因此,它们并不等价却也不互斥,而是相辅相成,共同支撑起这强大的 Angular 模块管理机制。

ESModules

// amodule.js
export class AModule {}

// main.js
import { AModule } from './amodule.js'

ECMAScript 6 Modules 从语言层面定义了 JavaScript 模块体系,某种程度上来说,我们无需在不同的模块加载机制上辗转反侧了(事实上,ESModule 彻底统一 JavaScript 还须时日,混乱的模块加载体系短期内不会得到缓解)。由于 ESModules 的概念与用法不属于本文的讨论范围,我们只关注它的诞生可以解决什么问题。

  • ESModules 提供了基于单个文件的命名空间,避免了全局的命名冲突
  • ESModules 能够定义并组织项目的代码结构与模块依赖
  • ESModules 保证了封装性,向外部隐藏了实现的细节
  • ESModules 保证了可复用性,而且每个模块都是单例,其内部维护着自己的状态;无论外部对其导入多次,都只是对这个单例的引用
  • ESModules 所暴露的 API 是静态的,后续无法动态修改,使得 Module Bundlers 可以通过 importexport 分析项目的无用代码
  • ESModules 的导入和加载在浏览器上通过网络阻塞请求完成,在服务器上则从文件系统阻塞载入(Dynamically Import 支持非阻塞的异步加载方式)

NgModules

@NgModule({
  declarations: [],
  entryComponents: [],
  providers: [],
  imports: [],
  exports: []
})
export class Module { }

那么 NgModules 又是什么?它除了提供 Dependency Injection 还有其他什么特性呢?解决了什么问题呢?

从上面的代码片段可以看到,Angular 通过 @NgModule 装饰器标识一个 NgModule 模块。@NgModules 提供了很多属性来定义一个 NgModule。为了更好地理解 NgModules 我们先简单了解这些属性的作用:

property description
declarations 声明属于当前模块的 class(如 components,directives,pipes)
这些声明的 class 都是私有的,外部模块无法访问,需通过 exports 暴露出去
providers 声明需要通过 Dependency Injection 注入到当前模块的 class,如 services
imports 引入其他 NgModule 声明的 exports 作为当前模块的 declarations ,相当于 ESModules import
但是这种引入的 declarations 不具有传递性。也就是说,B imports C 然后 A imports B ,那么 A 并没有引用到 C 而需要 A 再对 C 单独 imports 一次
exports 声明公开的 API,提供给外部模块 imports,相当于 ESModules export
但是不同于 imports,这种导出具有传递性。也就是说,B exports C 然后 A imports B,那么 A 则间接地导入了 C
entryComponents 声明依赖于 Angular 编译且动态载入到页面的组件
对于 Angular Compiler 来说,如果存在一些组件既非 entry component 也未声明于任何模板,则 Compiler 会把这些组件全部丢弃以减少 bundle files 大小;
整体相当于 dynamic import + tree shaking
bootstrap 声明启用当前 Angular App 启动时加载的组件
默认会把这个组件添加到 entryComponents 中
schemas 定义外来 Element 的语义结构,比如 Web Components 技术的 Custom Elements,这样 Angular 就会把这些 Custom Elements 当做 Native Elements 处理,而开发者也无需做其他配置或者特别的处理
jit 标识当前模块是否使用 JIT 编译器编译而非 AOT 编译器

从上表可以看出,@NgModule 提供了一系列描述模块的属性,通过 declarations 显式声明当前模块所拥有的 classes,然后通过 importsexports 显式声明模块的依赖与公开接口;额外地通过 providers 显式声明需要注入的模块,增加 schemas 来定义外部 Elements 等等。

这些描述充当着模块的配置信息,结合 Angular Compiler,可以在编译阶段对模块进行特殊的优化,例如不依赖 JavaScript AST 实现 Tree Shaking,支持 AOT 或者 JIT 模式,等等。

因此,NgModules 没有取代 ESModules 反而 Angular 在 ESModules 基础上通过模块的元数据描述拓展了模块加载机制更多的能力。

Feature Modules

As your app grows, you can organize code relevant for a specific feature. This helps apply clear boundaries for features. With feature modules, you can keep code related to a specific functionality or feature separate from other code. Delineating areas of your app helps with collaboration between developers and teams, separating directives, and managing the size of the root module. – Feature Modules from Angular Guide

Angular 对 NgModules 提出了一种组织模式:Feature Modules。

在前端 UI 开发过程中,我们经常会对 UI 业务逻辑划分模块,比如路由模块、表单模块、商品详情模块、购物车模块、支付模块,等等。每个模块都承担着特定的、单一的职责。为此,我们需要引入“作用域”的概念,避免模块之间暴露过多的内部信息。

这就是 Feature Modules 的用武之地。Angular 团队还进一步细分了 Feature Modules 类型:

  • Domain Feature Modules:一般指业务层面的模块,比如编辑信息、提交订单等
  • Routed Feature Modules:一般指由路由容器模块所包裹的业务模块,也就是 Routed Feature Modules 是受路由管控的 Domain Feature Modules,它相比 Domain Feature Modules 具有 Lazy-Loading 能力
  • Routing Modules:一般指存储路由信息的模块,用于独立配置每个模块的路由
  • Service Feature Modules:提供诸如消息传递、数据访问等基础能力的模块,比如用于处理 HTTP 请求的 HttpClientModule
  • Widget Feature Modules:一般指第三方 UI 组件库,它们封装好 components / directives / pipes 并提高给外部使用

Angular 拥有全局性的 Root Modules 但正如其名 Root 即作用于整个应用程序。为了避免每个模块的细节全部暴露到全局作用域,我们使用不同类型的 Feature Modules 来界定模块之间的边界以及暴露有限的信息,然后挂载到 Root Modules 使得每个 Feature Modules 能够相互通讯,最后构成整个应用程序的业务数据流。

Conclusion

Angular 团队最终没有重新发明另一个 JavaScript Module Loader,反而实现了一个增强器 NgModules:它不仅能够管理模块的依赖关系与接口声明,还能控制编译器的编译方式,也能够用来定义模块的组织结构,使得 Angular 框架在遵循 ECAMScript 规范的同时,构建起了自给自足的生态体系(比如早期引入 IoC 容器、提供 DI 实践,比如最近发布的 Ivy 编译器提供面向元编程能力,等等),这也促使 Angular 不断推陈出新,走在技术的前沿。

Off Topic: Is Angular Created for Large Enterprise-Scale Projects?

Angular 概念繁多,而且非常注重设计模式的运用。

诚然,任何框架都有一定的学习成本,也会相应地提出些能够有效解决问题的创新概念。这对于 Angular 并非缺陷。

至于对设计模式的重视程度是好是坏,抛开场景谈优劣简直是耍流氓。如果只是开发一些 HTML5 活动页面,那么 Angular 框架显得大材小用了,而且框架本身所强调的实践理念并不会产生利大于弊的效益。一方面在于这些东西的技术复杂度很可能不高,引入高复杂度的框架反而会让框架成为项目的瓶颈;另一方面这些项目的生命周期短暂,发挥不了 Angular 的设计理念,Reusability 更无从谈起。

不过,换种场景思考,如果这些 HTML5 活动页面是由某种网页构造器生成而非人力编码,那么这个网页构造器就会变成复杂度更高的项目,同时其生命周期也会变得更长。这才可能会是 Angular 的用武之地,特别地,Angular 不仅提供了一整套从开发到测试再到打包的完整工具链,还有配套详细的开发文档与最佳的实践指南。

然而,由于我在面向企业的大型项目方面没有足够的经验,无法得出 Angular created for large enterprise-scale projects 的结论。不过,可以确定的是,这种 Feaure Modules 的理念保障了大型项目的 Scalability。这让我想起另一个面试问题。这是面试另一家做企业服务的公司时技术 VP 问我的,“你认为开发我们这种产品在前端技术上有什么挑战?”

当时我的回答是:“主要在模块的组织形式上。这种产品不再是基于组件的简单拼装,而是基于功能逻辑的拼装。我们需要划分不同的功能模块,清晰界定每个模块的职责与接口,而且每个功能模块不仅包含了组件的交互逻辑,也包含着业务的数据逻辑,相当于一个功能模块即一个微型的前端服务。之后,我们通过某种机制把它们拼装起来。这样才能从技术上更灵活地满足企业的定制化开发需求”。

现在想来,这个“功能模块”就是所谓 Feature Modules 了吧。后来了解到,这家公司的产品虽面向企业却不提供定制化开发服务,但我深信,无论是否需要满足定制的开发需求,面向企业的系统往往包含了多个复杂的子系统,甚至有时会因某些子系统服务于特定的用户而专门建立独立的开发小组。对于这种系统而言,前端如果单纯从组件层面来考虑,则不可避免地陷入职责边界模糊、数据流重叠等技术问题,也会因而增加团队的沟通成本增加以及整个系统的迭代成本。

Extended: Micro Frontends

Techniques, strategies and recipes for building a modern web app with multiple teams using different JavaScript frameworks. – Micro Frontends

提到了“一个功能模块即一个微型的前端服务”,令我想起了“Micro Frontends”。

微前端架构从 2016 年提出到现在已历经三年的历史,似乎随着 Web Components 技术栈(同样诞生于 2016 年)的发展与推广,开发者对它的探讨与实践也越来越多。最近阿里团队也开源了他们的解决方案:QianKun

“微前端”的名字初次看上去形似服务器领域的“微服务”,事实上,它们确实有着血缘关系:微前端的概念从微服务衍生而来。它解决了不同团队开发同个系统的协作问题:把前端应用拆分成多个独立的模块,每个模块的开发工作从浏览器端覆盖到浏览器端与数据库层,每个团队都可以独立部署与维护,最终在浏览器端将这些服务组合在一起,正如 Micro-Frontends 网站 给出的协作模式图:

Organisation in Verticals

受于篇幅,不在这里细说微前端。

参考资料