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

[转]动态加载js文件的正确姿势 #8

Open
xiongwilee opened this issue May 16, 2017 · 3 comments
Open

[转]动态加载js文件的正确姿势 #8

xiongwilee opened this issue May 16, 2017 · 3 comments
Labels

Comments

@xiongwilee
Copy link
Owner

转自: https://github.com/letiantian/how-to-load-dynamic-script ,有改动

最近在做一个为网页生成目录的工具awesome-toc,该工具提供了以jquery插件的形式使用的代码,也提供了一个基于Bookmarklet(小书签)的浏览器插件。

小书签需要向网页中注入多个js文件,也就相当于动态加载js文件。在编写这部分代码时候遇到坑了,于是深究了一段时间。

我在这里整理了动态加载js文件的若干思路,这对于理解异步编程很有用处,而且也适用于Nodejs

一、硬编码在html源码中的script是如何加载的

如果html中有:

<script type="text/javascript" src="1.js"></script>
<script type="text/javascript" src="2.js"></script>

那么,浏览器解析到

<script type="text/javascript" src="1.js"></script>

会停止渲染页面,去拉取1.js(IO操作),等到1.js的内容获取到后执行。
1.js执行完毕后,浏览器解析到

<script type="text/javascript" src="2.js"></script>

进行和1.js类似的操作。

不过现在部分浏览器支持async属性和defer属性,这个可以参考:

async vs defer attributes
script的defer和async

script -MDN指出:async对内联脚本(inline script)没有影响,defer的话因浏览器以及版本不同而影响不同。

二、从一个例子出发

举个实际的例子:

<html>
<head></head>
<body>

    <div id="container">
        <div id="header"></div>
        <div id="body">
            <button id="only-button"> hello world</button>
        </div>
        <div id="footer"></div>
    </div>

    <script src="http://cdnjs.cloudflare.com/ajax/libs/jquery/2.0.0/jquery.min.js" type="text/javascript"></script>
    <script src="./your.js" type="text/javascript"></script>
    <script src="./my.js" type="text/javascript"></script>
    
</body>
</html>

js/your.js:

console.log('your.js: time='+Date.parse(new Date()));

function myAlert(msg) {
    console.log('alert at ' + Date.parse(new Date()));
    alert(msg);
}

function myLog(msg) {
    console.log(msg);
}

js/my.js:

myLog('my.js: time='+Date.parse(new Date()));
$('#only-button').click(function() {
    myAlert("hello world");
});

可以看出jqueryjs/your.jsjs/my.js三者的关系如下:

  • js/my.js依赖于jqueryjs/your.js
  • jqueryjs/your.js之间没有依赖关系。

浏览器打开index00.html,等待js加载完毕,点击按钮hello world将会触发alert("hello world");

firbug控制台输出:

下面开始探索如何动态加载js文件。

三、动态加载js文件的姿势

1、 一个错误的加载方式

文件js/loader01.js内容如下:

Loader = (function() {

  var loadScript = function(url) {
    var script = document.createElement( 'script' );
    script.setAttribute( 'src', url+'?'+'time='+Date.parse(new Date()));  // 不用缓存
    document.body.appendChild( script );
  };

  var loadMultiScript = function(url_array) {
    for (var idx=0; idx < url_array.length; idx++) {
      loadScript(url_array[idx]);
    }
  }

  return {
    load: loadMultiScript,
  };

})();  // end Loader

index01.html内容如下:

<html>
<head></head>
<body>

    <div id="container">
        <div id="header"></div>
        <div id="body">
            <button id="only-button"> hello world</button>
        </div>
        <div id="footer"></div>
    </div>

    <script src="./js/loader01.js" type="text/javascript"></script>
    <script type="text/javascript">
        Loader.load([
                    'http://cdnjs.cloudflare.com/ajax/libs/jquery/2.0.0/jquery.min.js', 
                    './js/your.js',
                    './js/my.js'
                     ]);
    </script>
    
</body>
</html>

浏览器打开index01.html,点击按钮hello world,会发现什么都没发生。打开firebug,进入控制台,可以看到这样的错误:

很明显,my.js没等jquery就先执行了。又由于存在依赖关系,脚本的执行出现了错误。这不是我想要的。

在网上可以找到关于动态加载的一些说明,例如:

Opera/Firefox(老版本)下:脚本执行的顺序与节点被插入页面的顺序一致

IE/Safari/Chrome下:执行顺序无法得到保证

注意:

新版本的Firefox下,脚本执行的顺序与插入页面的顺序不一定一致,但可通过将script标签的async属性设置为false来保证顺序执行
老版本的Chrome下,脚本执行的顺序与插入页面的顺序不一定一致,但可通过将script标签的async属性设置为false来保证顺序执行

真够乱的!!(这段描述来自:LABJS源码浅析。)

为了解决我们遇到的问题,我们可以在loadScript函数中修改script对象async的值:

var loadScript = function(url) {
  var script = document.createElement('script');
  script.async = false;  // 这里
  script.setAttribute('src', url+'?'+'time='+Date.parse(new Date())); 
  document.body.appendChild(script);
};

浏览器打开,发现可以正常执行!可惜该方法只在某些浏览器的某些版本中有效,没有通用性。script browser compatibility给出了下面的兼容性列表:

下面探索的方法都可以正确的加载和执行多个脚本,不过有些同样有兼容性问题(例如Pormise方式)。

2、正确的加载姿势

可以认为绝大部分浏览器动态加载脚本的方式如下:

  1. 动态加载多个脚本时,这些脚本的加载(IO操作)可能并行,可能串行。
  2. 一个脚本一旦加载完毕(IO结束),该脚本放入“待执行队列”,等待出队供js引擎去执行。

所以我们的示例中的三个js脚本的加载和执行顺序可以是下面的情况之一:

  1. jquery加载并执行,js/your.js加载并执行,js/my.js加载并执行。
  2. 和情况1类似,不过js/your.js在前,jquery在后。
  3. jqueryjs/your.js并行加载,按照加载完毕的顺序来执行;等jqueryjs/your.js都执行完毕后,加载并执行js/my.js

其中,“加载完毕”这是一个事件,浏览器的支持监测这个事件。这个事件在IE下是onreadystatechange ,其他浏览器下是onload

据此,Loading JavaScript without blocking给出了下面的代码:

function loadScript(url, callback){

    var script = document.createElement("script")
    script.type = "text/javascript";

    if (script.readyState){  //IE
        script.onreadystatechange = function(){
            if (script.readyState == "loaded" ||
                    script.readyState == "complete"){
                script.onreadystatechange = null;
                callback();
            }
        };
    } else {  //Others
        script.onload = function(){
            callback();
        };
    }

    script.src = url;
    document.body.appendChild(script);
}

callback函数可以是去加载另外一个js,不过如果要加载的js文件较多,就成了“回调地狱”(callback hell)。

回调地狱式可以通过一些模式来解决,例如下面给出的方式2:

Loader = (function() {

  var load_cursor = 0;
  var load_queue;

  var loadFinished = function() {
    load_cursor ++;
    if (load_cursor < load_queue.length) {
      loadScript();
    }
  }

  function loadError (oError) {
    console.error("The script " + oError.target.src + " is not accessible.");
  }


  var loadScript = function() {
    var url = load_queue[load_cursor];
    var script = document.createElement('script');
    script.type = "text/javascript";

    if (script.readyState){  //IE
        script.onreadystatechange = function(){
            if (script.readyState == "loaded" ||
                    script.readyState == "complete"){
                script.onreadystatechange = null;
                loadFinished();
            }
        };
    } else {  //Others
        script.onload = function(){
            loadFinished();
        };
    }

    script.onerror = loadError;

    script.src = url+'?'+'time='+Date.parse(new Date());
    document.body.appendChild(script);
  };

  var loadMultiScript = function(url_array) {
    load_cursor = 0;
    load_queue = url_array;
    loadScript();
  }

  return {
    load: loadMultiScript,
  };

})();  // end Loader

//loading ...
Loader.load([
            'http://cdnjs.cloudflare.com/ajax/libs/jquery/2.0.0/jquery.min.js', 
            './js/your.js',
            './js/my.js'
             ]);

load_queue是一个队列,保存需要依次加载的js的url。当一个js加载完毕后,load_cursor++用来模拟出队操作,然后加载下一个脚本。

onerror事件也添加了回调,用来处理无法加载的js文件。当遇到无法加载的js文件时停止加载,剩下的文件也不会加载了。

效果如下:

3、再优雅一点...

方式2是串行的去加载,我们稍加改进,让可以并行加载的js脚本尽可能地并行加载。

Loader = (function() {

  var group_queue;      // group list
  var group_cursor = 0; // current group cursor
  var current_group_finished = 0;  


  var loadFinished = function() {
    current_group_finished ++;
    if (current_group_finished == group_queue[group_cursor].length) {
      next_group();
      loadGroup();
    }
  };

  var next_group = function() {
    current_group_finished = 0;
    group_cursor ++;
  };

  var loadError = function(oError) {
    console.error("The script " + oError.target.src + " is not accessible.");
  };

  var loadScript = function(url) {
    console.log("load "+url);
    var script = document.createElement('script');
    script.type = "text/javascript";

    if (script.readyState){  //IE
        script.onreadystatechange = function() {
            if (script.readyState == "loaded" ||
                    script.readyState == "complete") {
                script.onreadystatechange = null;
                loadFinished();
            }
        };
    } else {  //Others
        script.onload = function(){
            loadFinished();
        };
    }

    script.onerror = loadError;

    script.src = url+'?'+'time='+Date.parse(new Date());
    document.body.appendChild(script);
  };

  var loadGroup = function() {
    if (group_cursor >= group_queue.length) 
      return;
    current_group_finished = 0;
    for (var idx=0; idx < group_queue[group_cursor].length; idx++) {
      loadScript(group_queue[group_cursor][idx]);
    }
  };

  var loadMultiGroup = function(url_groups) {
    group_cursor = 0;
    group_queue = url_groups;
    loadGroup();
  }

  return {
    load: loadMultiGroup,
  };

})();  // end Loader


//loading
var jquery = 'http://cdnjs.cloudflare.com/ajax/libs/jquery/2.0.0/jquery.min.js',
    your   = './js/your.js',
    my     = './js/my.js'
;
Loader.load([ [jquery, your], [my] ]);

Loader.load([ [jquery, your], [my] ]);代表着jqueryjs/your.js先尽可能快地加载和执行,等它们执行结束后,加载并执行./js/my.js

这里将每个子数组里的所有url看成一个group,group内部的脚本尽可能并行加载并执行,group之间则为串行。

这段代码里使用了一个计数器current_group_finished记录当前group中完成的url的数量,在这个数量和url的总数一致时,进入下一个group。

效果如下:

4、更优雅一点...

该方式是对方式3中代码的重构。

Loader = (function() {

  var group_queue = [];      // group list
  var current_group_finished = 0;  
  var finish_callback;
  var finish_context;

  var loadFinished = function() {
    current_group_finished ++;
    if (current_group_finished == group_queue[0].length) {
      next_group();
      loadGroup();
    }
  };

  var next_group = function() {
    group_queue.shift();
  };

  var loadError = function(oError) {
    console.error("The script " + oError.target.src + " is not accessible.");
  };

  var loadScript = function(url) {
    console.log("load "+url);
    var script = document.createElement('script');
    script.type = "text/javascript";

    if (script.readyState){  //IE
        script.onreadystatechange = function() {
            if (script.readyState == "loaded" ||
                    script.readyState == "complete") {
                script.onreadystatechange = null;
                loadFinished();
            }
        };
    } else {  //Others
        script.onload = function(){
            loadFinished();
        };
    }

    script.onerror = loadError;

    script.src = url+'?'+'time='+Date.parse(new Date());
    document.body.appendChild(script);
  };

  var loadGroup = function() {
    if (group_queue.length == 0) {
      finish_callback.call(finish_context);
      return;
    }
    current_group_finished = 0; 
    for (var idx=0; idx < group_queue[0].length; idx++) {
      loadScript(group_queue[0][idx]);
    }
  };

  var addGroup = function(url_array) {
    if (url_array.length > 0) {
      group_queue.push(url_array);
    }
  };

  var fire = function(callback, context) {
    finish_callback = callback || function() {};
    finish_context = context || {};
    loadGroup();
  };

  var instanceAPI = {
    load : function() {
      addGroup([].slice.call(arguments));
      return instanceAPI;
    },

    done : fire,
  };

  return instanceAPI;

})();  // end Loader


//loading
var jquery = 'http://cdnjs.cloudflare.com/ajax/libs/jquery/2.0.0/jquery.min.js',
    your   = './js/your.js',
    my     = './js/my.js'
;
// Loader.load(jquery, your).load(my).done();
Loader.load(jquery, your)
      .load(my)
      .done(function(){console.log(this.msg)}, {msg: 'finished'});

在调用多次load()函数后,必须调用done()函数。done()函数用来触发所有脚本的load。

5、更进一步...

这个方式是对方式4的重写。改进为调用load()时候尽可能去触发实际的load操作。

// 这里调试用的代码我没有删除

Loader = (function() {

    var group_queue  = [];      // group list

    //// url_item = {url:str, start: false, finished:false}

    // 用于调试
    var log = function(msg) {
        return;
        console.log(msg);
    }

    var isFunc = function(obj) { 
        return Object.prototype.toString.call(obj) == "[object Function]"; 
    }

    var isArray = function(obj) { 
        return Object.prototype.toString.call(obj) == "[object Array]"; 
    }

    var isAllStart = function(url_items) {
        for (var idx=0; idx<url_items.length; ++idx) {
            if (url_items[idx].start == false )
                return false;
        }
        return true;
    }

    var isAnyStart = function(url_items) {
        for (var idx=0; idx<url_items.length; ++idx) {
            if (url_items[idx].start == true )
                return true;
        }
        return false;
    }

    var isAllFinished = function(url_items) {
        for (var idx=0; idx<url_items.length; ++idx) {
            if (url_items[idx].finished == false )
                return false;
        }
        return true;
    }

    var isAnyFinished = function(url_items) {
        for (var idx=0; idx<url_items.length; ++idx) {
            if (url_items[idx].finished == true )
                return true;
        }
        return false;
    }

    var loadFinished = function() {
        nextGroup();
    };

    var showGroupInfo = function() {
        for (var idx=0; idx<group_queue.length; idx++) {
            group = group_queue[idx];
            if (isArray(group)) {
                log('**********************');
                for (var i=0; i<group.length; i++) {
                    log('url:     '+group[i].url);
                    log('start:   '+group[i].start);
                    log('finished:'+group[i].finished);
                    log('-------------------');
                }
                log('isAllStart: ' + isAllStart(group));
                log('isAnyStart: ' + isAnyStart(group));
                log('isAllFinished: ' + isAllFinished(group));
                log('isAnyFinished: ' + isAnyFinished(group));
                log('**********************');
            }
        }
    };

    var nextGroup = function() {
        while (group_queue.length > 0) {
            showGroupInfo();
            // is Func
            if (isFunc(group_queue[0])) {
                log('## nextGroup: exec func');
                group_queue[0]();  // exec
                group_queue.shift();
                continue;
            // is Array
            } else if (isAllFinished(group_queue[0])) {   
                log('## current group all finished');
                group_queue.shift();
                continue;
            } else if (!isAnyStart(group_queue[0])) {
                log('## current group no one start!');
                loadGroup();
                break;
            } else {
                break;
            }
        }
    };

    var loadError = function(oError) {
        console.error("The script " + oError.target.src + " is not accessible.");
    };

    var loadScript = function(url_item) {
        log("load "+url_item.url);
        url = url_item.url;
        url_item.start = true;
        var script = document.createElement('script');
        script.type = "text/javascript";

        if (script.readyState){  //IE
            script.onreadystatechange = function() {
                if (script.readyState == "loaded" ||
                        script.readyState == "complete") {
                    script.onreadystatechange = null;
                    url_item.finished = true;
                    loadFinished();
                }
            };
        } else {  //Others
            script.onload = function(){
                url_item.finished = true;
                loadFinished();
            };
        }

        script.onerror = loadError;

        script.src = url+'?'+'time='+Date.parse(new Date());
        document.body.appendChild(script);
    };

    var loadGroup = function() {
        for (var idx=0; idx < group_queue[0].length; idx++) {
            loadScript(group_queue[0][idx]);
        }
    };

    var addGroup = function(url_array) {
        log('add :' + url_array);
        if (url_array.length > 0) {
            group = [];
            for (var idx=0; idx<url_array.length; idx++) {
                url_item = {
                    url: url_array[idx],
                    start: false,
                    finished: false,
                };
                group.push(url_item);
            }
            group_queue.push(group);
        }
        nextGroup();
    };

    var addFunc = function(callback) {
        callback && isFunc(callback) &&  group_queue.push(callback);
        log(group_queue);
        nextGroup();
    };

    var instanceAPI = {
        load : function() {
            addGroup([].slice.call(arguments));
            return instanceAPI;
        },

        wait : function(callback) {
            addFunc(callback);
            return instanceAPI;
        }
    };

    return instanceAPI;

})();  // end Loader,这尼玛就是一个状态机


// loading
var jquery = 'http://cdnjs.cloudflare.com/ajax/libs/jquery/2.0.0/jquery.min.js',
    your   = './js/your.js',
    my     = './js/my.js'
;
// Loader.load(jquery, your).load(my);
Loader.load(jquery, your)
      .wait(function(){console.log("yeah, jquery and your.js were loaded")})
      .load(my)
      .wait(function(){console.log("yeah, my.js was loaded")});

上面的调用中,每次load时候会尝试马上加载和执行这些脚本,而不是像方式4那样要等done()被调用。

另外出现了新的函数wait,当wait之前的load和wait执行结束后,该wait中的匿名函数会被调用。

效果如下:

6、基于Promise+串行的思路

Promise是一种设计模式。关于Promise,下面的几篇文章值得一看:

当前浏览器对Promise的支持情况如下:

使用Promise解决脚本动态加载问题的方案如下:

function getJS(url) {
    return new Promise(function(resolve, reject) {
        var script = document.createElement('script');
        script.type = "text/javascript";

        if (script.readyState){  //IE
            script.onreadystatechange = function() {
                if (script.readyState == "loaded" ||
                        script.readyState == "complete") {
                    script.onreadystatechange = null;
                    resolve('success: '+url);
                }
            };
        } else {  //Others
            script.onload = function(){
                resolve('success: '+url);
            };
        }

        script.onerror = function() {
            reject(Error(url + 'load error!'));
        };

        script.src = url+'?'+'time='+Date.parse(new Date());
        document.body.appendChild(script);

    });
}

//loading
var jquery = 'http://cdnjs.cloudflare.com/ajax/libs/jquery/2.0.0/jquery.min.js',
    your   = './js/your.js',
    my     = './js/my.js'
;

getJS(jquery).then(function(msg){
    return getJS(your);
}).then(function(msg){
    return getJS(my);
}).then(function(msg){
    console.log(msg);
});

这个实现中js是串行加载的。

效果如下:

7、基于Promise+并行

可以使用Promise.all使jqueryjs/your.js并行加载。

Promise.all([getJS(jquery), getJS(your)]).then(function(results){
    return getJS(my);
}).then(function(msg){
    console.log(msg);
});

8、基于Generator+Promise

Promise配合生成器(Generator)可以让js程序按照串行的思维编写。

关于生成器,下面的几篇文章值得一看:

浏览器的支持情况如下:

来两个典型的生成器示例:

示例1:

function *addGenerator() {
  var i = 0;
  while (true) {
    i += yield i;
  }
}

var adder = addGenerator();
console.log( adder.next().value );  // yield i时候暂停 (循环1)
console.log( adder.next(5).value ); // 循环1中yield i的结果为5,i+=5,进入下一个循环(循环2),循环2中yield i 暂停,返回5
console.log( adder.next(5).value ); // 循环2中yield i的结果为5
console.log( adder.next(5).value ); // 循环3中yield i的结果为5
console.log( adder.next(50).value ); //循环4中yield i的结果为50,i+=50,进入循环6

输出:

0
5
10
15
65

示例2:

function* idMaker(){
  var index = 0;
  while(index < 3)
    yield index++;
}

var gen = idMaker();

while ( result = gen.next() ) {
    if (!result.done) {
        console.log(result.done + ':' + result.value);
    } else{
        console.log(result.done + ':' + result.value);
        break;
    }
}

输出:

false:0
false:1
false:2
true:undefined

下面的文章介绍了如何搭配Promise和Generator:

Generator+Promise实现js脚本动态加载的方式如下:

function getJS(url) {
    return new Promise(function(resolve, reject) {
        var script = document.createElement('script');
        script.type = "text/javascript";

        if (script.readyState){  //IE
            script.onreadystatechange = function() {
                if (script.readyState == "loaded" ||
                        script.readyState == "complete") {
                    script.onreadystatechange = null;
                    resolve('success: '+url);
                }
            };
        } else {  //Others
            script.onload = function() {
                resolve('success: '+url);
            };
        }

        script.onerror = function() {
            reject(Error(url + 'load error!'));
        };

        script.src = url+'?'+'time='+Date.parse(new Date());
        document.body.appendChild(script);

    });
}

function spawn(generatorFunc) {
  function continuer(verb, arg) {
    var result;
    try {
      result = generator[verb](arg);  // 这个result是生成器的返回值,有value和done两个属性
    } catch (err) {
      return Promise.reject(err);
    }
    if (result.done) {
      return result.value;
    } else {
      return Promise.resolve(result.value).then(onFulfilled, onRejected);  // result.value是promise对象
    }
  }
  var generator = generatorFunc();
  var onFulfilled = continuer.bind(continuer, "next");
  var onRejected = continuer.bind(continuer, "throw");
  return onFulfilled();
}

//// loading

var jquery = 'http://cdnjs.cloudflare.com/ajax/libs/jquery/2.0.0/jquery.min.js',
    your   = './js/your.js',
    my     = './js/my.js'
;

// “串行”代码在这里
spawn(function*() {
    try {
        yield getJS(jquery);
        console.log('jquery has loaded');
        yield getJS(your);
        console.log('your.js has loaded');
        yield getJS(my);
        console.log('my.js has loaded');
    } catch (err) { 
        console.log(err);
    }
});

效果如下:

四、其他

1、已有的轮子

For Your Script Loading Needs列出了许多工具,例如lazyloadLABjsRequireJS等。

有些工具也提供了新的思路,例如LABjs中可以使用ajax获取同域下的js文件。

2、参考:

1、开源工具

2、资料

@wilon
Copy link

wilon commented Mar 19, 2018

题主你好,我使用 三-2 的方法,能 load js文件,但里面的js代码不执行,啥情况?

测试:https://yeuk.github.io/test/loadjs.html ,没加载出gist

对比:https://yeuk.github.io/test/loadjs2.html ,希望出现的效果

@xiongwilee
Copy link
Owner Author

@wilon 你好,原因在console里:

4687967079fe42e1fd7c8cc257c2abeb.js:formatted:
1 Failed to execute 'write' on 'Document': 
It isn't possible to write into a document from an asynchronously-loaded external script unless it is explicitly opened

@tageecc
Copy link

tageecc commented May 12, 2018

很强

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

No branches or pull requests

3 participants