ERR_CONTENT_DECODING_FAILED — 當 Status 200 卻解碼失敗的踩坑紀錄

前言

之前在正式環境遇到一個棘手的問題:使用者回報某支 API 回傳 HTTP 200,瀏覽器卻直接噴出 ERR_CONTENT_DECODING_FAILED。當下排查了很久才發現問題出在 Response Header 中多了一個不該出現的 gzip,特別記錄一下整個排查過程。

問題現象

打開 DevTools 檢查異常 API 的 Response Headers,可以看到幾個關鍵資訊:

  • Content-Encoding: gzip — 伺服器宣稱回傳內容經過 gzip 壓縮
  • Transfer-Encoding: chunked — 資料以分段方式傳輸
  • X-Powered-By: ASP.NET — 請求可能直接打到後端 .NET 伺服器,或經過了某層 Proxy 轉發

問題核心:空 Body + gzip 標頭

瀏覽器收到 Content-Encoding: gzip 後,會自動嘗試用 gzip 解碼器解壓內容。但實際上這支 API 的 Response Body 是空的(長度為 0),解碼器無法處理一個空的壓縮流,因此直接報出 ERR_CONTENT_DECODING_FAILED

為什麼 Body 會是空的?根據 Header 中的 X-Powered-By: ASP.NET,可能有兩種原因:

  1. 後端程式異常中斷 — ASP.NET 拋出 Exception 後沒有回傳 500,而是直接結束了 Response,導致 Body 為空。
  2. 查詢超時 — 後端資料庫查詢耗時過久,連線中斷,最終只傳回了 Headers 就結束了。

關鍵線索:對比正常與異常的 API

進一步對比同專案中兩支 API 的 Response Headers,差異一目了然:

欄位 異常 API 正常 API
Content-Encoding gzip (無)
X-Powered-By ASP.NET (無)
X-Aspnet-Version 4.0.30319 (無)

正常的 API 回傳的 Headers 很乾淨,是經過 Nuxt BFF(Node.js)處理後回傳的;而異常的 API 卻直接暴露了 ASP.NET 的資訊。這代表:

  • 情況 A:這支 API 繞過了 Nuxt BFF,直接打到了後端 .NET 伺服器。
  • 情況 B:後端 .NET 伺服器強制啟動了 gzip 壓縮,但因資料量過大或超時導致傳輸中斷,最終變成「空 Body + gzip 標頭」的組合。

真相:Nitro 透傳 Response 物件導致 gzip 標頭外洩

後端伺服器其實沒有噴錯,它正常回傳了資料(只是內容為空陣列或空物件)。真正的問題出在 Nuxt Server(Nitro)端的 API Handler 寫法。

檢查 Server 端程式碼後,發現異常的 API Handler 是這樣寫的:

1
2
const response = await api.memberGetMemberPriorityHistories(query);
return response; // 直接回傳整個 Response 物件

這裡 api.memberGetMemberPriorityHistories() 回傳的是一個繼承自原生 Response 的物件。當直接 return response 時,Nitro 引擎會將其視為透傳(Proxy),把後端的所有原始標頭原封不動地轉發給瀏覽器。

整個流程是這樣的:

  1. 後端 ASP.NET 回傳帶有 Content-Encoding: gzip 的壓縮內容
  2. Nuxt Server(Node.js)在接收時已經自動解壓了 gzip 內容
  3. 但因為直接 return response,Nitro 把後端的原始 Headers(包含 Content-Encoding: gzip)照搬給瀏覽器
  4. 瀏覽器收到 Content-Encoding: gzip 標頭,嘗試解壓 Body —— 但 Body 早就被 Node.js 解壓過了,已經不是 gzip 格式
  5. 解碼失敗,噴出 ERR_CONTENT_DECODING_FAILED

簡單來說:gzip 被 Nuxt Server Side 解開了一次,但 gzip 標頭卻還在,瀏覽器又試圖解第二次,自然就失敗了。

這也解釋了為什麼對比表中異常 API 會帶有 X-Powered-By: ASP.NETX-Aspnet-Version — 這些都是後端的原始標頭被透傳出來的結果。

修正方式

根因找到後,修正方式很明確:不要直接回傳整個 Response 物件,改為只回傳 response.data 這樣 Nitro 會重新封裝成乾淨的 JSON Response,由 Nuxt 統一處理壓縮,後端的 ASP.NET 標頭就不會外洩。

修改前:

1
2
3
4
5
6
export default defineLogAndAuthHandler(async (event) => {
const query = getQuery(event);
const api = new GetMemberPriorityHistories();
const response = await api.memberGetMemberPriorityHistories(query);
return response; // 透傳整個物件,標頭外洩
});

修改後:

1
2
3
4
5
6
export default defineLogAndAuthHandler(async (event) => {
const query = getQuery(event);
const api = new GetMemberPriorityHistories();
const response = await api.memberGetMemberPriorityHistories(query);
return response.data; // 只回傳資料,Nitro 重新封裝 JSON
});

這個修正需要系統性地檢查所有 Server API Handler,只要有直接 return response 的地方都應該改為 return response.data,避免同樣的問題在其他 API 上重演。

結論

這個問題的根因不在後端、也不在前端元件,而是 Nuxt Server(Nitro)的 API Handler 直接透傳了後端 Response 物件。Node.js 在接收後端回應時已經解開了 gzip,但透傳卻把原始的 Content-Encoding: gzip 標頭一併帶給瀏覽器,導致瀏覽器嘗試二次解壓而失敗。

排查這類問題的關鍵:對比正常與異常 API 的 Response Headers。當你發現某支 API 突然多了 X-Powered-By: ASP.NETContent-Encoding: gzip,而其他 API 都沒有,很可能就是 Nitro 把後端的原始標頭透傳出來了。回去檢查 Server API Handler 的回傳值,把 return response 改成 return response.data 就能解決。

在 Nuxt 3 中打造統一的 API Handler:認證、日誌與錯誤處理的優雅封裝

前言

在開發後台管理系統時,我們經常會遇到一個問題:每個 API Handler 都需要處理認證驗證日誌記錄錯誤捕獲等重複性邏輯。如果在每個檔案中都寫一遍,不僅冗長,還容易遺漏。
本文將介紹如何在 Nuxt 3 的 Server API 中,利用 Higher-Order Function (高階函式) 的概念,打造一個統一的 defineLogAndAuthHandler,讓所有 API 自動擁有以下功能:

  • ✅ Session 認證與過期檢查
  • ✅ 請求/回應日誌記錄
  • ✅ 敏感資料自動遮蔽
  • ✅ 效能監控 (API 執行時間)
  • ✅ 統一錯誤處理與格式化

核心概念:Higher-Order Function

在 JavaScript/TypeScript 中,高階函式 (HOF) 是指「接收函式作為參數」或「回傳函式」的函式。我們的設計正是利用這個概念:

export const defineLogAndAuthHandler = <T extends EventHandlerRequest>(
  handler: EventHandler<T>
): EventHandler<T> => defineEventHandler<T>(async (event) => {
  // 在這裡加入認證、日誌等邏輯
  const response = await handler(event); // 執行原本的 Handler
  // 在這裡加入回應處理邏輯
  return response;
});
這就像是在原本的 API Handler 外面「包一層糖衣」,讓它自動具備額外的能力。

功能拆解
1. Session 認證與過期檢查
// 檢查是否已認證
if (!event.context?.session?.isAuthenticated && !isPublicRoute) {
  return createError({
    statusCode: 401,
    statusText: 'Session Expired',
  });
}
// 檢查 Session 是否已過期
const currentTime = new Date().getTime();
const lastLoginTimestamp = event.context?.session?.lastLoginTime;
const maxTimeDiff = (currentTime - lastLoginTimestamp) / 1000;
if (maxTimeDiff >= parseInt(maxExpiryInSeconds, 10)) {
  // 清除 Session 並回傳 401
  event.context.session.isAuthenticated = false;
  return createError({ statusCode: 401, statusText: 'Session Expired' });
}
設計重點:

白名單機制:登入、登出等 API 不需要認證
雙重檢查:不只檢查是否登入,還檢查 Session 是否超時
2. 請求日誌記錄與敏感資料遮蔽
const ignoreLogParams = ['password', 'newPassword', 'oldPassword'];
// 遮蔽敏感欄位
if (typeof body === 'object') {
  const filterBody = Object.assign({}, body);
  for (const key in filterBody) {
    if (ignoreLogParams.includes(key)) {
      filterBody[key] = '***mask***';
    }
  }
  logger.log('info', `[POST] ${JSON.stringify(filterBody)}`);
}
設計重點:

自動遮蔽密碼等敏感欄位,避免日誌外洩
支援 GET/POST/PUT/DELETE 不同方法的日誌格式
3. 效能監控
const startTime = Date.now();
const response = await handler(event);
const endTime = Date.now();
const duration = endTime - startTime;
logger.performance.log('info', `[${pathWithoutQueryString}] [${duration}ms]`);
設計重點:

記錄每個 API 的執行時間
方便後續分析效能瓶頸
4. 回應日誌與大型回應處理
const byPassLogUrls = ['/api/general/settings', '/api/promotion/promotions'];
function truncateString(body: string) {
  const resultSizeLimit = 1024;
  const stringSize = Buffer.byteLength(body);
  
  if (stringSize > resultSizeLimit) {
    return `Result size - ${stringSize} bytes`;
  }
  return body;
}
設計重點:

大型回應只記錄大小,避免日誌檔案爆炸
特定 API 可跳過輸出日誌(如設定檔、大量資料查詢)
5. 統一錯誤處理
catch (error: any) {
  console.error('API Error:', error);
  
  logger.error.log('error', `URL: ${event.path}, Message: ${error.message}`);
  
  return createError({
    statusCode: error.statusCode || 500,
    statusMessage: error.statusMessage || 'Internal Server Error',
    message: error.message || 'An unexpected error occurred',
  });
}
設計重點:

所有未捕獲的異常都會被統一處理
錯誤資訊會被記錄到日誌中,方便追蹤
使用方式
使用這個封裝後,原本的 API Handler 變得非常簡潔:

// server/api/member/summary.get.ts
import { GetMemberSummary } from '@/bospi/GetMemberSummary';
export default defineLogAndAuthHandler(async (event) => {
  const query = getQuery(event);
  const api = new GetMemberSummary();
  const response = await api.getMemberSummary(query);
  return response.data;
});
只需要專注在業務邏輯,認證、日誌、錯誤處理全部自動搞定!

架構圖
┌─────────────────────────────────────────────────────────┐
│                    Frontend (Browser)                    │
└─────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│              defineLogAndAuthHandler (Wrapper)           │
│  ┌─────────────────────────────────────────────────────┐│
│  │ 1. Session 認證檢查                                  ││
│  │ 2. 請求日誌記錄 (含敏感資料遮蔽)                      ││
│  │ 3. 效能計時開始                                      ││
│  └─────────────────────────────────────────────────────┘│
│                          │                               │
│                          ▼                               │
│  ┌─────────────────────────────────────────────────────┐│
│  │           實際的 API Handler (業務邏輯)              ││
│  └─────────────────────────────────────────────────────┘│
│                          │                               │
│                          ▼                               │
│  ┌─────────────────────────────────────────────────────┐│
│  │ 4. 效能計時結束                                      ││
│  │ 5. 回應日誌記錄                                      ││
│  │ 6. 錯誤捕獲與格式化                                  ││
│  └─────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│                    Backend SPI (ASP.NET)                 │
└─────────────────────────────────────────────────────────┘


這個設計模式其實就是 AOP (Aspect-Oriented Programming) 的體現——將「橫切關注點」(如日誌、認證、錯誤處理)從業務邏輯中抽離出來。

使用 Redis Sorted Set 實作 API Rate Limiting — 滑動視窗 + 封鎖機制

前言

在面向全球用戶的平台系統中,API 被濫用幾乎是必然會遇到的問題——無論是攻擊者暴力嘗試登入、
爬蟲高頻抓取資料,還是用戶無意間重複提交表單。

本文分享一個我們實際在生產環境中使用的 Redis-based API Rate Limiting 架構
涵蓋兩種策略:滑動視窗(Sliding Window)滑動視窗 + 封鎖鎖定(Sliding Window + Block Lock)
並支援以 Session ID、IP、Member Code 等多種身份維度進行限流。

Read More

LazyCache 踩坑紀錄:直接傳入 TimeSpan 預設是 SlidingExpiration,高流量下永遠不會過期

前言

這是一個只在正式環境才暴露的問題。
我們使用 LazyCache + Redis 搭建了一個兩層快取架構:
MemoryCache 作為 L1 熱快取,Redis 作為 L2 資料來源。
在 Dev、QAT、UAT 環境一切正常,
但上了 Production 後發現 Redis 資料更新了,應用卻永遠拿到舊值

原因?LazyCache 的 GetOrAdd 直接傳入 TimeSpan,預設行為是 SlidingExpiration

Read More

JamWebDebugger

前幾天同事介紹一個好用的工具,最主要是它可以錄製網頁的 Network ,Console 資訊

因為大家也知道 QA 很常反應問題 一般 IT 還需要重現這個問題. 如果 QA 能夠提供更多的資訊

我們就可以減少 Debugger 的時候.而且這個軟體還是免費而且也整合了 jira 等等的套件

JAM

我們以 myfunnow 這個旅遊網站當例子

Demo

可以看到 他在幾秒的時候 Console 有出現 401 的錯誤 並且在 Network 的 tab 當下有記錄每一個 request 的相關資訊

這些可以幫助 IT 跟 QA 更快反應問題

GitFlow

突然有感而發 想寫下部門之前用了很多年的 GitFlow 以及它們會遇到的問題,以及他們解決什麼問題

最主要觀念是

  1. 正常功能開發以 main branch 為主體(所有的分支都由這裡出發) ,分別 Merge 到 QAT 避免同步大家開發多個功能,
    有互相的影響以及將不必要的程式碼帶到正式環境

  2. 緊急修正的情況可以直接根據 main branch 拉出分支修正 避免其他影響 然後部屬到 Pre-prod 測試

首先我們先定義好 Git Branch 以及對應的環境

我們這裡以 main 當作我們的主要分支 所有 branch 都是以 main 為主

Branch Env Branch Remark
qat QAT QA Testing
main Pre-Prod, Prod 正式環境
feature/xxx 開發功能分支
hotfix/xxx 緊急功能修正
fix/xxx 排程功能部屬

我們環境實際上重要的有三種環境

Environment remark
QAT 給 QA 做測試的環境(所有的 Feature 都會先進到 QAT 給 QA 測試 測試完成的才會 Deploy to Pre-prod)
Pre-prod 上線前給 QA 做最後測試的環境
Prod 正式環境

那我們可以依照幾種情境講述會遇到的問題

  1. 開發功能 照順序進到 Prod

  2. 緊急修正問題 直接推到 Prod

  3. 開發功能 時程需要拖很長(歷時一兩個月)

以下是大致上的 Gitflow 流程圖

我們會一個一個介紹

1. 開發功能 照順序進到 Prod

首先我們先介紹我們有幾個 branch 以及大概的功能敘述

  1. feature/Profile : 他有會員功能的部分 (新增 UI 以及會修改到 Routing 的檔案)
  2. feature/ForgotAccount: 忘記帳號密碼的流程 (新增 UI 以及會修改到 Routing 的檔案)
  3. feature/Product: 新的產品頁面 (新增 UI 以及會修改到 Routing 的檔案)
  4. main: 我們的正式環境的 branch
  5. fix/Week1: 因為我們是每一週會上 Code 所以 branch 以 fix prefix 開頭 然後會把完成的功能 merge 進來

branch 流程順序如下:

  1. feature/Profile ,feature/ForgotAccount 都從 main 拉 branch 出來
  2. feature/Profile ,feature/ForgotAccount 各自開發 直到完成
  3. feature/Profile ,feature/ForgotAccount Merge qat

如圖片黑框的部分 這樣有可能會發生衝突

Issue 1. 因為根據剛剛上面的敘述 A 跟 B 在沒有協調的狀況下 它們有檔案衝突了

所以這是第一個衝突點,Routing 檔案衝突

Solution:

  1. 找 A 跟 B 一起選擇 confilt 檔案
  2. 並且要討論之後進到正式環境的順序是否是 A 先 B 後 或是 B 後 A 先 ,到時候要討論怎麼 Merge

接著測試到一個段落 QA 也認為 1 跟 2. 的 branch 差不多可以進 Prod 了,我們會透過剛剛提到的 5.
fix/Week1 將大家要進的程式碼統一 Merge 到此 branch 然後 merge to main ,部屬到 Pre-Prod 環境給 QA 做最後檢查

branch 流程順序如下:

  1. fix/Week1 都從 main 拉 branch 出來
  2. feature/Profile ,feature/ForgotAccount Merge fix/week1
  3. fix/week1 Merge main (需要有人 approve 或是檢查程式碼)

這樣好處是 Merge prod 只會有一個點 到時候要退版也會好退

Issue 2. 跟 Issue 1 一樣 有可能會有 Routing 檔案衝突或是其他檔案衝突

如圖片黑框的部分 fix/week1 是從上方這個 main branch 開的 然後 merge 進去這樣有可能會發生衝突

Solution:

  1. 定時 Sync main branch 到自己的 branch ex: feature/Profile 如果有衝突可以當下就定時解掉 避免上線前的困擾
  2. 找 A 跟 B 一起選擇 confilt 檔案
  3. 部屬時候如果有衝突的部分要再檢查一次功能

這樣就完成照順序進到 Prod 的功能了

2. 緊急修正問題 直接推到 Prod

想當然 我們在開發的過程中 有可能緊急有發生 Prod 有問題 需要緊急修正的問題

branch:
hotfix/RegisterIssue : 註冊問題修正的 branch

branch 流程順序如下:

  1. hotfix/RegisterIssue 都從 main 拉 branch 出來
  2. hotfix/RegisterIssue Merge main (緊急修正 要直接丟到 Pre-prod 做快速驗證後上線)
  3. hotfix/RegisterIssue Merge qat

為什麼要做 3 是因為要避免 qat 以及 main branch 有一些落差,理論上 qat 會比較新 但是功能應該要跟 main 相同

Issue 3. 有可能遇到的問題也是類似 ,有可能在 Merge qat 的時候 ,QAT 有新的功能在開發也修改到相同檔案

Solution:

  1. 找到衝突的檔案 並且找到功能的作者討論如何修改衝突
  2. 並且請功能的作者 他的 branch 先行 sync main 到自己的 branch 維持最新的版本 避免之後 Merge main 又會有衝突

3. 開發功能 時程需要拖很長(歷時一兩個月)

因為要先知道 如果一個 feature branch 存活的週期大於一個月以上一定要定時做一件事情

**定時 sync main to feature branch **

其餘流程要照著正常 feature 推動 這樣才不會有到時候要 Merge main 一堆衝突的困擾

心得

不管使用什麼 gitflow 都是要避免衝突,盡量要跟衝突的檔案的作者溝通 看上線時程,以及 sync main branch.
如果真有遇到 Merge 到錯誤的 branch (最好是先 force reset 到錯誤的地方開始解決 千萬不要再修改檔案避免造成更多錯誤)

iOS Safari Handle Elastic Scroll

先說明這個問題主要是 通常在滾動頁面的時候 會有兩個需求

  1. 往下滑動就要隱藏 Header

  2. 開始上滑就要顯示 Header

但是問題來了,iOS 有一個橡皮筋的功能叫做 Elastic Scroll ,他會造成你滑動到最上方以後 會再反彈

如果沒有寫好的話,明明就滑動到最上方還是隱藏 Header (明明 Chrome 跟其他瀏覽器不會)….

為了兼容這個問題 我們以 Vue Use 為例子做一個範例

裡面其實只有 directions 跟 y 最重要

因為我們判斷它是否為回彈事件為 判斷他 nextY ===0 代表他就在最上方

不用管他是什麼事件,這樣就可以解決了

這裡說明一下 為什麼使用這方法而不用 touchmove preventDefault 是因為 有可能舊版本的 safari 沒有這個 event…
也不想要考慮太多相容性 就先保留這個行為 但是讓結果相同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { useScroll } from '@vueuse/core';
import { ref, computed, toRefs, watch } from 'vue';

export default function useHeader() {
const showHeader = ref(true);
const winComputedRef = computed(() => window);
const { arrivedState, y, directions } = useScroll(winComputedRef);
const { top: toTop, bottom: toBottom } = toRefs(directions);
const { top: topArrived } = toRefs(arrivedState);
watch([toBottom, y], ([toBottomNewValue, nextY]) => {
//nextY to prevent iOS safari elastic scrolling
if (toBottomNewValue && nextY !== 0) {
showHeader.value = false;
} else if (nextY === 0) {
showHeader.value = true;
}
});

watch(
() => toTop.value,
(newValue) => {
if (newValue) {
showHeader.value = true;
}
}
);
return {
showHeader,
topArrived,
toBottom,
toTop,
y,
};
}

這個問題主要要考慮的是 iOS 以及 safari 版本不同 有可能有不同的事件 所以最好是找一個比較符合的解決方法

其他解法(要確認使用者大部分 Device 在什麼版本)

  1. touchmove prevent

  2. 使用 debounce 限制 scroll top 抓取事件 delay 50 ms 之類 然後判斷高度 可以使用 lodash.throttle 套件

Reference

Vue Use

SEO Prerender Introduce & Asp.net 實作

此篇文章是介紹 SEO Prerender,可能很多人會大概會想說聽過 Server Side Render 以及 Client Side Render ,
Prerender 簡單來說就是預先將網頁 snap shot 起來,等到搜尋引擎來的時候 就不需要當場在渲染頁面

以下是這三種方法的介紹以及優缺點

我這裡總結一下
就是 Prerender 以及 Server Side Render 都是後端產生的 html 差異在

Prerender 是預先拿取 snapshot 的檔案回來,效能比較好但是比較不即時
Server Side Render 是當下的 Request 才開始渲染網頁,比較消耗效能 但是比較即時

所以就看大家怎麼選擇

Asp.net 實作 Prerender

我們這裡會稍微介紹一下 怎麼使用 Asp.net 實作一個 Prerender 檢查的 Dll ,

首先我們要先知道在 Request 的什麼階段比較適合將 Snap Shot 的檔案回傳給搜尋引擎

那以下是我們的介紹

Request Flow

我們先來介紹當一個 Request 進到 IIS Server 中的生命週期

簡單來說 IIS 上面有一些預先檢查 EX: URL Rerewrite . 當這些檢查如果都不符合的時候
就會開始進到 Asp.net 生命週期,所以在做 Prerender 的檢查應該使用 Application Module
在 Request 還沒有進到 Asp.net 驗證以及 Routing 之前判斷,如果根據某一些 User Agent 就應該將預設好的 html
回給 search engine

sequenceDiagram
    User->> IIS: Request
    alt Rewrite not Match
        IIS->> Application Module: Request
    else Rewrite Match
        IIS->> User: Return the destination
    end
    alt Check User Agent
        Application Module->> Route: Enter into Routing
    else User Agent Match with Bot And Static File Exist
        Application Module->> User: Return the static file To User
    end

Application Flow

這是 ASP.NET 應用程式生命週期流程圖 這裡不多加詳述
剛剛說的檢查是否為 Search Engine 應該在 BeginRequest 就檢查,避免做太多不必要的檢查之類
因為看到 MapRequestHandler 代表就是已經進到 Route Config 裡面了

graph LR
    A(BeginRequest) --> B(AuthenticateRequest)
    B --> C(AuthorizeRequest)
    C --> D(ResolveRequestCache)
    D --> E(MapRequestHandler)
    E --> F(.....)

使用 BeginRequest 實作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

using System;
using System.Web;

public class Global : HttpApplication
{
protected void Application_BeginRequest(object sender, EventArgs e)
{

bool isRouteRequest = RouteTable.Routes.GetRouteData(new HttpContextWrapper(Context)) != null;
// 判斷條件
if (isSearchAgent(Request.UserAgent.ToLower()) && isNotStaticFileOrAjaxRequest() && isRouteRequest)
{
//回傳SnapShot檔案
}
}

public bool isSearchAgent(string userAgent)
{
string[] searchEngineUserAgents = { "googlebot", "bingbot", "yahoo", "baiduspider", "yandex" };

bool isSearchEngine = false;
foreach (var searchEngineUserAgent in searchEngineUserAgents)
{
if (userAgent.Contains(searchEngineUserAgent))
{
isSearchEngine = true;
break;
}
}
return true;
}

public bool isNotStaticFileOrAjaxRequest()
{
// 檢查是否為AJAX請求
bool isAjaxRequest = string.Equals(Request.Headers["X-Requested-With"], "XMLHttpRequest", StringComparison.OrdinalIgnoreCase);

// 檢查是否為靜態檔案存取
string fileExtension = System.IO.Path.GetExtension(Request.Url.AbsolutePath).ToLower();
bool isStaticFileRequest = fileExtension == ".css" || fileExtension == ".js" || fileExtension == ".jpg" || fileExtension == ".png" || fileExtension == ".gif";
return !isAjaxRequest && !isStaticFileRequest;
}
}

上述的例子是直接在 global.asax 裡面直接判斷,也可以使用 modules 來達到這件事情。

下面是常見的搜尋引擎的 UserAgent

搜索引擎 User-Agent
Google Googlebot/2.1 (+http://www.google.com/bot.html)
Bing Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)
Baidu Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)
Yahoo Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)
Yandex Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)
DuckDuckGo DuckDuckBot/1.0; (+http://duckduckgo.com/duckduckbot.html)
Sogou Sogou web spider/4.0(+http://www.sogou.com/docs/help/webmasters.htm#07)

結論

使用 Prerender 好處在於說可以自己決定要回傳什麼時候的 SnapShot 檔案給搜尋引擎使用,

但是反之也要注意更新檔案的頻率。

Pinia Writing Hint in Composition Api

在 Composition Api 使用 Pinia 做狀態管理的時候,官方網站上面有寫說

寫一陣子後才想到有遇過幾個問題

怎麼樣算是沒有 refs or reactive , 並且怎樣寫才是正確的呢?

先看一下我們定義的 State

以 number , array ,object 為例子來講解一下我們使用的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { defineStore } from 'pinia';
export const useCounter = defineStore({
id: 'counter',

state: () => ({
n: 2,
decrementedTimes: 0,
numbers: [],
data: {
user: 'joseph',
age: 13,
profile: {
role: 'aaa',
},
},
}),

getters: {
double: (state) => state.n * 2,
},

actions: {
increment(amount = 1) {
this.n += amount;
},
setupUser(userName, profile) {
this.data.user = userName;
this.data.profile.role = profile;
},
pushNumber(number) {
this.numbers.push(number);
},
},
});

Sample
我們用三個層面來探討

  1. Counter Store 建立實體
1
2
import { useCounter } from './stores/counter';
const counter = useCounter();
  1. Counter Store 解構方式取出 (錯誤方法)
1
2
3
4
5
6
const {
n: unRefCounts,
numbers: unRefNumbers,
data: unRefData,
double: unRefDouble,
} = useCounter();
  1. Counter Store 解構方式取出 (StoreToRefs)
1
const { n, numbers, data, double } = storeToRefs(counter);

Counter Store 建立實體

這個最不需要討論 因為建立實體後 可以調用所有的 method 屬性跟 Getter 都會是雙向綁定的
只是缺點是 每次使用都要 counter.xxx counter.xxx 有點麻煩

1
2
import { useCounter } from './stores/counter';
const counter = useCounter();

Counter Store 解構方式取出 (錯誤方法)

因為在 ES6 上面建立的解構的方法 所以一開始大家都會想要用這種方法片段的取出值

1
2
3
4
5
6
const {
n: unRefCounts,
numbers: unRefNumbers,
data: unRefData,
double: unRefDouble,
} = useCounter();

大家可以看到 標黑色的部分 因為單純的 number or string 這些都是 call by value 所以透過解構的方法並不會雙向綁定
但是物件跟陣列是會的

為了避免混用 我們還是少用這種方法

Counter Store 解構方式取出 (StoreToRefs)

這是官方推薦的方法

1
const { n, numbers, data, double } = storeToRefs(counter);

這樣就可以把 n 直接做雙向綁定 只要操作的時候使用 n.value ++就可以

但是要注意一件事情. storeToRefs 出來的值 如果再將其中的子屬性 assign 出去也不會有 two way binding 的效果

1
const userName = data.value.user;

那如果我們還是希望可以有子屬性有 two way binding 的效果可以參考以下範例

ex: 使用者的資料

1
2
3
4
5
6
data: {
UpdateTime: 0,
Name: "",
Status: "Active",
Profile: { Email: "", Phone: 0 }
}

因為有可能不是每次都要把所有使用者資料撈出來,會遇到一些寫法問題 請參考範例

Code Sample

Pinia 存取的結構:

這裡有發現有幾種寫法就算 Pinia 狀態有改變,畫面上面的值沒有跟著連動

Not Working Sample

  1. rootStore.data.Status
    直接把 rootStore 的屬性取出是無法做 reactive

  2. data.value.Profile.Phone
    透過 storeToRefs 拉出的屬性值注意只有屬性值不會 reactive

  3. rootStore.phoneNumber
    直接把 rootStore 的 Getter 取出是無法做 reactive

為什麼會這樣呢?

後面有回頭去看 Vue 3 reactive 用法

其實 reactive 是使用 proxy 做的

其實他文件上面有提到兩點很重要的

  • When you assign or destructure a reactive object’s property to a local variable, the reactivity is “disconnected” because access to the local variable no longer triggers the get / set proxy traps.
    意思是當你把 reactiv object 的屬性值單獨取出他就會 disconnected 因為 value update 不會被 proxy 的 get set 觸發到
    這就是對應到剛剛的問題 1 2 3
  • The returned proxy from reactive(), although behaving just like the original, has a different identity if we compare it to the original using the === operator.
    當你重新把 reactive object 在 assign 出去 他會視為是不同的物件

reactive work in vue

How to Fix It?

  1. 使用 getter method 透過 storeToRefs 取出單一的屬性值
    Getters are exactly the equivalent of computed values for the state of a Store.
    Reference
  2. 單獨把屬性值透過 computed 包裝起來
  3. 使用 watchEffect

以下是 working 的 sample

1
2
3
4
const { data, phoneNumber } = storeToRefs(rootStore);
const computedTime = computed(() => {
return data.value.UpdateTime;
});

為什麼要特地說明這個呢? 因為在 vue 3 中 會很常使用 composition api, 並且把一些共用的邏輯抽成 composeble 的 code
相對 Pinia 也是 可能會需要單獨取某一些值出來,所以特地記錄這塊

TypeScript Object Using Enum as Key

我們在使用 typescript 的時候 有可能需要宣告 Enum 來避免 傳入的參數可能大小寫不符合或是傳到沒有值的參數

那相對我們在宣告物件的時候也想要把這個概念串接下來 讓我們可以使用
如下方的範例: 我們先定義一個 Enum

1
2
3
4
5
enum DateFormat {
Default = 1, //DD/MM/YYYY HH:mm:ss
ShortDate = 2, //DD/MMM/YYYY
DateTimeWithComma = 3, //DD/MM/YYYY, HH:mm:ss
}

目標是想要使用 以下物件去宣告

1
2
3
4
5
6
7
8
9
10
type DateLanguage = {
en: string;
zh: string;
};

type DateSetting{
Default: DateLanguage
ShortDate: DateLanguage
DateTimeWithComma: DateLanguage
}

大家有發現到嗎 假設我們新增一個 Enum 也想要相對應在 type 新增一個 key 但是又怕打錯字 或是 key 錯大小寫

所以這是我今天想解決的問題

Solution

首先在這個 Case 我們使用 type 是因為 type 可以使用 typeof 取出 Key 並進行定義

如果想了解 Interface vs type 區別 可以參考 reference
如圖片
使用 keyof type DateFormat 可以讓 DateFormatKey 必須是這三個的其中之一 不然會報錯

接著使用 下面方法就可以定義出來我們想要的 Key 值

1
type DateFormatFields = { [key in DateFormatKeys]: DateLanguage };

接著我們就可以定義我們的 object

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const dateSetting: DateFormatFields = {
Default: {
en: 'DD/MM/YYYY HH:mm:ss',
zh: 'MM/DD/YYYY HH:mm:ss',
},
ShortDate: {
en: 'DD/MM/YYYY',
zh: 'MM/DD/YYYY',
},
DateTimeWithComma: {
en: 'DD/MM/YYYY, HH:mm:ss',
zh: 'MM/DD/YYYY, HH:mm:ss',
},
};

最後是如何去取出呢?

一開始發現想說可能可以用 C#的寫法把 Enum 的值 to string
=> DateFormat.DateTimeWithComma.toString()
後面發現不行
要透過下面方式

1
const key = DateFormat[DateFormat.DateTimeWithComma]; //string

是因為實際上我們使用 typescript 宣告的 enum 他對應的 out 會長這樣 所以可以用這方法取出值

1
2
3
4
5
6
7
8
9
10
11
Output
{
"1": "North",
"2": "East",
"3": "South",
"4": "West",
"North": 1,
"East": 2,
"South": 3,
"West": 4
}

那我們取出的方法就會是這樣

主要是因為 key 是 string 所以又要讓他 當成最上方的 type 傳入到我們的 object 內

1
console.log(dateSetting[key as DateFormatKeys]);

如果是這樣寫就會噴錯

1
console.log(dateSetting[key]);

Source Code

Reference

Key of type of 分析
Interface vs type 區別
Interface vs Type
How To Use Enums in TypeScript