Skip to content

Commit 15eb5a0

Browse files
authored
Merge pull request #7 from AndyWang505/feature/ux-enhancements
Feature/ux enhancements
2 parents ae40ba5 + 224a55f commit 15eb5a0

File tree

8 files changed

+324
-12
lines changed

8 files changed

+324
-12
lines changed

public/banner/banner_01.webp

310 KB
Loading

public/banner/banner_0907.jpg

-383 KB
Binary file not shown.

src/assets/banner/banner_01.webp

310 KB
Loading

src/components/Banner.astro

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,40 @@
11
---
2+
import { Image } from 'astro:assets';
23
import { BANNER_CONFIG, BANNER_HEIGHT_EXTEND } from '../config/banner';
34
45
interface Props {
56
src?: string;
67
alt?: string;
78
position?: 'top' | 'center' | 'bottom';
89
showCredit?: boolean;
10+
width?: number;
11+
height?: number;
912
}
1013
1114
const {
1215
src = BANNER_CONFIG.src,
1316
alt = "Blog banner image",
1417
position = BANNER_CONFIG.position as 'top' | 'center' | 'bottom',
15-
showCredit = BANNER_CONFIG.credit.enable
18+
showCredit = BANNER_CONFIG.credit.enable,
19+
width = 1920,
20+
height = 1080
1621
} = Astro.props;
1722
1823
const imageStyle = `object-position: ${position}`;
24+
25+
const isExternal = src && (src.startsWith('http') || src.startsWith('//'));
26+
27+
let bannerImage;
28+
if (!isExternal && src) {
29+
try {
30+
if (src.startsWith('/banner/')) {
31+
const imagePath = src.replace('/banner/', '');
32+
bannerImage = await import(`../assets/banner/${imagePath}`);
33+
}
34+
} catch (error) {
35+
console.warn(`Banner image not found in assets: ${src}`);
36+
}
37+
}
1938
---
2039

2140
{BANNER_CONFIG.enable && (
@@ -29,15 +48,33 @@ const imageStyle = `object-position: ${position}`;
2948
class="w-full h-full transition-all duration-700 opacity-100 scale-100 relative"
3049
>
3150
<!-- Banner 圖片 -->
32-
<img
33-
src={src}
34-
alt={alt}
35-
class="w-full h-full object-cover"
36-
style={imageStyle}
37-
loading="eager"
38-
decoding="async"
39-
fetchpriority="high"
40-
/>
51+
{bannerImage ? (
52+
<Image
53+
src={bannerImage.default}
54+
alt={alt}
55+
width={width}
56+
height={height}
57+
format="webp"
58+
class="w-full h-full object-cover"
59+
style={imageStyle}
60+
loading="eager"
61+
decoding="async"
62+
fetchpriority="high"
63+
quality={85}
64+
/>
65+
) : (
66+
<img
67+
src={src}
68+
alt={alt}
69+
class="w-full h-full object-cover"
70+
style={imageStyle}
71+
loading="eager"
72+
decoding="async"
73+
fetchpriority="high"
74+
width={width}
75+
height={height}
76+
/>
77+
)}
4178

4279
<!-- 版權信息 -->
4380
{showCredit && BANNER_CONFIG.credit.text && (
@@ -59,4 +96,4 @@ const imageStyle = `object-position: ${position}`;
5996
)}
6097
</div>
6198
</div>
62-
)}
99+
)}

src/components/Banner.astro.backup

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
import { BANNER_CONFIG, BANNER_HEIGHT_EXTEND } from '../config/banner';
3+
4+
interface Props {
5+
src?: string;
6+
alt?: string;
7+
position?: 'top' | 'center' | 'bottom';
8+
showCredit?: boolean;
9+
}
10+
11+
const {
12+
src = BANNER_CONFIG.src,
13+
alt = "Blog banner image",
14+
position = BANNER_CONFIG.position as 'top' | 'center' | 'bottom',
15+
showCredit = BANNER_CONFIG.credit.enable
16+
} = Astro.props;
17+
18+
const imageStyle = `object-position: ${position}`;
19+
---
20+
21+
{BANNER_CONFIG.enable && (
22+
<div
23+
id="banner-wrapper"
24+
class="absolute z-10 w-full transition-all duration-700 overflow-hidden"
25+
style="top: 0"
26+
>
27+
<div
28+
id="banner"
29+
class="w-full h-full transition-all duration-700 opacity-100 scale-100 relative"
30+
>
31+
<!-- Banner 圖片 -->
32+
<img
33+
src={src}
34+
alt={alt}
35+
class="w-full h-full object-cover"
36+
style={imageStyle}
37+
loading="eager"
38+
decoding="async"
39+
fetchpriority="high"
40+
/>
41+
42+
<!-- 版權信息 -->
43+
{showCredit && BANNER_CONFIG.credit.text && (
44+
<a
45+
href={BANNER_CONFIG.credit.url}
46+
id="banner-credit"
47+
target="_blank"
48+
rel="noopener"
49+
aria-label="Visit image source"
50+
class="group absolute flex justify-center items-center rounded-full px-3 right-4 -bottom-[3.25rem] bg-black/60 hover:bg-black/70 h-9 transition-all"
51+
>
52+
<div class="text-white/75 text-xs">{BANNER_CONFIG.credit.text}</div>
53+
{BANNER_CONFIG.credit.url && (
54+
<svg class="ml-1 w-3 h-3 text-white/75 opacity-0 group-hover:opacity-100 transition-opacity" fill="currentColor" viewBox="0 0 24 24">
55+
<path d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z" />
56+
</svg>
57+
)}
58+
</a>
59+
)}
60+
</div>
61+
</div>
62+
)}

src/components/Banner.astro.new

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
import { Picture } from 'astro:assets';
3+
4+
import { BANNER_CONFIG, BANNER_HEIGHT_EXTEND } from '../config/banner';
5+
6+
interface Props {
7+
width?: number;
8+
height?: number;
9+
src?: string;
10+
alt?: string;
11+
position?: 'top' | 'center' | 'bottom';
12+
showCredit?: boolean;
13+
}
14+
15+
const {
16+
src = BANNER_CONFIG.src,
17+
alt = "Blog banner image",
18+
position = BANNER_CONFIG.position as 'top' | 'center' | 'bottom',
19+
showCredit = BANNER_CONFIG.credit.enable
20+
,
21+
width = 1920,
22+
height = 1080
23+
} = Astro.props;
24+
25+
// 判斷是否為本地圖片路徑
26+
const isLocalImage = src && !src.startsWith('http') && !src.startsWith('//');
27+
28+
29+
const imageStyle = `object-position: ${position}`;
30+
---
31+
import { Picture } from 'astro:assets';
32+
33+
34+
{BANNER_CONFIG.enable && (
35+
<div
36+
id="banner-wrapper"
37+
class="absolute z-10 w-full transition-all duration-700 overflow-hidden"
38+
style="top: 0"
39+
>
40+
<div
41+
id="banner"
42+
class="w-full h-full transition-all duration-700 opacity-100 scale-100 relative"
43+
>
44+
<!-- Banner 圖片 -->
45+
<img
46+
src={src}
47+
alt={alt}
48+
class="w-full h-full object-cover"
49+
style={imageStyle}
50+
loading="eager"
51+
decoding="async"
52+
fetchpriority="high"
53+
/>
54+
55+
<!-- 版權信息 -->
56+
{showCredit && BANNER_CONFIG.credit.text && (
57+
<a
58+
href={BANNER_CONFIG.credit.url}
59+
id="banner-credit"
60+
target="_blank"
61+
rel="noopener"
62+
aria-label="Visit image source"
63+
class="group absolute flex justify-center items-center rounded-full px-3 right-4 -bottom-[3.25rem] bg-black/60 hover:bg-black/70 h-9 transition-all"
64+
>
65+
<div class="text-white/75 text-xs">{BANNER_CONFIG.credit.text}</div>
66+
{BANNER_CONFIG.credit.url && (
67+
<svg class="ml-1 w-3 h-3 text-white/75 opacity-0 group-hover:opacity-100 transition-opacity" fill="currentColor" viewBox="0 0 24 24">
68+
<path d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z" />
69+
</svg>
70+
)}
71+
</a>
72+
)}
73+
</div>
74+
</div>
75+
)}

src/config/banner.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const MAIN_PANEL_OVERLAPS_BANNER_HEIGHT: number = BANNER_HEIGHTS.overlaps
1919
// Banner main configuration
2020
export const BANNER_CONFIG = {
2121
enable: true,
22-
src: "/banner/banner_0907.jpg",
22+
src: "/banner/banner_01.webp",
2323
position: "center" as const,
2424
showOnHomePage: true,
2525
showOnOtherPages: true,
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
---
2+
title: 'Astro 圖片優化策略'
3+
description: '近期在重構部落格時,發現過去一直都有靜態資源載入的問題,例如 layout structure 先出來了,但圖片還在 loading,後來也發現其實 astro 有針對這類靜態資源做優化。'
4+
pubDate: 'September 8 2025'
5+
heroImage: ''
6+
tags: ['blog', 'performance']
7+
category: 'Astro'
8+
---
9+
10+
近期在重構部落格時,發現過去一直都有靜態資源載入的問題,例如 layout structure 先出來了,但圖片還在 loading,後來也發現其實 astro 有針對這類靜態資源做優化。
11+
12+
## 常見問題
13+
14+
在開發中,圖片往往是影響載入速度和使用者體驗的主要因素,常見的問題包括:
15+
16+
- 圖片檔案過大:例如,一張 4000px 的原始相片被直接放到網頁橫幅區,但實際顯示只有 1200px 寬。這會造成多餘的下載流量,特別在行動網路下效能很差。
17+
- CLS(Cumulative Layout Shift):若 `<img>` 沒有指定 width 和 height,瀏覽器在圖片載入前不知道該保留多少空間。結果文字或按鈕先載入,因此在圖片載入後會把內容往下推擠,讓畫面產生位移。
18+
- 缺乏響應式設計:一張 PC 用的大橫幅圖(例如 300KB),在手機上可能只需要 50KB。若沒有針對不同 viewport 提供適合的圖像,會造成小螢幕載入時間冗長。
19+
- 延遲載入處理:如果所有圖片在首頁就開始載入,即使是畫面外的圖片,也會影響網頁載入變慢。在沒有使用 loading="lazy" 時,用戶會需要等到全部圖片下載完才能互動。
20+
21+
## 原生作法
22+
23+
一般在沒有其他工具協助的情況下,最原生的處理方法:
24+
25+
- 手動壓縮圖片:透過 Photoshop、ImageOptim、Squoosh 等工具,每次新增圖片都要自己輸出壓縮版本,繁瑣且難以維護。
26+
- 自行生成多個尺寸版本:針對電腦、平板、手機手動輸出不同解析度的圖片,然後用 `<picture>``srcset` 配合 `sizes` 來顯示對應圖片。例如:
27+
```html
28+
<picture>
29+
<source srcset="banner-1200w.jpg" media="(min-width: 1024px)">
30+
<source srcset="banner-600w.jpg" media="(max-width: 600px)">
31+
<img src="banner-800w.jpg" alt="網站橫幅">
32+
</picture>
33+
```
34+
雖然可解決行動裝置載入大圖的問題,但圖片版本一多,維護成本也是會隨之上升。
35+
36+
- 在 `<img>` 標籤加屬性:例如 loading="lazy" 可讓非首頁圖片延後載入,而 decoding="async" 可以非同步解碼圖片,避免主執行緒阻塞。
37+
- 使用 CDN 動態調整:透過 Cloudflare Images、Imgix、Cloudinary 等服務,根據 URL 參數裁切或壓縮圖片。例如:
38+
```bash
39+
https://res.cloudinary.com/demo/image/upload/w_600,h_400,c_fill/sample.jpg
40+
```
41+
42+
## astro 針對圖片的處理
43+
44+
Astro 在 v2 之後提供了 `astro:assets` 模組,讓圖片優化變得自動化。它的特點是在建構時預處理,以及需求渲染(on-demand rendering),不但能壓縮格式,還能避免 CLS(Cumulative Layout Shift)。
45+
46+
> CLS 是 Google Core Web Vitals 的一個指標,用來衡量網頁在載入過程中,畫面元素是否發生「跳動、位移」,這會造成使用體驗很差,可能因此點錯東西。有興趣可以參考 [累計版面配置位移 (CLS)](https://web.dev/articles/cls?hl=zh-tw)。
47+
48+
### `<Image />`
49+
50+
可顯示來自 `src/` 或已經授權遠端來源的圖檔。
51+
52+
該元件可在 build 時(或需求渲染時)轉換尺寸、格式與品質(預設會轉為 WebP),並包含 `alt`、`loading`、`decoding` 等屬性,能夠自動推斷出寬高以避免布局偏移(CLS)。
53+
54+
```astro title="index.astro"
55+
---
56+
import { Image } from 'astro:assets';
57+
import hero from '../assets/hero.png';
58+
---
59+
<Image src={hero} alt="部落格封面圖片" />
60+
```
61+
62+
### `<Picture />`
63+
64+
自 Astro v3.3.0 起開始支援。
65+
66+
可以生成 `<picture>` 元件,支援多種格式與 fallback,可避免圖檔損毀時造成畫面不協調。
67+
68+
`Image` 元件相同,build 時預處理圖檔,支援需求渲染。
69+
70+
```astro title="index.astro"
71+
---
72+
import { Picture } from 'astro:assets';
73+
import cover from '../assets/cover.png';
74+
---
75+
<Picture src={cover} formats={['avif', 'webp']} alt="文章封面" />
76+
```
77+
78+
生成後的 HTML 會自帶 `<source>` 與 fallback 機制,適合做「進階響應式圖片」。
79+
80+
```html
81+
<picture>
82+
<source srcset="...avif" type="image/avif"/>
83+
<source srcset="...webp" type="image/webp"/>
84+
<img src="...png" width="1600" height="900" decoding="async" loading="lazy" alt="文章封面" />
85+
</picture>
86+
```
87+
88+
> 響應式圖像行為(Responsive image behavior),可依訪客裝置大小與解析度調整大小,Astro 會自動生成 `srcset``sizes`,並套用適當樣式。不過要注意在 `/public` 下的圖檔不會被優化,也不支援響應式,將以原檔輸出。
89+
90+
### `getImage()`
91+
92+
適用於希望圖像被用於非 HTML 顯示的場合,例如 API 路由,或 `<Image />``<Picture />` 不支援的選項時,可使用 getImage() 創建自訂圖檔邏輯。
93+
94+
```astro title="index.astro"
95+
---
96+
import { getImage } from 'astro:assets';
97+
import logo from '../assets/logo.png';
98+
99+
const logoData = await getImage({ src: logo, width: 200 });
100+
---
101+
<img src={logoData.src} alt="Logo" />
102+
```
103+
104+
## 靜態資源管理
105+
106+
接下來講一下關於 Astro 在靜態資源上的管理,主要分成 `/src``/public` 這兩個資料夾層級。
107+
108+
### `/src`
109+
110+
主要放程式碼相關資源(components、pages、樣式、圖片、字體等)
111+
112+
它的特性就是在 `/src` 底下的檔案都會經過 Vite / Astro 的打包處理。
113+
114+
圖片如果放在 /src/assets,用 Astro 的 `<Image />` 或 import 引入,Astro 也會自動幫你壓縮、產生不同尺寸及 hash(避免快取問題)。
115+
116+
舉例來說,如果 import 一張圖片可能是這樣:
117+
118+
```astro
119+
---
120+
import banner from '../assets/banner.png';
121+
---
122+
<img src={banner} alt="橫幅圖">
123+
```
124+
125+
編譯後 Astro 會把它轉換成 `/_astro/banner.[hash].png`,確保部署時不會出現路徑錯誤。
126+
127+
### `/public`
128+
129+
這裡主要放純靜態檔案,在 `/public` 下的檔案不會經過打包。
130+
131+
這些檔案會在 build 時,原封不動複製到輸出的 `/dist` 資料夾下,因此引入方式是用絕對路徑。
132+
133+
比較適合放 favicon、robots.txt、sitemap.xml 這類不會變動的檔案。
134+
135+
## 參考來源
136+
137+
* [Images | Docs](https://docs.astro.build/en/guides/images/)
138+
* [了解 Astro 圖片處理 (Image)](https://ithelp.ithome.com.tw/m/articles/10314783)

0 commit comments

Comments
 (0)