動態加載js文件的正確姿式

最近在作一個爲網頁生成目錄的工具awesome-toc,該工具提供了以jquery插件的形式使用的代碼,也提供了一個基於Bookmarklet(小書籤)的瀏覽器插件。javascript

小書籤須要向網頁中注入多個js文件,也就至關於動態加載js文件。在編寫這部分代碼時候遇到坑了,因而深究了一段時間。html

我在這裏整理了動態加載js文件的若干思路,這對於理解異步編程頗有用處,並且也適用於Nodejshtml5

代碼整理在了https://github.com/someus/how-to-load-dynamic-scriptjava

硬編碼在html源碼中的script是如何加載的

若是html中有:jquery

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

那麼,瀏覽器解析到git

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

會中止渲染頁面,去拉取1.js(IO操做),等到1.js的內容獲取到後執行。 1.js執行完畢後,瀏覽器解析到es6

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

進行和1.js相似的操做。github

不過如今部分瀏覽器支持async屬性和defer屬性,這個能夠參考:web

async vs defer attributes
script的defer和asyncajax

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文件。

方式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);
    }
});

效果以下:

輸入圖片說明

現有哪些工具能夠實現動態加載

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

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

資料

Script Execution Control

LABJS源碼淺析

Dynamic Script Execution Order

script節點的onload,onreadystatechange事件

readystatechange - MDN

readyState property - MSDN

Loading JavaScript without blocking

相關文章
相關標籤/搜索