Skip to content

一步一步实现最小化的AMD加载器

jnotnull edited this page Jul 16, 2014 · 2 revisions

原文链接:https://curiosity-driven.org/minimal-loader?utm_source=echojs 翻译:jnotnull


AMD简化了JS的模块化应用,但是因为它还不是浏览器端内置的机制,因此还是需要一些引导过程。好在写了一个加载器插件,它在优化后不到850个字符。 这个加载器要实现如下功能:

  1. 可以运行在当前任何流行的浏览器。浏览器可以没有实现ES6的规范,例如Promises,
  2. 提供最基本的AMD方法:define和require,
  3. 被Uglify.js压缩后,应该尽可能的小,但是可读性要强,
  4. 能够被其他新的加载插件扩展,
  5. 和require.js要保持兼容,

为了验证加载器运行是否正常,设计了如下的单元测试用例:


define('framework', ['component', 'library'], function(cmp, lib) {
    return { init: 'initialized:\ncomponent: ' + cmp.description +
        '\nand library: ' + lib.version};
});

require(['framework'], function(framework) {
    assert(framework.init === 'initialized:\ncomponent: uses library version: 0.0.1\nand library: 0.0.1');
});

define('library', [], function() {
    return { version: '0.0.1' };
});

define('component', ['library'], function(lib) {
    return { description: 'uses library version: ' + lib.version };
});

实现

实现分为两个部分:内部函数和外部函数

内部函数负责依赖定义和监听器实现

  • addLoadListener 用于当定义依赖的时候,注册一个可以执行的回调方法。如果之前已经定义过依赖的话,则可以立即执行改回调方法。
  • resolve 通过传递依赖对象的名称,然后通知所有监听器去执行回调。

外部函数实现是相对于内部函数而言的,它负责把方法暴露给全局的window对象

  • define:当依赖的模块都可用时,定义一个模块。
  • require:当依赖都加载后,执行回调。
(function() {

    var registry = {
        listeners: { },
        resolves: { }
    };

    function addLoadListener(name, listener) {
        if (name in registry.resolves) {
            // value is already loaded, call listener immediately
            listener(name, registry.resolves[name]);
        } else if (registry.listeners[name]) {
            registry.listeners[name].push(listener);
        } else {
            registry.listeners[name] = [ listener ];
        }
    }

    function resolve(name, value) {
        registry.resolves[name] = value;
        var libListeners = registry.listeners[name];
        if (libListeners) {
            libListeners.forEach(function(listener) {
                listener(name, value);
            });
            delete registry.listeners[name];
        }
    }

    window.require = function(deps, definition) {
        if (deps.length === 0) {
            // no dependencies, run definition now
            definition();
        } else {
            // we need to wait for all dependencies to load
            var values = [], loaded = 0;
            function dependencyLoaded(name, value) {
                values[deps.indexOf(name)] = value;
                if (++loaded >= deps.length) {
                    definition.apply(null, values);
                }
            }
            deps.forEach(function(dep) {
                addLoadListener(dep, dependencyLoaded);
            });
        }
    }

    window.define = function(name, deps, definition) {
        if (!definition) {
            // just two arguments - bind name to value (deps) now
            resolve(name, deps);
        } else {
            // asynchronous define with dependencies
            require(deps, function() {
                resolve(name, definition.apply(null, arguments));
            });
        }
    }

}());

单元测试结果: passed.

使用Uglify.js可以压缩到524字符.

!function(){function e(e,n){e in i.resolves?n(e,i.resolves[e]):i.listeners[e]?i.listeners[e].push(n):i.listeners[e]=[n]}function n(e,n){i.resolves[e]=n;var s=i.listeners[e];s&&(s.forEach(function(i){i(e,n)}),delete i.listeners[e])}var i={listeners:{},resolves:{}};window.require=function(n,i){function s(e,s){l[n.indexOf(e)]=s,++r>=n.length&&i.apply(null,l)}if(0===n.length)i();else{var l=[],r=0;n.forEach(function(n){e(n,s)})}},window.define=function(e,i,s){s?require(i,function(){n(e,s.apply(null,arguments))}):n(e,i)}}();

优化

虽然原始实现可以正常运行,而且已经很小了。然而通过重组,Uglify.js可以让他变的更小。

扁平化变量

通过分析压缩后的结果,可以看到有几个名字并没有被缩短:listeners和resolves。Uglify没有去改变他们是因为把它当成变量registry的一个属性了。如果这个变量被外部代码引用,就会造成名称不对应,引起无法预知的错误。Uglify不知道它是一个内部变量,因此不会对其进行压缩。一个解决的方法就是针对内部对象使用手工去缩小它的名字。

另一个解决方法是扁平化对象的结构。我们不再使用把listeners和resolves注册到对象的方法,我们直接定义变量。 使用扁平化后的注册对象后的实现(只修改了addLoadListener和resolve)


(function() {

    var listeners = { }, resolves = { };

    function addLoadListener(name, listener) {
        if (name in resolves) {
            // value is already loaded, call listener immediately
            listener(name, resolves[name]);
        } else if (listeners[name]) {
            listeners[name].push(listener);
        } else {
            listeners[name] = [ listener ];
        }
    }

    function resolve(name, value) {
        resolves[name] = value;
        var libListeners = listeners[name];
        if (libListeners) {
            libListeners.forEach(function(listener) {
                listener(name, value);
            });
            delete listeners[name];
        }
    }

    window.require = function(deps, definition) {
        if (deps.length === 0) {
            // no dependencies, run definition now
            definition();
        } else {
            // we need to wait for all dependencies to load
            var values = [], loaded = 0;
            function dependencyLoaded(name, value) {
                values[deps.indexOf(name)] = value;
                if (++loaded >= deps.length) {
                    definition.apply(null, values);
                }
            }
            deps.forEach(function(dep) {
                addLoadListener(dep, dependencyLoaded);
            });
        }
    }

    window.define = function(name, deps, definition) {
        if (!definition) {
            // just two arguments - bind name to value (deps) now
            resolve(name, deps);
        } else {
            // asynchronous define with dependencies
            require(deps, function() {
                resolve(name, definition.apply(null, arguments));
            });
        }
    }

}());

单元测试结果: passed.

使用Uglify.js压缩到428字符.

!function(){function n(n,e){n in t?e(n,t[n]):i[n]?i[n].push(e):i[n]=[e]}function e(n,e){t[n]=e;var l=i[n];l&&(l.forEach(function(i){i(n,e)}),delete i[n])}var i={},t={};window.require=function(e,i){function t(n,t){l[e.indexOf(n)]=t,++r>=e.length&&i.apply(null,l)}if(0===e.length)i();else{var l=[],r=0;e.forEach(function(e){n(e,t)})}},window.define=function(n,i,t){t?require(i,function(){e(n,t.apply(null,arguments))}):e(n,i)}}();

函数的不完全调用

(译者注:关于函数的不完全调用,可以参考正在翻译的 JavaScript中的不完全函数调用 ) 一些匿名函数可以通过使用bind生成不完全调用函数,一个例子就是上面的window.require里面的匿名函数。 不幸的是,因为bind采取的按照参数的左最多参数原则-首先是listener,然后是name 原有代码:

deps.forEach(function(dep) {
    addLoadListener(dep, dependencyLoaded);
});

修改参数顺序后代码:

deps.forEach(function(dep) {
    addLoadListener(dependencyLoaded, dep);
});

通过使用不完全调用函数,我们就可以删除掉之前的匿名函数了

deps.forEach(addLoadListener.bind(null, dependencyLoaded));

bind方法返回一个函数,他会使用两个参数dependencyLoaded和value去调用addLoadListener方法 因为dependencyLoaded没有在其他地方被引用,所以我们修改下它的位置

deps.forEach(addLoadListener.bind(null, function(name, value) {
    values[deps.indexOf(name)] = value;
    if (++loaded >= length) {
        definition.apply(null, values);
    }
}));

变量介绍

通过观察优化后的测试结果可以发现,require函数的dependencies变量的length属性重复了两次。length属性不能够被优化,但是通过使用一个变量去存储和读取,可以节省一些字节。 The comparison of length with 0 can be minimized to !length as 0 is falsy leading to this code: 因为长度为0的话代表false,所以我们可以通过比较长度和0的关系来优化如下代码

window.require = function(deps, definition) {
    var length = deps.length;
    if (!length) {
        // no dependencies, run definition now
        definition();
    } else {
        // we need to wait for all dependencies to load
        var values = [], loaded = 0;
        deps.forEach(addLoadListener.bind(null, function(name, value) {
            values[deps.indexOf(name)] = value;
            if (++loaded >= length) {
                definition.apply(null, values);
            }
        }));
    }
}

其他方面的修复

这些不走可以减少压缩的字节,但是可能降低代码的可读性 移除对window对象的赋值-在非严格模式下,赋值给未定义的变量会导致赋值给全局对象。 替换apply和bind方法中的null为0-因为参数在这些函数中并没有被用到,这样的替换可以为每个参数节省3个字符。 修改delete listeners[name]为listeners[name] = 0-这样可以节省3个字符,同时也不影响垃圾回收。 命名require方法名为req,同时在define中也使用这个名字-这个内部的名字可以被优化,这样可以节省3个字符。 最终优化后代码:

(function() {

    var listeners = { }, resolves = { };

    function addLoadListener(listener, name) {
        if (name in resolves) {
            // value is already loaded, call listener immediately
            listener(name, resolves[name]);
        } else if (listeners[name]) {
            listeners[name].push(listener);
        } else {
            listeners[name] = [ listener ];
        }
    }

    function resolve(name, value) {
        resolves[name] = value;
        var libListeners = listeners[name];
        if (libListeners) {
            libListeners.forEach(function(listener) {
                listener(name, value);
            });
            // remove listeners (delete listeners[name] is longer)
            listeners[name] = 0;
        }
    }

    function req(deps, definition) {
        var length = deps.length;
        if (!length) {
            // no dependencies, run definition now
            definition();
        } else {
            // we need to wait for all dependencies to load
            var values = [], loaded = 0;
            deps.forEach(addLoadListener.bind(0, function(name, value) {
                values[deps.indexOf(name)] = value;
                if (++loaded >= length) {
                    definition.apply(0, values);
                }
            }));
        }
    }

    /** @export */
    require = req;

    /** @export */
    define = function(name, deps, definition) {
        if (!definition) {
            // just two arguments - bind name to value (deps) now
            resolve(name, deps);
        } else {
            // asynchronous define with dependencies
            req(deps, function() {
                resolve(name, definition.apply(0, arguments));
            });
        }
    }

}());
测试结果: passed.

使用Uglify.js可以压缩到386字符.

!function(){function n(n,e){e in u?n(e,u[e]):t[e]?t[e].push(n):t[e]=[n]}function e(n,e){u[n]=e;var i=t[n];i&&(i.forEach(function(i){i(n,e)}),t[n]=0)}function i(e,i){var t=e.length;if(t){var u=[],f=0;e.forEach(n.bind(0,function(n,o){u[e.indexOf(n)]=o,++f>=t&&i.apply(0,u)}))}else i()}var t={},u={};require=i,define=function(n,t,u){u?i(t,function(){e(n,u.apply(0,arguments))}):e(n,t)}}();

插件

加载插件不仅仅可以被用来管理脚本依赖,也可以用于其他方面的加载需求。例如CodeMirror需要样式在内置编辑器之前呈现出来。 依赖名可以包含感叹号(!),用来表示哪个插件在工作-

css!codemirror/styles.css
表示css插件会加载codemirror/styles.css文件。 为了简化所有的外部依赖,甚至是脚本,我们要一个插件前缀(require.js没有这个限制)。加载插件本身将通过使用js插件来初始化,因为所有的插件都是脚本。 最新优化后的代码需要修改的唯一地方就是当依赖在初次请求的时候加载插件,然后去处理请求
function addLoadListener(listener, name) {
    if (name in resolves) {
        // value is already loaded, call listener immediately
        listener(name, resolves[name]);
    } else if (listeners[name]) {
        listeners[name].push(listener);
    } else {
        listeners[name] = [ listener ];

        // first time this dependency is requested
        // get the loader name from string before ! character
        req([ 'js!' + name.split('!')[0] ], function (loader) {
            loader(name);
        });
    }
}

下面的代码定义了两个插件: js-通过添加脚本标签,用来加载外部js模块.这些模块会包含define方法,因此这个加载器很简单。 css-用于样式。在返回这个插件之前,会校验是否所有样式已经生效。全部生效后,会返回。

(function(document, define, setTimeout) {

function addElement(name, properties) {
    var element = document.createElement(name);
    for (var item in properties) {
        element[item] = properties[item];
    }
    document.head.appendChild(element);
}

define('js!js', function(name) {
    var fileName = name.split('!')[1];
    addElement('SCRIPT', {
        src: fileName
    });
});

define('js!css', function(name) {
    var fileName = name.split('!')[1];
    addElement('LINK', {
        href: fileName,
        rel: 'stylesheet',
        onload: function check() {
            for (var i = 0, sheet; sheet = document.styleSheets[i]; i++) {
                if (sheet.href && (sheet.href.indexOf(fileName) > -1)) {
                    return define(name);
                }
            }
            // style is loaded but not being applied yet
            setTimeout(check, 50);
        }
    });
});

// require dependencies specified in 

使用Uglify.js压缩到463个字符.

!function(n,e,i){function t(e,i){var t=n.createElement(e);for(var r in i)t[r]=i[r];n.head.appendChild(t)}e("js!js",function(n){var e=n.split("!")[1];t("SCRIPT",{src:e})}),e("js!css",function(r){var f=r.split("!")[1];t("LINK",{href:f,rel:"stylesheet",onload:function o(){for(var t,u=0;t=n.styleSheets[u];u++)if(t.href&&t.href.indexOf(f)>-1)return e(r);i(o,50)}})}),i(require.bind(0,n.body.getAttribute("data-load").split(" "),Date),0)}(document,define,setTimeout);

这个代码片段使用了几个新的技术去减少了输出字节

拷贝全局变量

被使用超过一次的全局变量都被拷贝到函数的参数,因此可以节省30个字符

(function(document, define, setTimeout) {
 
// document, define and setTimeout are used more than once here
// as they are arguments Uglify.js will minimize each usage
 
}(document, define, setTimeout));

使用日期作为空函数

require函数需要两个参数-dependencies和一个回调函数,这个回调函数用于在依赖都加载后的执行。因为依赖. As dependencies are not used here instead of using empty function (function(){}) a side-effect free Date function is used saving 8 characters.

require(document.body.getAttribute('data-load').split(' '), Date);

偏函数应用

通过settimeout可以实现require方法的延时执行。这就意味这个浏览器可以使用内联样式去先渲染初始化页面布局,然后再去加载剩下的部分。交互脚本和新增的样式都可以从标签路径中移除了. 为了防止settimeout有匿名函数,我们在require里面创建了一个新的函数不完全调用。这个心的函数参数最少,调用的时候会传递依赖的长度和回调函数Date。

setTimeout(require.bind(0,
    document.body.getAttribute('data-load').split(' '), Date), 0);

关于Loader

压缩后的loader只有849个字符,但是如果你有更好的方法去减少字符,那就赶紧提交你的建议吧。 当前这个最小化的loader已经用在了当前blog中(查看本页最后的html代码)

Clone this wiki locally