Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
kuitos opened this issue Dec 18, 2015 · 37 comments
Open

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

kuitos opened this issue Dec 18, 2015 · 37 comments

Comments

@kuitos
Copy link
Owner

kuitos commented Dec 18, 2015

基于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体系下的代码做任何改造,即可实现按需加载&模块移植的需求的方式。如果有同学有改进建议或者更好的方案,欢迎一起探讨。

@wyntau
Copy link

wyntau commented Dec 18, 2015

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

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

@kuitos
Copy link
Owner Author

kuitos commented Dec 18, 2015

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

@wyntau
Copy link

wyntau commented Dec 19, 2015

赞, 研究一下~

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

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

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

@kuitos kuitos changed the title 基于ui-router的无侵入式angular按需加载方案 基于ui-router的非侵入式angular按需加载方案 Dec 21, 2015
@hstarorg
Copy link

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

@kuitos
Copy link
Owner Author

kuitos commented Dec 24, 2015

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

@hstarorg
Copy link

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

@kuitos
Copy link
Owner Author

kuitos commented Dec 24, 2015

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

@hstarorg
Copy link

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

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

@xufei
Copy link

xufei commented Dec 24, 2015

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

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

@kuitos
Copy link
Owner Author

kuitos commented Dec 24, 2015

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

@hstarorg
Copy link

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

@kuitos
Copy link
Owner Author

kuitos commented Dec 24, 2015

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

@hstarorg
Copy link

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

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

@xufei
Copy link

xufei commented Dec 24, 2015

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

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

@hstarorg
Copy link

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

@kuitos
Copy link
Owner Author

kuitos commented Dec 24, 2015

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

@hstarorg
Copy link

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

@lmk123
Copy link

lmk123 commented Jan 14, 2016

这个解决方案怎么样:https://github.com/lmk123/angularjs-requirejs-rjs-md5

@kerwin612
Copy link

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

@wyntau
Copy link

wyntau commented Sep 1, 2016

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

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

@yuezheng
Copy link

yuezheng commented Sep 5, 2016

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

@kuitos
Copy link
Owner Author

kuitos commented Sep 8, 2016

@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 了,不需要改代码。

@yuezheng
Copy link

yuezheng commented Sep 9, 2016

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

@john-jiang-ca
Copy link

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

@kuitos
Copy link
Owner Author

kuitos commented Oct 25, 2016

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

@zhicheng99
Copy link

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

@kuitos
Copy link
Owner Author

kuitos commented Dec 21, 2016

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

@zhicheng99
Copy link

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

@zhicheng99
Copy link

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

@baijunjie
Copy link

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

@kuitos
Copy link
Owner Author

kuitos commented Dec 22, 2016

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

@hstarorg
Copy link

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

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

@baijunjie
Copy link

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

@lucasluk
Copy link

lucasluk commented Feb 8, 2017

@zhicheng99 @kuitos

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

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

@zbrong
Copy link

zbrong commented Oct 27, 2017

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

@zbrong
Copy link

zbrong commented Oct 27, 2017

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

@xjw181221
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests