故事從上回的讓 JSON.parse() 內建日期解析小把戲說起,我提到將置換 JSON.parse 動作放在載入 jquery.js 之前,jQuery.ajax 便會內建將 2012-12-21T00:00:00Z 轉成 Date 型別的能力。寫完程式雛型進行重構時,我將 CSS、JavaScript 用 ASP.NET ScriptBundle 機制打包壓縮,依直覺會寫成:

bundles.Add(
    new ScriptBundle("~/bundles/site")
    .Include(
        "~/Scripts/addDateReviver.js",
        "~/Scripts/jquery-{version}.js"
    ));

測試發現 ScriptBundle 版 DateReviver 注入失效,追查問題出在 @Scripts.Render("~/bundles/site") 將 jquery.js 放在 addDateReviver.js 之前,而不是依據我 Include 的順序。

<script src="/img/loading.svg" data-src="/Scripts/jquery-3.3.1.js"></script>
<script src="/img/loading.svg" data-src="/Scripts/addDateReviver.js"></script>

既然 ASP.NET MVC 已完全開源,查原始碼找答案才是男子漢! 爬文找到 ScriptBundle 依靠 IBundleOrderer 決定檔案順序,而預設的 IBundleOrderer 是 DefaultBundleOrderer,追進DefaultBundleOrderer.cs 原始碼,找到以下影響檔案順序的邏輯:

// Goal is to return a list of files in FileSetOrderList where each registered file set is ordered if it exists
List<BundleFile> result = new List<BundleFile>();
List<BundleFile> fileList = new List<BundleFile>(files);

Dictionary<string, HashSet<BundleFile>> fileMap = BuildFileMap(fileList);
if (fileMap.Count == 0) {
    return result;
}

HashSet<VirtualFile> foundFiles = new HashSet<VirtualFile>(VirtualFileComparer.Instance);

// For each ordering if we find a file, output all files that match that name
foreach (BundleFileSetOrdering ordering in context.BundleCollection.FileSetOrderList) {
    AddOrderingFiles(ordering, fileList, fileMap, foundFiles, result);
}

// Last step, add all unused files to the final list
foreach (BundleFile f in fileList) {
    if (!foundFiles.Contains(f.VirtualFile)) {
        result.Add(f);
        foundFiles.Add(f.VirtualFile);
    }
}

return result;

由程式碼可知 DefaultBundleOrderer 會先依 BundleCollection.FileSetOrderList 巡過一輪放入檔案,剩下的再依當初放入順序排列,而 BundleCollection.FileSetOrderList 的內容由 BundleCollection.AddDefaultFileOrderings() 決定,它列舉了 jquery、modernizr、dojo、mootools、prototype、ext... 等常用程式庫,確保它們會優先載入並依照預先定義的順序排序。(以下取自 AddDefaultFileOrderings() API 文件說明)

/// The default ordering values are as follows:
/// <list type="bullet">
///     <item><description>reset.css</description></item>
///     <item><description>normalize.css</description></item>
///     <item><description>jquery.js</description></item>
///     <item><description>jquery-min.js</description></item>
///     <item><description>jquery-*</description></item>
///     <item><description>jquery-ui*</description></item>
///     <item><description>jquery.ui*</description></item>
///     <item><description>jquery.unobtrusive*</description></item>
///     <item><description>jquery.validate*</description></item>
///     <item><description>modernizr-*</description></item>
///     <item><description>dojo.*</description></item>  
///     <item><description>mootools-core*</description></item>
///     <item><description>mootools-*</description></item>
///     <item><description>prototype.js</description></item>
///     <item><description>prototype-*</description></item>
///     <item><description>scriptaculous-*</description></item>
///     <item><description>ext.js</description></item>
///     <item><description>ext-*</description></item>
/// </list>

看完 ASP.NET MVC 原始碼,真相大白。ScriptBundle 會優先載入常用程式庫,並依事先定義的順序排列。不信的話,我們來惡搞一下,故意將 jquery.ui.js 放在 jquery.js 前面,ext.js 本應排在 prototype.js 之後,我們也將它移到 jquery.js 之前:

bundles.Add(
    new ScriptBundle("~/bundles/site")
    .Include(
        "~/Scripts/addDateReviver.js",
        "~/Scripts/ext.js",
        "~/Scripts/jquery.ui.js",
        "~/Scripts/jquery-{version}.js",
        "~/Scripts/prototype.js"
    ));

執行結果顯示,不論怎麼亂搞,FileSetOrderList 都會確保上述的程式庫依序優先載入,其他 JavaScript 則排在後面:

<script src="/img/loading.svg" data-src="/Scripts/jquery-3.3.1.js"></script>
<script src="/img/loading.svg" data-src="/Scripts/jquery.ui.js"></script>
<script src="/img/loading.svg" data-src="/Scripts/prototype.js"></script>
<script src="/img/loading.svg" data-src="/Scripts/ext.js"></script>
<script src="/img/loading.svg" data-src="/Scripts/addDateReviver.js"></script>

回到一開始遇到問題,要解決不難。第一種做法是把 addDateReviver.js 移出該組 ScriptBundle 提前轉入,另一種做法則是自訂依 Include() 加入順序排序的 IBundleOrderer,參考 Stackoverlow 討論,使用 ForceOrdered() 擴充方法把 DefaultBundleOrderer 換成依 Include() 順序出貨,也是不錯的解法。

    internal class AsIsBundleOrderer : IBundleOrderer
    {
        public IEnumerable<BundleFile> OrderFiles(BundleContext context, IEnumerable<BundleFile> files)
        {
            return files;
        }
    }

    internal static class BundleExtensions
    {
        public static Bundle ForceOrdered(this Bundle sb)
        {
            sb.Orderer = new AsIsBundleOrderer();
            return sb;
        }
    }

    public class BundleConfig
    {
        public static void RegisterBundles(BundleCollection bundles)
        {
            bundles.Add(
                new ScriptBundle("~/bundles/site")
                .Include(
                    "~/Scripts/addDateReviver.js",
                    "~/Scripts/jquery-{version}.js"
                ).ForceOrdered());
        //...以下省略...

Reveal the principle of ScriptBundle DefaultBundleOrderer and how to customize it.


Comments

Be the first to post a comment

Post a comment