如何在 Angular 应用程序中以编程方式加载“惰性”模块?

5

我有一个单体Angular 15应用程序,不使用路由器。 它内部的组件数量正在增加,我想将大多数组件分离成一个单独的模块并单独加载它们。

我的应用程序已经有了一个闪屏界面和一个“加载”进度条,随着从服务器获取数据的进行而前进。 我希望主要的AppModule包含一组最小的组件来启动事情,然后我将加载其余的组件作为启动任务之一,并由进度条监视。

目前的状态...

app.module.ts:

import {NgModule, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {BrowserModule} from '@angular/platform-browser';
import {FormsModule} from "@angular/forms";
import {MatProgressBarModule} from '@angular/material/progress-bar';
import {MatSidenavModule} from "@angular/material/sidenav";

import {SharedModule} from "./shared.module";

import {AppComponent} from './app.component';
import {ResizeableSidenavDirective} from "../components/resizeable-sidenav.directive";
import {SplashScreenComponent} from "../components/splash-screen/splash-screen.component";

@NgModule({
    declarations: [
        AppComponent,
        SplashScreenComponent,
        ResizeableSidenavDirective,
    ],
    imports: [
        BrowserModule,
        BrowserAnimationsModule,
        FormsModule,
        MatProgressBarModule,
        MatSidenavModule,
        SharedModule
    ],
    providers: [],
    bootstrap: [AppComponent],
    schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AppModule { }

shared.module.ts:

import {NgModule, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {BrowserAnimationsModule} from "@angular/platform-browser/animations";
import {CommonModule} from "@angular/common";
import {LeafletModule} from "@asymmetrik/ngx-leaflet";

import {AppComponent} from './app.component';
import {MapViewComponent} from "../components/map-view/map-view.component";
import {Toaster} from "../components/toaster";

@NgModule({
    declarations: [
        MapViewComponent,
        Toaster,
    ],
    imports: [
        BrowserAnimationsModule,
        CommonModule,
        LeafletModule,
    ],
    exports: [
        MapViewComponent,
        Toaster,
    ],
    providers: [],
    bootstrap: [AppComponent],
    schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class SharedModule { }

lazy.modules.ts:

import {NgModule, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {CommonModule} from "@angular/common";
import {FormsModule} from "@angular/forms";
import {MatDialogModule} from "@angular/material/dialog";
import {MatSliderModule} from "@angular/material/slider";

import {SharedModule} from "./shared.module";

... big list of component imports ...

@NgModule({
    declarations: [
        MyFirstComponent,
        MySecondComponent,
        MyThirdComponent,
        ...
    ],
    imports: [
        CommonModule,
        FormsModule,
        MatDialogModule,
        MatSliderModule,
        SharedModule
    ],
    exports: [
        MyFirstComponent,
        MySecondComponent,
        MyThirdComponent,
        ...
    ],
    providers: [],
    bootstrap: [],
    schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class LazyModule { }

虽然我想问的问题是如何从启动屏幕内部做延迟加载,但我甚至无法构建以上代码。在LazyModule组件中有许多构建错误,例如:

'mat-slider'不是已知元素

(尽管MatSliderModule是LazyModule的一个包含导入)

找不到名称为'number'的管道。

(尽管CommonModule也是一个包含的导入)

无法绑定到'ngModel',因为它不是'input'的已知属性。

(尽管FormsModule也是一个包含的导入)

'my-second'不是一个已知元素:

(延迟组件MyFirstComponent的HTML引用MySecondComponent)

如果我在“AppModule”的引入中添加“LazyModule”,那么所有以上问题都会消失。奇怪的是,即使没有将“LazyModule”添加到“imports”列表中,只有导入“./lazy.module”,它也会构建成功。但是这会使我回到试图分解的单个、庞大的“main.js”文件。所以有两个问题:如何将“LazyModule”从“AppModule”中分离并使其构建成功? 在我的初始化中要进行什么调用来加载“LazyModule”,以及如何得到通知表示加载已完成?

更新1:我成功修复了第一个错误(mat-slider未知),方法是将MatSliderModule的导入从lazy.module移动到shared.module。这对我来说毫无意义,因为mat-slider只被一个懒加载组件使用。但这种技巧并没有解决“找不到number pipe”(CommonModule)或“无法绑定ngModel”(FormsModule)的问题。


1
查看此链接,了解加载非路由模块的必要性。对于独立组件,请查看此另一个链接 - Eliseo
第二个链接仍然使用路由器,但第一个链接似乎具有有关非路由器加载所需信息(即NgModuleFactoryLoader),尽管该文章是2019年的,但对于Angular 13来说已经过时了,因为显示的angular.json更改完全破坏了构建。可能足以回答第二个问题;现在我只需要找出第一个问题的答案。 - Brian White
没有看到任何明显不同的地方,所以不能解释构建错误。 - Brian White
我添加了一个更新,以便提供任何有关我的构建问题的提示。 - Brian White
显示剩余2条评论
2个回答

2
  1. 无需路由的懒加载。

您可以在不使用路由的情况下懒加载所有组件。 您只需要手动解析组件工厂并编译组件即可。 对于 Angular 13,它更加自动化,代码看起来像这样:

export class AppComponent {
  @ViewChild("formComponent", { read: ViewContainerRef })
  formComponent!: ViewContainerRef;

  constructor(private compiler: Compiler, private injector: Injector) {}

  async loadForm() {
    const { LazyFormModule } = await import("./lazy-form.component");
    const moduleFactory = await this.compiler.compileModuleAsync(
      LazyFormModule
    );
    const moduleRef = moduleFactory.create(this.injector);
    const componentFactory = moduleRef.instance.getComponent();
    this.formComponent.clear();
    this.formComponent.createComponent(componentFactory, {ngModuleRef: moduleRef});
  }
}

export class LazyFormModule {
  constructor(private componentFactoryResolver: ComponentFactoryResolver) {}

  getComponent() {
    return LazyFormComponent
  }
}

更多示例,请查看https://www.wittyprogramming.dev/articles/lazy-load-component-angular-without-routing/https://medium.com/@ckyidr9/lazy-load-feature-modules-without-routing-in-angular-9-ivy-220851cc7751

  1. 管理您的惰性加载逻辑。

为了使事情正常工作,我建议您只保留带有启动画面的空白应用程序,并删除所有依赖模块(例如,将它们注释掉)。然后使用第一部分的技术逐个添加惰性加载模块。所有必需的模块都应该放在惰性模块中,而不是应用程序模块中。

  1. 共享模块。

只有当您看到一些重复的模块时,您才应该添加用于公共逻辑的惰性共享模块(不要在应用程序中使用它)。


仅将逻辑移动到惰性模块是不够的,您应该使用答案第一部分中的方法,如 componentFactoryResolvercompileModuleAsyncmoduleFactory.create(this.injector)createComponent 等。 - Eugene Mihaylin
你的情况也是按需的,但需求不是一个点击或一个动作,而是一个组件初始化。 - Eugene Mihaylin
“第一部分”没有涉及 .module.ts 配置。链接的示例定义了一个带有惰性组件的 @NgModule。我要说的是,即使我这样做了,该组件仍然会出现在 main.js 中,并且可以像以前一样无需任何惰性加载逻辑就可用。 - Brian White
如果代码是通过惰性导入(通过路由器或其他方式)导入的,则其模块和内容将分别位于不同的块中。 - Eugene Mihaylin
你有两个问题,需要分别解决。 1)使虚拟组件的懒加载正常工作。 2)解决在工作懒加载时的次要(材料、表单等)模块导入。 - Eugene Mihaylin
显示剩余12条评论

2

Eugene给出了非常好的答案,对我弄清楚自己想要做什么非常有帮助。

我的问题并不是我真正想知道的。它应该是这样的:

我能否将源代码分成几个部分,以便在“闪屏”启动序列期间首先下载必要的部分,然后再下载其余部分?

这个问题的简短回答是“不行”。我的调查发现,可能会有一个长的答案,围绕着angular.json文件中的包含/排除指令,但这似乎很复杂,可能会带来维护上的麻烦,并且通常与Angular的设计背道而驰。

最终,我使用内置的Angular支持创建和加载模块。下面呈现的内容与我在其他地方找到的片段没有太大区别。然而,其中没有一个对我有意义,因为我缺少了一个非常基本的概念,每个人似乎都认为理所当然:

将代码分离成独立加载的JavaScript文件是自动的

Angular可以为您完成这项工作。与需要手动将文件分组到.a库或Java到.jar文件不同,使用Angular时,您只需不显式实例化任何要单独加载的内容。只要没有直接创建该类型的new对象,就可以导入它以访问类的字段和方法。

无论组件是分组到模块中还是声明standalone:true,诀窍在于简单地不直接引用它们。独立的部分被收集到单独的.js文件中,包括仅由它们使用的node_modules代码,然后可以按需加载(也称为“惰性加载”)。

以下是一个示例:

publish.module.ts:

import {CommonModule} from "@angular/common";
import {FormsModule} from "@angular/forms";
import {NgModule} from "@angular/core";

import {PublishStartComponent} from "./publish-start/publish-start.component";
import {PublishContinueComponent} from "./publish-continue/publish-continue.component";
import {PublishFinalComponent} from "./publish-final/publish-final.component";
import {PublishResultsComponent} from "./publish-results/publish-results.component";
import {RegionPlacerComponent} from "./region-placer.component";

@NgModule({
    declarations: [
        PublishStartComponent,
        PublishContinueComponent,
        PublishFinalComponent,
        PublishResultsComponent,
        RegionPlacerComponent,
    ],
    imports: [
        CommonModule,  // |number
        FormsModule,   // ngModel
    ]
})
export class PublishModule {
    getPublishStartFactory()    { return PublishStartComponent    }
    getPublishContinueFactory() { return PublishContinueComponent }
    getPublishFinalFactory()    { return PublishFinalComponent    }
    getPublishResultsFactory()  { return PublishResultsComponent  }
    getRegionPlacerFactory()    { return RegionPlacerComponent    }
}

在Angular 15中,文件的惰性加载和获取访问权限只需两行代码:
    const {PublishModule} = await import ("../../components/publish/publish.module")
    const pminstance = createNgModule(PublishModule, this.injector).instance

然后,可以通过使用“工厂”返回类型来实例化类:
    let thing = new (pmintstance.getPublishStartFactory())(...)

或者它可以传递给需要类型的函数:

    this.dialogService.open(pmintstance.getPublishStartFactory(), {...})

在我的情况下,它看起来像这样:

myapp.ts:

...
import {PublishModule} from "../../components/publish/publish.module";
...
@Component({...})
export class MyApp {
    ...
    private pmInstance: PublishModule|undefined
    ...
    constructor(dialogService: MatDialog, injector: Injector) {...}
    ...
    private async onPublishButtonFirstClick() {
        const {PublishModule} = await import ("../../components/publish/publish.module")
        this.pmInstance = createNgModule(PublishModule, this.injector).instance

        let dref = this.dialogService.open(this.pmInstance.getPublishStartFactory(), {
            ...
        })

        dref.afterClosed().subscribe((rid: string) => {
            if (rid == null || rid == "") return
            this.regionName = dref.componentInstance.regionName
            this.regionProjection = dref.componentInstance.regionType
            this.imageUrl = dref.componentInstance.imageUrl!
            this.imageSize = dref.componentInstance.imageSize!

            const rpc = document.getElementById("overlay-container")!
            const injector = Injector.create({
                providers: [
                    {provide: 'imageUrl', useValue: this.imageUrl!},
                    {provide: 'imageSize', useValue: this.imageSize!},
                ]
            })
            this.regionPlacerView = this.injector.get<ViewContainerRef>(ViewContainerRef);
            const rp = this.regionPlacerView.createComponent(this.pmInstance!.getRegionPlacerFactory(), {
                injector: injector
            })

            this.regionPlacer = rp.instance
        })
    }
    ...
}

这里还有更多内容,例如为那些被基础代码和懒加载代码共用的组件创建“共享”模块,但是在使用方式上不需要做任何改变。只需在基础/懒加载两侧正常访问共享模块的组件,Angular 将负责处理正确的事情(在本例中:将“共享”拆分为自己的文件,但作为 index.html 的一部分进行加载)。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接