静态文件管理:优雅地加载 CSS、JavaScript 和本地图片

📂 所属阶段:第一阶段 — 破冰启航(基础篇)
🔗 相关章节:Jinja2 模板引擎(下) · 环境搭建


1. 静态文件目录

1.1 Flask 默认目录结构

项目根目录/
└── app/
    ├── static/                    ← 静态文件目录(Flask 自动服务)
    │   ├── css/
    │   │   └── style.css
    │   ├── js/
    │   │   └── main.js
    │   ├── images/
    │   │   ├── logo.png
    │   │   └── banner.jpg
    │   └── uploads/              ← 用户上传文件
    │       ├── avatars/
    │       └── attachments/
    └── templates/

1.2 注册额外静态文件夹

# app/__init__.py
from flask import Flask

def create_app():
    app = Flask(__name__)

    # 添加额外的静态文件夹
    app.add_url_rule(
        '/media',
        endpoint='media',
        view_func=lambda: None
    )

    # 更简单的方式:
    from werkzeug.middleware.shared_data import SharedDataMiddleware
    app.wsgi_app = SharedDataMiddleware(
        app.wsgi_app,
        {"/media": "path/to/media"}
    )

    return app

2. 在模板中引用静态文件

2.1 基础引用

<!-- 使用 url_for 生成静态文件 URL -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Logo">

2.2 带版本号的引用(缓存控制)

<!-- 手动加版本号(修改后改版本号,强制浏览器重新加载) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css?v=1.0.1') }}">

2.3 自动加版本号

# app/utils.py
import hashlib
import os

def static_url(filename):
    """生成带哈希的静态文件 URL(用于缓存控制)"""
    filepath = os.path.join(
        current_app.root_path, "static", filename
    )
    if os.path.exists(filepath):
        with open(filepath, "rb") as f:
            version = hashlib.md5(f.read()).hexdigest()[:8]
        return url_for("static", filename=filename) + f"?v={version}"
    return url_for("static", filename=filename)

3. CSS 组织

3.1 主样式表

/* static/css/style.css */

/* 全站通用样式 */
:root {
    --primary-color: #3498db;
    --text-color: #333;
    --bg-color: #f5f5f5;
}

body {
    font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
    color: var(--text-color);
    background: var(--bg-color);
    margin: 0;
    padding: 0;
}

/* 容器 */
.container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 0 20px;
}

/* 导航栏 */
.navbar {
    background: #2c3e50;
    padding: 15px 0;
}
.navbar a {
    color: white;
    text-decoration: none;
    margin: 0 15px;
}

3.2 Bootstrap 集成

<!-- 在 base.html 中引入 Bootstrap -->
<!-- 方式一:CDN(推荐,简单快捷) -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">

<!-- 方式二:本地(自行下载到 static/css/) -->
<link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet">

<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>

3.3 自定义 CSS 覆盖 Bootstrap

{% extends "base.html" %}

{% block extra_head %}
<!-- 在子模板中添加额外样式 -->
<style>
    body { background: #fafafa; }
    .navbar { background: #2c3e50 !important; }
</style>
{% endblock %}

4. JavaScript 组织

4.1 基础使用

<script src="{{ url_for('static', filename='js/main.js') }}"></script>

{% block scripts %}
{{ super() }}  <!-- 调用父模板 scripts 块 -->
<script src="{{ url_for('static', filename='js/article.js') }}"></script>
{% endblock %}

4.2 页面加载后执行

// static/js/main.js

// 等待 DOM 加载完成
document.addEventListener("DOMContentLoaded", function() {
    console.log("页面加载完成!");

    // 搜索框防抖
    const searchInput = document.getElementById("search");
    if (searchInput) {
        let timer;
        searchInput.addEventListener("input", function() {
            clearTimeout(timer);
            timer = setTimeout(() => {
                console.log("搜索:", this.value);
                // 触发搜索
            }, 300);
        });
    }

    // AJAX 提交表单
    document.querySelectorAll(".ajax-form").forEach(form => {
        form.addEventListener("submit", async function(e) {
            e.preventDefault();
            const formData = new FormData(this);
            const response = await fetch(this.action, {
                method: "POST",
                body: formData
            });
            const result = await response.json();
            alert(result.message);
        });
    });
});

5. 文件上传

5.1 上传配置

# app/__init__.py
import os

app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024  # 最大 16MB
app.config["ALLOWED_EXTENSIONS"] = {"png", "jpg", "jpeg", "gif", "webp"}
UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "uploads")

def allowed_file(filename):
    return "." in filename and filename.rsplit(".", 1)[1].lower() in app.config["ALLOWED_EXTENSIONS"]

5.2 上传路由

# app/routes/upload.py
from flask import Blueprint, request, redirect, url_for, flash
from werkzeug.utils import secure_filename
from flask_login import current_user
import os
import uuid

upload_bp = Blueprint("upload", __name__)

@upload_bp.route("/upload/avatar", methods=["POST"])
def upload_avatar():
    if not current_user.is_authenticated:
        return redirect(url_for("auth.login"))

    if "file" not in request.files:
        flash("没有文件!")
        return redirect(url_for("profile"))

    file = request.files["file"]
    if file.filename == "":
        flash("没有选择文件!")
        return redirect(url_for("profile"))

    if file and allowed_file(file.filename):
        # 生成安全文件名(UUID + 原扩展名)
        ext = file.filename.rsplit(".", 1)[1].lower()
        filename = f"{uuid.uuid4().hex}.{ext}"
        filepath = os.path.join(app.root_path, "static", "uploads", "avatars", filename)

        os.makedirs(os.path.dirname(filepath), exist_ok=True)
        file.save(filepath)

        # 更新用户头像
        current_user.avatar = f"/static/uploads/avatars/{filename}"
        db.session.commit()

        flash("头像上传成功!")
        return redirect(url_for("profile"))

    flash("不支持的文件类型!")
    return redirect(url_for("profile"))

5.3 上传表单

<!-- templates/profile/upload_avatar.html -->
<form method="POST" action="{{ url_for('upload.upload_avatar') }}" enctype="multipart/form-data">
    <input type="file" name="file" accept="image/*" required>
    <button type="submit">上传头像</button>
</form>

<!-- 预览上传图片 -->
<img id="preview" style="max-width: 200px; display: none;">
<script>
document.querySelector('input[name="file"]').addEventListener("change", function() {
    const preview = document.getElementById("preview");
    preview.src = URL.createObjectURL(this.files[0]);
    preview.style.display = "block";
});
</script>

6. favicon

<!-- 在 base.html 中添加 -->
<head>
    <!-- favicon.ico 放在 static/ 目录,浏览器自动查找 -->
    <!-- 或者显式指定 -->
    <link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
</head>

7. 小结

静态文件使用速查:

目录:项目根目录/static/
引用:{{ url_for('static', filename='css/style.css') }}
上传:werkzeug.utils.secure_filename() 安全处理文件名
限制:app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024

💡 最佳实践:CSS 和 JS 文件在生产环境应该启用浏览器缓存,方法是在 URL 后加版本号或让 Nginx 配置 Cache-Control。


🔗 扩展阅读