繼續我的 HTML 文件檔計劃。封裝文件檔的另一項重點是把所有內容打包成單一 .html 檔 (高年級同學可能還記得 IE 時代有個 .mht,差不多的概念),把要引用的第三方程式庫、CSS、圖檔都嵌進 .html 是較可行的做法,若包成 ZIP 閱讀時要在本機解壓縮,應該沒人能接受吧?網頁 HTML 內嵌資源最簡單做法是轉 Base64 寫成 Data URI,如以下範例(script 的嵌入內容是 function hello() { alert('Hello, World!'); }):線上展示

<!DOCTYPE html>
<html>
    <body>
        <img src="data:image/png;base64,iVBORw0KGgoA...X+NAAAAABJRU5ErkJggg==" alt="" />
        <button onclick="hello()">Hello</button>
        <script src="data:text/plain;base64,ZnVuY3Rpb24gaGVsbG8oKSB7IGFsZXJ0KCJIZWxsbywgV29ybGQhIik7IH0="></script>
    </body>
</html>

Data URI 內嵌可行,但遇到肥一點的 JavaScript 程式庫還是有點困擾。以 Vue3 為例,雖可從 CDN 下載,但考量離線環境或網路封鎖區域,內嵌仍是較保險做法。開發測試用的 vue.global.js 大小為 463KB,線上營運版 vue.global.prod.js 小一點,但也有 129KB,網站有 HTTP GZIP 壓縮可減量,轉成 Dat URI 只能眼睜睜看著 .html 檔暴肥破百 K... 心生一計,何不把 js 壓縮後內嵌,用 JavaScript 解壓縮再載入。以 vue.global.prod.js 為例,用 PowerShell GZIP 壓縮可降到 49KB,縮小 62%:

param ([Parameter(Mandatory=$true)][string]$path)
$ms = New-Object IO.MemoryStream                
$cs = New-Object System.IO.Compression.GZipStream ($ms, [Io.Compression.CompressionMode]"Compress")
$json = Get-Content $path -Raw
$data = [System.Text.Encoding]::UTF8.GetBytes($json)
$cs.Write($data, 0, $data.Length)
$cs.Close()
$bytes = $ms.ToArray()
$ms.Close()
Set-Content -Path ($path + '.gz') -Value $bytes -Encoding Byte

下一步是研究如何在 JavaScript 解壓縮 GZIP 內容。現代瀏覽器果然沒讓人失望,內建的 DecompressionStream API 能支援 GZIP、DEFLATE/ZLIB、DEFLATE 格式,解壓縮不成問題。

GZIP 內容要嵌入 HTML 得轉成 Base64,為避免原始 Base64 讓 HTML 寬度過長,拖累文字檢視器或編輯器效能,我打算仿效金鑰、憑證 PEM 格式將 Base64 字串拆成多行,轉換邏輯不難,我用一小段 PowerShell 示範:

param ([Parameter(Mandatory=$true)][string]$path, [int]$width = 160)
$absPath = Resolve-Path $path
$b64 = [System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($absPath))
$i = 0
do {
    $b64.Substring($i, [Math]::Min($width, $b64.Length - $i))
    $i += $width
} while ($i -lt $b64.Length)

先試跑未壓縮版,把 vue.global.prod.js 轉成多行 Base64 放進 <script> 區塊 (.html 大小為 179KB),寫一小段 JavaScript 讀入 Base64 並去除空白、Tab、換行後轉成 Uint8Array 再轉字串,以 eval 執行載入 Vue 3,網頁就能用 Vue 跑 MVVM 囉。

<!DOCTYPE html>

<head>
    <script type="text/base64" id="vue.global.prod.js">
        dmFyIFZ1ZT1mdW5jdGlvbihlKXsidXNlIHN0cmljdCI7ZnVu
        KT0+e30scz0oKT0+ITEsaT0vXm9uW15hLXpdLyxsPWU9Pmku
        cihsZXQgaT0wO2k8dC5sZW5ndGg7aSsrKXtsZXRbZSxvLGws
		//...略...
        PT5uPT57aWYoISgia2V5ImluIG4pKXJldHVybjtjb25zdCBv
        +bW4sZX0oe30pOwo=    
    </script>
</head>

<body>
    <div id="app">
        <input type="text" v-model="message">
        <div>{{message}}</div>
    </div>
    <script>
        const vue = new TextDecoder().decode(
            new Uint8Array(atob(document.getElementById("vue.global.prod.js")
				.innerHTML.replace(/[\n\r \t]/g, ""))
				.split('').map(c => c.charCodeAt(0))))
        eval(vue);
        const vm = Vue.createApp({
            data() {
                return {
                    message: 'Hello Vue!'
                }
            }
        });
        vm.mount('#app');
    </script>
    
</body>

</html>

最後加上 GZip 解壓縮程序,.html 降到 68KB,程式如下:線上展示

<!DOCTYPE html>

<head>
    <script type="text/base64" id="vue.global.prod.js">
        H4sIAAAAAAAEAMy9CXPbxrYu+lcslosFbLdoycnOyQENs2zZcRzHdhI7ThxtHW2IbIqIQID...
		//...略...
        AncrI4EPihHN3Fg8kCDYQaesOmRGUrepX6pVHDQkWK2L8HoRDf6//x/pioSa3gACAA==
    </script>
</head>

<body>
    <div id="app">
        <input type="text" v-model="message">
        <div>{{message}}</div>
    </div>
    <script>
        async function loadVue() {
            const gzip = new Uint8Array(atob(document.getElementById("vue.global.prod.js").innerHTML.replace(/[\n\r \t]/g, "")).split('').map(c => c.charCodeAt(0)));
            const ds = new DecompressionStream("gzip");
            const blob = new Blob([gzip]);
            const reader = blob.stream().pipeThrough(ds).getReader();
            const data = [];
            while (true) {
                const { done, value } = await reader.read();
                if (done) break;
                data.push(value);
            }
            //concate all the chunks into a single Uint8Array, thanks Copilot <3
            const uint8Array = new Uint8Array(data.reduce((acc, chunk) => [...acc, ...chunk], []));
            const vue = new TextDecoder().decode(uint8Array);
            window.eval(vue);
        }

        loadVue().then(() => {
            const vm = Vue.createApp({
                data() {
                    return {
                        message: 'Hello Vue!'
                    }
                }
            });
            vm.mount('#app');
        });

    </script>

</body>

</html>

就醬,離 HTML 單檔文件又再前進一步。

Example of how to decompress GZIP content with JavaScript.


Comments

# by yoyo

轉成Base64反而大小會增肥至原本的4/3 是不是有點本末倒置了?

# by Jeffrey

to yoyo,關鍵在於用 GZIP 壓縮將 .js 由 129K 壓到 49K,轉 Base64 變肥 33% 約 65K,仍比原來減少一半以上。若不壓縮內容原始內容即可,壓縮後為二進位資料,不轉 Base64 放不進 HTML。

# by yoyo

了解,謝謝!

Post a comment