在 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 |
壓縮的好處
- 減少帶寬消耗:模板大小減少 40-55%,直接降低流量成本
- 加快頁面加載:更小的文件意味着更快的傳輸速度
- 提升用户體驗:首屏渲染時間縮短,特別是移動端用户
- 降低服務器負載:傳輸數據量減少,服務器壓力降低
- 節省存儲空間:生產環境的模板文件佔用更少空間
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 控制有以下限制:
- 只處理空白字符:不能移除 HTML 註釋
<!-- --> - 不影響 CSS:
<style>標籤內的 CSS 完全保留 - 不影響 JavaScript:
<script>標籤內的 JS 完全保留 - 不優化代碼:無法進行屬性合併、顏色優化等
示例對比:
<!-- 原始模板 -->
<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>
但這樣還不夠——我們需要:
- 移除 HTML 註釋
- 處理特殊標籤(
<pre>,<textarea>) - 保留模板語法
保留模板語法
模板語法的保留是本工具的核心。我們需要在遇到 {{ 和 {% 時,保持原樣輸出,直到遇到對應的 }} 和 %}。
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: 0→margin: 0 0 - 顏色優化:
#ff0000→red - 移除所有不必要的空格和換行
添加 JavaScript 壓縮
JavaScript 的壓縮需要更加小心,因為:
- 字符串中的註釋語法不應被處理
- 除法運算符
/容易與註釋混淆 - 轉義字符需要正確處理(
\",\') - 正則表達式需要保護
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: 0 → margin: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;
}
// 處理文件...
}
代碼質量優化
-
常量提取:避免魔法字符串
const DEFAULT_SUFFIX: &str = "min"; const MIN_MARKER: &str = ".min."; const VALID_EXTENSIONS: &[&str] = &["html", "htm", "xml", "svg"]; -
避免不必要的字符串分配:使用
eq_ignore_ascii_case而不是to_lowercase()// 優化後 ext_str.eq_ignore_ascii_case(valid_ext) // 優化前(會創建新字符串) ext_str.to_lowercase() == valid_ext -
空文件快速處理
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.html → file.min.html |
無 -d 有 -s |
自定義後綴 | file.html + -s prod → file.prod.html |
有 -d 無 -s |
不添加後綴 | file.html + -d out.html → out.html |
有 -d 有 -s |
後綴 + 自定義路徑 | file.html + -d out/ + -s prod → out/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 通過以下技術實現了高效的模板壓縮:
- 模板語法保留:完整保留
{{ }}和{% %}語法 - 三層壓縮策略:HTML 層、CSS 層、JS 層分別優化
- 智能邊緣處理:正確處理轉義字符、運算符、正則表達式
- 專業 CSS 優化:使用 lightningcss 進行屬性合併和顏色優化
- Rust 實現:高性能、內存安全
與 Askama 自帶壓縮的對比
| 特性 | Askama whitespace | askama-minify |
|---|---|---|
| 空白壓縮 | ✅ | ✅ |
| HTML 註釋移除 | ❌ | ✅ |
| CSS 壓縮優化 | ❌ | ✅ |
| JavaScript 壓縮 | ❌ | ✅ |
| 模板語法保留 | ✅ | ✅ |
| 構建時處理 | ❌ | ✅ |
項目已開源:https://github.com/wsafight/askama-minify
歡迎大家提出 issue 和 pr。
參考資料
- lightningcss - 出色的 CSS 解析和優化工具
- clap - 強大的命令行參數解析庫
- Askama - 靈活的 Rust 模板引擎