kuitos / kuitos.github.io

📝Kuitos's Blog https://github.com/kuitos/kuitos.github.io/issues

Home Page:https://kuitos.github.io/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

基于ui-router的非侵入式angular按需加载方案

kuitos opened this issue · comments

基于ui-router的非侵入式angular按需加载方案

用过angular1.x(后面提到的angular均指代的angular1.x框架)的同学应该都知道,angular自身的模块系统是不具备按需加载的能力的,笔者也赞同angular的模块系统是真正称得上设计上的败笔的观点的。2015年被黑的最惨的前端主流框架莫过于angular了,但实际上angular真正设计上的硬伤只有两个:鸡肋的模块系统以及相比其他MVVM框架略显丑陋的脏值检测机制。关于其他各种所谓致命缺陷的立论其实都是站不住脚的,这些观点的提出我可以归结于使用者对angular的不熟悉,不服的同学欢迎来辩😂

angular模块系统的问题

扯远了,说回正题。由于angular自身模块系统的限制,module不支持运行时添加依赖,也就是我们在定义入口模块时必须声明所有依赖项。当我们面临多项目整合的场景时(往往这类场景有按需加载的需求),这个就很恶心了,我们总不能在入口页写好所有可能会嵌入系统的项目的依赖项吧,而且要确保入口模块能找到所有依赖项对应的模块,相应的js还必须在入口处就加载好。。 更多关于angular模块化的问题,具体可以参见民工叔的这篇文章Angular的模块机制

市面上angular实现按需加载的通常方案

目前市面上流行的解决方案大概是这样的:基于requirejs等模块加载器,我们子模块的代码包裹在requirejs的模块定义语法下(define),然后在具体需要的时候在require回调里invoke我们子模块的controller或service等,可以参见这个seed项目angular-requirejs-seed

但是这种方式也有一些明显的问题:

  1. requirejs配合angular实现的那一套按需加载的方案实在是太挫了,真的是有碍观瞻啊!😂它是一套完全侵入式的方式,我个人是无法接受的。而且我认为在中小型规模的系统中,基于angular框架,我们自己需要写的代码量其实不会太大,即使在首页全部引入,在经过简单的合并压缩再配合gzip,文件体积完全在可控范围内,按需加载在这样的场景下价值有限。这也是我一直拒绝在angular体系中引入requirejs的原因。
  2. 如果我们采用angular的纯module的方式开发,那么我们自然会有包含各种controller、service、directive的不同模块,类似angular.module('directives',[]).directive('grid',function(){})
    的写法,而这些子模块必须在入口模块定义时声明其为依赖项,像这样angular.module('app',['directives'])即便你采用requirejs做按需加载。
  3. 我们不采用子单元纯module的方式开发,而是将所有的子单元都挂载在入口模块上,子模块写法类似angular.module('app').directive('grid',function(){}),这种做法副作用会相对少点,但是如果碰到多个项目在各个系统之间作嵌入时,很难做到不用修改代码即可完成嵌入,除非你能确保所有的系统入口模块命名一样。

基于ui-router的解决方案

刚好最近公司在做整个系统的去iframe化(没错之前各个产品嵌入主系统的做法是通过iframe。。不要笑!!😂),因为各个产品之间的切换是通过tab完成的,tab的切换又是通过ui-router控制去定位到各个产品的入口html,所以基于ui-router,我的思路是这样的:

  1. 首先要理清ui-router的工作方式:tab切换时触发ui-router的路由,ui-router会通过配置好的路由规则找寻相应的模板配置(这里假设我们路由配置的都是templateUrl的方式),得到url后会去发起ajax请求拿模板,拿到模板再会填充到ui-view内容区,最后做compile、link处理(省去其他细节),这时候ui-view区域显示的就是编译好的模板内容了。
  2. 基于此,我们可以在模板做编译之前,分析并拿到模板中的script标签,然后通过简单的脚步加载器将模板中定义的js加载到浏览器内存里,在所有的js资源加载完毕之后再去调用编译流程,一切OK!这里要顺带解释一个事情,因为ui-router里采用element.html(tpl)的方式将模板填充到ui-view中的,所以模板中的script标签并不会被浏览器按正常方式解析,而link、style标签不会受到影响(出于安全考虑?具体原因没查到知道的同学请不吝指教)。

但是我们要做的当然不能是直接去找到ui-router这一块的代码然后修改源码,这种做法是有违开闭原则的也是我一直批判的方式,不到万不得已绝不要去修改第三方插件的源码!ui-router处理路由模板的主逻辑在uiView指令里,然后angular里面又提供了强大的decorator机制。开码!

angular
    .module('ui.router.requirePolyfill', ['ng', 'ui.router', 'oc.lazyLoad'])
    .decorator('uiViewDirective', DecoratorConstructor);

  /**
   * 装饰uiView指令,给其加入按需加载的能力
   */
  DecoratorConstructor.$inject = ['$delegate', '$log', '$q', '$compile', '$controller', '$interpolate', '$state', '$ocLazyLoad'];
  function DecoratorConstructor($delegate, $log, $q, $compile, $controller, $interpolate, $state, $ocLazyLoad) {

    // 移除原始指令逻辑
    $delegate.pop();
    // 在原始ui-router的模版加载逻辑中加入脚本请求代码,实现按需加载需求
    $delegate.push({

      restrict: 'ECA',
      priority: -400,
      compile : function (tElement) {
        var initial = tElement.html();
        return function (scope, $element, attrs) {

          var current = $state.$current,
            name = getUiViewName(scope, attrs, $element, $interpolate),
            locals = current && current.locals[name];

          if (!locals) {
            return;
          }

          $element.data('$uiView', {name: name, state: locals.$$state});

          var template = locals.$template ? locals.$template : initial,
            processResult = processTpl(template);

          var compileTemplate = function () {
            $element.html(processResult.tpl);

            var link = $compile($element.contents());

            if (locals.$$controller) {
              locals.$scope = scope;
              locals.$element = $element;
              var controller = $controller(locals.$$controller, locals);
              if (locals.$$controllerAs) {
                scope[locals.$$controllerAs] = controller;
              }
              $element.data('$ngControllerController', controller);
              $element.children().data('$ngControllerController', controller);
            }

            link(scope);
          };

          // 主要实现
          // 模版中不含脚本则直接编译,否则在获取完脚本之后再做编译
          if (processResult.scripts.length) {
            loadScripts(processResult.scripts).then(compileTemplate);
          } else {
            compileTemplate();
          }

        };
      }

    });

    return $delegate;

最早期我自己实现了一个简单的script-loader用来做基本的动态脚本加载,但是后来发现一个问题:angular框架下我们单单的只是加载脚本是没用的,我们必须把脚本定义的module注入到主app的module下才有意义。尽管在下仔细读过大部分angular的核心部件代码,但是动态注册模块这个事情难度还是很大的,改造工作一度停滞不前。。直到我发现了这个库ocLazyLoad,这之后事情就好办了。
附上完整的实现代码:ui-router-require-polyfill文档。这里面为了解决脚本加载的时序问题,我在loadScript方法里加入了提取script seq属性的机制用于确定脚本顺序,同时为了解决gulp脚本合并时的问题,个人简单改造了下gulp-usemin插件,改造后的插件在这里,要做发布的脚本合并时请配合使用这个改造过的插件。[更新:pull request已被合并,可以直接install gulp usemin最新版本]

写在最后

这一套方案目前是我能想到的最接近完美的方案,最主要的是它是非侵入式而且基本不需要对原有angular体系下的代码做任何改造,即可实现按需加载&模块移植的需求的方式。如果有同学有改进建议或者更好的方案,欢迎一起探讨。

不知道我这一套怎么样 https://github.com/treri/angular-require/tree/examle

使用的是require.js进行加载.

@treri 基于requirejs的这一套方案正是我批判的啊,哈哈且让我先把我的方案写完😄

赞, 研究一下~

在项目中一直都因为angular不能按需加载 ,页面多了的话, 代码体积很可观, 所以一直都没有上.

后来找到了一种简单的办法, 可以使用requirejs加载, 虽然像你说的那样, 是侵入式的, 但是好在可以按照我想要的方式去工作. 所以这才在项目中用上angular.js.

今天看到了你写的, 确实不失为一种比较优雅的解决办法.

我这边以前用的也是类似@treri的requireJS方式加载(用的不全,都没用用到define,其中requirejs的作用就是下载js文件),通过监听路由change,调用requirejs下载文件,然后通过和angular源代码类似的方式动态注入模块。
现在打算切换到ocLazyLoad,该文章值得借鉴。

@hstarorg requires那套方案最大的问题在于,我必须在我的js代码中手动require具体的文件,这个是侵入式的,如果哪天这个模块要嵌入到一个非require方式的系统中,这套方案就不好使了。
我这套方案的基本思路是,不改变原有的通过script标签引入脚本的方式,通过改造ui-router实现读取模板文件中的script标签加载脚本,这样就不会破坏已有的代码结构,把移植代价降到最低。

@kuitos 这边是采用约定的方式避开侵入性的,默认取路由的第一部分为模块名称,然后每个模块下的文件都会被打包为app.js,app.css。所以requirejs这边按照这个约定去加载就可以了。

@hstarorg 我这边的环境没有你那么好。我目前处理的问题是好几个业务团队的产品要嵌入到主系统中(之前是iframe的方式),现在要做去iframe化,我们不能强制要求所有业务团队改造项目结构吧。你那种约定的方式也不失为一种有效的手段,不过处理多样的场景可能会力不从心。

@kuitos 正如你所说,处理多样的场景比较麻烦,所以打算切换到ocLazyLoad的方式。我这边是提供一个核心框架,很多团队各自编写模块。由于angular本身的模块机制没有隔离性,不知道有没有什么好办法处理命名冲突呢?(当前我这边还是用的约定,controller使用-的方式)

去iframe化之后,你应该也会遇到这个问题。

commented

@hstarorg 必须给业务团队加命名约束,他们的controller必须改成叫:

aaa.SomeController之类,每个都得加前缀,这是个大麻烦……

@hstarorg 是的,这个问题我也有想过,但是目前只想到通过加产品前缀的方式区分,就跟民工叔说的一样。有什么更好的方案请及时放出来😄

@kuitos 哎,希望angular2能更简单的升级吧。挺麻烦的事情,已有的模块很难说动他们去变更的。

我现在有一个初步想法就是,我们能不能通过在构建脚本中去批处理angular.module('app'),比如产品a中,就将其替换为angular.module('a.app'),angular.controller('ctrl')替换成angular.controller('a.ctrl')。不过有一个大坑就是,模板里面使用的自定义directive、controller、filter之类的,怎么弄?脚本用正则匹配然后replace不合适吧。。 @hstarorg @xufei

@kuitos 单纯的处理module是很easy的,毕竟module并不需要多处引用。但是如果是controller之类的就很难搞了,很难匹配到所有使用到的地方。个人觉得这种方式很难实现。

如果把angular包装一层,ngWarp.module().controller(),然后在这里面进行更名似乎可行么?

commented

@kuitos 嘿嘿,构建的时候放进去,我就是这么想的,不过动静也挺大的。

2年前我还在之前公司,当时规划了一个平台,js模块的部分类似npm,每个业务模块只放实现,也就是controller,service去掉外壳的部分,然后在这个平台上集中存放依赖关系,构建的时候生成外壳,就是这个module配置之类的,不过也好繁琐。

@xufei angular的公开api并不太多,而且使用ngWarp的话,可以保证api和angular一致。我是没打算让构建来做这个事,而让浏览器直接执行ngWarp代码

@hstarorg ngWrap的方式解决controller的定义没什么问题,关键是如何得知当前模版的controller属于哪个module?angular的模块系统做不到啊。。同样问题的还有各个模板(包括string形式的模板)里面引用的directive、filter等,怎么处理呢?

判断属于哪个模块,可以通过构建来处理。模板里面的controller定义,倒是好解决(ngw-controller="xxx")。对于directive和filter没有想到好的办法。。

受教了,感觉这就是个根据不同场景各取所需的事情,哈哈哈。。我用的是@treri 的angular-require

@ileler 推荐看一下百度的 FIS3. 我已经切换到了 FIS3 配合 和fis配套的mod.js, 整体很条理.

目前线上跑的项目, 你可以打开 devtool 看一下. http://m.wecook.cn

我们在当前的项目中正在使用ui.router 和ocLazyLoad配合实现按需加载,在开发中确实很方便,但是在打包过程中也遇到了文件合并的问题,比如说StateA所需的文件为['moduleA.crtl.js', 'modeuleA.serv.js'],正式环境中会把模块文件合并成一个,那么在load的时候也需要改。请问在grunt/gulp中如何自动化完成这部分工作?

@yuezheng 我们的方案是在 ui-router 里自动做 ocLazyLoad 的事情,而不是手动调api,比如某一个路由配置是这样的:

const state = {
  url: '/state',
  templateUrl: '/state.tpl.html'
}

state.tpl.html

<section>
......
</section>

<script src="moduleA.crtl.js"></script>
<script src="modeuleA.serv.js"></script>

这样每个路由页面 template 依赖的 js 资源 ui-router 会调用 ocLazyLoad 加载。
发布的时候,只需要配好相应的脚本,拿 gulp-usemin 举例,

state.tpl.html

<section>
......
</section>

<!-- build:js app.js -->
<script src="moduleA.crtl.js"></script>
<script src="modeuleA.serv.js"></script>
<!-- endbuild -->

这样生成环境就会自动访问打包好的 app.js 了,不需要改代码。

@kuitos 感谢,确实是很巧妙的方法!

commented

这种解决方案确实优雅,但是我想问下,如果手头的文件有很多abstract state 有什么好的解决办法么?另外公共的directive是怎么解决的呢?

@john-simon-mcgill 公共部分需要提前加载,所以最好的做法是依照业务分类做好模块化,将代码做好拆分,使得首次只加载最基本的部分。abstract state 的话尽量减少吧,也可以改造成空的 state 然后再 controller 里 go 到下一级的 state

@kuitos 我发现个问题,只要页面刷新 那么整个系统都是重定向到 $urlRouterProvider.otherwise("/")指定的页面。这是个问题 不知道能不能解决, 我希望的是即使刷新也最好停留在我访问到这个页面,期待回复....

@zhicheng99 是的会有这个问题,你需要集成 Futrue State 来实现这个能力,集成的代码目前在我们线上产品跑着,还没放到 github 上,有空我整理一下 publish 出来,你可以先看看链接自己研究下。。

@kuitos 谢谢 我先研究一下,正在优化项目的架构 突然发这么个问题

@kuitos 期待大神抽空出个简单的 Futrue State 示例吧 搞不定,require的这种加载方法没研究过, 还是觉得你写这种比较好

@kuitos angular 1.6.0 中使用报错,麻烦给看一下
f9cf169c-bcd2-4501-862d-2689b397b82d

bug 可以提到相应的 repo 下,谢谢 https://github.com/kuitos/angular-utils/issues

@kuitos @xufei , 关于ng2的同类型框架,核心代码:https://github.com/hstarorg/ng2-modular-platform ,希望能探讨更好的实现方式。

当前的实现问题有:不能aot,第三方依赖不太好打包;为了兼容Angular1的模块,使用iframe进行包裹(权限认证之类的,用ng2传给iframe),然后通过postMessage在两者之间通信。

最后还是选在用 require.js 来实现按需加载,并且引入组件化的概念
https://github.com/baijunjie/angular-ui-router-require

@zhicheng99 @kuitos

只要页面刷新 那么整个系统都是重定向到 $urlRouterProvider.otherwise("/")指定的页面。这是个问题 不知道能不能解决...

同用ui-router,在url https://xx.com/#/a中刷新后还是这个url,没有发现描述的重定向问题。能具体说说吗?这应该跟Angular(1.x)和ui-router版本没有关系吧。

和ui-router的版本有关,只能用0.4.x版本

用ui-router0.4.3和angularjs-1.6.6都不会有问题。

高版本就不兼容了,这里有解决办法吗