.js/.css 換版後,想防止網頁讀取 Cache 強迫改用新版,最無敵的做法是在網址加個 ?v=XXX 參數,每次換版一併更新,URL 參數不同讓原 Cache 失效,即可確保萬無一失。

但手工維護 ?v=... 參數意味著每次換版必須叫出所有引用該 .js/.css 的網頁,一一修改 URL ?v= 參數,算不上什麼高明的主意。有個巧妙解法是由系統讀取 .js/.css 內容產生雜湊當成 v 參數,如此只要檔案內容異動,v 參數便自動更新,永遠不必擔心換版後還讀到 Cache 這種鳥事。

要實現自動版本參數,ASP.NET Core 有內建 asp-append-version Tag,針對 ASP.NET MVC 我也寫過類似機制:

最近維護古蹟踩到相同問題,卻發現軍火庫沒有兵器可用,少了 WebForm 版的自動版本參數機制。老系統說不定還得再戰十年,還是花點時間解決吧!

於是我寫了一顆 WebControl 來解決問題,網站還是 ASP.NET 3.5 無 MemoryCache 可用,改用 System.Web.Cache。為提升效率,以檔案絕對路徑當 Key 快取內容雜湊值,避免重複運算。System.Web.Cache 老歸老,還是有 CacheDependency(string filename) 可針對檔案建立依賴,能自動偵測檔案修改觸發快取更新,十分方便。

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Web;
using System.Web.Hosting;
using System.Web.UI;
using System.Web.UI.WebControls;

namespace FrontEndPack
{
    public class IncludeResWithVersion : WebControl
    {
        public string Src
        {
            get { return (string)ViewState["src"]; }
            set { ViewState["src"] = value; }
        }

        public string GetFileHash(string src)
        {
            var cache = HttpContext.Current.Cache;
            var path = HttpContext.Current.Server.MapPath(src);
            if (!File.Exists(path)) return string.Empty;
            string cacheKey = "_res_hash__" + path;
            if (cache[cacheKey] != null) return cache[cacheKey] as string;
            using (SHA256 sha256 = SHA256.Create())
            {
                var hash = HttpServerUtility.UrlTokenEncode(
                    sha256.ComputeHash(File.ReadAllBytes(path)));
                cache.Add(cacheKey, hash, 
                    // 指定快取依賴,檔案異動時自動更新快取
                    new System.Web.Caching.CacheDependency(path),
                    DateTime.MaxValue, TimeSpan.FromMinutes(10),
                    System.Web.Caching.CacheItemPriority.Normal, null);
                return hash;
            }
        }

        protected override void Render(HtmlTextWriter writer)
        {
            var src = Src.Split('?').First();
            var hash = GetFileHash(src);
            var ext = Path.GetExtension(src);
            writer.Write("\n");
            switch (ext) {
                case ".js":
                    writer.Write(string.Format(@"<script src=""{0}?v={1}""></script>", src, hash));
                    break;
                case ".css":
                    writer.Write(string.Format(@"<link href=""{0}?v={1}"" rel=""stylesheet"" />", src, hash));
                    break;
                default:
                    throw new Exception("File type " + ext + " not supported.");
            }
            writer.Write("\n");
        }
    }
}

將 IncludeResWithVersion.cs 放進 App_Code 目錄,要使用的網頁先加上 <%@Register Namespace="FrontEndPack" TagPrefix="fep" %> 註冊,接著將原本的 <script src="test.js"></script> 改成 <fep:IncludeResWithVersion runat="server" Src="./test.js" /> 即可。

寫個簡單網頁驗證。用 <fep:IncludeResWithVersion runat="server" Src="./test.js" /> 載入 .js,寫一小段 JavaScript 顯示其產生的 URL 及 test.js 內容:

<%@Page Language="C#" %>
<%@Register Namespace="FrontEndPack" TagPrefix="fep" %>
<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8" />
    <fep:IncludeResWithVersion runat="server" Src="./test.js" />
</head>

<body style="width: 400px;">
    <span></span>
    <pre style="background: #ccc; padding: 6px;">
</pre>
    <script>
        let src = document.head.querySelector('script').src;
        document.querySelector('span').innerHTML = src;
        this.fetch(src)
            .then(response => response.text())
            .then(text => {
                document.querySelector('pre').innerHTML = text;
            });            
    </script>
</body>

</html>

將 test.js 第一行註解 Ver 1.0 改成 Ver 1.1,重新整理 ?v= 自動修改,成功!

Adding ?v= parameter is a good way to prevent cache, this article provide an example to generate the hash parameter based on file content.


Comments

# by 小黑

謝黑哥

# by yoyo

請問為什麼不是設定 HTTP Cache,而是採用此workaround呢?

# by noke

我是直接拿檔案最後修改時間當做版本(類似v=2311211146),也方便識別目前載入的檔案是什麼時候修改的。

# by Jeffrey

to yoyo,你是指設 No-Cache 或 ETag/If-None-Match 機制嗎?

# by yoyo

to Jeffrey, 是的,靜態檔案應該利用此機制即可

# by longer

無法使用在Web Application?? 出現剖析器錯誤<fep:IncludeResWithVersion.....

# by longer

還是乖乖的寫成assembly dll參考後,web.config註冊,這樣引用頁面也不必<@Register....。且Web網站和Web Application都可使用 <system.web> <pages> <controls> <add tagPrefix="fep" Assembly="FrontEndPack" namespace="FrontEndPack"> </add> </controls> </pages> </system.web>

# by longer

還是乖乖的寫成assembly,參考dll後,web.config註冊tagPrefix,這樣web網站和web 應用程式,都可使用,且引用頁面不必再Register。

Post a comment