遗世独立的组件——Angular应用中的单组件构建

本文内容提取自 《2017成都WEB前端交流大会》 中的主题演讲。

Angular 是一款面向构建工具友好的框架,除了部分特殊场景之外,所有实际应用中都需要将应用构建后再部署发布。大部分时候,我们都会将应用作为整体进行构建,不过,有些时候我们需要单独构建应用的一部分,而不影响应用主体。

例如,在下面这个例子中,我们只需要属于组件的 URL,就能将其引入到应用中并直接工作。

Distribution

如果我们直接查看上面用到的组件 JavaScript 文件,例如:

这是因为组件代码已经经过完整的构建,包括 Angular Compiler 的 AOT 编译,Build Optimizer 的优化和 UglifyJS 的 Minification,从而能够确保加载的大小和执行的速度。

不过只要仔细观察文件引导部分,很容易发现这是一个 UMD 格式的内容:

function(n,l){"object"==typeof exports&&"undefined"!=typeof module?module.exports=l(require("@angular/core"),require("@angular/forms")):"function"==typeof define&&define.amd?define("ngDemos.temperature",["@angular/core","@angular/forms"],l):(n.ngDemos=n.ngDemos||{},n.ngDemos.temperature=l(n.ng.core,n.ng.forms))
复制代码

并且依赖了 @angular/core@angular/forms 这两个 Angular Packages。

Infrastructure

为了能够与动态组件共用依赖,因此应用的主体部分(基础设施)必须将 Angular 的内容原封不动的暴露出来。借助于 UMD 格式很容易实现这一点,因为即便在不使用任何模块加载工具的情况下,也能够通过 Fallback 到全局变量来相互通信。而这里我们就是通过 Global Fallback 进行的。

Angular 自身的发布内容就提供了 UMD Bundles,其中约定了模块到全局变量的映射关系,例如:

  • @angular/core -> ng.core
  • @angular/common -> ng.common
  • ...

所以为了能够保持引用关系,我们需要使用相同的映射1。以 Rollup 为例:

const globals = {
  '@angular/animations': 'ng.animations',
  '@angular/core': 'ng.core',
  '@angular/common': 'ng.common',
  '@angular/compiler': 'ng.compiler',
  '@angular/forms': 'ng.forms',
  '@angular/platform-browser': 'ng.platformBrowser',
  '@angular/platform-browser/animations': 'ng.platformBrowser.animations',
  'rxjs/Observable': 'Rx',
  'rxjs/Subject': 'Rx',
  'rxjs/observable/fromPromise': 'Rx.Observable',
  'rxjs/observable/forkJoin': 'Rx.Observable',
  'rxjs/operator/map': 'Rx.Observable.prototype'
}

module.exports = {
  format: 'umd',
  exports: 'named',
  external: Object.keys(globals),
  globals: globals
}
复制代码

只要依赖自身、应用主体和动态组件都使用同一个映射表,依赖关系即便在打包后也不会受影响。

1. 如果不使用 Global Fallback,例如在运行时配置 RequireJs 或者 SystemJS 等模块管理器,就可以不需要映射直接基于名称管理依赖。

NgFactory

在 v2-v5 版本中2,Angular 的编译策略是产生额外的 JavaScript 文件3,包含组件模版信息,详情可以参考《空间换时间——Angular中的View Engine(待写)》。

例如,假设我们有一个模版为 <p>Hello World!</p>AppComponent 的组件,则编译后产生的 .ngfactory.js 为:

import * as i0 from "./app.component.css.shim.ngstyle"
import * as i1 from "@angular/core"
import * as i2 from "./app.component"
const styles_AppComponent = [i0.styles]
const RenderType_AppComponent = i1.ɵcrt({ encapsulation: 0, styles: styles_AppComponent, data: {} })
export { RenderType_AppComponent as RenderType_AppComponent }
export function View_AppComponent_0(_l) { return i1.ɵvid(0, [(_l()(), i1.ɵeld(0, 0, null, null, 1, "p", [], null, null, null, null, null)), (_l()(), i1.ɵted(-1, null, ["Hello World!"])), (_l()(), i1.ɵted(-1, null, ["\n"]))], null, null) }
export function View_AppComponent_Host_0(_l) { return i1.ɵvid(0, [(_l()(), i1.ɵeld(0, 0, null, null, 1, "app-root", [], null, null, null, View_AppComponent_0, RenderType_AppComponent)), i1.ɵdid(1, 49152, null, 0, i2.AppComponent, [], null, null)], null, null) }
const AppComponentNgFactory = i1.ɵccf("app-root", i2.AppComponent, View_AppComponent_Host_0, {}, {}, [])
export { AppComponentNgFactory as AppComponentNgFactory }
复制代码

其中的 View_AppComponent_0 就是编译后的模版,不过这里我们并不需要关心它。而我们需要真正关心的,是与 API 直接相关的 AppComponentNgFactory,它是一个 ComponentFactory 的实例,在 Angular 的很多视图操作中都会用到。

为了生成 NgFactory,需要用到 Angular Compiler。例如对于默认的 Angular CLI 项目,可以通过 yarn ngc -p src/tsconfig.app.json 或者 npx -p src/tsconfig.app.json,用法与 ngc 相同。

需要注意的一点是,虽然这里加载的单位是 Component4,但是编译的最小单位是 NgModule,所以必须把每个 Component 都放到 NgModule 里才能完成编译,但并不需要处理 NgModule 编译后的 NgFactory。

接着,仅需要把 Component 的 NgFactory 作为入口,打包成 UMD,即可做成一个独立组件,进行单独发布。

2. 不适用于 v6 及以上版本。

3. 在 v2-v4 版本中,AOT 编译时会产生 .ts 中间文件,之后再生成相应的 .js 文件。

4. 实际项目中将 NgModule 作为基本加载单位可能会是更好的选择,因为可以直接与 Angular Router 相集成。

Loading

虽然有了能独立发布的组件,但是我们仍然需要代码去加载它们。从实际工程的角度来说,使用一个成熟的模块加载器,例如 SystemJS,是很好的解决方案。不过这里为了突出本质内容,仍然选择什么都不用:

export class AppComponent {
  @ViewChild(ComponentOutlet, { read: ViewContainerRef }) container: ViewContainerRef
  
  scriptHost = document.querySelector('#dynamic-script-host')

  load(url: string): void {
    const segments = url.split('/')
    const name = segments[segments.length - 1].replace('.js', '')
    const script = document.createElement('script')
    script.src = url
    script.type = 'text/javascript'
    script.charset = 'utf-8'
    script.defer = true
    script.onload = () => {
      const cmpFactory = window.ngDemos[name]
      this.container.createComponent(cmpFactory)
    }
    this.scriptHost.appendChild(script)
  }

  clear(): void {
    this.container.clear()
  }
}
复制代码

这里使用了 JSONP 类似的方式,通过动态创建 <script> 标签来加载内容,并且基于约定来实现名称映射。

而由于 UMD 文件的导出内容是 NgFactory,便可直接通过 ViewContainerRef API 来进行实例化。


综上,我们可以在运行时来以组件为单位动态地加载内容,但是对于每一个独立组件而言,应当满足:

  • 具备业务逻辑(否则直接绑定 [innerHTML] 就好);
  • 更新频率较高(例如活动页面);
  • 不作为其它内容的依赖;

虽然这样实现了更方便的动态特性,但也会因此带来一些副作用,例如因为要暴露全部 API,所以构建过程中无法进行 Tree-Shaking,构建后的体积相比于统一构建而言会有所增加。

此外,由于编译后的代码会使用到 Private API,因此独立发布的组件与基础设施不能有太大的版本差异(例如一个 v4 另一个 v5 可能会出问题)。

完整的 Demo 可以参见 trotyl/ng-component-loader-demo: Demos app for dynamically loading standalone componentstrotyl/ng-standalone-components-demo: Demo components used for generating standalone bundles

本文地址:juejin.im/post/5a39ff…

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值