# Django 靜態文件管理 當你 Google 了很多文章後,大概率會看到官方介紹的 [How to manage static files](https://docs.djangoproject.com/en/5.1/howto/static-files/#how-to-manage-static-files-e-g-images-javascript-css)。 接著大概率又會找到 [跟 Static Files 有關的 setting 參數](https://docs.djangoproject.com/en/5.1/ref/settings/#static-files),從這篇文章中可以看到兩個很像的詞:`STATIC_ROOT` 跟 `STATIC_URL`。 如果讀者你查了很多文章,卻還是感覺有點不是很清楚他們之間的關係,那我猜原因可能是因為你少了一點==先備知識==。 # 靜態檔案 / Static Files & Static Root 一些網頁上的圖片、CSS、JS 等等的檔案都可以是你的網頁需要的靜態檔案 (Static Files)。 當你手上有很多資料時,第一個面臨的問題是,==要把他們放在哪裡?==。對於剛接觸 `Django` 的新手(也包括作者我) 來說,如果沒有什麼經驗,下意識都會認為資料應該放在==自己的電腦裡面==。 但其實在實際商業環境上會因為各種原因,你的靜態檔案不一定會放在執行網頁伺服器的主機裡面,==而是放在其他專門用來提供這種靜態資源服務的網站==,這也是 [How to deploy static files](https://docs.djangoproject.com/en/5.0/howto/static-files/deployment/) 這篇有一部分在介紹的事情。 不論我們是要將靜態檔案放在執行網頁伺服器的電腦上,或是交由第三方來幫你存放,首先的第一步一定是先把你整個專案中全部的靜態檔案==蒐集起來==,而 `STATIC_ROOT` 就是用來放置這些靜態檔案的資料夾。 所謂的蒐集起來,意思其實是複製一份到該路徑;你問我靜態檔案這麼多要怎麼辦,別擔心,Django 已經幫你寫好一個好用工具 `collectstatic` ,在 [How to manage static files 最下面的 deployment 章節](https://docs.djangoproject.com/en/5.0/howto/static-files/#deployment),告訴你可以使用以下指令,幫助你把你所有用到的靜態文件,依照其對應的格式通通蒐集到 `STATIC_ROOT` 裡面: ```shell $ python manage.py collectstatic ``` 這個 `collectstatic` 是什麼東西,怎麼這麼厲害? :::info 蒐集起來後,先別急,我們先看下去:) ::: # `django.contrib.staticfiles` 根據[這篇官方文檔](https://docs.djangoproject.com/en/5.0/ref/contrib/staticfiles/#collectstatic),可以知道 `collectstatic` 是由 `staticfiles` 這個 app 提供的一個 management commands。 沒錯,`django.contrib.staticfiles` 他是一個 app,你可以在對應的路徑找到他的身影: ![image](https://hackmd.io/_uploads/BkPRe_Op0.png) 可以看到他有一般 app 有的 `view.py`、`urls.py` 等等功能。不過他主要的功能就跟他的名字一樣,是來幫你處理靜態檔案的。 ## Finder & `STATICFILES_DIRS` 延續上面蒐集靜態檔案的部分,既然要蒐集靜態檔案,那 Django 怎麼知道我的靜態資料放在哪裡? 當你使用上面的 `collectstatic` 指令,在[官方的第六章教學文檔](https://docs.djangoproject.com/en/5.0/intro/tutorial06/) 開頭有提到他預設會請兩位 `Finder` ==來找出檔案==: ```python STATICFILES_FINDERS = [ "django.contrib.staticfiles.finders.FileSystemFinder", "django.contrib.staticfiles.finders.AppDirectoriesFinder", ] ``` 一樣可以在剛剛的 `staticfiles` app 裡面找到他們的身影。 ![image](https://hackmd.io/_uploads/ryw7ydD2R.png) 根據 [文檔](https://docs.djangoproject.com/en/5.0/ref/settings/#staticfiles-finders),這兩位分別做負責: - `FileSystemFinder` 會根據 `STATICFILES_DIRS` 中你設定的路徑去找 - 這也是為何教學中會說,如果你有其他放靜態檔案的路徑,要記得添加進 `STATICFILES_DIRS` 裡面。 - `AppDirectoriesFinder` 則會查找每個 app 路徑下的第一個 `static` 資料夾 你可以在 `django\contrib\staticfiles\management\commands\collectstatic.py` 中看到: ```python= # collectstatic.Command def collect(self): """ Perform the bulk of the work of collectstatic. Split off from handle() to facilitate testing. """ if self.symlink and not self.local: raise CommandError("Can't symlink to a remote destination.") if self.clear: self.clear_dir("") if self.symlink: handler = self.link_file else: handler = self.copy_file found_files = {} for finder in get_finders(): for path, storage in finder.list(self.ignore_patterns): # Prefix the relative path if the source storage contains it if getattr(storage, "prefix", None): prefixed_path = os.path.join(storage.prefix, path) else: prefixed_path = path if prefixed_path not in found_files: found_files[prefixed_path] = (storage, path) handler(path, prefixed_path, storage) else: self.log( "Found another file with the destination path '%s'. It " "will be ignored since only the first encountered file " "is collected. If this is not what you want, make sure " "every static file has a unique path." % prefixed_path, level=1, ) ... ... ``` 第 19 行的 `get_finders()` 是從 `\django\contrib\staticfiles\finders.py` 中引用的: ```python= ... # finders.get_finders def get_finders(): for finder_path in settings.STATICFILES_FINDERS: yield get_finder(finder_path) ... ``` 是不是看到 `STATICFILES_FINDERS` 了呢:) 而上面 `collectstatic.py` 的第 20 行的 `finder.list()` 函數,在 `finders.py` 中可以看到兩位 `Finder` 實作的方式不一樣: ```python= class FileSystemFinder(BaseFinder): """ A static files finder that uses the ``STATICFILES_DIRS`` setting to locate files. """ def __init__(self, app_names=None, *args, **kwargs): # List of locations with static files self.locations = [] # Maps dir paths to an appropriate storage instance self.storages = {} for root in settings.STATICFILES_DIRS: if isinstance(root, (list, tuple)): prefix, root = root else: prefix = "" if (prefix, root) not in self.locations: self.locations.append((prefix, root)) for prefix, root in self.locations: filesystem_storage = FileSystemStorage(location=root) filesystem_storage.prefix = prefix self.storages[root] = filesystem_storage super().__init__(*args, **kwargs) ...... def list(self, ignore_patterns): """ List all files in all locations. """ for prefix, root in self.locations: # Skip nonexistent directories. if os.path.isdir(root): storage = self.storages[root] for path in utils.get_files(storage, ignore_patterns): yield path, storage #======================================================== class AppDirectoriesFinder(BaseFinder): """ A static files finder that looks in the directory of each app as specified in the source_dir attribute. """ storage_class = FileSystemStorage source_dir = "static" def __init__(self, app_names=None, *args, **kwargs): # The list of apps that are handled self.apps = [] # Mapping of app names to storage instances self.storages = {} app_configs = apps.get_app_configs() if app_names: app_names = set(app_names) app_configs = [ac for ac in app_configs if ac.name in app_names] for app_config in app_configs: app_storage = self.storage_class( os.path.join(app_config.path, self.source_dir) ) if os.path.isdir(app_storage.location): self.storages[app_config.name] = app_storage if app_config.name not in self.apps: self.apps.append(app_config.name) super().__init__(*args, **kwargs) ... def list(self, ignore_patterns): """ List all files in all app storages. """ for storage in self.storages.values(): if storage.exists(""): # check if storage location exists for path in utils.get_files(storage, ignore_patterns): yield path, storage ``` - `FileSystemFinder` 根據 `STATICFILES_DIRS` 所給的路徑,建立各別的 ==倉庫 Storage== - `AppDirectoriesFinder` 是查找每個 app 路徑下的第一個 `static` 資料夾,建立各別的 ==倉庫 Storage== `list()` 函數就是回傳這些 ==倉庫== 的所在位置。 :::info - `FileSystemStorage` 來作為 ==倉庫== 的這部分可以參閱 [`FileSystemStorage` 的說明](https://docs.djangoproject.com/en/5.0/ref/files/storage/#the-filesystemstorage-class) - 這部分主要用在資料的搬遷方面,也就是在上面 `collectstatic.py` 的第 14 跟 16 行中的 `handler` 的部分,有興趣的人可以去看檔案的操作,可以對應到 [官方教學:How to write a custom storage class](https://docs.djangoproject.com/en/5.0/howto/custom-file-storage/) - 如果有自己執行 `collectstatic.py` (可以參考[這篇 Super Kai 的示範](https://stackoverflow.com/questions/8687927/static-root-vs-static-url-in-django)),會發現 `app_name/static/` 裡面的內容經過搬移後,`app_name/static/` 的前綴是會不見的,靜態檔案的路徑長得像 `STATIC_ROOT/your_static_files` - 這也是為何 [How to manage static files](https://docs.djangoproject.com/en/5.1/howto/static-files/#how-to-manage-static-files-e-g-images-javascript-css) 這篇教學會希望你將靜態檔案放到 `app_name/static/app_name/` 這層資料夾下,因為經過搬遷後,東西就會在 `STATIC_ROOT/app_name/your_static_files`,可以避免一旦發生同名檔案就會有人不見的情況發生 ::: # Static URL 根據 [官方文檔的說明](https://docs.djangoproject.com/en/5.1/ref/settings/#static-url): ``` URL to use when referring to static files located in STATIC_ROOT. ``` 翻成中文的話就是當你要參照存放在 `STATIC_ROOT` 內的靜態檔案時所使用的 URL。 然後你又看了 [How to manage static files](https://docs.djangoproject.com/en/5.1/howto/static-files/#how-to-manage-static-files-e-g-images-javascript-css) 的開頭,叫你修改 `setting.py` 中的內容: ```python STATIC_URL = "static/" ``` 然後就神奇的可以在 html 檔使用 DTL 插入靜態文件了: ```c {% load static %} {% static 'path/to/static_file' %} ``` ## `DEBUG = False` 先不管上面是怎麼達成的。假設我們是在==生產模式==,也就是令 `DEBUG = False`,此時 ==一定要==: - 有先設定 `STATIC_ROOT` ,並且執行 `collectstatic.py` 蒐集好靜態文件了 - 用電腦上 `STATIC_ROOT` 的位置提供靜態檔案,或是用第三方網站,在該網站提供靜態文件 - 前者的話 `STATIC_URL` 就要讓他等於 `STATIC_ROOT`,後者則是讓 `STATIC_URL` 等於該網站用來提供的網址。 - 這就是官方文檔所述 ==當你要參照存放在 `STATIC_ROOT` 內的靜態檔案時所使用的 URL== 的意思 ## `DEBUG = True` 那如果現在是在 ==開發模式==,情況會是怎樣? 在 [How to manage static files](https://docs.djangoproject.com/en/5.1/howto/static-files/#how-to-manage-static-files-e-g-images-javascript-css) 的開頭,官方說到: ``` In addition to these configuration steps, you’ll also need to actually serve the static files. During development, if you use django.contrib.staticfiles, this will be done automatically by runserver when DEBUG is set to True (see django.contrib.staticfiles.views.serve()). ``` `you’ll also need to actually serve the static files.` 的意思是說,你需要將那些靜態檔案的路徑,如同你在各個 `app.urls.py` 做的事情一樣,將各個網址對應到一個 `view.py` ,這個動作叫做 serve,就好像你提供一個這個網址的服務。 >畢竟你在存取靜態文件的時候,實際上也是跟伺服器請求一個 url ``` During development, if you use django.contrib.staticfiles, this will be done automatically by runserver when DEBUG is set to True (see django.contrib.staticfiles.views.serve()). ``` 在 [官網的 runserver 指令的介紹](https://docs.djangoproject.com/en/5.1/ref/django-admin/#runserver),有說如果你有把 `django.contrib.staticfiles` 放到 `INSTALLED_APPS`,那麼 `django.contrib.staticfiles` 其下的 `runserver` 指令,會覆蓋掉原本的 `runserver` 指令。上面 [`collectstatic.py` 的官方文檔往下翻](https://docs.djangoproject.com/en/5.0/ref/contrib/staticfiles/#collectstatic) 也有提到這件事。 而當我們去看 `django.contrib.staticfiles` 這個 app 的 `urls.py`: ```python= from django.conf import settings from django.conf.urls.static import static from django.contrib.staticfiles.views import serve urlpatterns = [] def staticfiles_urlpatterns(prefix=None): """ Helper function to return a URL pattern for serving static files. """ if prefix is None: prefix = settings.STATIC_URL return static(prefix, view=serve) # Only append if urlpatterns are empty if settings.DEBUG and not urlpatterns: urlpatterns += staticfiles_urlpatterns() ``` 就會看到,如果是 ==開發模式==,自動就會將靜態檔案的路徑,以 `staticfiles.views.py` 來提供服務,在第 12 行可以看到,會將==所有靜態檔案的前面加上 `STATIC_URL`==。 >細節要去看 `django.conf.urls.static` 裡面的 `static` 函數做的事情。 當我們去看 `staticfiles.views.py`: ```python= """ Views and functions for serving static files. These are only to be used during development, and SHOULD NOT be used in a production setting. """ import os import posixpath from django.conf import settings from django.contrib.staticfiles import finders from django.http import Http404 from django.views import static def serve(request, path, insecure=False, **kwargs): """ Serve static files below a given point in the directory structure or from locations inferred from the staticfiles finders. To use, put a URL pattern such as:: from django.contrib.staticfiles import views path('<path:path>', views.serve) in your URLconf. It uses the django.views.static.serve() view to serve the found files. """ if not settings.DEBUG and not insecure: raise Http404 normalized_path = posixpath.normpath(path).lstrip("/") absolute_path = finders.find(normalized_path) if not absolute_path: if path.endswith("/") or path == "": raise Http404("Directory indexes are not allowed here.") raise Http404("'%s' could not be found" % path) document_root, path = os.path.split(absolute_path) return static.serve(request, path, document_root=document_root, **kwargs) ``` 在第 34 行的地方,出現了上面介紹的 `finder`,也就是說,靜態路徑的查找方式,==也是採用上面介紹的兩種查找方式==。 因此在 ==開發模式下==,如果你有把 `django.contrib.staticfiles` 放到 `INSTALLED_APPS` (預設是有放),你一樣可以把靜態文件依照 `finder` 查找的規則來放置,只不過他會將你所存取的靜態文件,==都看作是放在 `STATIC_URL`== 這個目錄底下(上上面提到的),而 `STATIC_URL` 是放在==根目錄==或者說 ==localhost== 底下 (看你的 `STATIC_URL` 寫了什麼)。 ## 回到開頭 說了這麼多,現在回來看一開始的部分,首先令: ```python STATIC_URL = "static/" ``` 接著是 `html` 中使用 DTL 引用的部分: ```c {% load static %} {% static 'path/to/static_file' %} ``` - 上面在 html 檔用 DTL 插入靜態文件,`{% static 'path/to/static_file' %}` 的意思就是把 `STATIC_URL` 加在路徑的前面,變成 `/STATIC_URL/path/to/static_file`。 - 如果是 CSS、JS 等其他檔案內需要使用靜態文件的話,建議就直接在該檔案內使用相對路徑來存取靜態文件,不然就得要使用一些技巧,透過 DTL 來存取了。 這樣一來,當你在==開發模式==的時候,就可以不用特地使用 `collectstatic.py` 來蒐集靜態文件,而是直接透過 `finder` 來幫你找檔案。但要是你改成==產業模式==,那就一定要==先蒐集==、==再提供==靜態文件的服務,不然會找不到靜態檔案。 :::info 上面說明了為何官方強烈要求生產模式時一定要設置 `DEBUG=True` 等步驟: - 不安全的部分可以參見 [這個章節](https://docs.djangoproject.com/en/5.1/ref/contrib/staticfiles/#runserver) ::: --- :::warning 感謝你看到這裡,不如先喘口氣,再接著往下看:) ::: --- # 絕對路徑 在 [這篇的 Paths in asset definitions 章節](https://docs.djangoproject.com/en/5.0/topics/forms/media/#paths-in-asset-definitions) 有特別提到,如果路徑是以 `/`、`http://` 或 `https://` 開頭,那麼他會被認定是 ==絕對路徑==。 - `/` 就是根目錄。 - 所以你如果有個固定用來放靜態文件的網址,你可以直接透過該網址存取靜態文件。 - 或是使用 `manage.py` 下 `collectstatic` 指令,蒐集靜態文件統一到你設定的 `STATIC_ROOT`,假設你放在該專案的根目錄下的 `static` 資料夾內,你也可以路徑輸入 `/static/...` 來存取 :::info [官方在 `STATIC_ROOT` 的說明](https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-STATIC_ROOT) 有特別強調,`STATIC_ROOT` 是當你要佈置到商業環境時,蒐集靜態資料後==暫時存放==的地方,靜態資料==長期存放==的地方要是你可以透過 `STATICFILES_FINDERS` 找到的地點,而不是放置在 `STATIC_ROOT` 裡面。 ::: # Vite 注意事項 當你使用 `Vite` 來幫你處理打包等等事宜,在處理 `js` 檔的外部引入,也就是 `import` 的部分時,如果沒有特別設置,預設情況如同官方的描述: ```js import imgUrl from './img.png' document.getElementById('hero-img').src = imgUrl ``` ### 開發情境 在開發的時候 `imgUrl` 會是 `/img.png`,也就是以你的 entry 檔(像是 `index.html`)所在目錄作為根目錄,也是你 npm server 開啟的路徑位置。 ### 建置輸出 build 之後的輸出檔案(例如 `index.js`),`imgUrl` 則會變成 `/assets/img.2d8efhg.png`。 會長這樣的原因是,如果沒有特別設定 `vite.config.js`,`build.outDir` 跟 `build.assetsDir` 會分別預設為 `dist` 跟 `assets` 這兩個資料夾,而輸出的 `index.html` 會位在 `dist` 裡面,`/assets/img.2d8efhg.png` 就是以 `dist` 作為根目錄時所採用的相對路徑。 :::info `vite.config.js` 的 Build 參數,[請參見官方文檔的 Build Options 部分](https://vitejs.dev/config/build-options#build-outdir)。 ::: ## `vite.config.js` 範例 下面是一個範例。至於有哪些參數,可以輸入什麼,請參閱 [官方 Config 介紹](https://vitejs.dev/config/)。 ```js= import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ // base:"/", plugins: [react()], build: { outDir:"dist", assetsDir:".", // generate .vite/manifest.json in outDir manifest: true, rollupOptions: { // overwrite default .html entry // comment below line so vite will build index.html automatically // input: '/src/main.jsx', output:{ // dir:"./dist/", assetFileNames:(assetInfo) =>{ // maybe can use "assetInfo" to do some processing // console.log(assetInfo.name); return "static/[ext]/[name].[ext]" }, entryFileNames:"static/js/[name].js" } }, }, }) ``` 這裡以 `rollupOptions` 做介紹,首先會從 `Vite` 官網導到 [`rollupjs` 的官方文檔](https://rollupjs.org/configuration-options/): ![image](https://hackmd.io/_uploads/r1kHfBDhC.png) 在 `rollupjs` 的部分,我們可以找他可以代入 `output` 這個屬性,而 `output` 這個屬性中可以再代入 `assetFileNames` 這個屬性,這個屬性可以是字串 `string`,可以是一個函數 `((assetInfo: PreRenderedAsset) => string)`,然後預設值是 `"assets/[name]-[hash][extname]"`。 ![image](https://hackmd.io/_uploads/B1sQGHvhC.png) :::info 可以注意到預設值的路徑是有特別對應到 `build.assetsDir` 的預設值,也就是說如果你有指定 `assetFileNames`,那 `build.assetsDir` 會以 `assetFileNames` 的為主。 ::: --- # 問題 這時聰明的讀者應該會發現一個問題,上上面有介紹 Django 看到以 `/` 開頭的路徑,是判定為絕對路徑;在上面有介紹 `js` 或 `css` 中,以 `/` 開頭的路徑是相對路徑,並且 `Vite` 沒有特別設定的話,也是以 `/` 開頭的==相對路徑== 做輸出。 這樣會導致當你使用 `Vite` 輸出的 `js`、`css` 等檔案作為 Django 的靜態文件時,發生絕對路徑不存在的問題: ![image](https://hackmd.io/_uploads/ByqeC4v2R.png) 上圖中,我在設定裡面只有令輸出的名稱叫做 `svg/react.svg`,同時設定不要有 `assets` 資料夾。 但是由於沒有設定 `vite.config.js` 的 `base` 參數,因此就會預設以 `/` 作為開頭,導致 Django 以為他是個==絕對路徑的靜態文件==: ![image](https://hackmd.io/_uploads/ByCHCVD2R.png) ### 解法 1 既然預設輸出會被 Django 視為絕對路徑,而我們又知道靜態檔案是依據那兩個 finder 的查找邏輯在搜尋的: 假設 `STATIC_URL=static/`,那我們就只要將輸出的文件放到某個 `static` 資料夾下,這樣就會讓 Django 以為檔案在 `/static` 底下,就會叫 finder 依照他們的原則去找出檔案。 那麼接著就只要將 ==輸出的文件所在路徑== 放到 `STATICFILES_DIRS` 裡面就行: ![image](https://hackmd.io/_uploads/SJpDgHD2C.png) 這樣一來 Finder 就找的到 `/static/svg/react.svg` 了: ![image](https://hackmd.io/_uploads/HJwpQSw20.png) |找到前|找到後| |:-:|:-:| |![image](https://hackmd.io/_uploads/HyZ9u2DhA.png)|![image](https://hackmd.io/_uploads/BktqnhPn0.png)| ### 解法 2 ==比較建議的解法==。將 `vite.config.js` 中,`base` 參數設為 `./` 以啟用相對路徑表示法,這樣內部檔案在引用的時候是採用相對路徑,就不會有這個問題了。