守望者--AIR技术交流

 找回密码
 立即注册

QQ登录

只需一步,快速开始

扫一扫,访问微社区

搜索
热搜: ANE FlasCC 炼金术
查看: 270|回复: 2

[技术资料] 玩转AMD(1) - 设计思路

[复制链接]
  • TA的每日心情
    擦汗
    2018-4-10 15:18
  • 签到天数: 447 天

    [LV.9]以坛为家II

    1742

    主题

    2094

    帖子

    13万

    积分

    超级版主

    Rank: 18Rank: 18Rank: 18Rank: 18Rank: 18

    威望
    562
    贡献
    29
    金币
    51661
    钢镚
    1422

    开源英雄守望者

    发表于 2016-8-17 17:32:21 | 显示全部楼层 |阅读模式
    来源:http://div.io/topic/909


    AMD 的全称是 Asynchronous Module Definition。顾名思义,这是一种定义模块的方式,并且是异步的。在其 Spec 的第一段描述中,就强调了特别适合浏览器环境。

    The Asynchronous Module Definition (AMD) API specifies a mechanism for defining modules such that the module and its dependencies can be asynchronously loaded. This is particularly well suited for the browser environment where synchronous loading of modules incurs performance, usability, debugging, and cross-domain access problems.

    我觉得,AMD 适合浏览器环境开发的主要特性有下面几点:

    • 开发时不需要页面上写一大堆 script 引用,一个 require 初始化模块就搞定。不需要每增加一个文件,就需要到 HTML 或者其他地方添加一个 script 标签或文件声明。
    • 开发时不需要依赖额外的环境,如果不是联调或数据模拟,我甚至不需要启动 WebServer ,直接 file:// 都能跑。想想如果你开发时写的是 CommonJS 形式的模块,你就需要依赖一个进行特殊处理的 WebServer,或者是一个编译工具。随着系统规模增加,编译工具还需要监听文件修改,进行增量编译。
    • 方便打包构建,打包构建应该便于进行模块的合并,并且在绝大多数场景不需要对页面进行修改。
    • 能够通过多种构建方案产生多种打包组合,方便从系统加载过程与缓存进行性能优化。特别是规模较大的应用,哪些东西首次加载、哪些东西缓加载、哪些东西需要被拼合、哪些东西经常变更等等,在构建的时候都会被考虑,对系统性能都可能有较大的影响。

    AMD 要求保留两个全局变量(这句话不严谨)。

    • define - 模块定义的入口
    • require - 用于加载模块

    应该可以很容易看出来,AMD 和 CMD 是不兼容的,不能在一个页面共存。

    不是所有的 define 都是 AMD。那么,怎么知道页面中的 define 是不是 AMD 的呢?可以判断 define.amd 是否存在。

    使用 define 定义模块

    早先的时候,很多项目和团队都是采用类似 namespace 的模式在写 JavaScript:

    lib.string.trim = function (source) {
    };

    这种模式存在一些弊端。首先, lib 和 lib.string 必须存在并且是 Object 啊,要不运行就报错了。然后,lib.string.trim 可能会被覆盖,并且很难追查。我们曾经遇到过这样的例子:

    // 最开始的人是这么实现的
    lib.string.trim = function (source) {
        return source.replace(/(^[\s\t\xa0\u3000]+|[\s\t\xa0\u3000]+$)/g, '');
    };
    
    // 后来某个人不知道怎么想的。可能是有需要,也没看原来有没有,就在某个很隐蔽的地方这么写
    lib.string.trim = function (source) {
        return source.replace(/(^[\s\t\xa0]+|[\s\t\xa0]+$)/g, '');
    };
    
    // QA测出来全角空格trim不掉了,可是怎么看代码都是对的
    // 从这可以得出一个经典结论:不怕神一样的对手,就怕猪一样的队友

    后来,大家开始使用一些包装。基本上很多公司都有自己的包装方法,很多库或者框架也有自己的包装方法。大多是 namespace style 的 id,使用 factory 的返回值作为模块(那时候还不叫模块),不过多少还会有些细微的区别。

    defineNamespace('lib.string.trim', function () {
        return function (source) {
        };
    });

    各搞各的多乱啊,还好后来有了 AMD,大家都按这个玩,一个 define 解决问题。这样跳槽的时候技术成本就能小一些。上面只是简单大致回顾下,也懒的去挖坟和考证,下面看看 AMD 的 define:

    define(id?, dependencies?, factory);
    
    id - string
    dependencies - Array
    factory - Function | Object
    

    从 define 的签名可以看到,id 和 dependencies 是可选的。扩展一下,define 总共有下面 4 种形式:

    define(factory);
    define(id, factory);
    define(dependencies, factory);
    define(id, dependencies, factory);
    

    这 4 种形式里,不同的形式适合不同的场合使用,有的形式基本不可能被用到,只是由于参数设计的形式,出现了这种组合形式。在后面会给出一些说明和建议。

    模块 ID 的形式

    模块 ID 可能会被用在 define 和 require 时。它是一个 string literal。在 AMD 里,对 ID 形式的要求和 CommonJS 是一样的。我列一些关键点(不全):

    • / 分隔 terms
    • term 是 驼峰形式的 identifier,或者 . ,或者 ..
    • 不包含文件的扩展名,比如 .js
    • . 或 .. 开头的叫做 Relative, 否则叫做 Top-Level

    这一看不就是路径嘛。这种 ID 的设计实在是太方便了。AMD Loader 会根据模块 ID 去加载对应的模块声明文件,既直观又能保证正确性。以前写 namespace 模式的时候,我们必须在项目里规范 “文件声明的namespace和文件存放路径必须严格对应” 这种看似常识的条目,但不经意间总有人break。

    写惯了 namespace 模式的人,可能需要适应一下这种 path style 的 ID 形式。

    define 时只允许使用 Top-Level ID,这点应该很好理解。使用 local require 时可以使用 Relative ID,这是 AMD 中一个很重要的特点。

    既然 ID 是 path style,并且 require 时能够通过路径对应,开发的时候写 define 干嘛还多此一举写 ID 呢?所以 define 中 ID 是可以省略的嘛,而且开发时能不写就不写比较好。不是因为懒,为了方便模块迁移。后面会详细说说这点。

    我有一点不太理解,为什么 term 要求是驼峰形式的 identifier。如果有人知道,麻烦告诉我

    factory

    AMD 规定,factory 可以是 Function 或 Object。如果 factory 是 Function,Loader 将在合适的时候执行 factory,并且把返回值作为模块对象。

    define(function () {
    return { color: 'red' }; });

    factory 是 Function 的方式非 AMD 独创和独有,主要好处我觉得有:

    1. 获得一个模块独立的环境,模块需要用到但是不想向外暴露的东西可以封到里面。
    2. 模块初始化过程可以很方便的定义。
    3. 模块初始化的时机可以被控制。

    作为 CommonJS 的 Transport,factory 中也可以通过 exports 和 module.exports 暴露对象,前提是 dependencies 显式或隐式(使用默认值)包含 exports 和 module,以及 factory 形参也需要声明它们。

    define(function (require, exports, module) {
        module.exports = {
            color: 'red'
        };
    });
    
    // or
    
    define(function (require, exports) {
        exports.color = 'red';
    });

    后面我们会对模块初始化时机进行分析。

    模块的依赖声明

    因为 JavaScript 的动态性,我们以前一直有这样的困扰:没有办法通过工具分析系统的模块组成与依赖关系。

    // 这种依赖基本是不好分析的。你想case by case,还是构建运行时?
    var oper = lib.hasClass(element, 'xxx') ? 'removeClass' : 'addClass';
    lib[oper](element, 'xxx');

    在 AMD 中,模块是必须声明自己的依赖的,否则 Loader 没有办法把依赖的模块加载回来。这就给了我们通过工具分析模块的可能,我们就能在此之上做更多的工作:分析系统的设计是否合理、自动生成线上构建优化方案等。AMD 提供了两种依赖声明的形式:

    方式一: 通过 define 的 dependencies 参数声明依赖

    define 的 dependencies 参数,默认值是 ['require', 'exports', 'module']。一看就知道这是为了作为 CommonJS Transport 而存在的设计。虽然自立门户了,但是做人不能忘本。

    require / exports / module 这三个模块名称是被保留的,也就是说,你自己的模块 ID 不能用这三个名字。具体这三个东西代表什么,可以参考 CommonJS Modules/1.1.1 Module Context

    dependencies 声明的依赖模块,会在 factory 调用时作为参数传递,顺序一致。第一反应这是合理的,我依赖的东西 ready 了我才有办法 ready 咯。但这里没这么简单,后面在 模块初始化时机 里再讲。

    define(
        ['conf', 'ui']
        function (conf, ui) {
            function init() {
                ui.conf(conf);
                ui.init();
            }
    
            return init;
        }
    );

    方式二: 在 factory 中通过 require 声明依赖

    这种声明依赖的方式更直观更符合编程的习惯,我写着写着,想依赖啥就在当前位置 require 一个,然后马上使用。

    由于 dependencies 参数默认值是 ['require', 'exports', 'module'],所以 dependencies 不需要声明了。factory 的形参为啥只写 require 呢? exports 和 module 用不到还写个毛啊。

    define(
        function (require) {
            function init() {
                var ui = require('ui');
                ui.conf(require('conf'));
                ui.init();
            }
    
            return init;
        }
    );

    require(string) 形式的 require 是 sync require,并且调用参数必须是字符串字面量。require spec页面有详细的说明,Loader 可以通过正则分析 factory function 的 toString 结果,抽取出依赖的模块,并加载和初始化它们。通过正则分析的原因是,对于一个浏览器端运行的 Loader,内置 AST 分析的功能,其大小和分析效率一定是不可接受的。

    两种依赖声明方式的一些分析

    可以看到,方式一更像是在头部声明依赖,代码中使用;方式二是使用时声明。采用方式二写代码基本上和写 Node.JS 差不多,确实写起来更爽,特别是模块文件超过一屏的时候。想想当你要增加一个依赖的时候,你需要回到头部在 dependencies 中添加一个依赖,然后在 factory 里添加一个参数,会不会觉得很累?

    但是方式二性能比较差。正则分析总需要消耗时间,特别是大规模应用,源代码体积非常大的时候。AMD 设计的时候早就考虑到这个问题了。在 Simplified CommonJS wrapping 章节,做了如下说明:

    If the dependencies argument is present, the module loader SHOULD NOT scan for dependencies within the factory function.

    性能问题解决了。看到这里,应该很容易知道 AMD 的一些开发实践应该怎么做了:开发时按方式二写,上线前通过工具打包成方式一。再展开下一章就没法写了,所以具体的玩法还是在 应用实践 章节再展开吧。

    模块初始化时机

    大多数情况下,使用 AMD 时会选择 Function 作为 factory,模块以执行 factory 进行初始化。下面我们通过两个问题,分析模块初始化时机。

    依赖的种类

    第一个问题来了,挖掘机...哦不对,问题是:是不是模块需要在依赖都初始化完后再进行初始化呢?

    define('a', function (require) {
        var b = require('b');
        b.init();
    
        return {
            foo: function () {
            }
        };
    });
    
    
    define('b', function (require) {
        var a = require('a');
    
        return {
            init: function () {},
            foo: function () {
                a.foo();
            }
        };
    });

    上面的例子可以很容易看出来,是跑不过去的,就算跑过了结果也是不符合预期的。因为 a 在模块初始化时需要用到 b。b 在模块初始化时也需要依赖 a,虽然不是马上用到,但是此时如果 a 没有存在,后面的 foo 方法在被调用的时候就不符合预期。

    这是一种循环依赖的场景。不是所有的循环依赖都是不合理的,这里也不打算对什么场景的循环依赖合理什么场景不合理做更多探讨。

    上面的例子中万幸的是,b 其实在模块初始化时并不需要 a 已经初始化完成。所以我们可以改造一下代码:

    define('a', function (require) {
        var b = require('b');
        b.init();
    
        return {
            foo: function () {
            }
        };
    });
    
    
    define('b', function (require) {
        return {
            init: function () {},
            foo: function () {
                require('a').foo();
            }
        };
    });

    这样两个模块理论上完全可以正常初始化和正常工作了。借用金大为同学07年的时候和我解释 JSI 时讲到的依赖划分定义,这里也做一些划分,以便于后面能更简洁地进行描述。

    • 装载时依赖 - 模块在初始化过程就需要用到的依赖模块,我们认为这种依赖是装载时依赖。a 对 b 的依赖就是装载时依赖
    • 运行时依赖- 模块在初始化过程不需要用到,但是在后续的运行过程中需要用到的依赖模块,我们认为这种依赖是运行时依赖。b 对 a 的依赖就是运行时依赖

    回到问题1: 是不是模块需要在依赖都初始化完后再进行初始化呢?答案显然是否定的。更进一步的思考,我们可以得出一个似乎正确的描述:模块需要在其装载时依赖都初始化完后再进行初始化。

    题外话,对于循环依赖,只要依赖环中任何一条边是运行时依赖,这个环理论上就是活的。如果全部边都是装载时依赖,这个环就是死的。RequireJS 的网站上也有说到解决循环依赖的方法

    之前有说到,依赖声明有两种方式。对于 dependencies 参数中声明的依赖,怎么算呢?下面是之前的例子另外一种可能的形式,纯为了说明问题构建:

    define('a', ['require', 'b'], function (require, b) {
        b.init();
    
        return {
            foo: function () {
            }
        };
    });
    
    
    define('b', ['require', 'a'], function (require) {
        return {
            init: function () {},
            foo: function () {
                require('a').foo();
            }
        };
    });

    AMD 的设计是需要考虑 Loader 实现的可行性的。虽然 a 出现在了 b 的 dependencies 声明里,但是 a 是 b 的运行时依赖。这个怎么判断呢?只能通过 factory 的 length 和 dependencies 来判断。如果 dependencies 里声明,并且 factory 的形参里包含了,则一定是装载时依赖。如果 factory 的形参里未包含,则说明这个依赖有可能是运行时依赖

    我之前是这么觉得的,再后来,我发现 AMD 的 spec 里加了这么一段,验证了我的想法。

    The dependencies argument is optional. If omitted, it should default to ["require", "exports", "module"]. However, if the factory function's arity (length property) is less than 3, then the loader may choose to only call the factory with the number of arguments corresponding to the function's arity or length.

    上面例子中 b 模块的形式,我觉得重点在于:

    • 如果 dependencies 是开发时手工维护的,那添加 dependencies 时,一定会为 factory 加上形参。
    • 如果开发时在 factory 中使用 require,dependencies 是通过构建工具生成的,那 factory 形参和内部代码不会也不应该被改变。在 factory 内部依然存在对该依赖模块的 require 代码。那么即使这个依赖模块是装载时依赖,在这个 require 中对这个依赖模块进行初始化并返回,也来得及。这就解决了 dependencies 包含但形参未包含的装载时依赖的初始化问题。

    上面AMD 的 spec 里的那个 3,我个人觉得可能是因为对两种不同开发模式场景的简单划分条件,虽然不够严谨,但简单易用,能覆盖所有非脑残的应用场景。更严谨的条件应该是 “dependencies 只由 require / exports / module 中的一个或多个组成”。

    何时初始化

    挖掘机又来了:模块是不是能初始化的时候就马上进行初始化最好?

    define('main', function (require) {
        require('uiPlugin');
        var view1 = require('view1');
        var view2 = require('view2');
    
        return {
            refresh: function () {
                view1.refresh();
                view2.refresh();
            }
        };
    });
    
    define('ui', function () {
        function UI(options) {
        }
    
        UI.plugin = function () {};
        return UI;
    });
    
    define('uiPlugin', function (require) {
        var UI = require('ui');
        UI.plugin({});
    });
    
    define('view1', function (require) {
        var UI = require('ui');
        var myUI = new UI();
        return {
            refresh: function () {}
        }
    });
    
    define('view2', function (require) {
        var UI = require('ui');
        var myUI = new UI();
        return {
            refresh: function () {}
        }
    });

    这个例子稍微有些复杂。首先,因为请求返回的顺序是不确定的,所以对于 main 模块来说,view1 和 view2 可能在 uiPlugin 之前返回。假设此时 ui 已经返回,那 view1 和 view2 完全具备了初始化的条件。如果这时候马上对它们进行初始化,那可能获得不期望的视图呈现,因为 uiPlugin 还没准备好,ui 还没有完成插件扩展。

    回到问题2: 模块是不是能初始化的时候就马上进行初始化最好?答案也是否定的。上面的例子,你可以说 view1 和 view2 都写上对 uiPlugin 的依赖就解决问题了,而且也应该写。但是我们想要说明问题的重点在于:由于请求返回顺序的不确定性,能初始化时马上进行初始化的方式,没法保证模块初始化的顺序和代码里依赖声明顺序是一致的。

    延伸一下,CMD 声称自己是 用时定义 的,这点比 AMD 更优秀。曾经有不少人把我当成元芳,问我怎么看这事。下面随便扯扯。

    在 AMD spec 里,对指定 dependencies 的场景,有相应描述

    The dependencies must be resolved prior to the execution of the module factory function, and the resolved values should be passed as arguments to the factory function with argument positions corresponding to indexes in the dependencies array.

    The dependencies argument is optional. If omitted, it should default to ["require", "exports", "module"]. However, if the factory function's arity (length property) is less than 3, then the loader may choose to only call the factory with the number of arguments corresponding to the function's arity or length.

    这段之前有引用过一部分并进行说明了,这里再啰嗦下凑字数。我是这么理解的,对于 factory 中的形式参数,Loader 应该对 dependencies 里的声明模块,先执行 factory 初始化好,然后按顺序传递给 factory。但是,当 factory 的形式参数数目少于3时,Loader 可以根据参数数量的前几个 dependencies 模块,去 call factory。也就是说,dependencies 数组里,后面一些模块的初始化时机,是可以自由把握的;在call factory的时候,dependencies 数组中位于形式参数 length 后面 index 的模块,不一定要初始化完毕。

    然后是 AMD 对 define 中的同步 require 的描述

    Dependencies can be found in an AMD module when this form of define() is used:

        define(function (require) {
            var a = require('a');
        });

    The define factory function can be parsed for require('') calls (for instance by using a language parser or using Function.prototype.toString() and regexps) to find dependencies, load and execute the dependencies, then run the code above. In that manner, the require('a') call can return the module value.

    这里只说了 Loader 可以去 parse 出 require 的模块,去加载它,然后执行依赖模块,然后run the code above。这样 require('a') 就能返回相应模块。我理解这里的意思是,在 require('a') 执行前,需要完成 a 模块的载入和初始化执行,但并没有说必须在 factory 执行前就要完成 a 模块的载入和初始化执行。否则这里的描述就应该是 then call the factory,而不是 then run the code above

    而且,就算不看 AMD spec,只看 Loader,它也是有循环依赖的处理机制的。循环依赖出现的情况,是没法保证 factory 运行的时候,dependencies 全部加载完毕的。

    综上,AMD 对于:

    1. factory 内部 require 的依赖模块
    2. 在 dependencies 中声明但是在 factory 形参列表之外的依赖模块

    这两种形式声明的依赖,并 没有明确规定执行 factory 初始化的时机。只不过 AMD 玩家通常用 RequireJS,它对于不形成环的依赖模块,都会在自己初始化前先初始化依赖模块,大家就以为 AMD 是这样。最新的 RequireJS 是怎样的策略,我也没测了。

    CMD 的 用时定义,确实能让依赖模块初始化的顺序和代码里依赖声明顺序一致。这点的明确是一个进步。近一年来 AMD spec 做了很多补充说明和规定完善,希望什么时候能完善这个部分。

    Require

    通常一个应用会有入口模块或系统初始化模块,页面脚本在合适的时机(DOMReady是常用的时机)需要使用这个模块进行应用的初始化。Require 作为使用模块的唯一函数,是使用了 AMD 应用的脚本入口。

    require(['main'], function (main) {
        main.init();
    });

    require 的形式

    require有两种形式,或者说有两种调用方式:

    1. 异步 require - require({Array}ids, {Function}callback)
    2. 同步 require - require({string}id)

    异步 require 中, ids 与 callback 的关系类似于 define 中 dependencies 与 factory 的关系。Loader 会负责加载 ids 中的模块,初始化完成,然后调用 callback。调用时传入的参数根据 ids 中声明的模块顺序。

    同步 require 用于返回一个现有的模块,如果模块不存在,不允许去请求模块,必须抛出一个错误。

    require 的类型

    require有两种类型:

    1. 全局 require
    2. 局部 require

    这个应该不难理解。你在页面中直接使用的 require 是一个全局函数。这就是全局 require。一些 Loader 的全局 require不叫做 require,比如 curl。

    在模块的 define 中,通常用到的是局部 require,除非你忘记在 factory 的形参中写 require。

    全局 require局部 require的区别在于,局部 require拥有当前所属模块的一些信息,运行的行为受到当前所属模块的影响。所以局部 require可以接受 Relative ID。

    define('foo/a', function (require) {
        var b = require('./b');
        b.init();
    
        return {
            foo: function () {
            }
        };
    });
    
    
    define('foo/b', function (require) {
        return {
            init: function () {},
            foo: function () {
                require('./a').foo();
            }
        };
    });

    模块查找

    require(['main'], function (main) {
        main.init();
    });

    用之前这个简单的例子,页面上有这么一段脚本,Loader 需要去请求并初始化 main 模块,在 main 模块初始化完成后,调用 callback 函数。要请求 main 模块,Loader 需要知道它的 URL。

    Loader 会提供一个配置方法,通常是 require.config。开发者需要通过这个方法对应用进行配置,Loader 根据这些配置去计算模块的 URL。

    Loader 会用到进行模块查找的配置项有:

    require.config({
        baseUrl: 'src'
    });

    通常情况下,模块会根据 ID 到 baseUrl 下寻找。如果模块不在正常的位置,开发者需要配置 paths。如果是 Package,需要配置 packages。后面会讲到 Package。

    继续上面的例子。假设 main 模块中声明依赖 ./conf,Loader 需要加载其依赖并完成初始化。但是 ./conf 是一个 Relative ID,Loader 需要将其转换成 Top-Level ID: conf。这个过程我们叫做 normalize。在 normalize 后,Loader 会用 Top-Level ID: conf,根据配置,计算出其地址,然后发起请求。

    在 AMD 中,由于 ID 是 path style,所以很多人在使用中会有些混淆。这里想要强调几点:

    1. ID 和 URL 其实还是分开的概念。
    2. 模块 URL 的计算一定是基于 Top-Level ID 的,一定在 normalize 后。
    3. 由于 paths 和 packages 配置项的存在,模块不一定在 baseUrl 下,模块和目录结果不一定是完全对应的。
    4. 默认情况下 paths 是相对 baseUrl 的,配置了 paths 时不同 ID 的模块可能对应到同一个 define 文件。

    前面几点容易理解,不太容易理解的是第 4 点。我们举个例子,看看下面的配置:

    require.config({
        baseUrl: 'src',
        paths: {
            'bizUI': 'common/ui'
        }
    })

    这时候,你 require bizUI/TreeView 和 common/ui/TreeView,都会对应到 src/common/ui/TreeView.js 文件,这个文件不仅仅代表了一个模块。当然,一般开发者在应用中只会使用 bizUI/TreeView 去 require 模块。但是,你应该意识到这点,这里可能会是一个坑。比如团队来了新人,ta并不知道你这么玩了,也没仔细看 require.config,完全可能直接 require common/ui/TreeView。

    Package

    熟悉 CommonJS 的人都应该熟知 Package 的概念。我理解,Package 是独立的同类功能的代码和资源集合,是一种包装方式。在 AMD 中,Package 的概念和 CommonJS 相似,但并没有对 Package 的结构和组织方式做任何的规定约束。

    AMD 通过 packages 配置项,实现了对 Package 的支持。因为一个应用或一个页面可能会引入多个包,packages 配置项是一个 Array,其中每一项是单一的 Package 配置。Package 配置的关键点有:

    1. Package 是需要被独立组织的,甚至是独立开发,模块查找规则需要一些配置支持。相应配置项名称为 location。
    2. Package 在使用上绝大多数时候需要一个统一的出口(用于内部组织、整体配置、统一暴露等)。相应的配置项名称为 main。
    3. Package 的名称当然是少不了。相应配置项名称为 name。

    模块的灵活性

    在 AMD 中,模块的灵活性主要体现在:

    1. define 的时候,ID 是可以省略的。
    2. 模块声明和引用依赖,可以通过 Relative ID。

    这是一种和传统 namespace 模式完全不同的理念,让模块组织的模式从思路上发生了根本变化。想象一些场景:

    1. 在项目开发的过程中,由于重构式的结构微调,模块文件可能要移动位置。
    2. 某个目录下的所有模块,由于可能被跨项目复用,需要抽取成 Package。
    3. 启动一个和原来项目在整体结构上比较类似的新项目,有的文件要复制过去(别说复制就一定是错误行为,总有些项目配置、项目启动模块之类的东西是无需封装,复制更方便的)。
    4. 你发现 namespace 的某一级命名不够贴切,但是一想起每个文件里的 namespace 声明都要改,是不是很犹豫?

    在传统模式下,无论如何你都要挨个去改 namespace 名称的。在 AMD 模式下,你可以完全平滑的迁移任何模块。如果同目录下(含子目录)的模块之间依赖全部使用 Relative ID 来 require,所有模块的依赖声明你都不需要改。

    AMD 的模块能做到如此灵活的秘诀在于,在开发时,模块的 ID 是由应用的页面入口决定的,不是由模块的开发者决定的。页面入口通过 Loader Config 决定了哪些东西叫什么,从哪里找。在打包构建与合并时,ID 还是需要被固化的,否则合并的一个文件中包含多个模块定义,就没法知道谁是谁了。当然,想要拥有这些灵活的特性,模块开发者在编写模块时不能显示声明模块 ID,并且对非 Package 的依赖引用应该使用 Relative ID。

    打包合并的支持

    AMD 在开发时,一个模块一个文件。但是对于线上的服务来说,n个模块就有n个请求,从性能角度来说这是不可接受的。AMD 号称特别适合浏览器环境,设计的时候不可能不考虑到这点。其支持方式是这样的:

    require(['main'], function (main) {
        main.init();
    });

    还是用之前的老例子,页面上有这么一段脚本。Loader 会发起 main 模块的请求,然后分析并发起对其依赖模块的请求。但是如果 main 模块请求返回的内容里,包含了 main 模块以及其所有依赖模块的 define,那 Loader 就没必要发起依赖模块的请求了。

    所以,开发时正常分文件定义模块,上线前通过工具构建打包,页面上启动应用的代码不需要更改。这种方式就能满足大部分的应用。下面是简单的开发时和打包后代码示例(不同工具打包的代码可能会有细微差别,下面代码仅为说明)。

    // 开发时 main 模块,src/main.js
    define(function (require) {
        var conf = require('./conf');
        return {
            init: function () {}
        };
    });
    
    // 开发时 conf 模块,src/conf.js
    define(function () {
        return {
            pageSize: 30
        };
    });
    
    // 打包后的 src/main.js
    define('main', ['require', './conf'], function (require) {
        var conf = require('./conf');
        return {
            init: function () {}
        };
    });
    define('conf', [], function () {
        return {
            pageSize: 30
        };
    });

    可以看到,打包后的代码有如下几个要点:

    1. 模块 ID 被固化了。不固化 Loader 认不出你是谁啊。
    2. dependencies 被加上了。为了性能的考虑,Loader 不用再分析 factory body。
    3. factory body 没有任何变化。
    4. 打包产物可以被正常语法压缩,包括 factory 的特殊参数 require / exports / module。为什么呢,感兴趣的人可以自己思考下。

    之前在 spec 上并没有这方面的描述,后来忘记哪一次再看的时候,发现多了好多应用场景的描述,其中 Transporting more than one module at a time 就有这种场景的描述:

    Multiple define calls can be made within a single script. The order of the define calls SHOULD NOT be significant. Earlier module definitions may specify dependencies that are defined later in the same script. It is the responsibility of the module loader to defer loading unresolved dependencies until the entire script is loaded to prevent unnecessary requests.

    虽然很多人英文都很好,但我还是想啰嗦下里面的重点,大家感受一下:

    1. 多个 define 可以放在一个 script 中。
    2. 这些 define 的顺序应该没有任何影响。
    3. 如果定义在前面的模块依赖了后面的模块,Loader 不能没分析到后面的模块,就脑残地发起请求。

    插件

    AMD 设计了插件机制,主要用于资源的加载,并且用了一个专门的 spec 描述这个插件机制:Loader Plugin

    一个 Loader Plugin Resource 的形式是, ! 分割两个部分,前面部分是插件模块的 ID,后面部分是资源 ID:

    [Plugin Module ID]![resource ID]
    

    AMD 插件机制的巧妙之处在于:

    1. 资源通过一个正常的 AMD 模块加载,编写插件模块就是编写一个 AMD 模块。
    2. resource ID 是会被 normalize 的,模块就可以通过 Relative resource ID 来 require 自己所需要的资源。

    resource 加载

    对于Web应用来说,第一反应能想到的资源主要有:

    • CSS
    • 图片
    • 文本
    • 页面模板(一种特殊的文本)
    • 多媒体内容
    • 任何需要被包装,以便于反复使用的东西。页面模板其实属于这种的特例

    大多数资源的加载需要通过网络,所以可能是异步的。 AMD 要求 Plugin 模块必须包含一个 load 方法。我们通过这个方法的签名,看看资源加载对异步的支持:

    load: function (resourceId, require, load, config)
    

    Loader 通过调用 Plugin 模块的load方法,发起对资源的加载。调用的时候会传给你几个东西:

    1. resourceId: 这是 normalize 后的 ID。所以实现的时候,load 方法就只管根据 resource ID 去加载资源就好了。
    2. require: 一个 local require,当你要计算加载资源地址的时候,可以调用 require.toUrl 方法。require.config 后,配置信息没有通过 Loader 的任何方法暴露给插件,通过配置查找模块 URL 的过程被封装成了 require.toUrl 方法对外暴露。
    3. load: 最简单的异步处理方法就是 callback,load 就是这个 callback 函数。在异步资源返回后,用资源的值去调用 load 函数,这个值就会被 Loader 缓存下来,同样的 resource 下次使用会直接返回这个值,不会再走 Plugin 模块的 load。
    4. config: 对当前资源的配置。用户可以在 require.config 的 config 项中配置模块的独有信息。

    通过一个简单的加载 CSS 的 Plugin 模块,可以更容易明白 load 方法的作用。

    define('css', {
        load: function (resourceId, req, load) {
            var link = document.createElement('link');
            link.setAttribute('rel', 'stylesheet');
            link.setAttribute('type', 'text/css');
            link.setAttribute('href', req.toUrl(resourceId));
    
            var parent = document.getElementsByTagName('head')[0]
                || document.body;
            parent.appendChild(link);
    
            parent = null;
            link = null;
    
            load(true);
        }
    });

    题外话,AMD 的设计中并没有提供模块或资源卸载的 API,所以在应用程序设计之初就要考虑并规避可能产生的问题。比如加载的 CSS 资源是没有办法通过 AMD 的途径卸载的,所以需要避免不同的 CSS 之间通过前后关系进行优先级管理。

    resource ID normalize

    如果 resource ID 是 path style 的,resource ID 会自动按照默认方式进行 normalize,Plugin 模块的开发者不需要做任何事情。

    你可以编写 Plugin 模块的 normailze 方法,在如下场景:

    1. resource ID 不是 path style
    2. 希望 resource 被缓存的粒度不是 path。但是 Loader 缓存 resource 是根据 normalize 后的 ID 决定的,normalize 过程在 load 之前。所以你不能根据资源内容自定义缓存粒度。

    本篇结束

    想着尽量分析详细些,没想写了这么多,有点写不下去了。自己看了一遍,基本上想说的关键点都已经说到了。还有一些不是很核心,但也还算有用的东西,就不细说了,感兴趣的自己看吧。主要有:

    • map
    • shim
    • module config
    • Loader Plugin 的 fromText

    了解 AMD 的一些设计点,有助于在应用开发中更合理地设计结构与模块。说白了,用 AMD 的主要目的还是为了让应用开发更方便。

    守望者AIR技术交流社区(www.airmyth.com)
    回复

    使用道具 举报

  • TA的每日心情
    擦汗
    2018-4-10 15:18
  • 签到天数: 447 天

    [LV.9]以坛为家II

    1742

    主题

    2094

    帖子

    13万

    积分

    超级版主

    Rank: 18Rank: 18Rank: 18Rank: 18Rank: 18

    威望
    562
    贡献
    29
    金币
    51661
    钢镚
    1422

    开源英雄守望者

     楼主| 发表于 2016-8-17 17:33:20 | 显示全部楼层

    玩转AMD(2) - 应用实践


    在 设计思路 篇中,已经对 AMD 在设计上的一些考虑做了比较详细的论述。所以这一篇只会提一些建议,引用一些 设计思路 篇中的结论,不会再详细描述为什么。

    本篇提出的所有建议,都是针对于开发时就使用 AMD 的玩法。据我所知,有一些团队在开发时按照 CommonJS 的方式编写模块,通过开发时工具监听文件变化实时编译,上线前通过工具构建,AMD 纯粹被当作模块包装来用。本篇提出的建议不涵盖这种应用场景。

    部分建议有一定的重叠,或者理由是相同的。举一反三能力较强的阅读者可能会觉得我很罗嗦,见谅。

    开发时

    模块声明不要写 ID

    将模块 ID 交给应用页面决定,便于重构和模块迁移。模块开发者应该适应这点,从模块定义时就决定模块名称的思路中解放出来。这是使用 AMD 的开发者能获得的最大便利。

    // good
    define(
        function (require) {
            var sidebar = require('./common/sidebar');
            function sidebarHideListener(e) {}
    
            return {
                init: function () {
                    sidebar.on('hide', sidebarHideListener)
                    sidebar.init();
                }
            };
        }
    );
    
    // bad
    define(
        'main',
        function (require) {
            var sidebar = require('./common/sidebar');
            function sidebarHideListener(e) {}
    
            return {
                init: function () {
                    sidebar.on('hide', sidebarHideListener)
                    sidebar.init();
                }
            };
        }
    );

    模块划分应尽可能细粒度

    细粒度划分模块,有助于更精细地进行模块变更、依赖、按需加载和引用等方面的管理,有利于让系统结构更清晰,让设计上的问题提早暴露,也能从一定程度上避免一些看起来也合理的循环依赖。

    举个例子:在 namespace 模式下我们可能将一些 util function 通过 method 方式暴露,在 AMD 模块划分时,应该拆分成多个模块。

    // good: 分成多个模块
    define(
        function () {
            function comma() {}
            return comma;
        }
    );
    define(
        function () {
            function pad() {}
            return pad;
        }
    );
    
    // bad
    define(
        function () {
            return {
                comma: function () {},
                pad: function () {}
            };
        }
    );

    在 factory 中使用 require 引用依赖模块,不要写 dependencies 参数

    需要啥就在当前位置 require 一个,然后马上使用是最方便的。当模块文件比较大的时候,我想没有谁会喜欢回到头部在 dependencies 中添加一个依赖,然后在 factory 里添加一个参数。

    另外,只使用 dependencies 参数声明依赖的方式,解决不了循环依赖的问题。为了项目中模块定义方式的一致性,也应该统一在 factory 中使用 require 引用依赖模块。

    // good
    define(
        function (require) {
            var sidebar = require('./common/sidebar');
            function sidebarHideListener(e) {}
    
            return {
                init: function () {
                    sidebar.on('hide', sidebarHideListener)
                    sidebar.init();
                }
            };
        }
    );
    
    // bad
    define(
        ['./common/sidebar'],
        function (sidebar) {
            function sidebarHideListener(e) {}
    
            return {
                init: function () {
                    sidebar.on('hide', sidebarHideListener)
                    sidebar.init();
                }
            };
        }
    );

    对于要使用的依赖模块,即用即 require

    遵守 即用即 require 的原则有如下原因:

    • require 与使用的距离越远,代码的阅读与维护成本越高。
    • 避免无意义的 装载时依赖。在 设计思路 篇中有提到:对于循环依赖,只要依赖环中任何一条边是运行时依赖,这个环理论上就是活的。如果全部边都是装载时依赖,这个环就是死的。遵守 即用即 require 可以有效避免出现死循环依赖。
    // good
    define(
        function (require) {
            return function (callback) {
                var requester = require('requester');
                requester.send(url, method, callback);
            };
        }
    );
    
    // bad
    define(
        function (require) {
            var requester = require('requester');
            return function (callback) {
                requester.send(url, method, callback);
            };
        }
    );

    对于 package 依赖,require 使用 Top-Level ID;对于相同功能模块群组下的依赖,require 使用 Relative ID

    这条的理由与 模块声明不要写 ID 相同,都是为了获得 AMD 提供的模块灵活性。

    // good
    define(
        function (require) {
            var _ = require('underscore');
            var conf = require('./conf');
    
            return {}
        }
    );
    
    // bad
    define(
        function (require) {
            var _ = require('underscore');
            var conf = require('conf');
    
            return {}
        }
    );

    相同功能模块群组 的界定需要开发者自己分辨,这取决于你对未来变更可能性的判断。

    下面的目录结构划分中,假设加载器的 baseUrl 指向 src 目录,你可以认为 src 下是一个 相同功能模块群组;你也可以认为 common 是一个 相同功能模块群组,biz1 是一个 相同功能模块群组。如果是后者,biz1 中模块对 common 中模块的 require,可以使用 Relative ID,也可以使用 Top-Level ID。

    但是无论如何,common 或 biz1 中模块的相互依赖,应该使用 Relative ID。

    project/
        |- src/
            |- common/
                |- conf.js
                |- sidebar.js
            |- biz1/
                |- list.js
                |- edit.js
                |- add.js
            |- main.js
        |- dep/
            |- underscore/
        |- index.html
    

    模块的资源引用,在 factory 头部声明

    有时候,一些模块需要依赖一些资源,常见一个业务模块需要依赖相应的模板和 CSS 资源。这些资源需要被加载,但是在模块内部代码中并不一定会使用它们。把这类资源的声明写在模块定义的开始部分,会更清晰。

    另外,为了便于重构和模块迁移,对于资源的引用,resource ID 也应该使用 Relative ID 的形式。

    define(
        function (require) {
            require('css!./list.css');
            require('tpl!./list.tpl.html');
    
            var Action = require('er/Action');
            var listAction = new Action({});
    
            return listAction;
        }
    );

    不要使用 paths

    在 设计思路 篇中有说到,默认情况下 paths 是相对 baseUrl 的,配置了 paths 时不同 ID 的模块可能对应到同一个 define 文件。在一个系统里,同一个文件对应到多个模块,这种二义很容易导致难以理解的,并且会留下坑。

    // bad
    require.config({
        baseUrl: 'src',
        paths: {
            'conf': 'common/conf'
        }
    });

    那 paths 在什么地方用到呢?在 打包构建 章节会有一些说明。

    使用第三方库,通过 package 引入

    通常,在项目里会用到一些第三方库,除非你所有东西都自己实现。就算所有东西都自己实现,基础的业务无关部分,也应该作为独立的 package。

    一个建议是,在项目开始就应该规划良好的项目目录结构,在这个时候确定 package 的存放位置。一个项目的源代码应该放在一个独立目录下(比如叫做 src),这里面的所有文件都是和项目业务相关的代码。存放第三方库 package 的目录应该和项目源代码目录分开。

    project/
        |- src/
            |- common/
                |- conf.js
                |- sidebar.js
            |- biz1/
                |- list.js
                |- edit.js
                |- add.js
            |- main.js
        |- dep/
            |- underscore/
        |- index.html
    

    如果有可能,定义一种 package 目录组织的规范,自己开发的 package 都按照这个方式组织,用到的第三方库也按照这种方式做一个包装,便于通过工具进行 package 的管理(导入、删除、package间依赖管理等)。

    说明: 源代码不按照 CommonJS 建议放在 lib 目录的原因是,node package 是放在 lib 目录的,frontend package 应该有所区分。
    
    package/
        |- src/
        |- doc/
        |- test/
        |- package.json
    

    广告时间来了:

    EFE 技术团队在决定使用 AMD 后,就马上规范了 项目目录结构 和 package结构。这是我们认为比较合理的方式。我们使用了很多业内的标准和工具(CommonJS Package / Semver 等),在此之上做一些前端应用的细化,具有通用性,并不专门为我们的项目特点定制,执行的过程中也一直比较顺利。我们后来基于此也搭建了内部的 npm 作为 package 发布平台,开发的 EDP 也包含了项目中使用和管理 package 功能。希望能给开发者,特别是所在团队还没有做相应工作的开发者,一些参考和启发。

    业务重复的功能集合,趁早抽取 package

    这和尽早重构是一个道理。那么,什么样的东西需要被抽取成 package 呢?

    • 如果项目业务无关的基础库或框架是自己开发的,那一开始就应该作为 package 存在。
    • 业务公共代码一般是不需要抽取成 package 的。
    • 一些业务公共模块集,如果预期会被其他项目用到,就应该抽取成 package。举个例子,正在开发的项目是面向 PC 的,项目中有个数据访问层,如果之后还要做 Mobile 的版本,这个数据访问层就应该抽象成 package。

    package 内部模块之间的项目依赖,require 使用 Relative ID

    package 内部模块之间的依赖通过 Relative ID require,能够保证 package 内部封装的整体性。在 AMD 环境下,package 使用者可能会需要多版本并存,或者在项目中根据自己的喜好对引入的 package 命名(比如 xxui,使用者可能会期望在项目里使用时,package 名称就叫做 ui)。

    // good
    define(
        function (require) {
            var util = require('./util');
            var Control = require('./Control');
    
            function Button(options) {}
            util.inherits(Button, Control);
    
            return Button;
        }
    );
    
    // bad
    define(
        function (require) {
            var util = require('esui/util');
            var Control = require('esui/Control');
    
            function Button(options) {}
            util.inherits(Button, Control);
    
            return Button;
        }
    );

    package 内部模块对主模块的依赖,不使用 require('.')

    package 开发者会指定一个主模块,通常主模块就叫做 main。package 内其他模块对它的依赖可以使用 require('.') 和 require('./main') 两种方式。

    但是,我们无法排除 package 的使用者在配置 package 的时候,认为把另外一个模块作为主模块更方便,从而进行了非主流的配置。

    // 非主流 package 配置
    require.config({
        baseUrl: 'src',
        packages: [
            {
                name: 'esui',
                location: '../dep/esui',
                main: 'notmain'
            }
        ]
    });

    使用 require('./main') 就能规避这个问题。所以,不要使用 require('.')。

    可以对环境和模块进行区分,不需要太强迫症

    有的第三方库,本身更适合作为环境引入,基本上项目所有模块开发时候都会以这些库的存在为前提。这样的东西就更适合作为环境引入,不一定 非要把它当作模块,在每个模块中 require 它。

    典型的例子有 es5-shim / jquery 等。

    直接作为环境引入的方法是,在页面中,在引入 Loader 的 script 前引入。

    <script src="es5-shim.js"></script>
    <script src="amd-loader.js"></script>

    打包构建

    构建工具

    r.js 是 RequireJS 附带的 optimize 工具,比较成熟,打包构建 AMD 模块的构建产物优秀。

    Grunt 和 Gulp 下的一些 AMD 构建插件,有的用了 r.js,有的是自己写的,构建产物的质量参差不齐,选用之前可以看看。我觉得以下几点可以判断构建产物是否优秀:

    1. ID 被固化
    2. factory 中 require 的依赖被提取填充到 dependencies
    3. Relative ID 的 require,不需要在构建阶段 normalize
    4. factory 没有进行任何修改,包括参数和函数体
    5. 对 package 的主模块进行了处理

    我们团队开发的 EDP 中,AMD 模块构建就是自己写的。如果想自己实现 AMD 模块的构建,上面的几点和 EDP 都有一定的参考价值。

    但是,在我所知道的 AMD 构建工具中,都需要通过配置,手工指定哪些模块需要合并,合并的时候 exclude 哪些模块,include 哪些模块。还没有一个工具能够很好的分析系统,自动进行比较优化的构建。我们在这方面有一些积累,但是实践的效果尚不明确,所以就不说了。

    即使在构建阶段,把所有的模块定义都合并到主模块的文件中,构建方案还是需要将散模块单独构建生成单独的文件。在多页面对模块交叉引用,或按需加载时,会比较有帮助。

    CDN

    因为性能的考虑,线上环境静态资源通过 CDN 分发是一种常用做法。此时,静态资源和页面处于不同的域名下,线上环境的 Loader 配置需要通过 paths,让 Loader 能够正确加载静态资源。

    require.config({
        baseUrl: 'src',
        paths: {
            'biz1': 'http://static-domain/project/biz1',
            'biz2': 'http://static-domain/project/biz2'
        }
    });

    如果所有的模块都整体通过 CDN 分发,可以直接指定 baseUrl。

    require.config({
        baseUrl: 'http://static-domain/project'
    });

    开发环境和线上环境的配置信息差异,根据 DRY 原则,这个工作一定要用工具在构建过程自动完成。

    使用内容摘要作为文件名的玩法

    在构建过程,使用文件内容的摘要作为文件名,是一种常用的优化手段。这种方式能够在 HTTP 层面设置强 cache,让用户能够最大程度缓存,减少网络传输的流量和时间。

    但是在 AMD 中,模块 ID 与路径应该是一个对应关系。怎么破?这里提供两种玩法:

    第一种方式:将打包后的模块定义合并文件,直接在页面上通过 script 标签引入。

    <script src="amd-loader.js"></script>
    <script src="combined-md5.js"></script>
    <script>
    require(['main'], function (main) {
        main.init();
    });
    </script>

    第二种方式:通过 paths 配置映射。

    <script src="amd-loader.js"></script>
    <script>
    require.config({
        paths: {
            'main': 'main-file-md5',
            ......
        }
    });
    require(['main'], function (main) {
        main.init();
    });
    </script>

    在一个 Web 应用,特别是规模较大的 Web 应用中,为了性能最优化的考虑,可能会两种方式结合着玩:

    • 系统一开始进入就需要的模块,通过第一种方式载入;需要按需加载的模块,通过第二种方式配置
    • 模块定义合并文件可以根据变更频度打包成多个,充分利用缓存和浏览器的并行下载
    • paths 配置项是 id prefix 匹配的,工具处理时注意模块文件同名目录下文件的路径处理
    • 需要按需加载的模块数量通常不小,根据 DRY 原则,线上环境 paths 配置一定要用工具在构建过程自动完成

    本篇结束

    AMD 有很多特性,有的是为开发时设计的,有的是为线上环境设计的。理解其设计思路,选择合适的开发方式,和构建方式,整个过程才能不别扭,更顺畅。

    用一句话来总结,就是 要按常理出牌

    本来 Dissecting AMD 应该到此结束了,但是 Loader 的选择也是一件很重要的事情。保守地选择 RequireJS,在绝大多数情况下是没问题的,但是不代表它没有缺陷。而且,RequireJS 的体积确实不小。所以我们开发了一个 AMDLoader:ESL。下一篇,我打算围绕 ESL,对 Loader 的细节做一些阐述。这不单是广告,内容一定是技术有料的。

    守望者AIR技术交流社区(www.airmyth.com)
    回复 支持 反对

    使用道具 举报

  • TA的每日心情
    擦汗
    2018-4-10 15:18
  • 签到天数: 447 天

    [LV.9]以坛为家II

    1742

    主题

    2094

    帖子

    13万

    积分

    超级版主

    Rank: 18Rank: 18Rank: 18Rank: 18Rank: 18

    威望
    562
    贡献
    29
    金币
    51661
    钢镚
    1422

    开源英雄守望者

     楼主| 发表于 2016-8-17 17:34:08 | 显示全部楼层

    玩转AMD(3) - Loader 


    不用 RequireJS 的理由

    理由很简单,因为太大了。RequireJS 经过语法压缩和 GZip 后,体积超过了 6k。RequireJS 最新版本已经降到了 6.1k,在 2012 年底时候的版本是接近 7k。由于下面的一些期望,让我们觉得这个体积比较大:

    • 在移动环境上应用
    • 在 baidu 搜索页面上用

    既然这个体积比较大,那多少合适呢。当时我们拍了脑袋,一个 Loader,各种流转与依赖处理,两种 require,URL 查询,再加上异步的插件机制,就算看起来比较复杂,GZip 后 3k 应该没问题。开发时间我们规划了一个月,主要还是为编写测试用例留出一些时间。

    后来...后来,事情远远超出了想象。我们开发了 AMD Loader: ESL,包括前前后后的一些改进和 new feature,开发过程持续了一年半多。现在虽然是一个特性稳定版本,但是仍未结束,可预见的未来还有 shim 的支持需要添加。至于体积,我们也没控制住,在每个我们觉得无法或缺的 feature 中,它的体积最终是 3.4k。如果你觉得所有的错误信息你都不需要,那么可以选择 min 版本,体积是 3.1k,超过最初的梦想并不太多。可见男人的承诺多半不靠谱。

    不过,这是我觉得为数不多对得起自己 精工 信念的良心作品。

    模块初始化时机:用时定义

    ESL 从 1.8 开始,对模块初始化时机的处理策略是 用时定义。原因有两个:

    1. 能够保证 require 的执行顺序
    2. 能够较好处理循环依赖的问题

    保证 require 的执行顺序

    这点在 设计思路 中已经描述过了。这里再赘述下问题解决的过程。其实很简单,没遇到问题的时候觉得一切都好,问题暴露了,经过分析,只能通过 用时定义 解决。出现问题的场景大体是这样的:

    // 主模块定义
    define(
        function (require) {
            // 加载框架扩展,在框架基础上做业务扩展
            require('./framework-extend');
    
            // 使用一些业务模块,进行系统初始化
            var biz = require('./biz');
            biz.init();
        }
    );
    
    // framework-extend定义
    define(
        function (require) {
            // 框架通过 package 方式引入的,所以是 Top-Level ID require
            var FrameworkClass = require('framework/class');
    
            // 框架提供了扩展接口
            FrameworkClass.extend({});
    
            // 最后无需返回任何东西
        }
    );
    
    // biz定义
    define(
        function (require) {
            // 引入框架
            var FrameworkClass = require('framework/class');
    
            var biz = new FrameworkClass();
            return biz;
        }
    );

    由于 biz 模块网络返回在 framework-extend 之前,所以更早进行了初始化,导致问题出现了。

    上面这种组织方式其实是基本 OK 的。但是面对出现的问题,我们的解决办法只有两个:

    1. biz 模块不止一个,我们不得不在每个 biz 模块里都 require('./framework-extend')。临时先这样解决了问题。
    2. 模块初始化时机使用用时定义策略。这点后来 ESL 做了一次升级,完成了这个进化。

    从这里也能看出来,在 AMD 的玩法下,框架对于扩展机制的设计思路应该倾向 包装 而不是 自身扩展

    解决循环依赖

    对于一个比处女更处女的人(这里指星座),站在 Loader 实现的角度,完美兼容任何活的循环依赖是一种情怀,不值钱的情怀。

    依赖关系可以想象成数据结构中的 ,循环依赖是图中的环。依赖的类型可以认为是边的属性。async require 可以认为是遍历的入口。

    这么说比较抽象,还是举个例子好了,有index、b、c三个模块,构成循环依赖,那就有 3 条边。假设其中某两个模块的依赖(3条边中的1条)是 运行时依赖,其他依赖是 装载时依赖,就有3种可能性:

    >> 表示 装载时依赖
    > 表示 运行时依赖
    
    下面说明不同场景下 require(['index'], callback) 的初始化顺序:
    
    
          index --->>--- b --->>--- c
            \                      /
             \                    /
               ---------<--------
    
    场景1下,模块初始化顺序应该是c、b、index
    
    
          index --->---- b --->>--- c
            \                      /
             \                    /
               ---------<<--------
    
    场景2下,模块初始化顺序应该是index、c、b
    
    
          index --->>--- b --->---- c
            \                      /
             \                    /
               ---------<<--------
    
    场景3下,模块初始化顺序应该是b、index、c
    

    看起来,这应该是一个很简单的问题,就算数据结构没学好,也应该很容易解决才对。但是,有一些难题,让我们没法通过传统的方法解决:

    1. 模块是经过网络加载的,图的整体结构在一开始并不被知道,随着请求的返回才逐渐清晰。
    2. 你无法知道一个依赖是不是 装载时依赖。某个依赖可能在某个函数内部 require,但是 factory 运行时会调用到。

    问题2是很关键的问题,依赖分析都无法确定一个依赖是不是 装载时依赖,那玩个毛啊?好吧,也不是完全无解,既然分析的时候不知道,那就运行试试咯。用时定义不就解决了么。这里的逻辑是:假设所有模块已经 standby (即问题1不存在),当我要初始化一个模块,它的 装载时依赖 应该能被顺利初始化,否则就是模块编写者的问题。

    当然,还有问题1的存在,还有一些浏览器兼容性的问题,还有一个循环依赖真的是死依赖怎么办等等的问题,所以,我们通过一些手段,达到一些前置条件,使这个问题能够顺利解决:

    • 对模块的初始化分成4个阶段,分别在每个阶段进行必要的处理:
      • PRE_DEFINED: 调用过define,在内部生成了模块对象存储结构。这个用于解决不同浏览器的兼容问题。
      • ANALYZED: 完成分析,主要是抽取 factory 中的 require,根据 factory 形参确定哪些依赖一定是 装载时依赖(这个阶段没确定的不一定不是装载时依赖),看哪些依赖的模块还没有,发起请求。
      • PREPARED: 所有依赖模块都已经请求返回,并且经过分析,到达PREPARED。
      • DEFINED: 调用过 factory,完成初始化。
    • 保存 async require 的模块,这些模块需要自动初始化。这些是 Loader 在各种时间点,进行模块初始化尝试的入口。
    • 对分析阶段确定的 装载时依赖,在 factory 调用前,需要完成对它们的初始化。因为会被作为 factory 调用的参数传入。
    • 对死循环依赖,做一个打断。这个很简单,如果自己的 factory 正在执行,就不能被再次调用。否则就没完没了了。

    讲到这里,其中关键点算是完了,里面还有很多细节,这些就不展开了。总之,ESL 解决了循环依赖的问题。我所知的 AMD Loader 里,没有一家完全解决了这问题,包括 RequireJS。

    在开发过程中,除了使用 AMD官方提供的测试用例,我们还编写了很多自己的测试用例。通过test页面可以看到,带有 data-ignore="timestat" 的测试用例部分是截至 RequireJS 2.1.14 都无法支持的。下面是我们构建的一些依赖用例的场景:

          index --->>--- h1 --->>--- h2 --->--- s1 --->>--- h3 
                           \                                /
                            \                              /
                              --------------<<-------------
    
    
          index --->>--- a --->>--- b --->>--- c --->>--- d --->>--- e
                          \                     \                   /
                           \                     \                 /
                            \                     ----<<--- f --<--
                             \                             /
                              \                           /
                               ----<<---- g ----- << -----
    
    
          index --->>--- b --->>--- c --->>--- g --->>--- h -->--
            \           /          / \                          /
             \         /          /   \                        /
              \        -<<-- d --<     ----<<---- j ---<<--- i
               \              \
                \              \
                 -<<-- e --<<---
    

    错误信息

    由于错误的代码或错误的路径配置等原因,在模块加载与初始化阶段可能会发生错误,这不可避免。在ESL中,我们认为下面这两种错误是必须要报出来的,否则会给开发和线上环境的问题追查带来极大困扰。

    相关的错误处理导致 ESL 体积上有一些膨胀,但这是值得的。同时我们也提供了 min 版本,比 normal 版本减少了 0.3k 的体积,如果对自己非常有信心,或者系统已经测试完备,并且对体积有严苛的要求,可以选用 min 版本。

    模块加载失败

    require(['main'], function (main) {
        main.init();
    });

    模块加载失败是开发过程和线上环境都比较常见的错误。上面的代码,当模块加载失败时,callback 函数不会执行。如果没有相关提示信息,开发者可能会无从下手,很难追查问题。通常导致模块加载失败的原因可能有:

    • 请求不到模块或依赖模块
      • ID->URL 过程错误,从而向一个错误的地址发起请求
      • 网络问题导致请求失败
      • 由于误删除或修改,模块定义文件不存在
    • 返回的模块定义文件,内容不是期望的模块定义代码或其他问题
    • 死循环依赖导致模块无法完成初始化
    • factory 执行的过程发生错误

    ESL 对模块加载失败的报错方式参考的是 RequireJS 的做法:通过 waitSeconds 参数配置等待时间,单位是秒。当等待时间超过这个配置时,通过 throw Error的方式,报告相应错误。这个错误不能被 catch,可以在 console 面板中看到错误信息:

    [MODULE_TIMEOUT]Hang( deaddependencies/a, deaddependencies/b ) Miss( none )
    

    可以看到,错误分成两种:

    1. Hang: 模块已经正常请求返回,但是在初始化的过程被卡住了。可能的原因是 factory 执行错误或存在死循环依赖。
    2. Miss: 需要模块,但是请求不到。可能是 URL 错误,或者请求没有返回预期的模块定义代码内容。

    有了相关信息,就能够通过开发者工具的 network 面板或者追查相应模块定义文件,快速定位错误。这里还有一个技巧,对于 Hang 的错误,从最后一个开始追查,很可能是依赖链最后一个点没有正确初始化,导致整个链无法初始化。

    错误地在模块定义中使用了 global require

    define(
        function () {
            require('./conf');
        }
    );

    这是我们在应用过程中,发现的最难追查的问题,没有之一。这通常是由于疏忽导致的。看看上面的代码,看起来貌似没有问题,但是,factory 的形参少了 require。在运行的时候,整个过程是这样的:

    1. Loader 能通过分析 factory 的 body,知道其依赖,并发起请求
    2. 其依赖模块请求回来后,能正常分析,到达 PREPARED 状态
    3. 由于全局存在 global require,所以没有形参,就会使用 global require,浏览器不会报变量不存在的错误
    4. 根据 AMD 的规定,sync require 找不到相应模块时,需要抛出错误

    这下问题来了: 开发者看到了模块不存在的错误,但是怎么看都是对的,模块定义的请求被正确返回了,返回的模块定义代码也没问题,怎么就找不到呢,你TM是在逗我?

    当第二次有人让我帮忙追查这种问题的时候,我觉得这事不能有第三次了。而且就算我凭着经验能很快定位,但是不好意思找我的人,找不到我的人就会陷在这种问题里,可能一整天都无法进展下去。根据常理来说,使用 Relative ID 去调用 global require 肯定是有问题的,所以,在某一次的升级中,加入了相关的校验和错误报告:

    [REQUIRE_FATAL]Relative ID is not allowed in global require: ./assertrelativeid/local/index
    

    性能优化

    在实测中,ESL 性能比 RequireJS 高。但是我们没有分析具体高在什么地方,因为对于 Loader 来说,在多模块的测试环境下,我们难以估计和刨去网络请求的时间。而且对于 RequireJS 这么大的一个 Loader,如果我们性能低过他,那就不要做了。

    在开发过程中,我们只是尽可能的做一些我们能想到的优化。在这里,我想说的是两个印象比较深刻的点。一些常识性的点以及随手的优化,比如给 script 标签添加 async 属性,根据状态对是否进行接下来的处理进行预先判断等等,这些点就不细说了。

    对配置信息的索引

    AMD 规定的配置项还是不少的,在 ID normalize、ID to URL 阶段都可能用到这些配置项。其中,paths 和 map 配置项是 ID prefix 匹配的。当模块数目比较多的时候,频繁的 ID normalize、ID to URL 都可能带来运行的性能开销。

    ESL 在 require.config 调用的时候,在内部生成一份便于检索的索引结构,能够让 ID normalize、ID to URL 运行性能更高。在高频度的运行中,带来的性能提升就比较可观了。

    下面通过 paths 配置项,来说明这个问题。paths 的配置是一个 Object,其中 key 是 ID prefix,value 是对应的查找路径。

    {
        'com': 'com-url',
        'one': 'url/one',
        'one/two': 'url/onetwo'
    }

    在 ID to URL 阶段,Loader 需要使用 for in 遍历这个对象。这个遍历过程有一些性能点:

    1. 需要遍历每个属性,否则没法确定哪个是最匹配的。比如 one/two/three 应该匹配到 one/two 而不是 one。
    2. ID prefix 匹配的过程,不能通过 indexOf。因为 one2 不应该被 one 匹配。
    3. for in 本身就要慢一些。

    ESL 为 paths 在内部生成的索引数据类似下面这样:

    // 1. 以数组形式索引。
    // 2. 用 key 生成 RegExp 对象,用于匹配过程。
    // 3. 根据 key 进行了排序,在遍历过程,如果遇上匹配,能直接退出。
    [
        {
            k: 'one/two',
            v: 'url/onetwo',
            reg: /^one\/two(\/|$)/
        },
        {
            k: 'one',
            v: 'url/one',
            reg: /^one(\/|$)/
        },
        {
            k: 'com',
            v: 'com-url',
            reg: /^com(\/|$)/
        }
    ]

    对于其他配置项,如 maps、packages 等,如果是 Object,ESL 都会在内部生成类似格式的索引数据;对于 ID Prefix 的匹配规则,都会生成用于匹配的 RegExp。这就是我们在配置信息应用上的优化。没什么特别的,常规优化手段。

    local require 的缓存

    这是我们在应用过程中发现并优化的性能问题。我们之前考虑到问题可能出现,但没想到暴露得这么快,优化后效果也比较明显。

    我们鼓励在 AMD 应用中,对于要使用的依赖模块,即用即 require。所以,类似下面的代码是没问题的,并且我们是鼓励将 factory 中用不到的依赖,尽量降级成 运行时依赖

    define(
        function (require) {
            return function (source) {
                var result = require('./trim')(source);
                // ...
    
                return result;
            };
        }
    );

    但是,当访问的频度比较高的时候,问题就会被暴露。印象中我们当时遇到的场景是,n 大约 20000 个图形对象要进行动画,在图形对象的方法中包含了 require 代码,相当于每个 step 调用 n 次 require。这里的瓶颈在于,对 ID 进行了 n 次 normalize。这就很要命了。

    解决办法也很简单,就是常规解决方案,在 require 内加一层 cache。效果很明显,腰不疼腿不酸了。

    if (typeof requireId === 'string') {
        if (!requiredCache[requireId]) {
            requiredCache[requireId] =
                nativeRequire(normalize(requireId, baseId));
        }
    
        return requiredCache[requireId];
    }

    在这里得到了一些感悟,所有可预见的性能问题都不要忽略,把事情做在前面。根据墨菲定律,可能发生的一定会发生。

    网络请求上的考虑

    urlArgs

    不同的 WebServer 可能给资源添加不同的缓存策略,所以大多应用在升级的时候都会碰到用户在缓存策略内的访问不是最新的,但是我们期望在升级时用户应该马上体验到最新版本。

    浏览器对资源的缓存是以 URL 为单元的。刷新缓存的一个常用方法时,升级时让用户访问不同的资源 URL。通过 使用内容摘要作为文件名的玩法 其实就是这样的道理,但是这种玩法是有一定的成本的,并且不是所有的应用都有必要使用这种方法。我们需要一种方法,让用户不更改 baseUrl / paths / packages 配置,又让所有模块的访问地址与之前不同。最简单的方式是引入一个新的配置项。

    我们注意到,RequireJS 支持一个 urlArgs 配置项。这个配置是一个 string,所有的模块请求都会在 URL 后面附加这样的一个参数串,令 URL 可以和原先不同,达到刷新缓存的目的。

    require.config({
        baseUrl: 'src',
        urlArgs: 'v=1.0.0'
    });
    
    // 对于模块main,url将是src/main.js?v=1.0.0

    看起来这不错诶。但是依然存在一个问题:所有模块都会被刷新缓存,即使有的模块在升级过程并没有被修改。

    ESL 在这个问题上的处理,借(chao)鉴(xi)了 RequireJS,并在此之上做了一些扩充:urlArgs 支持 string | Object。

    // ESL 支持和 RequireJS 一样的 urlArgs 配置
    require.config({
        baseUrl: 'src',
        urlArgs: 'v=1.0.0'
    });
    
    // ESL 还支持 Object 作为 urlArgs 配置
    // 为了和 AMD 标准配置项保持风格一致,key 是 ID Prefix 的匹配规则
    require.config({
        baseUrl: 'src',
        urlArgs: {
            'common': 'v=1.0.1',
            'common/config': 'v=1.0.2',
            'biz': 'v=1.0.2',
            '*': 'v=1.0.0'
        }
    });

    可以看到,如果一个应用是按照一级或二级目录作为模块集划分,并以此为更新单元,urlArgs 能够带来很大的便利。但对于非常精细的缓存更新控制,urlArgs 还是不太好用的,虽然也能做到。

    另外,urlArgs 通常会使用版本信息来配置。 版本的控制和管理是一种艺术,管好了可以很清晰,管不好一团糟。这里就不做展开了。

    noRequests

    require(['container', 'MyClass'], function (container, MyClass) {
        var myClass = new MyClass();
        container.add(myClass)
    });

    我们可能在页面中会使用上面类似的方式使用模块,在一个 async require 里包含多个模块。在开发时一切都没问题,但是构建过程可能会对模块做一些合并,这种情况下线上环境就会发起一些无用的请求,虽然系统能够正常运行。

    通过 使用内容摘要作为文件名的玩法 中推荐的方法一,在页面中添加 script 标签去引入合并的文件,不通过 Loader 去请求模块,能够较好规避这个问题。但是有的应用场景中,我们希望相关模块能够缓加载,或者希望由 Loader 负责和控制模块的加载过程。

    对于这种应用场景,ESL 通过 noRequests 配置项进行支持。noRequests 是一个 Object,其中 key 是 ID Prefix 的匹配串,value 可以是 *、模块 ID、模块 ID组成的数组。下面的例子简单进行了说明

    require.config({
        noRequests: {
            'noRequests/simple/cat': 'noRequests/simple/index',
            'noRequests/all/cat': '*',
            'noRequests/complex/child': [ 
                'noRequests/complex/index1',
                'noRequests/complex/index2'
            ]
        }
    });
    
    // require(['noRequests/simple/cat', 'noRequests/simple/index'])
    // 不发起 noRequests/simple/cat 的请求
    
    // noRequests/all/cat 和任何模块在同一个 async require 时,不发起请求
    // noRequests/all/cat 只有在单独被 async require 时,才会发起请求
    
    // noRequests/complex/child 下所有模块,在和 noRequests/complex/index1 或 noRequests/complex/index2 同时 async require 时,不发起请求

    配置信息合并

    我们预见到,开发者可能会在不同的地方,对 Loader 进行不同的配置。下面是一种场景:

    require.config({
        baseUrl: src
    });
    
    // 一些其他代码
    
    // 然后构建工具生成了 paths 配置
    require.config({
        paths: {
            // ......
        }
    });

    如果仅仅是这种场景,那么 Loader 的实现完全不需要做配置信息的合并。但是我们发现还有更复杂的应用场景,一个应用要接合各种不同的团队开发的模块,不同团队的模块的上线是完全独立的,每个团队在应用中有一个地方能添加自己的代码。于是,对于同一个配置项,可能会在多个地方出现。

    // 在一个应用的不同地方,分布了同一个配置项的多次配置
    
    require.config({
        paths: {
            biz1: 'http://biz1-domain/path'
        }
    });
    
    require.config({
        paths: {
            biz2: 'http://biz2-domain/path'
        }
    });

    在上面的场景中,对于 paths 的 biz1 和 biz2 配置,都是不希望丢失的。这要求后执行的 require.config 不能覆盖之前的配置信息,而应该进行合并。

    这里的实现并没有什么复杂的,提出这点只是想为 Loader 的选用提个醒,选择 Loader 的时候,需要根据应用场景,考虑 Loader 是否进行了相关支持。

    最后

    作为一个有节操的广告贴,我不希望吹牛一样列举 ESL 特性,而想尽可能去讲一些我们在开发过程中特别的思考点、技术点以及遇到的问题,能够给有耐心看广告的读者一些收获。

    感谢每一个能耐心看到这的读者。祝你们早日当上CTO,迎娶白富美,登上人生巅峰。

    守望者AIR技术交流社区(www.airmyth.com)
    回复 支持 反对

    使用道具 举报

    您需要登录后才可以回帖 登录 | 立即注册

    本版积分规则

    
    关闭

    站长推荐上一条 /4 下一条

    QQ|手机版|Archiver|网站地图|小黑屋|守望者 ( 京ICP备14061876号

    GMT+8, 2019-7-17 15:18 , Processed in 0.051754 second(s), 32 queries .

    守望者AIR

    守望者AIR技术交流社区

    本站成立于 2014年12月31日

    快速回复 返回顶部 返回列表