博客 / 詳情

返回

手寫一個 Askama 模板壓縮工具

在 Web 開發中,前端資源的大小直接影響用户體驗。大型模板文件不僅佔用帶寬,還會延長頁面加載時間。雖然市面上有很多 HTML 壓縮工具,但對於使用了模板引擎的 HTML 文件(如 Askama、Jinja2 等),通用壓縮器往往會破壞模板語法。

於是個人寫了一個 Askama 模板壓縮工具 askama-minify,專門用於壓縮 Askama 模板文件,同時完美保留模板語法。

Askama 為什麼要壓縮

模板文件佔用的空間

在實際的 Web 項目中,模板文件往往佔據相當大的體積:

項目類型 模板數量 總大小 壓縮後大小
小型網站 10-20 200-500KB 100-250KB
中型應用 50-100 1-3MB 500KB-1.5MB
大型系統 200+ 5-10MB 2-5MB

壓縮的好處

  1. 減少帶寬消耗:模板大小減少 40-55%,直接降低流量成本
  2. 加快頁面加載:更小的文件意味着更快的傳輸速度
  3. 提升用户體驗:首屏渲染時間縮短,特別是移動端用户
  4. 降低服務器負載:傳輸數據量減少,服務器壓力降低
  5. 節省存儲空間:生產環境的模板文件佔用更少空間

Askama 自帶的壓縮配置

Askama 本身提供了 whitespace 控制功能,在項目根目錄的 askama.toml 中配置:

[general]
# 三種模式可選
whitespace = "suppress"   # 或 "minimize" / "preserve"

三種模式對比:

模式 行為 適用場景
preserve 保留所有空白(默認) 開發調試
suppress 激進移除空白 生產環境
minimize 適度移除空白 平衡模式

也可以在單個模板上覆蓋:

#[derive(Template)]
#[template(path = "example.html", whitespace = "suppress")]
struct ExampleTemplate;

Askama 自帶壓縮的侷限性

Askama 的 whitespace 控制有以下限制:

  1. 只處理空白字符:不能移除 HTML 註釋 <!-- -->
  2. 不影響 CSS<style> 標籤內的 CSS 完全保留
  3. 不影響 JavaScript<script> 標籤內的 JS 完全保留
  4. 不優化代碼:無法進行屬性合併、顏色優化等

示例對比:

<!-- 原始模板 -->
<style>
    body {
        margin-top: 0;
        margin-bottom: 0;
        /* 這是 CSS 註釋 */
        background-color: #ff0000;
    }
</style>
<script>
    // 這是 JS 註釋
    console.log("Hello");
</script>
<!-- Askama whitespace = "suppress" 的結果 -->
<style>body{margin-top:0;margin-bottom:0;/*這是CSS註釋*/background-color:#ff0000;}</style><script>//這是JS註釋
console.log("Hello");</script>
<!-- askama-minify 的結果 -->
<style>body{margin:0 0;background-color:red}</style><script>console.log("Hello");</script>

可以看到,askama-minify 做得更徹底:

  • 移除了所有註釋
  • 合併了 CSS 屬性
  • 優化了顏色值
  • 壓縮了 JavaScript

項目演進

任何項目都不是一蹴而就的,下面是關於 askama-minify 庫的編寫思路。希望能對大家有一些幫助。

為什麼需要專門的工具(補充)

在使用 Askama 這樣的 Rust 模板引擎時,我們的模板文件中會包含特殊的語法:

<!-- Askama 模板語法 -->
<div>{{ title }}</div>
{% for item in items %}
    <p>{{ item.name }}</p>
{% endfor %}

通用的 HTML 壓縮器(如 html-minifier)可能會:

  • {{ }} 識別為無效語法而破壞
  • {% %} 中的空格錯誤處理
  • 無法區分模板語法和普通文本

因此我們需要一個專門設計的壓縮工具。

簡單的 HTML 壓縮

最基礎的 HTML 壓縮非常簡單:移除多餘的空白字符即可。

pub fn minify_html_simple(content: &str) -> String {
    let mut result = String::with_capacity(content.len());
    let mut last_was_space = false;

    for ch in content.chars() {
        if ch.is_whitespace() {
            if !last_was_space && !result.is_empty() {
                result.push(' ');
                last_was_space = true;
            }
        } else {
            result.push(ch);
            last_was_space = false;
        }
    }

    result
}

這個簡單版本會將:

<div>    <p>   Hello   </p>    </div>

壓縮為:

<div> <p> Hello </p> </div>

但這樣還不夠——我們需要:

  1. 移除 HTML 註釋
  2. 處理特殊標籤(<pre>, <textarea>
  3. 保留模板語法

保留模板語法

模板語法的保留是本工具的核心。我們需要在遇到 {{{% 時,保持原樣輸出,直到遇到對應的 }}%}

pub fn minify_html(content: &str) -> String {
    let mut result = String::with_capacity(content.len());
    let mut chars = content.chars().peekable();
    let mut in_template_brace = false;  // {{ }}
    let mut in_template_chevron = false; // {% %}

    while let Some(ch) = chars.next() {
        // 檢測模板語法開始
        if ch == '{' {
            if let Some(&next_ch) = chars.peek() {
                if next_ch == '{' {
                    in_template_brace = true;
                    result.push(ch);
                    continue;
                } else if next_ch == '%' {
                    in_template_chevron = true;
                    result.push(ch);
                    continue;
                }
            }
        }

        // 在模板語法內,保持原樣
        if in_template_brace || in_template_chevron {
            result.push(ch);
            // 檢測模板語法結束
            if in_template_brace && ch == '}' && result.ends_with("}}") {
                in_template_brace = false;
            } else if in_template_chevron && ch == '}' && result.ends_with("%}") {
                in_template_chevron = false;
            }
            continue;
        }

        // ... 其他處理邏輯
    }

    result
}

測試一下:

輸入: <div>{{ title }}</div>
輸出: <div>{{ title }}</div>  // 完美保留

輸入: <div>  {{  title  }}</div>
輸出: <div> {{ title }}</div>  // 模板外空格壓縮,模板內保留

移除 HTML 註釋

HTML 註釋的移除需要小心,不能破壞字符串中的 <!--

// HTML 註釋處理(只在不在 script/style 內時處理)
if !in_script && !in_style && ch == '<' && chars.peek() == Some(&'!') {
    let mut comment = String::from("<");
    comment.push(chars.next().unwrap()); // '!'

    if chars.peek() == Some(&'-') {
        comment.push(chars.next().unwrap()); // first '-'
        if chars.peek() == Some(&'-') {
            comment.push(chars.next().unwrap()); // second '-'
            // 這是一個註釋,跳過直到 -->
            while let Some(c) = chars.next() {
                comment.push(c);
                if comment.ends_with("-->") {
                    break;
                }
            }
            continue; // 跳過註釋
        }
    }
    result.push_str(&comment);
    continue;
}

處理特殊標籤

某些標籤(如 <pre><textarea>)的內容需要完全保留原樣,包括空格和換行:

let mut in_pre = false;
let mut in_textarea = false;

// 在標籤檢測時
if tag_name == "pre" {
    in_pre = true;
} else if tag_name == "textarea" {
    in_textarea = true;
} else if tag_name == "/pre" {
    in_pre = false;
} else if tag_name == "/textarea" {
    in_textarea = false;
}

// 在字符處理時
if in_pre || in_textarea {
    result.push(ch);  // 完全保留
    continue;
}

添加 CSS 優化

HTML 中的 <style> 標籤內容可以使用專業的 CSS 優化器。這裏選擇 lightningcss,它是 Parcel 團隊開發的高性能 CSS 解析器:

use lightningcss::stylesheet::{MinifyOptions, ParserOptions, PrinterOptions, StyleSheet};

pub fn minify_css(css_code: &str) -> String {
    let stylesheet = StyleSheet::parse(css_code, ParserOptions::default());

    match stylesheet {
        Ok(mut sheet) => {
            sheet.minify(MinifyOptions::default()).ok();
            let result = sheet.to_css(PrinterOptions {
                minify: true,
                ..PrinterOptions::default()
            });

            match result {
                Ok(output) => output.code,
                Err(e) => {
                    eprintln!("Warning: Failed to minify CSS: {:?}", e);
                    css_code.to_string()
                },
            }
        }
        Err(e) => {
            eprintln!("Warning: Failed to parse CSS: {:?}", e);
            css_code.to_string()
        },
    }
}

lightningcss 的優化效果非常好:

/* 輸入 */
body {
    margin-top: 0;
    margin-bottom: 0;
    background-color: #ff0000;
}

/* 輸出 */
body{margin:0 0;background-color:red}
  • 屬性合併:margin-top: 0; margin-bottom: 0margin: 0 0
  • 顏色優化:#ff0000red
  • 移除所有不必要的空格和換行

添加 JavaScript 壓縮

JavaScript 的壓縮需要更加小心,因為:

  1. 字符串中的註釋語法不應被處理
  2. 除法運算符 / 容易與註釋混淆
  3. 轉義字符需要正確處理(\", \'
  4. 正則表達式需要保護
pub fn minify_js(js_code: &str) -> String {
    let mut result = String::with_capacity(js_code.len());
    let mut chars = js_code.chars().peekable();
    let mut in_string = false;
    let mut in_single_comment = false;
    let mut in_multi_comment = false;
    let mut string_char = '\0';

    while let Some(ch) = chars.next() {
        // 處理單行註釋
        if !in_string && !in_multi_comment && ch == '/' && chars.peek() == Some(&'/') {
            in_single_comment = true;
            chars.next(); // 跳過第二個 /
            continue;
        }

        if in_single_comment {
            if ch == '\n' {
                in_single_comment = false;
            }
            continue;
        }

        // 處理多行註釋
        if !in_string && !in_single_comment && ch == '/' && chars.peek() == Some(&'*') {
            in_multi_comment = true;
            chars.next(); // 跳過 *
            continue;
        }

        if in_multi_comment {
            if ch == '*' && chars.peek() == Some(&'/') {
                in_multi_comment = false;
                chars.next(); // 跳過 /
            }
            continue;
        }

        // 處理字符串
        if ch == '"' || ch == '\'' || ch == '`' {
            if !in_string {
                in_string = true;
                string_char = ch;
            } else if ch == string_char {
                // 檢查是否被轉義:計算前面的反斜槓數量
                let mut backslash_count = 0;
                let mut temp_result = result.clone();
                while temp_result.ends_with('\\') {
                    backslash_count += 1;
                    temp_result.pop();
                }
                // 偶數個反斜槓(包括0個)意味着引號沒有被轉義
                if backslash_count % 2 == 0 {
                    in_string = false;
                }
            }
            result.push(ch);
            continue;
        }

        if in_string {
            result.push(ch);
            continue;
        }

        // 壓縮空白(保留必要的空格)
        // ...
    }

    result
}

測試轉義字符處理:

// 輸入
let s = "test\\";  // 字符串中有轉義的反斜槓
let s2 = 'quote\'';

// 輸出
let s="test\\";   // 正確保留轉義字符
let s2='quote\'';  // 正確保留轉義字符

整合三層壓縮

將 HTML、CSS、JS 壓縮整合在一起,在解析 HTML 時識別 <script><style> 標籤:

pub fn minify_html(content: &str) -> String {
    let mut in_script = false;
    let mut in_style = false;
    let mut script_content = String::new();
    let mut style_content = String::new();

    while let Some(ch) = chars.next() {
        // 標籤處理
        if ch == '<' {
            // ... 讀取標籤名

            if tag_name == "script" {
                in_script = true;
            } else if tag_name == "/script" {
                // 壓縮並輸出 script 內容
                if !script_content.trim().is_empty() {
                    let minified = minify_js(&script_content);
                    result.push_str(&minified);
                }
                script_content.clear();
                in_script = false;
            } else if tag_name == "style" {
                in_style = true;
            } else if tag_name == "/style" {
                // 壓縮並輸出 style 內容
                if !style_content.trim().is_empty() {
                    let minified = minify_css(&style_content);
                    result.push_str(&minified);
                }
                style_content.clear();
                in_style = false;
            }
        }

        // 收集 script/style 內容
        if !in_tag {
            if in_script {
                script_content.push(ch);
                continue;
            } else if in_style {
                style_content.push(ch);
                continue;
            }
        }
    }
}

壓縮效果

經過三層壓縮,整體壓縮率可達 40-55%

層級 貢獻率 示例
CSS 優化 20-30% margin-top: 0; margin-bottom: 0margin:0 0
JS 壓縮 15-25% 移除註釋和空白
HTML 壓縮 10-15% 移除換行和縮進
註釋移除 5-10% 取決於註釋密度

完整示例:

<!-- 輸入:324 字節 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>{{ title }}</title>
    <!-- 這是註釋 -->
    <style>
        body {
            margin: 0;
            padding: 20px;
            background-color: #f0f0f0;
        }
    </style>
</head>
<body>
    <h1>{{ heading }}</h1>
    {% for item in items %}
        <p>{{ item.name }}</p>
    {% endfor %}
    <script>
        // 這是註釋
        console.log("Hello");
    </script>
</body>
</html>
<!-- 輸出:152 字節,-53% -->
<!doctype html><html lang=zh-CN><meta charset=UTF-8><title>{{ title }}</title><style>body{background-color:#f0f0f0;margin:0;padding:20px}</style><body><h1>{{ heading }}</h1>{% for item in items %} <p>{{ item.name }}</p>{% endfor %}<script>console.log("Hello");</script>

其他技術細節

命令行參數設計

使用 clap 庫來處理命令行參數:

use clap::Parser;

#[derive(Parser, Debug)]
#[command(name = "askama-minify")]
struct Args {
    /// 要壓縮的文件或文件夾路徑
    #[arg(value_name = "PATH")]
    path: PathBuf,

    /// 遞歸處理文件夾(默認啓用)
    #[arg(short, long, default_value_t = true)]
    recursive: bool,

    /// 輸出文件或文件夾路徑
    #[arg(short = 'd', long)]
    output: Option<PathBuf>,

    /// 輸出文件的後綴名(例如: "min" 會生成 .min.html)
    #[arg(short = 's', long)]
    suffix: Option<String>,
}

文件處理優化

使用 walkdir 庫實現高效的文件夾遍歷:

use walkdir::WalkDir;

let walker = if recursive {
    WalkDir::new(path)
} else {
    WalkDir::new(path).max_depth(1)
};

for entry in walker.into_iter().filter_map(|e| e.ok()) {
    let file_path = entry.path();
    if !file_path.is_file() || !is_template_file(file_path) {
        continue;
    }
    // 處理文件...
}

代碼質量優化

  1. 常量提取:避免魔法字符串

    const DEFAULT_SUFFIX: &str = "min";
    const MIN_MARKER: &str = ".min.";
    const VALID_EXTENSIONS: &[&str] = &["html", "htm", "xml", "svg"];
  2. 避免不必要的字符串分配:使用 eq_ignore_ascii_case 而不是 to_lowercase()

    // 優化後
    ext_str.eq_ignore_ascii_case(valid_ext)
    
    // 優化前(會創建新字符串)
    ext_str.to_lowercase() == valid_ext
  3. 空文件快速處理

    if original_size == 0 {
     fs::write(output_path, "")?;
     return Ok((0, 0));
    }

使用方式

安裝

# 克隆倉庫
git clone https://github.com/wsafight/askama-minify.git
cd askama-minify

# 編譯
cargo build --release

編譯後的二進制文件位於 target/release/askama-minify

基本用法

# 壓縮單個文件(默認生成 .min.html 後綴)
./target/release/askama-minify template.html

# 指定輸出文件
./target/release/askama-minify -d output.html template.html

# 壓縮整個文件夾
./target/release/askama-minify templates/

# 輸出到指定目錄並保持目錄結構
./target/release/askama-minify -d dist/ templates/

命令行選項

選項 簡寫 説明 默認值
--output <PATH> -d 輸出文件或文件夾路徑 原路徑
--suffix <SUFFIX> -s 輸出文件後綴名 min
--recursive -r 遞歸處理子文件夾 true

後綴規則

配置 結果 示例
-d-s 默認後綴 min file.htmlfile.min.html
-d-s 自定義後綴 file.html + -s prodfile.prod.html
-d-s 不添加後綴 file.html + -d out.htmlout.html
-d-s 後綴 + 自定義路徑 file.html + -d out/ + -s prodout/file.prod.html

集成到構建流程

方式一:在 build.rs 中使用

// build.rs
use std::process::Command;

fn main() {
    // 在生產構建時自動壓縮模板
    if std::env::var("PROFILE").as_deref() == Ok("release") {
        let status = Command::new("./target/release/askama-minify")
            .args(["-d", "dist/templates/", "templates/"])
            .status()
            .expect("Failed to execute askama-minify");

        if !status.success() {
            panic!("Template minification failed");
        }
    }
}

方式二:在 Makefile 中使用

# Makefile
.PHONY: build minify-templates

build: minify-templates
    cargo build --release

minify-templates:
    askama-minify -d dist/templates/ -s prod templates/

方式三:在 CI/CD 中使用

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install Rust
        uses: actions-rs/toolchain@v1
        with:
          toolchain: stable
      - name: Build askama-minify
        run: |
          git clone https://github.com/wsafight/askama-minify.git
          cd askama-minify
          cargo build --release
      - name: Minify templates
        run: ./askama-minify/target/release/askama-minify -d dist/ -s prod templates/
      - name: Deploy
        run: # 你的部署腳本

在 Askama 中使用壓縮後的模板

有兩種使用方式:

方式一:切換模板路徑(推薦)

開發環境使用源模板,生產環境使用壓縮模板:

use askama::Template;

#[derive(Template)]
#[template(
    path = "{{ template_path }}",  // 通過配置傳入
    whitespace = "suppress"
)]
struct HomePage {
    title: String,
}

// 根據環境變量選擇模板路徑
fn get_template_path(name: &str) -> String {
    if std::env::var("PROFILE").as_deref() == Ok("release") {
        format!("dist/{}.prod.html", name)  // 使用壓縮版
    } else {
        format!("templates/{}.html", name)   // 使用源文件
    }
}

方式二:構建時替換

# 開發環境
cp templates/*.html templates/

# 生產構建時
askama-minify -d templates/ -s prod templates/

實際項目示例

假設你有以下項目結構:

my-app/
├── templates/
│   ├── base.html
│   ├── index.html
│   └── user/
│       ├── profile.html
│       └── settings.html
├── dist/              # 壓縮後的輸出目錄
├── Cargo.toml
└── build.rs

開發時:直接使用 templates/ 下的原始文件

部署前:運行壓縮命令

askama-minify -d dist/ -s prod templates/

輸出:

dist/
├── base.prod.html
├── index.prod.html
└── user/
    ├── profile.prod.html
    └── settings.prod.html

配置 Askama 使用生產模板

# askama.toml
[general]
dirs = ["dist"]  # 指向壓縮後的目錄

總結

askama-minify 通過以下技術實現了高效的模板壓縮:

  1. 模板語法保留:完整保留 {{ }}{% %} 語法
  2. 三層壓縮策略:HTML 層、CSS 層、JS 層分別優化
  3. 智能邊緣處理:正確處理轉義字符、運算符、正則表達式
  4. 專業 CSS 優化:使用 lightningcss 進行屬性合併和顏色優化
  5. Rust 實現:高性能、內存安全

與 Askama 自帶壓縮的對比

特性 Askama whitespace askama-minify
空白壓縮
HTML 註釋移除
CSS 壓縮優化
JavaScript 壓縮
模板語法保留
構建時處理

項目已開源:https://github.com/wsafight/askama-minify

歡迎大家提出 issue 和 pr。

參考資料

  • lightningcss - 出色的 CSS 解析和優化工具
  • clap - 強大的命令行參數解析庫
  • Askama - 靈活的 Rust 模板引擎
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.