利用Angular实现多团队模块化SPA开发框架

0、前言

当一个公司有多个开发团队时,咱们可能会遇到这样一些问题:css

  1. 技术选项杂乱,你们各玩各
  2. 业务重复度高,各类通用api,登陆注销,权限管理都须要重复实现(甚至一个团队都须要重复实现)
  3. 业务壁垒,业务之间的互通变得比较麻烦
  4. 部署方式复杂,多个域名(或IP地址)访问,给用户形成较大的记忆难度
  5. 多套系统,风格难以统一
  6. 等等...

固然,解决方式有很多。如下就来说解下咱们这边的一种解决方案。前端

一、思路

Angualrwebpack

Angular(注:非AngularJS) 是流行的前端 MVVM 框架之一,配合 TypeScript,很是适合用来作后台管理系统。因为咱们曾今的一套 Angularjs 开发框架,咱们继续选择 Angular 来进行实现,并尽量的兼容 AngularJS 的模块。git

SPAangularjs

SPA 仍是多页?多余 Mvvm 来讲,多页并非标配。并且多页开发中,咱们势必会关注更多的内容,包括通用header,footer,而不只仅是页面的核心内容。github

模块化web

为何要模块化呢?当有多个团队开发时(或者项目较大时),咱们但愿各个团队开发出来的东西都是 模块(不只限于JS模块),这样可让咱们独立发布、更新、删除模块,也能让咱们的关注点集中在特定模块下,提升开发效率和可维护性。express

平台化gulp

咱们须要有一个运行平台(Website站点),容许在里面运行指定的模块。这样就能够实现单一入口,也容易实现通用逻辑,模块共享机制等等。api

兼容 AngularJS 模块

在考虑将框架切换到 Angular 时,咱们无可避免的会遇到如何兼容当前已有模块的问题。大体可选的方案以下:

  1. 参考 AngualrJS -> Angular 官方升级指南,一步步将模块切换为 Angular 的实现。(工做量大,须要开发团队调整不少东西)
  2. iframe嵌入,会有必定的体验差别,但对开发团队来讲,基本无缝升级,也不须要作什么改动。(无疑,咱们选择了这套方案)

模块打包

咱们须要将单个的模块打包为资源包,进行更新。这样才能作到模块独立发布,及时生效。

CSS冲突

在大型 SPA 中,CSS冲突是很大的一个问题。咱们指望经过技术手段,可以根据当前使用的模块,加载和卸载CSS。

跨页面共享数据

因为涉及到iframe兼容旧有模块,咱们无可避免,须要考虑跨窗口的页面共享。

公共模块

当一个团队的模块较多时,就会有一些公共的东西被抽取出来,这个过程,框架是没法知道的,因此这个时候,咱们就须要考虑支持公共模块。(模块之间也有依赖关系)

三、实现

基于以上的一些思考,咱们首先须要实现一个基础的平台网站,这个没什么难度,直接用 Angular 实现便可。有了这一套东西,咱们的登陆注销,基本的菜单权限管理,也就实现了。

在这个基础之上,咱们也能实现公共服务、公共组件了(封装一系列经常使用的玩意)。

如何模块化?如何打包?

注意:此模块并不是Angular自己的模块。 咱们经过约定,在 modules/ 下的每个目录都是一个业务模块。一个业务模块通常会包含,静态资源、CSS以及JS。根据这个思路,咱们的打包策略就是:遍历 modules/ 的全部目录,对每个目录进行单独打包(webpack多entry打包+CSS抽取),另外使用 gulp 来处理相关的静态资源(在我看来,gulp才是构建工具,webpack是打包工具,因此混合使用,物尽其用)。

通常来讲,webpack 会把全部相关依赖打包在一块儿,A、B 模块都依赖了 @angular/core 识别会重复打包,并且框架中,也已经打包了 @angular 相关组件。这个时候,常规的打包配置就不太合适了。那该如何作呢?

考虑到 Angular 也提供了 CDN 版本,因此咱们将 Angular 的组件经过文件合并,做为全局全量访问,如 ng.coreng.common 等。

既然这样,那咱们打包的时候,就能够利用 webpackexternals 功能,把相关依赖替换为全局变量。

externals: [{
  'rxjs': 'Rx',
  '@angular/common': 'ng.common',
  '@angular/compiler': 'ng.compiler',
  '@angular/core': 'ng.core',
  '@angular/http': 'ng.http',
  '@angular/platform-browser': 'ng.platformBrowser',
  '@angular/platform-browser-dynamic': 'ng.platformBrowserDynamic',
  '@angular/router': 'ng.router',
  '@angular/forms': 'ng.forms',
  '@angular/animations': 'ng.animations'
}

这样处理以后,咱们打包后的文件,也就不会有 Angular 框架代码了。

注:这个对引入资源的方式也有必定要求,就不能直接引入内层资源了。

如何动态加载模块

打包完成以后,这个时候就要考虑平台如何加载这些模块了(发布过程就不说了,放到指定位置便可)。

何时决定加载模块呢?实际上是访问特定路由的时候,因此咱们的顶级路由,会使用Promise方法来实现,以下:

const loadModule = (moduleName) => {
  return () => {
    return ModuleLoaderService.load(moduleName);
  };
};

const dynamicRoutes = [];

modules.forEach(item => {
  dynamicRoutes.push({
    path: item.path,
    canActivate: [AuthGuard],
    canActivateChild: [AuthGuard],
    loadChildren: loadModule(item.module)
  });
});
const appRoutes: Routes = [{
  path: 'login', component: LoginComponent
}, {
  path: 'logout', component: LogoutComponent
}, {
  path: '', component: LayoutComponent, canActivate: [AuthGuard],
  children: [
    { path: '', component: HomeComponent },
    ...dynamicRoutes,
    { path: '**', component: NotFoundComponent },
  ]
}];

咱们把每一个模块,按照 umd 的格式进行打包。而后再须要使用该模块的时候,使用动态构建 script 来运行脚本。

load(moduleName, isDepModule = false): Promise<any> {
  let module = window['xxx'][moduleName];
  if (module) {
    return Promise.resolve(module);
  }
  return new Promise((resolve, reject) => {
    let path = `${root}${moduleName}/app.js?rnd=${Math.random()}`;
    this._loadCss(moduleName);
    this.http.get(path)
      .toPromise()
      .then(res => {
        let code = res.text();
        this._DomEval(code);
        return window['xxx'][moduleName];
      })
      .then(mod => {
        window['xxx'][moduleName] = mod;
        let AppModule = mod.AppModule;
        // route change will call useModuleStyles function.
        // this.useModuleStyles(moduleName, isDepModule);
        resolve(AppModule);
      })
      .catch(err => {
        console.error('Load module failed: ', err);
        resolve(EmptyModule);
      });
  });
}

// 取自jQuery
_DomEval(code, doc?) {
  doc = doc || document;
  let script = doc.createElement('script');
  script.text = code;
  doc.head.appendChild(script).parentNode.removeChild(script);
}

CSS的动态加载相对比较简单,代码以下:

_loadCss(moduleName: string): void {
  let cssPath = `${root}${moduleName}/app.css?rnd=${Math.random()}`;
  let link = document.createElement('link');
  link.setAttribute('rel', 'stylesheet');
  link.setAttribute('href', cssPath);
  link.setAttribute('class', `xxx-module-style ${moduleName}`);
  document.querySelector('head').appendChild(link);
}

为了可以在模块切换时卸载,还须要提供一个方法,供路由切换时使用:

useModuleStyles(moduleName: string): void {
  let xxxModuleStyles = [].slice.apply(document.querySelectorAll('.xxx-module-style'));
  let moduleDeps = this._getModuleAndDeps(moduleName);
  moduleDeps.push(moduleName);
  xxxModuleStyles.forEach(link => {
    let disabled = true;
    for (let i = moduleDeps.length - 1; i >= 0; i--) {
      if (link.className.indexOf(moduleDeps[i]) >= 0) {
        disabled = false;
        moduleDeps.splice(i, 1);
        break;
      }
    }
    link.disabled = disabled;
  });
}

公共模块依赖

为了处理模块依赖,咱们能够借鉴 AMD规范 以及使用 requirejs 做为加载器。当前在个人实现里,是自定义了一套加载器,后期应该会切换到 AMD 规范上去。

如何兼容 AngularJS 模块?

为了兼容 AngularJS 的模块,咱们引入了 iframe, iframe会先加载一套曾今的 AngularJS 宿主,而后再这个宿主中,运行 AngularJS 模块。为了实现通讯,咱们须要两套平台程序中,都引入一个基于 postMessage 实现的跨窗口通讯库(由于默认跨域,因此用postMessage实现),有了它以后,咱们就能够很方便的两边通讯了。

AOT编译

按照 Angular 官方的 Aot 编译流程便可。

多Tab页

在后台系统中,多Tab页是比较经常使用了。可是多Tab页,在单页中使用,会有必定的性能风险,这个依据实际的状况,进行使用。实现多Tab页的核心就是如何动态加载组件以及如何获取到要加载的组件。

多Tab页面,实际就是一个 Tabset 组件,只是在 tab-item 的实现稍显特别一些,相关动态加载的源码:

@ViewChild('dynamicComponentContainer', { read: ViewContainerRef }) dynamicComponentContainer: ViewContainerRef;

constructor(
  private elementRef: ElementRef,
  private renderer: Renderer2,
  private tabset: TabsetComponent,
  private resolver: ComponentFactoryResolver,
  private parentContexts: ChildrenOutletContexts
) {
}

public destroy() {
  let el = this.elementRef.nativeElement as HTMLElement;
  // tslint:disable-next-line:no-unused-expression
  el.parentNode && (el.parentNode.removeChild(el));
}

private loadComponent(component: any) {
  let context = this.parentContexts.getContext(PRIMARY_OUTLET);
  let injector = ReflectiveInjector.fromResolvedProviders([], this.dynamicComponentContainer.injector);
  const resolver = context.resolver || this.resolver;
  let factory = resolver.resolveComponentFactory(component);
  //   let componentIns = factory.create(injector);
  //   this.dynamicComponentContainer.insert(componentIns.hostView);
  this.dynamicComponentContainer.createComponent(factory);
}

注意:要考虑组件卸载方法,如 destroy()

为了获取到当前要渲染的组件,咱们能够借用路由来抓取:

this.router.events.subscribe(evt => {
  if (evt instanceof NavigationEnd) {
    let pageComponent;
    let pageName;
    try {
      let nextRoute = this.route.children[0].children[0];
      pageName = this.location.path();
      pageComponent = nextRoute.component;
    } catch (e) {
      pageName = '$$notfound';
      pageComponent = NotFoundComponent;
    }
    let idx = this.pageList.length + 1;
    if (!this.pageList.find(x => x.name === pageName)) {
      this.pageList.push({
        header: `页面${idx}`,
        comp: pageComponent,
        name: pageName,
        closable: true
      });
    }
    setTimeout(() => {
      this.selectedPage = pageName;
    });
  }
});

三、总结

以上就是大概的实现思路以及部分相关的细节。其余细节就须要根据实际的状况,进行酌情处理。

该思路并不只限于 Angular 框架,使用 Vue、React 也能够作到相似的效果。同时,这套东西也比较适合中小企业的后台平台(不必定非要多团队,一个团队按模块开发也是不错的)。

如须要了解更多细节,能够参考:ngx-modular-platform,能给个 star 就更好了。

在此抛砖引玉,但愿能集思广益,提炼出更好的方案。欢迎讨论和 提Issue, 发PR

本文github地址

相关文章
相关标签/搜索