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


Posted in Javascript onNovember 27, 2017

0、前言

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

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

当然,解决方式有不少。以下就来讲解下我们这边的一种解决方案。

1、思路

Angualr

Angular(注:非AngularJS) 是流行的前端 MVVM 框架之一,配合 TypeScript,非常适合用来做后台管理系统。由于我们曾今的一套 Angularjs 开发框架,我们继续选择 Angular 来进行实现,并尽可能的兼容 AngularJS 的模块。

SPA

选 SPA 还是多页?多余 Mvvm 来说,多页并不是标配。而且多页开发中,我们势必会关注更多的内容,包括通用header,footer,而不仅仅是页面的核心内容。

模块化

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

平台化

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

兼容 AngularJS 模块

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

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

模块打包

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

CSS冲突

在大型 SPA 中,CSS冲突是很大的一个问题。我们期望通过技术手段,能够根据当前使用的模块,加载和卸载CSS。

跨页面共享数据

由于涉及到iframe兼容旧有模块,我们无可避免,需要考虑跨窗口的页面共享。

公共模块

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

3、实现

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

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

如何模块化?如何打包?

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

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

考虑到 Angular 也提供了 CDN 版本,所以我们将 Angular 的组件通过文件合并,作为全局全量访问,如 ng.core、ng.common 等。

既然这样,那我们打包的时候,就可以利用 webpack 的 externals 功能,把相关依赖替换为全局变量。

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;
  });
 }
});

3、总结

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

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

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

本文github地址

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持三水点靠木。

Javascript 相关文章推荐
JS函数重载的解决方案
May 13 Javascript
JavaScript+Java实现HTML页面转为PDF文件保存的方法
May 30 Javascript
AngularJS 实现JavaScript 动画效果详解
Sep 08 Javascript
Seajs是什么及sea.js 由来,特点以及优势
Oct 13 Javascript
微信小程序request请求后台接口php的实例详解
Sep 20 Javascript
详解Vue路由History mode模式中页面无法渲染的原因及解决
Sep 28 Javascript
浅谈Vue路由快照实现思路及其问题
Jun 07 Javascript
vue实现一个炫酷的日历组件
Oct 08 Javascript
vue elementUI 表单校验功能之数组多层嵌套
Jun 04 Javascript
vue Tab切换以及缓存页面处理的几种方式
Nov 05 Javascript
js实现树形数据转成扁平数据的方法示例
Feb 27 Javascript
js实现计算器功能
Aug 10 Javascript
JavaScript实现修改伪类样式
Nov 27 #Javascript
Vue.js搭建移动端购物车界面
Jun 28 #Javascript
Vue实现购物车场景下的应用
Nov 27 #Javascript
javascript字体颜色控件的开发 JS实现字体控制
Nov 27 #Javascript
vue购物车插件编写代码
Nov 27 #Javascript
Vue.js devtool插件安装后无法使用的解决办法
Nov 27 #Javascript
微信小程序页面跳转功能之从列表的item项跳转到下一个页面的方法
Nov 27 #Javascript
You might like
对盗链说再见...
2006/10/09 PHP
php基础知识:函数基础知识
2006/12/13 PHP
Yii结合CKEditor实现图片上传功能
2014/06/13 PHP
PHP+jquery实时显示网站在线人数的方法
2015/01/04 PHP
Symfony2使用Doctrine进行数据库查询方法实例总结
2016/03/18 PHP
基于jquery &amp; json的省市区联动代码
2012/06/26 Javascript
setTimeout函数兼容各主流浏览器运行执行效果实例
2013/06/13 Javascript
js判断数据类型如判断是否为数组是否为字符串等等
2014/01/15 Javascript
解决用jquery load加载页面到div时,不执行页面js的问题
2014/02/22 Javascript
javascript 获取元素样式必杀技
2014/05/04 Javascript
jQuery插件实现无缝滚动特效
2015/11/24 Javascript
使用jQuery或者原生js实现鼠标滚动加载页面新数据
2016/03/06 Javascript
Kindeditor在线文本编辑器如何过滤HTML
2016/04/14 Javascript
jQuery树控件zTree使用方法详解(一)
2017/02/28 Javascript
解决vue-cli创建项目的loader问题
2018/03/13 Javascript
基于VuePress 轻量级静态网站生成器的实现方法
2018/04/17 Javascript
Vue-CLI3.x 设置反向代理的方法
2018/12/06 Javascript
vue实现二级导航栏效果
2019/10/19 Javascript
js实现简单掷骰子效果
2019/10/24 Javascript
JavaScript使用canvas绘制随机验证码
2020/02/17 Javascript
Vant picker 多级联动操作
2020/11/02 Javascript
python编写简单爬虫资料汇总
2016/03/22 Python
Python实现针对给定字符串寻找最长非重复子串的方法
2018/04/21 Python
python对于requests的封装方法详解
2019/01/03 Python
Python tkinter常用操作代码实例
2020/01/03 Python
Python类型转换的魔术方法详解
2020/12/23 Python
华为的Java面试题
2014/03/07 面试题
Windows和Linux动态库应用异同
2016/07/28 面试题
如何提高MySql的安全性
2014/06/19 面试题
奖学金自我鉴定范文
2013/10/03 职场文书
学校就业推荐信范文
2014/05/19 职场文书
化工专业求职信
2014/07/01 职场文书
社区端午节活动总结
2015/02/11 职场文书
员工聘用合同范本
2015/09/21 职场文书
Django集成富文本编辑器summernote的实现步骤
2021/05/31 Python
CSS几步实现赛博朋克2077风格视觉效果
2021/06/16 HTML / CSS