[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"post-\u002Fblog\u002Fnuxt-gtm-ga4":3},{"id":4,"title":5,"body":6,"category":1032,"date":1033,"description":1034,"draft":1035,"extension":1036,"meta":1037,"navigation":269,"path":1038,"readingTime":304,"seo":1039,"stem":1040,"tags":1041,"__hash__":1043},"blog\u002Fblog\u002Fnuxt-gtm-ga4.md","在 Nuxt 網站導入 GTM 與 GA4：從載入腳本到自訂事件",{"type":7,"value":8,"toc":1024},"minimark",[9,13,18,54,57,65,69,76,140,155,201,208,218,221,225,228,339,349,363,579,586,590,601,713,716,720,727,911,921,924,998,1004,1007,1020],[10,11,12],"p",{},"做個人品牌網站時，我想知道訪客看了哪些作品、有沒有點聯絡，所以導入了 Google Tag Manager（GTM）和 Google Analytics 4（GA4）。一開始我分不清這兩個工具的差別，實際做過才比較清楚。",[14,15,17],"h2",{"id":16},"gtm-和-ga4-差在哪","GTM 和 GA4 差在哪",[19,20,21,34],"table",{},[22,23,24],"thead",{},[25,26,27,31],"tr",{},[28,29,30],"th",{},"工具",[28,32,33],{},"負責的事",[35,36,37,46],"tbody",{},[25,38,39,43],{},[40,41,42],"td",{},"GA4",[40,44,45],{},"收集並分析數據，例如瀏覽量和事件",[25,47,48,51],{},[40,49,50],{},"GTM",[40,52,53],{},"管理網站上的追蹤代碼，決定什麼時候觸發",[10,55,56],{},"簡單來說，GA4 是看報表的地方，GTM 是放代碼的地方。網站只要載入 GTM，之後新增或調整追蹤，大多可以在 GTM 後台完成，不用每次都改程式。",[10,58,59,60,64],{},"網站和 GTM 之間靠 ",[61,62,63],"code",{},"dataLayer"," 溝通：網站把事件推進去，GTM 再依照設定送到 GA4。",[14,66,68],{"id":67},"先試-nuxt-模組後來改成自己載入","先試 Nuxt 模組，後來改成自己載入",[10,70,71,72,75],{},"我一開始查到 Nuxt 有官方模組 ",[61,73,74],{},"@nuxt\u002Fscripts","，可以管理第三方腳本，所以先用它接 GA4：",[77,78,83],"pre",{"className":79,"code":80,"language":81,"meta":82,"style":82},"language-ts shiki shiki-themes github-dark","\u002F\u002F plugins\u002Fanalytics.client.ts\nexport default defineNuxtPlugin(() => {\n  useScriptGoogleAnalytics({ id: 'G-XXXXXXXXXX' })\n})\n","ts","",[61,84,85,94,118,134],{"__ignoreMap":82},[86,87,90],"span",{"class":88,"line":89},"line",1,[86,91,93],{"class":92},"sAwPA","\u002F\u002F plugins\u002Fanalytics.client.ts\n",[86,95,97,101,104,108,112,115],{"class":88,"line":96},2,[86,98,100],{"class":99},"snl16","export",[86,102,103],{"class":99}," default",[86,105,107],{"class":106},"svObZ"," defineNuxtPlugin",[86,109,111],{"class":110},"s95oV","(() ",[86,113,114],{"class":99},"=>",[86,116,117],{"class":110}," {\n",[86,119,121,124,127,131],{"class":88,"line":120},3,[86,122,123],{"class":106},"  useScriptGoogleAnalytics",[86,125,126],{"class":110},"({ id: ",[86,128,130],{"class":129},"sU2Wk","'G-XXXXXXXXXX'",[86,132,133],{"class":110}," })\n",[86,135,137],{"class":88,"line":136},4,[86,138,139],{"class":110},"})\n",[10,141,142,143,146,147,150,151,154],{},"之後為了用 GTM 管理追蹤，我換成 ",[61,144,145],{},"useScriptGoogleTagManager","，再把設定移到 ",[61,148,149],{},"nuxt.config.ts","，並透過模組提供的 ",[61,152,153],{},"proxy.dataLayer"," 推送事件：",[77,156,158],{"className":79,"code":157,"language":81,"meta":82,"style":82},"const { proxy } = useScriptGoogleTagManager()\nproxy.dataLayer?.push({ event, ...params })\n",[61,159,160,184],{"__ignoreMap":82},[86,161,162,165,168,172,175,178,181],{"class":88,"line":89},[86,163,164],{"class":99},"const",[86,166,167],{"class":110}," { ",[86,169,171],{"class":170},"sDLfK","proxy",[86,173,174],{"class":110}," } ",[86,176,177],{"class":99},"=",[86,179,180],{"class":106}," useScriptGoogleTagManager",[86,182,183],{"class":110},"()\n",[86,185,186,189,192,195,198],{"class":88,"line":96},[86,187,188],{"class":110},"proxy.dataLayer?.",[86,190,191],{"class":106},"push",[86,193,194],{"class":110},"({ event, ",[86,196,197],{"class":99},"...",[86,199,200],{"class":110},"params })\n",[10,202,203,204,207],{},"但上線後資料一直讀取不到。自訂事件不能等，我就先拿掉模組的設定，改成直接引用 GTM 的 JS，事件也直接推進 ",[61,205,206],{},"window.dataLayer","，資料才順利進來。",[10,209,210,211,213,214,217],{},"事後查文件才發現，",[61,212,74],{}," v1 調整了行為：registry 沒有設定 ",[61,215,216],{},"trigger"," 時不會自動插入腳本，而我當時的設定正好沒有寫。",[10,219,220],{},"這個做法的好處是流程全部看得見，之後要控制載入時機也比較容易；代價是要自己處理重複載入和伺服器端渲染的問題。",[14,222,224],{"id":223},"載入-gtm不要拖慢首屏","載入 GTM：不要拖慢首屏",[10,226,227],{},"GTM 的腳本不影響網站功能，所以我把它放到瀏覽器閒置時才載入：",[77,229,231],{"className":79,"code":230,"language":81,"meta":82,"style":82},"onMounted(() => {\n  const run = () => initializeAnalytics()\n\n  if ('requestIdleCallback' in window) {\n    requestIdleCallback(run, { timeout: 3000 })\n  } else {\n    setTimeout(run, 1000)\n  }\n})\n",[61,232,233,244,265,271,288,302,313,328,334],{"__ignoreMap":82},[86,234,235,238,240,242],{"class":88,"line":89},[86,236,237],{"class":106},"onMounted",[86,239,111],{"class":110},[86,241,114],{"class":99},[86,243,117],{"class":110},[86,245,246,249,252,255,258,260,263],{"class":88,"line":96},[86,247,248],{"class":99},"  const",[86,250,251],{"class":106}," run",[86,253,254],{"class":99}," =",[86,256,257],{"class":110}," () ",[86,259,114],{"class":99},[86,261,262],{"class":106}," initializeAnalytics",[86,264,183],{"class":110},[86,266,267],{"class":88,"line":120},[86,268,270],{"emptyLinePlaceholder":269},true,"\n",[86,272,273,276,279,282,285],{"class":88,"line":136},[86,274,275],{"class":99},"  if",[86,277,278],{"class":110}," (",[86,280,281],{"class":129},"'requestIdleCallback'",[86,283,284],{"class":99}," in",[86,286,287],{"class":110}," window) {\n",[86,289,291,294,297,300],{"class":88,"line":290},5,[86,292,293],{"class":106},"    requestIdleCallback",[86,295,296],{"class":110},"(run, { timeout: ",[86,298,299],{"class":170},"3000",[86,301,133],{"class":110},[86,303,305,308,311],{"class":88,"line":304},6,[86,306,307],{"class":110},"  } ",[86,309,310],{"class":99},"else",[86,312,117],{"class":110},[86,314,316,319,322,325],{"class":88,"line":315},7,[86,317,318],{"class":106},"    setTimeout",[86,320,321],{"class":110},"(run, ",[86,323,324],{"class":170},"1000",[86,326,327],{"class":110},")\n",[86,329,331],{"class":88,"line":330},8,[86,332,333],{"class":110},"  }\n",[86,335,337],{"class":88,"line":336},9,[86,338,139],{"class":110},[10,340,341,344,345,348],{},[61,342,343],{},"requestIdleCallback"," 會等主執行緒有空才執行，",[61,346,347],{},"timeout"," 則確保不會一直等不到。不支援的瀏覽器就延遲一秒。",[10,350,351,352,354,355,358,359,362],{},"實際載入的方式，是建立 ",[61,353,63],{}," 並動態插入 script。Nuxt 會在伺服器端渲染，",[61,356,357],{},"window"," 和 ",[61,360,361],{},"document"," 只存在於瀏覽器，所以要先判斷環境：",[77,364,366],{"className":79,"code":365,"language":81,"meta":82,"style":82},"const loadGTM = () => {\n  if (!import.meta.client || document.getElementById('gtm-script')) return\n\n  const { public: { gtmId } } = useRuntimeConfig()\n\n  window.dataLayer = window.dataLayer || []\n  window.dataLayer.push({ 'gtm.start': Date.now(), event: 'gtm.js' })\n\n  const script = document.createElement('script')\n  script.id = 'gtm-script'\n  script.async = true\n  script.src = `https:\u002F\u002Fwww.googletagmanager.com\u002Fgtm.js?id=${gtmId}`\n  document.head.appendChild(script)\n}\n",[61,367,368,383,422,426,452,456,471,498,502,523,534,545,561,573],{"__ignoreMap":82},[86,369,370,372,375,377,379,381],{"class":88,"line":89},[86,371,164],{"class":99},[86,373,374],{"class":106}," loadGTM",[86,376,254],{"class":99},[86,378,257],{"class":110},[86,380,114],{"class":99},[86,382,117],{"class":110},[86,384,385,387,389,392,395,398,401,404,407,410,413,416,419],{"class":88,"line":96},[86,386,275],{"class":99},[86,388,278],{"class":110},[86,390,391],{"class":99},"!import",[86,393,394],{"class":110},".",[86,396,397],{"class":170},"meta",[86,399,400],{"class":110},".client ",[86,402,403],{"class":99},"||",[86,405,406],{"class":110}," document.",[86,408,409],{"class":106},"getElementById",[86,411,412],{"class":110},"(",[86,414,415],{"class":129},"'gtm-script'",[86,417,418],{"class":110},")) ",[86,420,421],{"class":99},"return\n",[86,423,424],{"class":88,"line":120},[86,425,270],{"emptyLinePlaceholder":269},[86,427,428,430,432,436,439,442,445,447,450],{"class":88,"line":136},[86,429,248],{"class":99},[86,431,167],{"class":110},[86,433,435],{"class":434},"s9osk","public",[86,437,438],{"class":110},": { ",[86,440,441],{"class":170},"gtmId",[86,443,444],{"class":110}," } } ",[86,446,177],{"class":99},[86,448,449],{"class":106}," useRuntimeConfig",[86,451,183],{"class":110},[86,453,454],{"class":88,"line":290},[86,455,270],{"emptyLinePlaceholder":269},[86,457,458,461,463,466,468],{"class":88,"line":304},[86,459,460],{"class":110},"  window.dataLayer ",[86,462,177],{"class":99},[86,464,465],{"class":110}," window.dataLayer ",[86,467,403],{"class":99},[86,469,470],{"class":110}," []\n",[86,472,473,476,478,481,484,487,490,493,496],{"class":88,"line":315},[86,474,475],{"class":110},"  window.dataLayer.",[86,477,191],{"class":106},[86,479,480],{"class":110},"({ ",[86,482,483],{"class":129},"'gtm.start'",[86,485,486],{"class":110},": Date.",[86,488,489],{"class":106},"now",[86,491,492],{"class":110},"(), event: ",[86,494,495],{"class":129},"'gtm.js'",[86,497,133],{"class":110},[86,499,500],{"class":88,"line":330},[86,501,270],{"emptyLinePlaceholder":269},[86,503,504,506,509,511,513,516,518,521],{"class":88,"line":336},[86,505,248],{"class":99},[86,507,508],{"class":170}," script",[86,510,254],{"class":99},[86,512,406],{"class":110},[86,514,515],{"class":106},"createElement",[86,517,412],{"class":110},[86,519,520],{"class":129},"'script'",[86,522,327],{"class":110},[86,524,526,529,531],{"class":88,"line":525},10,[86,527,528],{"class":110},"  script.id ",[86,530,177],{"class":99},[86,532,533],{"class":129}," 'gtm-script'\n",[86,535,537,540,542],{"class":88,"line":536},11,[86,538,539],{"class":110},"  script.async ",[86,541,177],{"class":99},[86,543,544],{"class":170}," true\n",[86,546,548,551,553,556,558],{"class":88,"line":547},12,[86,549,550],{"class":110},"  script.src ",[86,552,177],{"class":99},[86,554,555],{"class":129}," `https:\u002F\u002Fwww.googletagmanager.com\u002Fgtm.js?id=${",[86,557,441],{"class":110},[86,559,560],{"class":129},"}`\n",[86,562,564,567,570],{"class":88,"line":563},13,[86,565,566],{"class":110},"  document.head.",[86,568,569],{"class":106},"appendChild",[86,571,572],{"class":110},"(script)\n",[86,574,576],{"class":88,"line":575},14,[86,577,578],{"class":110},"}\n",[10,580,581,582,585],{},"用固定的 ",[61,583,584],{},"id"," 檢查，可以避免重複載入。",[14,587,589],{"id":588},"cookie-同意把選擇存起來","Cookie 同意：把選擇存起來",[10,591,592,593,596,597,600],{},"追蹤訪客行為前，可能需要先取得同意，所以我做了一個 Cookie 橫幅和 ",[61,594,595],{},"useConsent"," composable，把訪客的選擇存在 ",[61,598,599],{},"localStorage","，同意之後才載入 GTM：",[77,602,604],{"className":79,"code":603,"language":81,"meta":82,"style":82},"const accept = () => {\n  localStorage.setItem('cookie-consent', JSON.stringify({ analytics: true }))\n  loadGTM()\n}\n\nconst reject = () => {\n  localStorage.setItem('cookie-consent', JSON.stringify({ analytics: false }))\n}\n",[61,605,606,621,654,661,665,669,684,709],{"__ignoreMap":82},[86,607,608,610,613,615,617,619],{"class":88,"line":89},[86,609,164],{"class":99},[86,611,612],{"class":106}," accept",[86,614,254],{"class":99},[86,616,257],{"class":110},[86,618,114],{"class":99},[86,620,117],{"class":110},[86,622,623,626,629,631,634,637,640,642,645,648,651],{"class":88,"line":96},[86,624,625],{"class":110},"  localStorage.",[86,627,628],{"class":106},"setItem",[86,630,412],{"class":110},[86,632,633],{"class":129},"'cookie-consent'",[86,635,636],{"class":110},", ",[86,638,639],{"class":170},"JSON",[86,641,394],{"class":110},[86,643,644],{"class":106},"stringify",[86,646,647],{"class":110},"({ analytics: ",[86,649,650],{"class":170},"true",[86,652,653],{"class":110}," }))\n",[86,655,656,659],{"class":88,"line":120},[86,657,658],{"class":106},"  loadGTM",[86,660,183],{"class":110},[86,662,663],{"class":88,"line":136},[86,664,578],{"class":110},[86,666,667],{"class":88,"line":290},[86,668,270],{"emptyLinePlaceholder":269},[86,670,671,673,676,678,680,682],{"class":88,"line":304},[86,672,164],{"class":99},[86,674,675],{"class":106}," reject",[86,677,254],{"class":99},[86,679,257],{"class":110},[86,681,114],{"class":99},[86,683,117],{"class":110},[86,685,686,688,690,692,694,696,698,700,702,704,707],{"class":88,"line":315},[86,687,625],{"class":110},[86,689,628],{"class":106},[86,691,412],{"class":110},[86,693,633],{"class":129},[86,695,636],{"class":110},[86,697,639],{"class":170},[86,699,394],{"class":110},[86,701,644],{"class":106},[86,703,647],{"class":110},[86,705,706],{"class":170},"false",[86,708,653],{"class":110},[86,710,711],{"class":88,"line":330},[86,712,578],{"class":110},[10,714,715],{},"我也加了一個開關，決定要不要啟用橫幅，之後調整時不用改動流程。",[14,717,719],{"id":718},"自訂事件把追蹤集中在一個地方","自訂事件：把追蹤集中在一個地方",[10,721,722,723,726],{},"頁面瀏覽量回答不了「訪客對什麼有興趣」，所以我另外定義了自訂事件，並把推送邏輯集中在 ",[61,724,725],{},"useAnalytics","：",[77,728,730],{"className":79,"code":729,"language":81,"meta":82,"style":82},"export const useAnalytics = () => {\n  const push = (event: string, params: Record\u003Cstring, unknown> = {}) => {\n    if (!import.meta.client) return\n    window.dataLayer?.push({ event, ...params })\n  }\n\n  const trackProjectView = (project: { id: string; title: string }) =>\n    push('project_view', { project_id: project.id, project_name: project.title })\n\n  return { trackProjectView }\n}\n",[61,731,732,750,803,821,834,838,842,882,895,899,907],{"__ignoreMap":82},[86,733,734,736,739,742,744,746,748],{"class":88,"line":89},[86,735,100],{"class":99},[86,737,738],{"class":99}," const",[86,740,741],{"class":106}," useAnalytics",[86,743,254],{"class":99},[86,745,257],{"class":110},[86,747,114],{"class":99},[86,749,117],{"class":110},[86,751,752,754,757,759,761,764,767,770,772,775,777,780,783,786,788,791,794,796,799,801],{"class":88,"line":96},[86,753,248],{"class":99},[86,755,756],{"class":106}," push",[86,758,254],{"class":99},[86,760,278],{"class":110},[86,762,763],{"class":434},"event",[86,765,766],{"class":99},":",[86,768,769],{"class":170}," string",[86,771,636],{"class":110},[86,773,774],{"class":434},"params",[86,776,766],{"class":99},[86,778,779],{"class":106}," Record",[86,781,782],{"class":110},"\u003C",[86,784,785],{"class":170},"string",[86,787,636],{"class":110},[86,789,790],{"class":170},"unknown",[86,792,793],{"class":110},"> ",[86,795,177],{"class":99},[86,797,798],{"class":110}," {}) ",[86,800,114],{"class":99},[86,802,117],{"class":110},[86,804,805,808,810,812,814,816,819],{"class":88,"line":120},[86,806,807],{"class":99},"    if",[86,809,278],{"class":110},[86,811,391],{"class":99},[86,813,394],{"class":110},[86,815,397],{"class":170},[86,817,818],{"class":110},".client) ",[86,820,421],{"class":99},[86,822,823,826,828,830,832],{"class":88,"line":136},[86,824,825],{"class":110},"    window.dataLayer?.",[86,827,191],{"class":106},[86,829,194],{"class":110},[86,831,197],{"class":99},[86,833,200],{"class":110},[86,835,836],{"class":88,"line":290},[86,837,333],{"class":110},[86,839,840],{"class":88,"line":304},[86,841,270],{"emptyLinePlaceholder":269},[86,843,844,846,849,851,853,856,858,860,862,864,866,869,872,874,876,879],{"class":88,"line":315},[86,845,248],{"class":99},[86,847,848],{"class":106}," trackProjectView",[86,850,254],{"class":99},[86,852,278],{"class":110},[86,854,855],{"class":434},"project",[86,857,766],{"class":99},[86,859,167],{"class":110},[86,861,584],{"class":434},[86,863,766],{"class":99},[86,865,769],{"class":170},[86,867,868],{"class":110},"; ",[86,870,871],{"class":434},"title",[86,873,766],{"class":99},[86,875,769],{"class":170},[86,877,878],{"class":110}," }) ",[86,880,881],{"class":99},"=>\n",[86,883,884,887,889,892],{"class":88,"line":330},[86,885,886],{"class":106},"    push",[86,888,412],{"class":110},[86,890,891],{"class":129},"'project_view'",[86,893,894],{"class":110},", { project_id: project.id, project_name: project.title })\n",[86,896,897],{"class":88,"line":336},[86,898,270],{"emptyLinePlaceholder":269},[86,900,901,904],{"class":88,"line":525},[86,902,903],{"class":99},"  return",[86,905,906],{"class":110}," { trackProjectView }\n",[86,908,909],{"class":88,"line":536},[86,910,578],{"class":110},[10,912,913,914,917,918,920],{},"元件只要呼叫 ",[61,915,916],{},"trackProjectView(project)","，不需要知道 ",[61,919,63],{}," 的格式。事件名稱也集中在一起，不會出現同一件事有兩種名字。",[10,922,923],{},"我追蹤的事件主要有這些：",[19,925,926,936],{},[22,927,928],{},[25,929,930,933],{},[28,931,932],{},"事件",[28,934,935],{},"想知道的事",[35,937,938,948,958,968,978,988],{},[25,939,940,945],{},[40,941,942],{},[61,943,944],{},"project_view",[40,946,947],{},"訪客看了哪個專案",[25,949,950,955],{},[40,951,952],{},[61,953,954],{},"project_demo_click",[40,956,957],{},"誰點了線上展示",[25,959,960,965],{},[40,961,962],{},[61,963,964],{},"project_github_click",[40,966,967],{},"誰點了 GitHub",[25,969,970,975],{},[40,971,972],{},[61,973,974],{},"contact_click",[40,976,977],{},"從哪裡點了聯絡，例如導覽列或頁尾",[25,979,980,985],{},[40,981,982],{},[61,983,984],{},"contact_submit",[40,986,987],{},"誰送出了聯絡表單",[25,989,990,995],{},[40,991,992],{},[61,993,994],{},"social_click",[40,996,997],{},"點了哪個社群連結",[10,999,1000,1001,1003],{},"事件推進 ",[61,1002,63],{}," 之後，還要在 GTM 後台建立對應的觸發條件和 GA4 事件代碼，資料才會進到 GA4。只寫前端是不夠的。",[14,1005,1006],{"id":1006},"這次學到的事",[1008,1009,1010,1014,1017],"ul",{},[1011,1012,1013],"li",{},"先想清楚要回答什麼問題，再決定追蹤什麼，不是事件越多越好。",[1011,1015,1016],{},"第三方腳本也算效能的一部分，能延後就延後。",[1011,1018,1019],{},"把追蹤邏輯包成 composable，元件會乾淨很多，之後改名或調整也只要改一個地方。",[1021,1022,1023],"style",{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":82,"searchDepth":96,"depth":96,"links":1025},[1026,1027,1028,1029,1030,1031],{"id":16,"depth":96,"text":17},{"id":67,"depth":96,"text":68},{"id":223,"depth":96,"text":224},{"id":588,"depth":96,"text":589},{"id":718,"depth":96,"text":719},{"id":1006,"depth":96,"text":1006},"case","2026-07-30","記錄在個人品牌網站導入 Google Tag Manager 與 GA4 的做法，包含延後載入、Cookie 同意機制，以及用 dataLayer 追蹤專案與聯絡行為。",false,"md",{},"\u002Fblog\u002Fnuxt-gtm-ga4",{"title":5,"description":1034},"blog\u002Fnuxt-gtm-ga4",[50,42,1042],"Nuxt","HM3DIhhjuw5bwH0UvPRJUojLDKuYMSBohKxzUz0tqLc"]